Spaces:
Running
Running
"""Allows building all the variable fonts of a DesignSpace version 5 by | |
splitting the document into interpolable sub-space, then into each VF. | |
""" | |
from __future__ import annotations | |
import itertools | |
import logging | |
import math | |
from typing import Any, Callable, Dict, Iterator, List, Tuple, cast | |
from fontTools.designspaceLib import ( | |
AxisDescriptor, | |
AxisMappingDescriptor, | |
DesignSpaceDocument, | |
DiscreteAxisDescriptor, | |
InstanceDescriptor, | |
RuleDescriptor, | |
SimpleLocationDict, | |
SourceDescriptor, | |
VariableFontDescriptor, | |
) | |
from fontTools.designspaceLib.statNames import StatNames, getStatNames | |
from fontTools.designspaceLib.types import ( | |
ConditionSet, | |
Range, | |
Region, | |
getVFUserRegion, | |
locationInRegion, | |
regionInRegion, | |
userRegionToDesignRegion, | |
) | |
LOGGER = logging.getLogger(__name__) | |
MakeInstanceFilenameCallable = Callable[ | |
[DesignSpaceDocument, InstanceDescriptor, StatNames], str | |
] | |
def defaultMakeInstanceFilename( | |
doc: DesignSpaceDocument, instance: InstanceDescriptor, statNames: StatNames | |
) -> str: | |
"""Default callable to synthesize an instance filename | |
when makeNames=True, for instances that don't specify an instance name | |
in the designspace. This part of the name generation can be overriden | |
because it's not specified by the STAT table. | |
""" | |
familyName = instance.familyName or statNames.familyNames.get("en") | |
styleName = instance.styleName or statNames.styleNames.get("en") | |
return f"{familyName}-{styleName}.ttf" | |
def splitInterpolable( | |
doc: DesignSpaceDocument, | |
makeNames: bool = True, | |
expandLocations: bool = True, | |
makeInstanceFilename: MakeInstanceFilenameCallable = defaultMakeInstanceFilename, | |
) -> Iterator[Tuple[SimpleLocationDict, DesignSpaceDocument]]: | |
"""Split the given DS5 into several interpolable sub-designspaces. | |
There are as many interpolable sub-spaces as there are combinations of | |
discrete axis values. | |
E.g. with axes: | |
- italic (discrete) Upright or Italic | |
- style (discrete) Sans or Serif | |
- weight (continuous) 100 to 900 | |
There are 4 sub-spaces in which the Weight axis should interpolate: | |
(Upright, Sans), (Upright, Serif), (Italic, Sans) and (Italic, Serif). | |
The sub-designspaces still include the full axis definitions and STAT data, | |
but the rules, sources, variable fonts, instances are trimmed down to only | |
keep what falls within the interpolable sub-space. | |
Args: | |
- ``makeNames``: Whether to compute the instance family and style | |
names using the STAT data. | |
- ``expandLocations``: Whether to turn all locations into "full" | |
locations, including implicit default axis values where missing. | |
- ``makeInstanceFilename``: Callable to synthesize an instance filename | |
when makeNames=True, for instances that don't specify an instance name | |
in the designspace. This part of the name generation can be overridden | |
because it's not specified by the STAT table. | |
.. versionadded:: 5.0 | |
""" | |
discreteAxes = [] | |
interpolableUserRegion: Region = {} | |
for axis in doc.axes: | |
if hasattr(axis, "values"): | |
# Mypy doesn't support narrowing union types via hasattr() | |
# TODO(Python 3.10): use TypeGuard | |
# https://mypy.readthedocs.io/en/stable/type_narrowing.html | |
axis = cast(DiscreteAxisDescriptor, axis) | |
discreteAxes.append(axis) | |
else: | |
axis = cast(AxisDescriptor, axis) | |
interpolableUserRegion[axis.name] = Range( | |
axis.minimum, | |
axis.maximum, | |
axis.default, | |
) | |
valueCombinations = itertools.product(*[axis.values for axis in discreteAxes]) | |
for values in valueCombinations: | |
discreteUserLocation = { | |
discreteAxis.name: value | |
for discreteAxis, value in zip(discreteAxes, values) | |
} | |
subDoc = _extractSubSpace( | |
doc, | |
{**interpolableUserRegion, **discreteUserLocation}, | |
keepVFs=True, | |
makeNames=makeNames, | |
expandLocations=expandLocations, | |
makeInstanceFilename=makeInstanceFilename, | |
) | |
yield discreteUserLocation, subDoc | |
def splitVariableFonts( | |
doc: DesignSpaceDocument, | |
makeNames: bool = False, | |
expandLocations: bool = False, | |
makeInstanceFilename: MakeInstanceFilenameCallable = defaultMakeInstanceFilename, | |
) -> Iterator[Tuple[str, DesignSpaceDocument]]: | |
"""Convert each variable font listed in this document into a standalone | |
designspace. This can be used to compile all the variable fonts from a | |
format 5 designspace using tools that can only deal with 1 VF at a time. | |
Args: | |
- ``makeNames``: Whether to compute the instance family and style | |
names using the STAT data. | |
- ``expandLocations``: Whether to turn all locations into "full" | |
locations, including implicit default axis values where missing. | |
- ``makeInstanceFilename``: Callable to synthesize an instance filename | |
when makeNames=True, for instances that don't specify an instance name | |
in the designspace. This part of the name generation can be overridden | |
because it's not specified by the STAT table. | |
.. versionadded:: 5.0 | |
""" | |
# Make one DesignspaceDoc v5 for each variable font | |
for vf in doc.getVariableFonts(): | |
vfUserRegion = getVFUserRegion(doc, vf) | |
vfDoc = _extractSubSpace( | |
doc, | |
vfUserRegion, | |
keepVFs=False, | |
makeNames=makeNames, | |
expandLocations=expandLocations, | |
makeInstanceFilename=makeInstanceFilename, | |
) | |
vfDoc.lib = {**vfDoc.lib, **vf.lib} | |
yield vf.name, vfDoc | |
def convert5to4( | |
doc: DesignSpaceDocument, | |
) -> Dict[str, DesignSpaceDocument]: | |
"""Convert each variable font listed in this document into a standalone | |
format 4 designspace. This can be used to compile all the variable fonts | |
from a format 5 designspace using tools that only know about format 4. | |
.. versionadded:: 5.0 | |
""" | |
vfs = {} | |
for _location, subDoc in splitInterpolable(doc): | |
for vfName, vfDoc in splitVariableFonts(subDoc): | |
vfDoc.formatVersion = "4.1" | |
vfs[vfName] = vfDoc | |
return vfs | |
def _extractSubSpace( | |
doc: DesignSpaceDocument, | |
userRegion: Region, | |
*, | |
keepVFs: bool, | |
makeNames: bool, | |
expandLocations: bool, | |
makeInstanceFilename: MakeInstanceFilenameCallable, | |
) -> DesignSpaceDocument: | |
subDoc = DesignSpaceDocument() | |
# Don't include STAT info | |
# FIXME: (Jany) let's think about it. Not include = OK because the point of | |
# the splitting is to build VFs and we'll use the STAT data of the full | |
# document to generate the STAT of the VFs, so "no need" to have STAT data | |
# in sub-docs. Counterpoint: what if someone wants to split this DS for | |
# other purposes? Maybe for that it would be useful to also subset the STAT | |
# data? | |
# subDoc.elidedFallbackName = doc.elidedFallbackName | |
def maybeExpandDesignLocation(object): | |
if expandLocations: | |
return object.getFullDesignLocation(doc) | |
else: | |
return object.designLocation | |
for axis in doc.axes: | |
range = userRegion[axis.name] | |
if isinstance(range, Range) and hasattr(axis, "minimum"): | |
# Mypy doesn't support narrowing union types via hasattr() | |
# TODO(Python 3.10): use TypeGuard | |
# https://mypy.readthedocs.io/en/stable/type_narrowing.html | |
axis = cast(AxisDescriptor, axis) | |
subDoc.addAxis( | |
AxisDescriptor( | |
# Same info | |
tag=axis.tag, | |
name=axis.name, | |
labelNames=axis.labelNames, | |
hidden=axis.hidden, | |
# Subset range | |
minimum=max(range.minimum, axis.minimum), | |
default=range.default or axis.default, | |
maximum=min(range.maximum, axis.maximum), | |
map=[ | |
(user, design) | |
for user, design in axis.map | |
if range.minimum <= user <= range.maximum | |
], | |
# Don't include STAT info | |
axisOrdering=None, | |
axisLabels=None, | |
) | |
) | |
subDoc.axisMappings = mappings = [] | |
subDocAxes = {axis.name for axis in subDoc.axes} | |
for mapping in doc.axisMappings: | |
if not all(axis in subDocAxes for axis in mapping.inputLocation.keys()): | |
continue | |
if not all(axis in subDocAxes for axis in mapping.outputLocation.keys()): | |
LOGGER.error( | |
"In axis mapping from input %s, some output axes are not in the variable-font: %s", | |
mapping.inputLocation, | |
mapping.outputLocation, | |
) | |
continue | |
mappingAxes = set() | |
mappingAxes.update(mapping.inputLocation.keys()) | |
mappingAxes.update(mapping.outputLocation.keys()) | |
for axis in doc.axes: | |
if axis.name not in mappingAxes: | |
continue | |
range = userRegion[axis.name] | |
if ( | |
range.minimum != axis.minimum | |
or (range.default is not None and range.default != axis.default) | |
or range.maximum != axis.maximum | |
): | |
LOGGER.error( | |
"Limiting axis ranges used in <mapping> elements not supported: %s", | |
axis.name, | |
) | |
continue | |
mappings.append( | |
AxisMappingDescriptor( | |
inputLocation=mapping.inputLocation, | |
outputLocation=mapping.outputLocation, | |
) | |
) | |
# Don't include STAT info | |
# subDoc.locationLabels = doc.locationLabels | |
# Rules: subset them based on conditions | |
designRegion = userRegionToDesignRegion(doc, userRegion) | |
subDoc.rules = _subsetRulesBasedOnConditions(doc.rules, designRegion) | |
subDoc.rulesProcessingLast = doc.rulesProcessingLast | |
# Sources: keep only the ones that fall within the kept axis ranges | |
for source in doc.sources: | |
if not locationInRegion(doc.map_backward(source.designLocation), userRegion): | |
continue | |
subDoc.addSource( | |
SourceDescriptor( | |
filename=source.filename, | |
path=source.path, | |
font=source.font, | |
name=source.name, | |
designLocation=_filterLocation( | |
userRegion, maybeExpandDesignLocation(source) | |
), | |
layerName=source.layerName, | |
familyName=source.familyName, | |
styleName=source.styleName, | |
muteKerning=source.muteKerning, | |
muteInfo=source.muteInfo, | |
mutedGlyphNames=source.mutedGlyphNames, | |
) | |
) | |
# Copy family name translations from the old default source to the new default | |
vfDefault = subDoc.findDefault() | |
oldDefault = doc.findDefault() | |
if vfDefault is not None and oldDefault is not None: | |
vfDefault.localisedFamilyName = oldDefault.localisedFamilyName | |
# Variable fonts: keep only the ones that fall within the kept axis ranges | |
if keepVFs: | |
# Note: call getVariableFont() to make the implicit VFs explicit | |
for vf in doc.getVariableFonts(): | |
vfUserRegion = getVFUserRegion(doc, vf) | |
if regionInRegion(vfUserRegion, userRegion): | |
subDoc.addVariableFont( | |
VariableFontDescriptor( | |
name=vf.name, | |
filename=vf.filename, | |
axisSubsets=[ | |
axisSubset | |
for axisSubset in vf.axisSubsets | |
if isinstance(userRegion[axisSubset.name], Range) | |
], | |
lib=vf.lib, | |
) | |
) | |
# Instances: same as Sources + compute missing names | |
for instance in doc.instances: | |
if not locationInRegion(instance.getFullUserLocation(doc), userRegion): | |
continue | |
if makeNames: | |
statNames = getStatNames(doc, instance.getFullUserLocation(doc)) | |
familyName = instance.familyName or statNames.familyNames.get("en") | |
styleName = instance.styleName or statNames.styleNames.get("en") | |
subDoc.addInstance( | |
InstanceDescriptor( | |
filename=instance.filename | |
or makeInstanceFilename(doc, instance, statNames), | |
path=instance.path, | |
font=instance.font, | |
name=instance.name or f"{familyName} {styleName}", | |
userLocation={} if expandLocations else instance.userLocation, | |
designLocation=_filterLocation( | |
userRegion, maybeExpandDesignLocation(instance) | |
), | |
familyName=familyName, | |
styleName=styleName, | |
postScriptFontName=instance.postScriptFontName | |
or statNames.postScriptFontName, | |
styleMapFamilyName=instance.styleMapFamilyName | |
or statNames.styleMapFamilyNames.get("en"), | |
styleMapStyleName=instance.styleMapStyleName | |
or statNames.styleMapStyleName, | |
localisedFamilyName=instance.localisedFamilyName | |
or statNames.familyNames, | |
localisedStyleName=instance.localisedStyleName | |
or statNames.styleNames, | |
localisedStyleMapFamilyName=instance.localisedStyleMapFamilyName | |
or statNames.styleMapFamilyNames, | |
localisedStyleMapStyleName=instance.localisedStyleMapStyleName | |
or {}, | |
lib=instance.lib, | |
) | |
) | |
else: | |
subDoc.addInstance( | |
InstanceDescriptor( | |
filename=instance.filename, | |
path=instance.path, | |
font=instance.font, | |
name=instance.name, | |
userLocation={} if expandLocations else instance.userLocation, | |
designLocation=_filterLocation( | |
userRegion, maybeExpandDesignLocation(instance) | |
), | |
familyName=instance.familyName, | |
styleName=instance.styleName, | |
postScriptFontName=instance.postScriptFontName, | |
styleMapFamilyName=instance.styleMapFamilyName, | |
styleMapStyleName=instance.styleMapStyleName, | |
localisedFamilyName=instance.localisedFamilyName, | |
localisedStyleName=instance.localisedStyleName, | |
localisedStyleMapFamilyName=instance.localisedStyleMapFamilyName, | |
localisedStyleMapStyleName=instance.localisedStyleMapStyleName, | |
lib=instance.lib, | |
) | |
) | |
subDoc.lib = doc.lib | |
return subDoc | |
def _conditionSetFrom(conditionSet: List[Dict[str, Any]]) -> ConditionSet: | |
c: Dict[str, Range] = {} | |
for condition in conditionSet: | |
minimum, maximum = condition.get("minimum"), condition.get("maximum") | |
c[condition["name"]] = Range( | |
minimum if minimum is not None else -math.inf, | |
maximum if maximum is not None else math.inf, | |
) | |
return c | |
def _subsetRulesBasedOnConditions( | |
rules: List[RuleDescriptor], designRegion: Region | |
) -> List[RuleDescriptor]: | |
# What rules to keep: | |
# - Keep the rule if any conditionset is relevant. | |
# - A conditionset is relevant if all conditions are relevant or it is empty. | |
# - A condition is relevant if | |
# - axis is point (C-AP), | |
# - and point in condition's range (C-AP-in) | |
# (in this case remove the condition because it's always true) | |
# - else (C-AP-out) whole conditionset can be discarded (condition false | |
# => conditionset false) | |
# - axis is range (C-AR), | |
# - (C-AR-all) and axis range fully contained in condition range: we can | |
# scrap the condition because it's always true | |
# - (C-AR-inter) and intersection(axis range, condition range) not empty: | |
# keep the condition with the smaller range (= intersection) | |
# - (C-AR-none) else, whole conditionset can be discarded | |
newRules: List[RuleDescriptor] = [] | |
for rule in rules: | |
newRule: RuleDescriptor = RuleDescriptor( | |
name=rule.name, conditionSets=[], subs=rule.subs | |
) | |
for conditionset in rule.conditionSets: | |
cs = _conditionSetFrom(conditionset) | |
newConditionset: List[Dict[str, Any]] = [] | |
discardConditionset = False | |
for selectionName, selectionValue in designRegion.items(): | |
# TODO: Ensure that all(key in conditionset for key in region.keys())? | |
if selectionName not in cs: | |
# raise Exception("Selection has different axes than the rules") | |
continue | |
if isinstance(selectionValue, (float, int)): # is point | |
# Case C-AP-in | |
if selectionValue in cs[selectionName]: | |
pass # always matches, conditionset can stay empty for this one. | |
# Case C-AP-out | |
else: | |
discardConditionset = True | |
else: # is range | |
# Case C-AR-all | |
if selectionValue in cs[selectionName]: | |
pass # always matches, conditionset can stay empty for this one. | |
else: | |
intersection = cs[selectionName].intersection(selectionValue) | |
# Case C-AR-inter | |
if intersection is not None: | |
newConditionset.append( | |
{ | |
"name": selectionName, | |
"minimum": intersection.minimum, | |
"maximum": intersection.maximum, | |
} | |
) | |
# Case C-AR-none | |
else: | |
discardConditionset = True | |
if not discardConditionset: | |
newRule.conditionSets.append(newConditionset) | |
if newRule.conditionSets: | |
newRules.append(newRule) | |
return newRules | |
def _filterLocation( | |
userRegion: Region, | |
location: Dict[str, float], | |
) -> Dict[str, float]: | |
return { | |
name: value | |
for name, value in location.items() | |
if name in userRegion and isinstance(userRegion[name], Range) | |
} | |