Source code for fontTools.designspaceLib.split

"""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
]


[docs] 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"
[docs] 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
[docs] 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
[docs] 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) }