Harry Dalton 2da2653837 Allow "minimum"/"maximum" keys to be missing, as well as None
Some areas of the library check for both representations, and so doing
this here too means we are less likely to break existing code.

Despite this, flexibility introduces ambiguity, and so if typing gives
us confidence that such an input is unlikely, we could re-review this;
conditions with missing keys are not safe to use across the entire code-
base.
2022-09-01 17:21:04 +01:00

437 lines
17 KiB
Python

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