File: variableScalar.py

package info (click to toggle)
fonttools 4.62.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 27,652 kB
  • sloc: python: 145,583; xml: 103; makefile: 24
file content (265 lines) | stat: -rw-r--r-- 9,671 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
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