up follow livre
This commit is contained in:
parent
b4b4398bb0
commit
3a7a3849ae
12242 changed files with 2564461 additions and 6914 deletions
|
|
@ -0,0 +1,475 @@
|
|||
"""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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue