• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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"""
11from __future__ import annotations
12
13from dataclasses import dataclass
14from typing import Dict, Optional, Tuple, Union
15import logging
16
17from fontTools.designspaceLib import (
18    AxisDescriptor,
19    AxisLabelDescriptor,
20    DesignSpaceDocument,
21    DesignSpaceDocumentError,
22    DiscreteAxisDescriptor,
23    SimpleLocationDict,
24    SourceDescriptor,
25)
26
27LOGGER = logging.getLogger(__name__)
28
29# TODO(Python 3.8): use Literal
30# RibbiStyleName = Union[Literal["regular"], Literal["bold"], Literal["italic"], Literal["bold italic"]]
31RibbiStyle = str
32BOLD_ITALIC_TO_RIBBI_STYLE = {
33    (False, False): "regular",
34    (False, True): "italic",
35    (True, False): "bold",
36    (True, True): "bold italic",
37}
38
39
40@dataclass
41class StatNames:
42    """Name data generated from the STAT table information."""
43
44    familyNames: Dict[str, str]
45    styleNames: Dict[str, str]
46    postScriptFontName: Optional[str]
47    styleMapFamilyNames: Dict[str, str]
48    styleMapStyleName: Optional[RibbiStyle]
49
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(language for label in labels for language in label.labelNames)
93            languages.add("en")
94            for language in languages:
95                styleName = " ".join(
96                    label.labelNames.get(language, label.defaultName)
97                    for label in labels
98                    if not label.elidable
99                )
100                if not styleName and doc.elidedFallbackName is not None:
101                    styleName = doc.elidedFallbackName
102                styleNames[language] = styleName
103
104    if "en" not in familyNames or "en" not in styleNames:
105        # Not enough information to compute PS names of styleMap names
106        return StatNames(
107            familyNames=familyNames,
108            styleNames=styleNames,
109            postScriptFontName=None,
110            styleMapFamilyNames={},
111            styleMapStyleName=None,
112        )
113
114    postScriptFontName = f"{familyNames['en']}-{styleNames['en']}".replace(" ", "")
115
116    styleMapStyleName, regularUserLocation = _getRibbiStyle(doc, userLocation)
117
118    styleNamesForStyleMap = styleNames
119    if regularUserLocation != userLocation:
120        regularStatNames = getStatNames(doc, regularUserLocation)
121        styleNamesForStyleMap = regularStatNames.styleNames
122
123    styleMapFamilyNames = {}
124    for language in set(familyNames).union(styleNames.keys()):
125        familyName = familyNames.get(language, familyNames["en"])
126        styleName = styleNamesForStyleMap.get(language, styleNamesForStyleMap["en"])
127        styleMapFamilyNames[language] = (familyName + " " + styleName).strip()
128
129    return StatNames(
130        familyNames=familyNames,
131        styleNames=styleNames,
132        postScriptFontName=postScriptFontName,
133        styleMapFamilyNames=styleMapFamilyNames,
134        styleMapStyleName=styleMapStyleName,
135    )
136
137
138def _getSortedAxisLabels(
139    axes: list[Union[AxisDescriptor, DiscreteAxisDescriptor]],
140) -> Dict[str, list[AxisLabelDescriptor]]:
141    """Returns axis labels sorted by their ordering, with unordered ones appended as
142    they are listed."""
143
144    # First, get the axis labels with explicit ordering...
145    sortedAxes = sorted(
146        (axis for axis in axes if axis.axisOrdering is not None),
147        key=lambda a: a.axisOrdering,
148    )
149    sortedLabels: Dict[str, list[AxisLabelDescriptor]] = {
150        axis.name: axis.axisLabels for axis in sortedAxes
151    }
152
153    # ... then append the others in the order they appear.
154    # NOTE: This relies on Python 3.7+ dict's preserved insertion order.
155    for axis in axes:
156        if axis.axisOrdering is None:
157            sortedLabels[axis.name] = axis.axisLabels
158
159    return sortedLabels
160
161
162def _getAxisLabelsForUserLocation(
163    axes: list[Union[AxisDescriptor, DiscreteAxisDescriptor]],
164    userLocation: SimpleLocationDict,
165) -> list[AxisLabelDescriptor]:
166    labels: list[AxisLabelDescriptor] = []
167
168    allAxisLabels = _getSortedAxisLabels(axes)
169    if allAxisLabels.keys() != userLocation.keys():
170        LOGGER.warning(
171            f"Mismatch between user location '{userLocation.keys()}' and available "
172            f"labels for '{allAxisLabels.keys()}'."
173        )
174
175    for axisName, axisLabels in allAxisLabels.items():
176        userValue = userLocation[axisName]
177        label: Optional[AxisLabelDescriptor] = next(
178            (
179                l
180                for l in axisLabels
181                if l.userValue == userValue
182                or (
183                    l.userMinimum is not None
184                    and l.userMaximum is not None
185                    and l.userMinimum <= userValue <= l.userMaximum
186                )
187            ),
188            None,
189        )
190        if label is None:
191            LOGGER.debug(
192                f"Document needs a label for axis '{axisName}', user value '{userValue}'."
193            )
194        else:
195            labels.append(label)
196
197    return labels
198
199
200def _getRibbiStyle(
201    self: DesignSpaceDocument, userLocation: SimpleLocationDict
202) -> Tuple[RibbiStyle, SimpleLocationDict]:
203    """Compute the RIBBI style name of the given user location,
204    return the location of the matching Regular in the RIBBI group.
205
206    .. versionadded:: 5.0
207    """
208    regularUserLocation = {}
209    axes_by_tag = {axis.tag: axis for axis in self.axes}
210
211    bold: bool = False
212    italic: bool = False
213
214    axis = axes_by_tag.get("wght")
215    if axis is not None:
216        for regular_label in axis.axisLabels:
217            if regular_label.linkedUserValue == userLocation[axis.name]:
218                regularUserLocation[axis.name] = regular_label.userValue
219                bold = True
220                break
221
222    axis = axes_by_tag.get("ital") or axes_by_tag.get("slnt")
223    if axis is not None:
224        for urpright_label in axis.axisLabels:
225            if urpright_label.linkedUserValue == userLocation[axis.name]:
226                regularUserLocation[axis.name] = urpright_label.userValue
227                italic = True
228                break
229
230    return BOLD_ITALIC_TO_RIBBI_STYLE[bold, italic], {
231        **userLocation,
232        **regularUserLocation,
233    }
234