variableScalar.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. from __future__ import annotations
  2. from collections.abc import Mapping
  3. from dataclasses import dataclass
  4. from fontTools.designspaceLib import DesignSpaceDocument
  5. from fontTools.ttLib.ttFont import TTFont
  6. from fontTools.varLib.models import (
  7. VariationModel,
  8. noRound,
  9. normalizeValue,
  10. piecewiseLinearMap,
  11. )
  12. import typing
  13. import warnings
  14. if typing.TYPE_CHECKING:
  15. from typing import Self
  16. LocationTuple = tuple[tuple[str, float], ...]
  17. """A hashable location."""
  18. def Location(location: Mapping[str, float]) -> LocationTuple:
  19. """Create a hashable location from a dictionary-like location."""
  20. return tuple(sorted(location.items()))
  21. class VariableScalar:
  22. """A scalar with different values at different points in the designspace."""
  23. values: dict[LocationTuple, int]
  24. """The values across various user-locations. Must always include the default
  25. location by time of building."""
  26. def __init__(self, location_value=None):
  27. self.values = {
  28. Location(location): value
  29. for location, value in (location_value or {}).items()
  30. }
  31. # Deprecated: only used by the add_to_variation_store() backwards-compat
  32. # shim. New code should use VariableScalarBuilder instead.
  33. self.axes = []
  34. def __repr__(self):
  35. items = []
  36. for location, value in self.values.items():
  37. loc = ",".join(
  38. [
  39. f"{ax}={int(coord) if float(coord).is_integer() else coord}"
  40. for ax, coord in location
  41. ]
  42. )
  43. items.append("%s:%i" % (loc, value))
  44. return "(" + (" ".join(items)) + ")"
  45. @property
  46. def does_vary(self) -> bool:
  47. values = list(self.values.values())
  48. return any(v != values[0] for v in values[1:])
  49. def add_value(self, location: Mapping[str, float], value: int):
  50. self.values[Location(location)] = value
  51. def add_to_variation_store(self, store_builder, model_cache=None, avar=None):
  52. """Deprecated: use VariableScalarBuilder.add_to_variation_store() instead."""
  53. warnings.warn(
  54. "VariableScalar.add_to_variation_store() is deprecated. "
  55. "Use VariableScalarBuilder.add_to_variation_store() instead.",
  56. DeprecationWarning,
  57. stacklevel=2,
  58. )
  59. if not self.axes:
  60. raise ValueError(
  61. ".axes must be defined on variable scalar before calling "
  62. "add_to_variation_store()"
  63. )
  64. builder = VariableScalarBuilder(
  65. axis_triples={
  66. ax.axisTag: (ax.minValue, ax.defaultValue, ax.maxValue)
  67. for ax in self.axes
  68. },
  69. axis_mappings=({} if avar is None else dict(avar.segments)),
  70. model_cache=model_cache if model_cache is not None else {},
  71. )
  72. return builder.add_to_variation_store(self, store_builder)
  73. @dataclass
  74. class VariableScalarBuilder:
  75. """A helper class for building variable scalars, or otherwise interrogating
  76. their variation model for interpolation or similar."""
  77. axis_triples: dict[str, tuple[float, float, float]]
  78. """Minimum, default, and maximum for each axis in user-coordinates."""
  79. axis_mappings: dict[str, Mapping[float, float]]
  80. """Optional mappings from normalized user-coordinates to normalized
  81. design-coordinates."""
  82. model_cache: dict[tuple[LocationTuple, ...], VariationModel]
  83. """We often use the same exact locations (i.e. font sources) for a large
  84. number of variable scalars. Instead of creating a model for each, cache
  85. them. Cache by user-location to avoid repeated mapping computations."""
  86. @classmethod
  87. def from_ttf(cls, ttf: TTFont) -> Self:
  88. return cls(
  89. axis_triples={
  90. axis.axisTag: (axis.minValue, axis.defaultValue, axis.maxValue)
  91. for axis in ttf["fvar"].axes
  92. },
  93. axis_mappings=(
  94. {}
  95. if (avar := ttf.get("avar")) is None
  96. else {axis: segments for axis, segments in avar.segments.items()}
  97. ),
  98. model_cache={},
  99. )
  100. @classmethod
  101. def from_designspace(cls, doc: DesignSpaceDocument) -> Self:
  102. return cls(
  103. axis_triples={
  104. axis.tag: (axis.minimum, axis.default, axis.maximum)
  105. for axis in doc.axes
  106. },
  107. axis_mappings={
  108. axis.tag: {
  109. normalizeValue(
  110. user, (axis.minimum, axis.default, axis.maximum)
  111. ): normalizeValue(
  112. design,
  113. (
  114. axis.map_forward(axis.minimum),
  115. axis.map_forward(axis.default),
  116. axis.map_forward(axis.maximum),
  117. ),
  118. )
  119. for user, design in axis.map
  120. }
  121. for axis in doc.axes
  122. if axis.map
  123. },
  124. model_cache={},
  125. )
  126. def _fully_specify_location(self, location: LocationTuple) -> LocationTuple:
  127. """Validate and fully-specify a user-space location by filling in
  128. missing axes with their user-space defaults."""
  129. full = {}
  130. for axtag, value in location:
  131. if axtag not in self.axis_triples:
  132. raise ValueError("Unknown axis %s in %s" % (axtag, location))
  133. full[axtag] = value
  134. for axtag, (_, axis_default, _) in self.axis_triples.items():
  135. if axtag not in full:
  136. full[axtag] = axis_default
  137. return Location(full)
  138. def _normalize_location(self, location: LocationTuple) -> dict[str, float]:
  139. """Normalize a user-space location, applying avar mappings if present.
  140. TODO: This only handles avar1 (per-axis piecewise linear mappings),
  141. not avar2 (multi-dimensional mappings).
  142. """
  143. result = {}
  144. for axtag, value in location:
  145. axis_min, axis_default, axis_max = self.axis_triples[axtag]
  146. normalized = normalizeValue(value, (axis_min, axis_default, axis_max))
  147. mapping = self.axis_mappings.get(axtag)
  148. if mapping is not None:
  149. normalized = piecewiseLinearMap(normalized, mapping)
  150. result[axtag] = normalized
  151. return result
  152. def _full_locations_and_values(
  153. self, scalar: VariableScalar
  154. ) -> list[tuple[LocationTuple, int]]:
  155. """Return a list of (fully-specified user-space location, value) pairs,
  156. preserving order and length of scalar.values."""
  157. return [
  158. (self._fully_specify_location(loc), val)
  159. for loc, val in scalar.values.items()
  160. ]
  161. def default_value(self, scalar: VariableScalar) -> int:
  162. """Get the default value of a variable scalar."""
  163. default_loc = Location(
  164. {tag: default for tag, (_, default, _) in self.axis_triples.items()}
  165. )
  166. for location, value in self._full_locations_and_values(scalar):
  167. if location == default_loc:
  168. return value
  169. raise ValueError("Default value could not be found")
  170. def value_at_location(
  171. self, scalar: VariableScalar, location: LocationTuple
  172. ) -> float:
  173. """Interpolate the value of a scalar from a user-location."""
  174. location = self._fully_specify_location(location)
  175. pairs = self._full_locations_and_values(scalar)
  176. # If user location matches exactly, no axis mapping or variation model needed.
  177. for loc, val in pairs:
  178. if loc == location:
  179. return val
  180. values = [val for _, val in pairs]
  181. normalized_location = self._normalize_location(location)
  182. value = self.model(scalar).interpolateFromMasters(normalized_location, values)
  183. if value is None:
  184. raise ValueError("Insufficient number of values to interpolate")
  185. return value
  186. def model(self, scalar: VariableScalar) -> VariationModel:
  187. """Return a variation model based on a scalar's values.
  188. Variable scalars with the same fully-specified user-locations will use
  189. the same cached variation model."""
  190. pairs = self._full_locations_and_values(scalar)
  191. cache_key = tuple(loc for loc, _ in pairs)
  192. cached_model = self.model_cache.get(cache_key)
  193. if cached_model is not None:
  194. return cached_model
  195. normalized_locations = [self._normalize_location(loc) for loc, _ in pairs]
  196. axisOrder = list(self.axis_triples.keys())
  197. model = self.model_cache[cache_key] = VariationModel(
  198. normalized_locations, axisOrder=axisOrder
  199. )
  200. return model
  201. def get_deltas_and_supports(self, scalar: VariableScalar):
  202. """Calculate deltas and supports from this scalar's variation model."""
  203. values = list(scalar.values.values())
  204. return self.model(scalar).getDeltasAndSupports(values, round=round)
  205. def add_to_variation_store(
  206. self, scalar: VariableScalar, store_builder
  207. ) -> tuple[int, int]:
  208. """Serialize this scalar's variation model to a store, returning the
  209. default value and variation index."""
  210. deltas, supports = self.get_deltas_and_supports(scalar)
  211. store_builder.setSupports(supports)
  212. index = store_builder.storeDeltas(deltas, round=noRound)
  213. # NOTE: Default value should be an exact integer by construction of
  214. # VariableScalar.
  215. return int(self.default_value(scalar)), index