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