blob: 1474e5fcf560343feb759680c121f25802c05bf6 [file] [log] [blame]
"""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,
}