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