[designspaceLib] Add designspace 5 code
This commit is contained in:
parent
169731c7f5
commit
35e560603e
File diff suppressed because it is too large
Load Diff
424
Lib/fontTools/designspaceLib/split.py
Normal file
424
Lib/fontTools/designspaceLib/split.py
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
from fontTools.designspaceLib import (
|
||||||
|
AxisDescriptor,
|
||||||
|
DesignSpaceDocument,
|
||||||
|
DiscreteAxisDescriptor,
|
||||||
|
InstanceDescriptor,
|
||||||
|
RuleDescriptor,
|
||||||
|
SimpleLocationDict,
|
||||||
|
SourceDescriptor,
|
||||||
|
VariableFontDescriptor,
|
||||||
|
)
|
||||||
|
from fontTools.designspaceLib.statNames import StatNames, getStatNames
|
||||||
|
from fontTools.designspaceLib.types import (
|
||||||
|
Range,
|
||||||
|
Region,
|
||||||
|
ConditionSet,
|
||||||
|
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 isinstance(axis, DiscreteAxisDescriptor):
|
||||||
|
discreteAxes.append(axis)
|
||||||
|
else:
|
||||||
|
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 isinstance(axis, AxisDescriptor):
|
||||||
|
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:
|
||||||
|
c[condition["name"]] = Range(
|
||||||
|
condition.get("minimum", -math.inf),
|
||||||
|
condition.get("maximum", 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)
|
||||||
|
}
|
224
Lib/fontTools/designspaceLib/statNames.py
Normal file
224
Lib/fontTools/designspaceLib/statNames.py
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
"""Compute name information for a given location in user-space coordinates
|
||||||
|
using STAT data. This can be used to fill-in automatically the names of an
|
||||||
|
instance:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
instance = doc.instances[0]
|
||||||
|
names = getStatNames(doc, instance.getFullUserLocation(doc))
|
||||||
|
print(names.styleNames)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, Optional, Tuple, Union
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fontTools.designspaceLib import (
|
||||||
|
AxisDescriptor,
|
||||||
|
AxisLabelDescriptor,
|
||||||
|
DesignSpaceDocument,
|
||||||
|
DesignSpaceDocumentError,
|
||||||
|
DiscreteAxisDescriptor,
|
||||||
|
SimpleLocationDict,
|
||||||
|
SourceDescriptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# TODO(Python 3.8): use Literal
|
||||||
|
# RibbiStyleName = Union[Literal["regular"], Literal["bold"], Literal["italic"], Literal["bold italic"]]
|
||||||
|
RibbiStyle = str
|
||||||
|
BOLD_ITALIC_TO_RIBBI_STYLE = {
|
||||||
|
(False, False): "regular",
|
||||||
|
(False, True): "italic",
|
||||||
|
(True, False): "bold",
|
||||||
|
(True, True): "bold italic",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StatNames:
|
||||||
|
"""Name data generated from the STAT table information."""
|
||||||
|
|
||||||
|
familyNames: Dict[str, str]
|
||||||
|
styleNames: Dict[str, str]
|
||||||
|
postScriptFontName: Optional[str]
|
||||||
|
styleMapFamilyNames: Dict[str, str]
|
||||||
|
styleMapStyleName: Optional[RibbiStyle]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def getStatNames(
|
||||||
|
doc: DesignSpaceDocument, userLocation: SimpleLocationDict
|
||||||
|
) -> StatNames:
|
||||||
|
"""Compute the family, style, PostScript names of the given ``userLocation``
|
||||||
|
using the document's STAT information.
|
||||||
|
|
||||||
|
Also computes localizations.
|
||||||
|
|
||||||
|
If not enough STAT data is available for a given name, either its dict of
|
||||||
|
localized names will be empty (family and style names), or the name will be
|
||||||
|
None (PostScript name).
|
||||||
|
|
||||||
|
.. versionadded:: 5.0
|
||||||
|
"""
|
||||||
|
familyNames: Dict[str, str] = {}
|
||||||
|
defaultSource: Optional[SourceDescriptor] = doc.findDefault()
|
||||||
|
if defaultSource is None:
|
||||||
|
LOGGER.warning("Cannot determine default source to look up family name.")
|
||||||
|
elif defaultSource.familyName is None:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Cannot look up family name, assign the 'familyname' attribute to the default source."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
familyNames = {
|
||||||
|
"en": defaultSource.familyName,
|
||||||
|
**defaultSource.localisedFamilyName,
|
||||||
|
}
|
||||||
|
|
||||||
|
styleNames: Dict[str, str] = {}
|
||||||
|
# If a free-standing label matches the location, use it for name generation.
|
||||||
|
label = doc.labelForUserLocation(userLocation)
|
||||||
|
if label is not None:
|
||||||
|
styleNames = {"en": label.name, **label.labelNames}
|
||||||
|
# Otherwise, scour the axis labels for matches.
|
||||||
|
else:
|
||||||
|
# Gather all languages in which at least one translation is provided
|
||||||
|
# Then build names for all these languages, but fallback to English
|
||||||
|
# whenever a translation is missing.
|
||||||
|
labels = _getAxisLabelsForUserLocation(doc.axes, userLocation)
|
||||||
|
languages = set(language for label in labels for language in label.labelNames)
|
||||||
|
languages.add("en")
|
||||||
|
for language in languages:
|
||||||
|
styleName = " ".join(
|
||||||
|
label.labelNames.get(language, label.defaultName)
|
||||||
|
for label in labels
|
||||||
|
if not label.elidable
|
||||||
|
)
|
||||||
|
if not styleName and doc.elidedFallbackName is not None:
|
||||||
|
styleName = doc.elidedFallbackName
|
||||||
|
styleNames[language] = styleName
|
||||||
|
|
||||||
|
postScriptFontName = None
|
||||||
|
if "en" in familyNames and "en" in styleNames:
|
||||||
|
postScriptFontName = f"{familyNames['en']}-{styleNames['en']}".replace(" ", "")
|
||||||
|
|
||||||
|
styleMapStyleName, regularUserLocation = _getRibbiStyle(doc, userLocation)
|
||||||
|
|
||||||
|
styleNamesForStyleMap = styleNames
|
||||||
|
if regularUserLocation != userLocation:
|
||||||
|
regularStatNames = getStatNames(doc, regularUserLocation)
|
||||||
|
styleNamesForStyleMap = regularStatNames.styleNames
|
||||||
|
|
||||||
|
styleMapFamilyNames = {}
|
||||||
|
for language in set(familyNames).union(styleNames.keys()):
|
||||||
|
familyName = familyNames.get(language, familyNames["en"])
|
||||||
|
styleName = styleNamesForStyleMap.get(language, styleNamesForStyleMap["en"])
|
||||||
|
styleMapFamilyNames[language] = (familyName + " " + styleName).strip()
|
||||||
|
|
||||||
|
return StatNames(
|
||||||
|
familyNames=familyNames,
|
||||||
|
styleNames=styleNames,
|
||||||
|
postScriptFontName=postScriptFontName,
|
||||||
|
styleMapFamilyNames=styleMapFamilyNames,
|
||||||
|
styleMapStyleName=styleMapStyleName,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _getSortedAxisLabels(
|
||||||
|
axes: list[Union[AxisDescriptor, DiscreteAxisDescriptor]],
|
||||||
|
) -> Dict[str, list[AxisLabelDescriptor]]:
|
||||||
|
"""Returns axis labels sorted by their ordering, with unordered ones appended as
|
||||||
|
they are listed."""
|
||||||
|
|
||||||
|
# First, get the axis labels with explicit ordering...
|
||||||
|
sortedAxes = sorted(
|
||||||
|
(axis for axis in axes if axis.axisOrdering is not None),
|
||||||
|
key=lambda a: a.axisOrdering,
|
||||||
|
)
|
||||||
|
sortedLabels: Dict[str, list[AxisLabelDescriptor]] = {
|
||||||
|
axis.name: axis.axisLabels for axis in sortedAxes
|
||||||
|
}
|
||||||
|
|
||||||
|
# ... then append the others in the order they appear.
|
||||||
|
# NOTE: This relies on Python 3.7+ dict's preserved insertion order.
|
||||||
|
for axis in axes:
|
||||||
|
if axis.axisOrdering is None:
|
||||||
|
sortedLabels[axis.name] = axis.axisLabels
|
||||||
|
|
||||||
|
return sortedLabels
|
||||||
|
|
||||||
|
|
||||||
|
def _getAxisLabelsForUserLocation(
|
||||||
|
axes: list[Union[AxisDescriptor, DiscreteAxisDescriptor]],
|
||||||
|
userLocation: SimpleLocationDict,
|
||||||
|
) -> list[AxisLabelDescriptor]:
|
||||||
|
labels: list[AxisLabelDescriptor] = []
|
||||||
|
|
||||||
|
allAxisLabels = _getSortedAxisLabels(axes)
|
||||||
|
if allAxisLabels.keys() != userLocation.keys():
|
||||||
|
LOGGER.warning(
|
||||||
|
f"Mismatch between user location '{userLocation.keys()}' and available "
|
||||||
|
f"labels for '{allAxisLabels.keys()}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
for axisName, axisLabels in allAxisLabels.items():
|
||||||
|
userValue = userLocation[axisName]
|
||||||
|
label: Optional[AxisLabelDescriptor] = next(
|
||||||
|
(
|
||||||
|
l
|
||||||
|
for l in axisLabels
|
||||||
|
if l.userValue == userValue
|
||||||
|
or (
|
||||||
|
l.userMinimum is not None
|
||||||
|
and l.userMaximum is not None
|
||||||
|
and l.userMinimum <= userValue <= l.userMaximum
|
||||||
|
)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if label is None:
|
||||||
|
LOGGER.debug(
|
||||||
|
f"Document needs a label for axis '{axisName}', user value '{userValue}'."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
labels.append(label)
|
||||||
|
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def _getRibbiStyle(
|
||||||
|
self: DesignSpaceDocument, userLocation: SimpleLocationDict
|
||||||
|
) -> Tuple[RibbiStyle, SimpleLocationDict]:
|
||||||
|
"""Compute the RIBBI style name of the given user location,
|
||||||
|
return the location of the matching Regular in the RIBBI group.
|
||||||
|
|
||||||
|
.. versionadded:: 5.0
|
||||||
|
"""
|
||||||
|
regularUserLocation = {}
|
||||||
|
axes_by_tag = {axis.tag: axis for axis in self.axes}
|
||||||
|
|
||||||
|
bold: bool = False
|
||||||
|
italic: bool = False
|
||||||
|
|
||||||
|
axis = axes_by_tag.get("wght")
|
||||||
|
if axis is not None:
|
||||||
|
for regular_label in axis.axisLabels:
|
||||||
|
if regular_label.linkedUserValue == userLocation[axis.name]:
|
||||||
|
regularUserLocation[axis.name] = regular_label.userValue
|
||||||
|
bold = True
|
||||||
|
break
|
||||||
|
|
||||||
|
axis = axes_by_tag.get("ital") or axes_by_tag.get("slnt")
|
||||||
|
if axis is not None:
|
||||||
|
for urpright_label in axis.axisLabels:
|
||||||
|
if urpright_label.linkedUserValue == userLocation[axis.name]:
|
||||||
|
regularUserLocation[axis.name] = urpright_label.userValue
|
||||||
|
italic = True
|
||||||
|
break
|
||||||
|
|
||||||
|
return BOLD_ITALIC_TO_RIBBI_STYLE[bold, italic], {
|
||||||
|
**userLocation,
|
||||||
|
**regularUserLocation,
|
||||||
|
}
|
122
Lib/fontTools/designspaceLib/types.py
Normal file
122
Lib/fontTools/designspaceLib/types.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
|
from fontTools.designspaceLib import (
|
||||||
|
DesignSpaceDocument,
|
||||||
|
RangeAxisSubsetDescriptor,
|
||||||
|
SimpleLocationDict,
|
||||||
|
VariableFontDescriptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value, minimum, maximum):
|
||||||
|
return min(max(value, minimum), maximum)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Range:
|
||||||
|
minimum: float
|
||||||
|
"""Inclusive minimum of the range."""
|
||||||
|
maximum: float
|
||||||
|
"""Inclusive maximum of the range."""
|
||||||
|
default: float = 0
|
||||||
|
"""Default value"""
|
||||||
|
|
||||||
|
def __post__init__(self):
|
||||||
|
self.minimum, self.maximum = sorted((self.minimum, self.maximum))
|
||||||
|
self.default = clamp(self.default, self.minimum, self.maximum)
|
||||||
|
|
||||||
|
def __contains__(self, value: Union[float, Range]) -> bool:
|
||||||
|
if isinstance(value, Range):
|
||||||
|
return self.minimum <= value.minimum and value.maximum <= self.maximum
|
||||||
|
return self.minimum <= value <= self.maximum
|
||||||
|
|
||||||
|
def intersection(self, other: Range) -> Optional[Range]:
|
||||||
|
if self.maximum < other.minimum or self.minimum > other.maximum:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return Range(
|
||||||
|
max(self.minimum, other.minimum),
|
||||||
|
min(self.maximum, other.maximum),
|
||||||
|
self.default, # We don't care about the default in this use-case
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# A region selection is either a range or a single value, as a Designspace v5
|
||||||
|
# axis-subset element only allows a single discrete value or a range for a
|
||||||
|
# variable-font element.
|
||||||
|
Region = Dict[str, Union[Range, float]]
|
||||||
|
|
||||||
|
# A conditionset is a set of named ranges.
|
||||||
|
ConditionSet = Dict[str, Range]
|
||||||
|
|
||||||
|
# A rule is a list of conditionsets where any has to be relevant for the whole rule to be relevant.
|
||||||
|
Rule = List[ConditionSet]
|
||||||
|
Rules = Dict[str, Rule]
|
||||||
|
|
||||||
|
|
||||||
|
def locationInRegion(location: SimpleLocationDict, region: Region) -> bool:
|
||||||
|
for name, value in location.items():
|
||||||
|
if name not in region:
|
||||||
|
return False
|
||||||
|
regionValue = region[name]
|
||||||
|
if isinstance(regionValue, (float, int)):
|
||||||
|
if value != regionValue:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if value not in regionValue:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def regionInRegion(region: Region, superRegion: Region) -> bool:
|
||||||
|
for name, value in region.items():
|
||||||
|
if not name in superRegion:
|
||||||
|
return False
|
||||||
|
superValue = superRegion[name]
|
||||||
|
if isinstance(superValue, (float, int)):
|
||||||
|
if value != superValue:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if value not in superValue:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def userRegionToDesignRegion(doc: DesignSpaceDocument, userRegion: Region) -> Region:
|
||||||
|
designRegion = {}
|
||||||
|
for name, value in userRegion.items():
|
||||||
|
axis = doc.getAxis(name)
|
||||||
|
if isinstance(value, (float, int)):
|
||||||
|
designRegion[name] = axis.map_forward(value)
|
||||||
|
else:
|
||||||
|
designRegion[name] = Range(
|
||||||
|
axis.map_forward(value.minimum),
|
||||||
|
axis.map_forward(value.maximum),
|
||||||
|
axis.map_forward(value.default),
|
||||||
|
)
|
||||||
|
return designRegion
|
||||||
|
|
||||||
|
|
||||||
|
def getVFUserRegion(doc: DesignSpaceDocument, vf: VariableFontDescriptor) -> Region:
|
||||||
|
vfUserRegion: Region = {}
|
||||||
|
# For each axis, 2 cases:
|
||||||
|
# - it has a range = it's an axis in the VF DS
|
||||||
|
# - it's a single location = use it to know which rules should apply in the VF
|
||||||
|
for axisSubset in vf.axisSubsets:
|
||||||
|
axis = doc.getAxis(axisSubset.name)
|
||||||
|
if isinstance(axisSubset, RangeAxisSubsetDescriptor):
|
||||||
|
vfUserRegion[axis.name] = Range(
|
||||||
|
max(axisSubset.userMinimum, axis.minimum),
|
||||||
|
min(axisSubset.userMaximum, axis.maximum),
|
||||||
|
axisSubset.userDefault or axis.default,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
vfUserRegion[axis.name] = axisSubset.userValue
|
||||||
|
# Any axis not mentioned explicitly has a single location = default value
|
||||||
|
for axis in doc.axes:
|
||||||
|
if axis.name not in vfUserRegion:
|
||||||
|
vfUserRegion[axis.name] = axis.default
|
||||||
|
return vfUserRegion
|
Loading…
x
Reference in New Issue
Block a user