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