xref: /aosp_15_r20/external/fonttools/Lib/fontTools/designspaceLib/statNames.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""Compute name information for a given location in user-space coordinates
2using STAT data. This can be used to fill-in automatically the names of an
3instance:
4
5.. code:: python
6
7    instance = doc.instances[0]
8    names = getStatNames(doc, instance.getFullUserLocation(doc))
9    print(names.styleNames)
10"""
11
12from __future__ import annotations
13
14from dataclasses import dataclass
15from typing import Dict, Optional, Tuple, Union
16import logging
17
18from fontTools.designspaceLib import (
19    AxisDescriptor,
20    AxisLabelDescriptor,
21    DesignSpaceDocument,
22    DesignSpaceDocumentError,
23    DiscreteAxisDescriptor,
24    SimpleLocationDict,
25    SourceDescriptor,
26)
27
28LOGGER = logging.getLogger(__name__)
29
30# TODO(Python 3.8): use Literal
31# RibbiStyleName = Union[Literal["regular"], Literal["bold"], Literal["italic"], Literal["bold italic"]]
32RibbiStyle = str
33BOLD_ITALIC_TO_RIBBI_STYLE = {
34    (False, False): "regular",
35    (False, True): "italic",
36    (True, False): "bold",
37    (True, True): "bold italic",
38}
39
40
41@dataclass
42class StatNames:
43    """Name data generated from the STAT table information."""
44
45    familyNames: Dict[str, str]
46    styleNames: Dict[str, str]
47    postScriptFontName: Optional[str]
48    styleMapFamilyNames: Dict[str, str]
49    styleMapStyleName: Optional[RibbiStyle]
50
51
52def getStatNames(
53    doc: DesignSpaceDocument, userLocation: SimpleLocationDict
54) -> StatNames:
55    """Compute the family, style, PostScript names of the given ``userLocation``
56    using the document's STAT information.
57
58    Also computes localizations.
59
60    If not enough STAT data is available for a given name, either its dict of
61    localized names will be empty (family and style names), or the name will be
62    None (PostScript name).
63
64    .. versionadded:: 5.0
65    """
66    familyNames: Dict[str, str] = {}
67    defaultSource: Optional[SourceDescriptor] = doc.findDefault()
68    if defaultSource is None:
69        LOGGER.warning("Cannot determine default source to look up family name.")
70    elif defaultSource.familyName is None:
71        LOGGER.warning(
72            "Cannot look up family name, assign the 'familyname' attribute to the default source."
73        )
74    else:
75        familyNames = {
76            "en": defaultSource.familyName,
77            **defaultSource.localisedFamilyName,
78        }
79
80    styleNames: Dict[str, str] = {}
81    # If a free-standing label matches the location, use it for name generation.
82    label = doc.labelForUserLocation(userLocation)
83    if label is not None:
84        styleNames = {"en": label.name, **label.labelNames}
85    # Otherwise, scour the axis labels for matches.
86    else:
87        # Gather all languages in which at least one translation is provided
88        # Then build names for all these languages, but fallback to English
89        # whenever a translation is missing.
90        labels = _getAxisLabelsForUserLocation(doc.axes, userLocation)
91        if labels:
92            languages = set(
93                language for label in labels for language in label.labelNames
94            )
95            languages.add("en")
96            for language in languages:
97                styleName = " ".join(
98                    label.labelNames.get(language, label.defaultName)
99                    for label in labels
100                    if not label.elidable
101                )
102                if not styleName and doc.elidedFallbackName is not None:
103                    styleName = doc.elidedFallbackName
104                styleNames[language] = styleName
105
106    if "en" not in familyNames or "en" not in styleNames:
107        # Not enough information to compute PS names of styleMap names
108        return StatNames(
109            familyNames=familyNames,
110            styleNames=styleNames,
111            postScriptFontName=None,
112            styleMapFamilyNames={},
113            styleMapStyleName=None,
114        )
115
116    postScriptFontName = f"{familyNames['en']}-{styleNames['en']}".replace(" ", "")
117
118    styleMapStyleName, regularUserLocation = _getRibbiStyle(doc, userLocation)
119
120    styleNamesForStyleMap = styleNames
121    if regularUserLocation != userLocation:
122        regularStatNames = getStatNames(doc, regularUserLocation)
123        styleNamesForStyleMap = regularStatNames.styleNames
124
125    styleMapFamilyNames = {}
126    for language in set(familyNames).union(styleNames.keys()):
127        familyName = familyNames.get(language, familyNames["en"])
128        styleName = styleNamesForStyleMap.get(language, styleNamesForStyleMap["en"])
129        styleMapFamilyNames[language] = (familyName + " " + styleName).strip()
130
131    return StatNames(
132        familyNames=familyNames,
133        styleNames=styleNames,
134        postScriptFontName=postScriptFontName,
135        styleMapFamilyNames=styleMapFamilyNames,
136        styleMapStyleName=styleMapStyleName,
137    )
138
139
140def _getSortedAxisLabels(
141    axes: list[Union[AxisDescriptor, DiscreteAxisDescriptor]],
142) -> Dict[str, list[AxisLabelDescriptor]]:
143    """Returns axis labels sorted by their ordering, with unordered ones appended as
144    they are listed."""
145
146    # First, get the axis labels with explicit ordering...
147    sortedAxes = sorted(
148        (axis for axis in axes if axis.axisOrdering is not None),
149        key=lambda a: a.axisOrdering,
150    )
151    sortedLabels: Dict[str, list[AxisLabelDescriptor]] = {
152        axis.name: axis.axisLabels for axis in sortedAxes
153    }
154
155    # ... then append the others in the order they appear.
156    # NOTE: This relies on Python 3.7+ dict's preserved insertion order.
157    for axis in axes:
158        if axis.axisOrdering is None:
159            sortedLabels[axis.name] = axis.axisLabels
160
161    return sortedLabels
162
163
164def _getAxisLabelsForUserLocation(
165    axes: list[Union[AxisDescriptor, DiscreteAxisDescriptor]],
166    userLocation: SimpleLocationDict,
167) -> list[AxisLabelDescriptor]:
168    labels: list[AxisLabelDescriptor] = []
169
170    allAxisLabels = _getSortedAxisLabels(axes)
171    if allAxisLabels.keys() != userLocation.keys():
172        LOGGER.warning(
173            f"Mismatch between user location '{userLocation.keys()}' and available "
174            f"labels for '{allAxisLabels.keys()}'."
175        )
176
177    for axisName, axisLabels in allAxisLabels.items():
178        userValue = userLocation[axisName]
179        label: Optional[AxisLabelDescriptor] = next(
180            (
181                l
182                for l in axisLabels
183                if l.userValue == userValue
184                or (
185                    l.userMinimum is not None
186                    and l.userMaximum is not None
187                    and l.userMinimum <= userValue <= l.userMaximum
188                )
189            ),
190            None,
191        )
192        if label is None:
193            LOGGER.debug(
194                f"Document needs a label for axis '{axisName}', user value '{userValue}'."
195            )
196        else:
197            labels.append(label)
198
199    return labels
200
201
202def _getRibbiStyle(
203    self: DesignSpaceDocument, userLocation: SimpleLocationDict
204) -> Tuple[RibbiStyle, SimpleLocationDict]:
205    """Compute the RIBBI style name of the given user location,
206    return the location of the matching Regular in the RIBBI group.
207
208    .. versionadded:: 5.0
209    """
210    regularUserLocation = {}
211    axes_by_tag = {axis.tag: axis for axis in self.axes}
212
213    bold: bool = False
214    italic: bool = False
215
216    axis = axes_by_tag.get("wght")
217    if axis is not None:
218        for regular_label in axis.axisLabels:
219            if (
220                regular_label.linkedUserValue == userLocation[axis.name]
221                # In the "recursive" case where both the Regular has
222                # linkedUserValue pointing the Bold, and the Bold has
223                # linkedUserValue pointing to the Regular, only consider the
224                # first case: Regular (e.g. 400) has linkedUserValue pointing to
225                # Bold (e.g. 700, higher than Regular)
226                and regular_label.userValue < regular_label.linkedUserValue
227            ):
228                regularUserLocation[axis.name] = regular_label.userValue
229                bold = True
230                break
231
232    axis = axes_by_tag.get("ital") or axes_by_tag.get("slnt")
233    if axis is not None:
234        for upright_label in axis.axisLabels:
235            if (
236                upright_label.linkedUserValue == userLocation[axis.name]
237                # In the "recursive" case where both the Upright has
238                # linkedUserValue pointing the Italic, and the Italic has
239                # linkedUserValue pointing to the Upright, only consider the
240                # first case: Upright (e.g. ital=0, slant=0) has
241                # linkedUserValue pointing to Italic (e.g ital=1, slant=-12 or
242                # slant=12 for backwards italics, in any case higher than
243                # Upright in absolute value, hence the abs() below.
244                and abs(upright_label.userValue) < abs(upright_label.linkedUserValue)
245            ):
246                regularUserLocation[axis.name] = upright_label.userValue
247                italic = True
248                break
249
250    return BOLD_ITALIC_TO_RIBBI_STYLE[bold, italic], {
251        **userLocation,
252        **regularUserLocation,
253    }
254