| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 |
- from __future__ import annotations
- from collections.abc import Mapping
- from dataclasses import dataclass
- from fontTools.designspaceLib import DesignSpaceDocument
- from fontTools.ttLib.ttFont import TTFont
- from fontTools.varLib.models import (
- VariationModel,
- noRound,
- normalizeValue,
- piecewiseLinearMap,
- )
- import typing
- import warnings
- if typing.TYPE_CHECKING:
- from typing import Self
- LocationTuple = tuple[tuple[str, float], ...]
- """A hashable location."""
- def Location(location: Mapping[str, float]) -> LocationTuple:
- """Create a hashable location from a dictionary-like location."""
- return tuple(sorted(location.items()))
- class VariableScalar:
- """A scalar with different values at different points in the designspace."""
- values: dict[LocationTuple, int]
- """The values across various user-locations. Must always include the default
- location by time of building."""
- def __init__(self, location_value=None):
- self.values = {
- Location(location): value
- for location, value in (location_value or {}).items()
- }
- # Deprecated: only used by the add_to_variation_store() backwards-compat
- # shim. New code should use VariableScalarBuilder instead.
- self.axes = []
- def __repr__(self):
- items = []
- for location, value in self.values.items():
- loc = ",".join(
- [
- f"{ax}={int(coord) if float(coord).is_integer() else coord}"
- for ax, coord in location
- ]
- )
- items.append("%s:%i" % (loc, value))
- return "(" + (" ".join(items)) + ")"
- @property
- def does_vary(self) -> bool:
- values = list(self.values.values())
- return any(v != values[0] for v in values[1:])
- def add_value(self, location: Mapping[str, float], value: int):
- self.values[Location(location)] = value
- def add_to_variation_store(self, store_builder, model_cache=None, avar=None):
- """Deprecated: use VariableScalarBuilder.add_to_variation_store() instead."""
- warnings.warn(
- "VariableScalar.add_to_variation_store() is deprecated. "
- "Use VariableScalarBuilder.add_to_variation_store() instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- if not self.axes:
- raise ValueError(
- ".axes must be defined on variable scalar before calling "
- "add_to_variation_store()"
- )
- builder = VariableScalarBuilder(
- axis_triples={
- ax.axisTag: (ax.minValue, ax.defaultValue, ax.maxValue)
- for ax in self.axes
- },
- axis_mappings=({} if avar is None else dict(avar.segments)),
- model_cache=model_cache if model_cache is not None else {},
- )
- return builder.add_to_variation_store(self, store_builder)
- @dataclass
- class VariableScalarBuilder:
- """A helper class for building variable scalars, or otherwise interrogating
- their variation model for interpolation or similar."""
- axis_triples: dict[str, tuple[float, float, float]]
- """Minimum, default, and maximum for each axis in user-coordinates."""
- axis_mappings: dict[str, Mapping[float, float]]
- """Optional mappings from normalized user-coordinates to normalized
- design-coordinates."""
- model_cache: dict[tuple[LocationTuple, ...], VariationModel]
- """We often use the same exact locations (i.e. font sources) for a large
- number of variable scalars. Instead of creating a model for each, cache
- them. Cache by user-location to avoid repeated mapping computations."""
- @classmethod
- def from_ttf(cls, ttf: TTFont) -> Self:
- return cls(
- axis_triples={
- axis.axisTag: (axis.minValue, axis.defaultValue, axis.maxValue)
- for axis in ttf["fvar"].axes
- },
- axis_mappings=(
- {}
- if (avar := ttf.get("avar")) is None
- else {axis: segments for axis, segments in avar.segments.items()}
- ),
- model_cache={},
- )
- @classmethod
- def from_designspace(cls, doc: DesignSpaceDocument) -> Self:
- return cls(
- axis_triples={
- axis.tag: (axis.minimum, axis.default, axis.maximum)
- for axis in doc.axes
- },
- axis_mappings={
- axis.tag: {
- normalizeValue(
- user, (axis.minimum, axis.default, axis.maximum)
- ): normalizeValue(
- design,
- (
- axis.map_forward(axis.minimum),
- axis.map_forward(axis.default),
- axis.map_forward(axis.maximum),
- ),
- )
- for user, design in axis.map
- }
- for axis in doc.axes
- if axis.map
- },
- model_cache={},
- )
- def _fully_specify_location(self, location: LocationTuple) -> LocationTuple:
- """Validate and fully-specify a user-space location by filling in
- missing axes with their user-space defaults."""
- full = {}
- for axtag, value in location:
- if axtag not in self.axis_triples:
- raise ValueError("Unknown axis %s in %s" % (axtag, location))
- full[axtag] = value
- for axtag, (_, axis_default, _) in self.axis_triples.items():
- if axtag not in full:
- full[axtag] = axis_default
- return Location(full)
- def _normalize_location(self, location: LocationTuple) -> dict[str, float]:
- """Normalize a user-space location, applying avar mappings if present.
- TODO: This only handles avar1 (per-axis piecewise linear mappings),
- not avar2 (multi-dimensional mappings).
- """
- result = {}
- for axtag, value in location:
- axis_min, axis_default, axis_max = self.axis_triples[axtag]
- normalized = normalizeValue(value, (axis_min, axis_default, axis_max))
- mapping = self.axis_mappings.get(axtag)
- if mapping is not None:
- normalized = piecewiseLinearMap(normalized, mapping)
- result[axtag] = normalized
- return result
- def _full_locations_and_values(
- self, scalar: VariableScalar
- ) -> list[tuple[LocationTuple, int]]:
- """Return a list of (fully-specified user-space location, value) pairs,
- preserving order and length of scalar.values."""
- return [
- (self._fully_specify_location(loc), val)
- for loc, val in scalar.values.items()
- ]
- def default_value(self, scalar: VariableScalar) -> int:
- """Get the default value of a variable scalar."""
- default_loc = Location(
- {tag: default for tag, (_, default, _) in self.axis_triples.items()}
- )
- for location, value in self._full_locations_and_values(scalar):
- if location == default_loc:
- return value
- raise ValueError("Default value could not be found")
- def value_at_location(
- self, scalar: VariableScalar, location: LocationTuple
- ) -> float:
- """Interpolate the value of a scalar from a user-location."""
- location = self._fully_specify_location(location)
- pairs = self._full_locations_and_values(scalar)
- # If user location matches exactly, no axis mapping or variation model needed.
- for loc, val in pairs:
- if loc == location:
- return val
- values = [val for _, val in pairs]
- normalized_location = self._normalize_location(location)
- value = self.model(scalar).interpolateFromMasters(normalized_location, values)
- if value is None:
- raise ValueError("Insufficient number of values to interpolate")
- return value
- def model(self, scalar: VariableScalar) -> VariationModel:
- """Return a variation model based on a scalar's values.
- Variable scalars with the same fully-specified user-locations will use
- the same cached variation model."""
- pairs = self._full_locations_and_values(scalar)
- cache_key = tuple(loc for loc, _ in pairs)
- cached_model = self.model_cache.get(cache_key)
- if cached_model is not None:
- return cached_model
- normalized_locations = [self._normalize_location(loc) for loc, _ in pairs]
- axisOrder = list(self.axis_triples.keys())
- model = self.model_cache[cache_key] = VariationModel(
- normalized_locations, axisOrder=axisOrder
- )
- return model
- def get_deltas_and_supports(self, scalar: VariableScalar):
- """Calculate deltas and supports from this scalar's variation model."""
- values = list(scalar.values.values())
- return self.model(scalar).getDeltasAndSupports(values, round=round)
- def add_to_variation_store(
- self, scalar: VariableScalar, store_builder
- ) -> tuple[int, int]:
- """Serialize this scalar's variation model to a store, returning the
- default value and variation index."""
- deltas, supports = self.get_deltas_and_supports(scalar)
- store_builder.setSupports(supports)
- index = store_builder.storeDeltas(deltas, round=noRound)
- # NOTE: Default value should be an exact integer by construction of
- # VariableScalar.
- return int(self.default_value(scalar)), index
|