| """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) |
| } |