statNames.py 9.0 KB

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