254 lines
8.9 KiB
Python
254 lines
8.9 KiB
Python
|
"""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)
|
||
|
if labels:
|
||
|
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
|
||
|
|
||
|
if "en" not in familyNames or "en" not in styleNames:
|
||
|
# Not enough information to compute PS names of styleMap names
|
||
|
return StatNames(
|
||
|
familyNames=familyNames,
|
||
|
styleNames=styleNames,
|
||
|
postScriptFontName=None,
|
||
|
styleMapFamilyNames={},
|
||
|
styleMapStyleName=None,
|
||
|
)
|
||
|
|
||
|
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]
|
||
|
# In the "recursive" case where both the Regular has
|
||
|
# linkedUserValue pointing the Bold, and the Bold has
|
||
|
# linkedUserValue pointing to the Regular, only consider the
|
||
|
# first case: Regular (e.g. 400) has linkedUserValue pointing to
|
||
|
# Bold (e.g. 700, higher than Regular)
|
||
|
and regular_label.userValue < regular_label.linkedUserValue
|
||
|
):
|
||
|
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 upright_label in axis.axisLabels:
|
||
|
if (
|
||
|
upright_label.linkedUserValue == userLocation[axis.name]
|
||
|
# In the "recursive" case where both the Upright has
|
||
|
# linkedUserValue pointing the Italic, and the Italic has
|
||
|
# linkedUserValue pointing to the Upright, only consider the
|
||
|
# first case: Upright (e.g. ital=0, slant=0) has
|
||
|
# linkedUserValue pointing to Italic (e.g ital=1, slant=-12 or
|
||
|
# slant=12 for backwards italics, in any case higher than
|
||
|
# Upright in absolute value, hence the abs() below.
|
||
|
and abs(upright_label.userValue) < abs(upright_label.linkedUserValue)
|
||
|
):
|
||
|
regularUserLocation[axis.name] = upright_label.userValue
|
||
|
italic = True
|
||
|
break
|
||
|
|
||
|
return BOLD_ITALIC_TO_RIBBI_STYLE[bold, italic], {
|
||
|
**userLocation,
|
||
|
**regularUserLocation,
|
||
|
}
|