Spaces:
Paused
Paused
| """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) | |
| } | |