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