blob: 0b7cdf4be05dea1e810b4fddf4bf026bc1a50a85 [file] [log] [blame]
"""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)
}