statNames.py 8.9 KB

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