|
"""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"): |
|
|
|
|
|
|
|
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 |
|
""" |
|
|
|
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() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"): |
|
|
|
|
|
|
|
axis = cast(AxisDescriptor, axis) |
|
subDoc.addAxis( |
|
AxisDescriptor( |
|
|
|
tag=axis.tag, |
|
name=axis.name, |
|
labelNames=axis.labelNames, |
|
hidden=axis.hidden, |
|
|
|
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 |
|
], |
|
|
|
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, |
|
) |
|
) |
|
|
|
|
|
|
|
|
|
|
|
designRegion = userRegionToDesignRegion(doc, userRegion) |
|
subDoc.rules = _subsetRulesBasedOnConditions(doc.rules, designRegion) |
|
subDoc.rulesProcessingLast = doc.rulesProcessingLast |
|
|
|
|
|
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, |
|
) |
|
) |
|
|
|
|
|
vfDefault = subDoc.findDefault() |
|
oldDefault = doc.findDefault() |
|
if vfDefault is not None and oldDefault is not None: |
|
vfDefault.localisedFamilyName = oldDefault.localisedFamilyName |
|
|
|
|
|
if keepVFs: |
|
|
|
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, |
|
) |
|
) |
|
|
|
|
|
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]: |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(): |
|
|
|
if selectionName not in cs: |
|
|
|
continue |
|
if isinstance(selectionValue, (float, int)): |
|
|
|
if selectionValue in cs[selectionName]: |
|
pass |
|
|
|
else: |
|
discardConditionset = True |
|
else: |
|
|
|
if selectionValue in cs[selectionName]: |
|
pass |
|
else: |
|
intersection = cs[selectionName].intersection(selectionValue) |
|
|
|
if intersection is not None: |
|
newConditionset.append( |
|
{ |
|
"name": selectionName, |
|
"minimum": intersection.minimum, |
|
"maximum": intersection.maximum, |
|
} |
|
) |
|
|
|
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) |
|
} |
|
|