xref: /aosp_15_r20/external/fonttools/Lib/fontTools/varLib/models.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""Variation fonts interpolation models."""
2
3__all__ = [
4    "normalizeValue",
5    "normalizeLocation",
6    "supportScalar",
7    "piecewiseLinearMap",
8    "VariationModel",
9]
10
11from fontTools.misc.roundTools import noRound
12from .errors import VariationModelError
13
14
15def nonNone(lst):
16    return [l for l in lst if l is not None]
17
18
19def allNone(lst):
20    return all(l is None for l in lst)
21
22
23def allEqualTo(ref, lst, mapper=None):
24    if mapper is None:
25        return all(ref == item for item in lst)
26
27    mapped = mapper(ref)
28    return all(mapped == mapper(item) for item in lst)
29
30
31def allEqual(lst, mapper=None):
32    if not lst:
33        return True
34    it = iter(lst)
35    try:
36        first = next(it)
37    except StopIteration:
38        return True
39    return allEqualTo(first, it, mapper=mapper)
40
41
42def subList(truth, lst):
43    assert len(truth) == len(lst)
44    return [l for l, t in zip(lst, truth) if t]
45
46
47def normalizeValue(v, triple, extrapolate=False):
48    """Normalizes value based on a min/default/max triple.
49
50    >>> normalizeValue(400, (100, 400, 900))
51    0.0
52    >>> normalizeValue(100, (100, 400, 900))
53    -1.0
54    >>> normalizeValue(650, (100, 400, 900))
55    0.5
56    """
57    lower, default, upper = triple
58    if not (lower <= default <= upper):
59        raise ValueError(
60            f"Invalid axis values, must be minimum, default, maximum: "
61            f"{lower:3.3f}, {default:3.3f}, {upper:3.3f}"
62        )
63    if not extrapolate:
64        v = max(min(v, upper), lower)
65
66    if v == default or lower == upper:
67        return 0.0
68
69    if (v < default and lower != default) or (v > default and upper == default):
70        return (v - default) / (default - lower)
71    else:
72        assert (v > default and upper != default) or (
73            v < default and lower == default
74        ), f"Ooops... v={v}, triple=({lower}, {default}, {upper})"
75        return (v - default) / (upper - default)
76
77
78def normalizeLocation(location, axes, extrapolate=False):
79    """Normalizes location based on axis min/default/max values from axes.
80
81    >>> axes = {"wght": (100, 400, 900)}
82    >>> normalizeLocation({"wght": 400}, axes)
83    {'wght': 0.0}
84    >>> normalizeLocation({"wght": 100}, axes)
85    {'wght': -1.0}
86    >>> normalizeLocation({"wght": 900}, axes)
87    {'wght': 1.0}
88    >>> normalizeLocation({"wght": 650}, axes)
89    {'wght': 0.5}
90    >>> normalizeLocation({"wght": 1000}, axes)
91    {'wght': 1.0}
92    >>> normalizeLocation({"wght": 0}, axes)
93    {'wght': -1.0}
94    >>> axes = {"wght": (0, 0, 1000)}
95    >>> normalizeLocation({"wght": 0}, axes)
96    {'wght': 0.0}
97    >>> normalizeLocation({"wght": -1}, axes)
98    {'wght': 0.0}
99    >>> normalizeLocation({"wght": 1000}, axes)
100    {'wght': 1.0}
101    >>> normalizeLocation({"wght": 500}, axes)
102    {'wght': 0.5}
103    >>> normalizeLocation({"wght": 1001}, axes)
104    {'wght': 1.0}
105    >>> axes = {"wght": (0, 1000, 1000)}
106    >>> normalizeLocation({"wght": 0}, axes)
107    {'wght': -1.0}
108    >>> normalizeLocation({"wght": -1}, axes)
109    {'wght': -1.0}
110    >>> normalizeLocation({"wght": 500}, axes)
111    {'wght': -0.5}
112    >>> normalizeLocation({"wght": 1000}, axes)
113    {'wght': 0.0}
114    >>> normalizeLocation({"wght": 1001}, axes)
115    {'wght': 0.0}
116    """
117    out = {}
118    for tag, triple in axes.items():
119        v = location.get(tag, triple[1])
120        out[tag] = normalizeValue(v, triple, extrapolate=extrapolate)
121    return out
122
123
124def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None):
125    """Returns the scalar multiplier at location, for a master
126    with support.  If ot is True, then a peak value of zero
127    for support of an axis means "axis does not participate".  That
128    is how OpenType Variation Font technology works.
129
130    If extrapolate is True, axisRanges must be a dict that maps axis
131    names to (axisMin, axisMax) tuples.
132
133      >>> supportScalar({}, {})
134      1.0
135      >>> supportScalar({'wght':.2}, {})
136      1.0
137      >>> supportScalar({'wght':.2}, {'wght':(0,2,3)})
138      0.1
139      >>> supportScalar({'wght':2.5}, {'wght':(0,2,4)})
140      0.75
141      >>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
142      0.75
143      >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)}, ot=False)
144      0.375
145      >>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
146      0.75
147      >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
148      0.75
149      >>> supportScalar({'wght':3}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
150      -1.0
151      >>> supportScalar({'wght':-1}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
152      -1.0
153      >>> supportScalar({'wght':3}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
154      1.5
155      >>> supportScalar({'wght':-1}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
156      -0.5
157    """
158    if extrapolate and axisRanges is None:
159        raise TypeError("axisRanges must be passed when extrapolate is True")
160    scalar = 1.0
161    for axis, (lower, peak, upper) in support.items():
162        if ot:
163            # OpenType-specific case handling
164            if peak == 0.0:
165                continue
166            if lower > peak or peak > upper:
167                continue
168            if lower < 0.0 and upper > 0.0:
169                continue
170            v = location.get(axis, 0.0)
171        else:
172            assert axis in location
173            v = location[axis]
174        if v == peak:
175            continue
176
177        if extrapolate:
178            axisMin, axisMax = axisRanges[axis]
179            if v < axisMin and lower <= axisMin:
180                if peak <= axisMin and peak < upper:
181                    scalar *= (v - upper) / (peak - upper)
182                    continue
183                elif axisMin < peak:
184                    scalar *= (v - lower) / (peak - lower)
185                    continue
186            elif axisMax < v and axisMax <= upper:
187                if axisMax <= peak and lower < peak:
188                    scalar *= (v - lower) / (peak - lower)
189                    continue
190                elif peak < axisMax:
191                    scalar *= (v - upper) / (peak - upper)
192                    continue
193
194        if v <= lower or upper <= v:
195            scalar = 0.0
196            break
197
198        if v < peak:
199            scalar *= (v - lower) / (peak - lower)
200        else:  # v > peak
201            scalar *= (v - upper) / (peak - upper)
202    return scalar
203
204
205class VariationModel(object):
206    """Locations must have the base master at the origin (ie. 0).
207
208    If the extrapolate argument is set to True, then values are extrapolated
209    outside the axis range.
210
211      >>> from pprint import pprint
212      >>> locations = [ \
213      {'wght':100}, \
214      {'wght':-100}, \
215      {'wght':-180}, \
216      {'wdth':+.3}, \
217      {'wght':+120,'wdth':.3}, \
218      {'wght':+120,'wdth':.2}, \
219      {}, \
220      {'wght':+180,'wdth':.3}, \
221      {'wght':+180}, \
222      ]
223      >>> model = VariationModel(locations, axisOrder=['wght'])
224      >>> pprint(model.locations)
225      [{},
226       {'wght': -100},
227       {'wght': -180},
228       {'wght': 100},
229       {'wght': 180},
230       {'wdth': 0.3},
231       {'wdth': 0.3, 'wght': 180},
232       {'wdth': 0.3, 'wght': 120},
233       {'wdth': 0.2, 'wght': 120}]
234      >>> pprint(model.deltaWeights)
235      [{},
236       {0: 1.0},
237       {0: 1.0},
238       {0: 1.0},
239       {0: 1.0},
240       {0: 1.0},
241       {0: 1.0, 4: 1.0, 5: 1.0},
242       {0: 1.0, 3: 0.75, 4: 0.25, 5: 1.0, 6: 0.6666666666666666},
243       {0: 1.0,
244        3: 0.75,
245        4: 0.25,
246        5: 0.6666666666666667,
247        6: 0.4444444444444445,
248        7: 0.6666666666666667}]
249    """
250
251    def __init__(self, locations, axisOrder=None, extrapolate=False):
252        if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations):
253            raise VariationModelError("Locations must be unique.")
254
255        self.origLocations = locations
256        self.axisOrder = axisOrder if axisOrder is not None else []
257        self.extrapolate = extrapolate
258        self.axisRanges = self.computeAxisRanges(locations) if extrapolate else None
259
260        locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations]
261        keyFunc = self.getMasterLocationsSortKeyFunc(
262            locations, axisOrder=self.axisOrder
263        )
264        self.locations = sorted(locations, key=keyFunc)
265
266        # Mapping from user's master order to our master order
267        self.mapping = [self.locations.index(l) for l in locations]
268        self.reverseMapping = [locations.index(l) for l in self.locations]
269
270        self._computeMasterSupports()
271        self._subModels = {}
272
273    def getSubModel(self, items):
274        """Return a sub-model and the items that are not None.
275
276        The sub-model is necessary for working with the subset
277        of items when some are None.
278
279        The sub-model is cached."""
280        if None not in items:
281            return self, items
282        key = tuple(v is not None for v in items)
283        subModel = self._subModels.get(key)
284        if subModel is None:
285            subModel = VariationModel(subList(key, self.origLocations), self.axisOrder)
286            self._subModels[key] = subModel
287        return subModel, subList(key, items)
288
289    @staticmethod
290    def computeAxisRanges(locations):
291        axisRanges = {}
292        allAxes = {axis for loc in locations for axis in loc.keys()}
293        for loc in locations:
294            for axis in allAxes:
295                value = loc.get(axis, 0)
296                axisMin, axisMax = axisRanges.get(axis, (value, value))
297                axisRanges[axis] = min(value, axisMin), max(value, axisMax)
298        return axisRanges
299
300    @staticmethod
301    def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
302        if {} not in locations:
303            raise VariationModelError("Base master not found.")
304        axisPoints = {}
305        for loc in locations:
306            if len(loc) != 1:
307                continue
308            axis = next(iter(loc))
309            value = loc[axis]
310            if axis not in axisPoints:
311                axisPoints[axis] = {0.0}
312            assert (
313                value not in axisPoints[axis]
314            ), 'Value "%s" in axisPoints["%s"] -->  %s' % (value, axis, axisPoints)
315            axisPoints[axis].add(value)
316
317        def getKey(axisPoints, axisOrder):
318            def sign(v):
319                return -1 if v < 0 else +1 if v > 0 else 0
320
321            def key(loc):
322                rank = len(loc)
323                onPointAxes = [
324                    axis
325                    for axis, value in loc.items()
326                    if axis in axisPoints and value in axisPoints[axis]
327                ]
328                orderedAxes = [axis for axis in axisOrder if axis in loc]
329                orderedAxes.extend(
330                    [axis for axis in sorted(loc.keys()) if axis not in axisOrder]
331                )
332                return (
333                    rank,  # First, order by increasing rank
334                    -len(onPointAxes),  # Next, by decreasing number of onPoint axes
335                    tuple(
336                        axisOrder.index(axis) if axis in axisOrder else 0x10000
337                        for axis in orderedAxes
338                    ),  # Next, by known axes
339                    tuple(orderedAxes),  # Next, by all axes
340                    tuple(
341                        sign(loc[axis]) for axis in orderedAxes
342                    ),  # Next, by signs of axis values
343                    tuple(
344                        abs(loc[axis]) for axis in orderedAxes
345                    ),  # Next, by absolute value of axis values
346                )
347
348            return key
349
350        ret = getKey(axisPoints, axisOrder)
351        return ret
352
353    def reorderMasters(self, master_list, mapping):
354        # For changing the master data order without
355        # recomputing supports and deltaWeights.
356        new_list = [master_list[idx] for idx in mapping]
357        self.origLocations = [self.origLocations[idx] for idx in mapping]
358        locations = [
359            {k: v for k, v in loc.items() if v != 0.0} for loc in self.origLocations
360        ]
361        self.mapping = [self.locations.index(l) for l in locations]
362        self.reverseMapping = [locations.index(l) for l in self.locations]
363        self._subModels = {}
364        return new_list
365
366    def _computeMasterSupports(self):
367        self.supports = []
368        regions = self._locationsToRegions()
369        for i, region in enumerate(regions):
370            locAxes = set(region.keys())
371            # Walk over previous masters now
372            for prev_region in regions[:i]:
373                # Master with extra axes do not participte
374                if set(prev_region.keys()) != locAxes:
375                    continue
376                # If it's NOT in the current box, it does not participate
377                relevant = True
378                for axis, (lower, peak, upper) in region.items():
379                    if not (
380                        prev_region[axis][1] == peak
381                        or lower < prev_region[axis][1] < upper
382                    ):
383                        relevant = False
384                        break
385                if not relevant:
386                    continue
387
388                # Split the box for new master; split in whatever direction
389                # that has largest range ratio.
390                #
391                # For symmetry, we actually cut across multiple axes
392                # if they have the largest, equal, ratio.
393                # https://github.com/fonttools/fonttools/commit/7ee81c8821671157968b097f3e55309a1faa511e#commitcomment-31054804
394
395                bestAxes = {}
396                bestRatio = -1
397                for axis in prev_region.keys():
398                    val = prev_region[axis][1]
399                    assert axis in region
400                    lower, locV, upper = region[axis]
401                    newLower, newUpper = lower, upper
402                    if val < locV:
403                        newLower = val
404                        ratio = (val - locV) / (lower - locV)
405                    elif locV < val:
406                        newUpper = val
407                        ratio = (val - locV) / (upper - locV)
408                    else:  # val == locV
409                        # Can't split box in this direction.
410                        continue
411                    if ratio > bestRatio:
412                        bestAxes = {}
413                        bestRatio = ratio
414                    if ratio == bestRatio:
415                        bestAxes[axis] = (newLower, locV, newUpper)
416
417                for axis, triple in bestAxes.items():
418                    region[axis] = triple
419            self.supports.append(region)
420        self._computeDeltaWeights()
421
422    def _locationsToRegions(self):
423        locations = self.locations
424        # Compute min/max across each axis, use it as total range.
425        # TODO Take this as input from outside?
426        minV = {}
427        maxV = {}
428        for l in locations:
429            for k, v in l.items():
430                minV[k] = min(v, minV.get(k, v))
431                maxV[k] = max(v, maxV.get(k, v))
432
433        regions = []
434        for loc in locations:
435            region = {}
436            for axis, locV in loc.items():
437                if locV > 0:
438                    region[axis] = (0, locV, maxV[axis])
439                else:
440                    region[axis] = (minV[axis], locV, 0)
441            regions.append(region)
442        return regions
443
444    def _computeDeltaWeights(self):
445        self.deltaWeights = []
446        for i, loc in enumerate(self.locations):
447            deltaWeight = {}
448            # Walk over previous masters now, populate deltaWeight
449            for j, support in enumerate(self.supports[:i]):
450                scalar = supportScalar(loc, support)
451                if scalar:
452                    deltaWeight[j] = scalar
453            self.deltaWeights.append(deltaWeight)
454
455    def getDeltas(self, masterValues, *, round=noRound):
456        assert len(masterValues) == len(self.deltaWeights)
457        mapping = self.reverseMapping
458        out = []
459        for i, weights in enumerate(self.deltaWeights):
460            delta = masterValues[mapping[i]]
461            for j, weight in weights.items():
462                if weight == 1:
463                    delta -= out[j]
464                else:
465                    delta -= out[j] * weight
466            out.append(round(delta))
467        return out
468
469    def getDeltasAndSupports(self, items, *, round=noRound):
470        model, items = self.getSubModel(items)
471        return model.getDeltas(items, round=round), model.supports
472
473    def getScalars(self, loc):
474        """Return scalars for each delta, for the given location.
475        If interpolating many master-values at the same location,
476        this function allows speed up by fetching the scalars once
477        and using them with interpolateFromMastersAndScalars()."""
478        return [
479            supportScalar(
480                loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges
481            )
482            for support in self.supports
483        ]
484
485    def getMasterScalars(self, targetLocation):
486        """Return multipliers for each master, for the given location.
487        If interpolating many master-values at the same location,
488        this function allows speed up by fetching the scalars once
489        and using them with interpolateFromValuesAndScalars().
490
491        Note that the scalars used in interpolateFromMastersAndScalars(),
492        are *not* the same as the ones returned here. They are the result
493        of getScalars()."""
494        out = self.getScalars(targetLocation)
495        for i, weights in reversed(list(enumerate(self.deltaWeights))):
496            for j, weight in weights.items():
497                out[j] -= out[i] * weight
498
499        out = [out[self.mapping[i]] for i in range(len(out))]
500        return out
501
502    @staticmethod
503    def interpolateFromValuesAndScalars(values, scalars):
504        """Interpolate from values and scalars coefficients.
505
506        If the values are master-values, then the scalars should be
507        fetched from getMasterScalars().
508
509        If the values are deltas, then the scalars should be fetched
510        from getScalars(); in which case this is the same as
511        interpolateFromDeltasAndScalars().
512        """
513        v = None
514        assert len(values) == len(scalars)
515        for value, scalar in zip(values, scalars):
516            if not scalar:
517                continue
518            contribution = value * scalar
519            if v is None:
520                v = contribution
521            else:
522                v += contribution
523        return v
524
525    @staticmethod
526    def interpolateFromDeltasAndScalars(deltas, scalars):
527        """Interpolate from deltas and scalars fetched from getScalars()."""
528        return VariationModel.interpolateFromValuesAndScalars(deltas, scalars)
529
530    def interpolateFromDeltas(self, loc, deltas):
531        """Interpolate from deltas, at location loc."""
532        scalars = self.getScalars(loc)
533        return self.interpolateFromDeltasAndScalars(deltas, scalars)
534
535    def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
536        """Interpolate from master-values, at location loc."""
537        scalars = self.getMasterScalars(loc)
538        return self.interpolateFromValuesAndScalars(masterValues, scalars)
539
540    def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
541        """Interpolate from master-values, and scalars fetched from
542        getScalars(), which is useful when you want to interpolate
543        multiple master-values with the same location."""
544        deltas = self.getDeltas(masterValues, round=round)
545        return self.interpolateFromDeltasAndScalars(deltas, scalars)
546
547
548def piecewiseLinearMap(v, mapping):
549    keys = mapping.keys()
550    if not keys:
551        return v
552    if v in keys:
553        return mapping[v]
554    k = min(keys)
555    if v < k:
556        return v + mapping[k] - k
557    k = max(keys)
558    if v > k:
559        return v + mapping[k] - k
560    # Interpolate
561    a = max(k for k in keys if k < v)
562    b = min(k for k in keys if k > v)
563    va = mapping[a]
564    vb = mapping[b]
565    return va + (vb - va) * (v - a) / (b - a)
566
567
568def main(args=None):
569    """Normalize locations on a given designspace"""
570    from fontTools import configLogger
571    import argparse
572
573    parser = argparse.ArgumentParser(
574        "fonttools varLib.models",
575        description=main.__doc__,
576    )
577    parser.add_argument(
578        "--loglevel",
579        metavar="LEVEL",
580        default="INFO",
581        help="Logging level (defaults to INFO)",
582    )
583
584    group = parser.add_mutually_exclusive_group(required=True)
585    group.add_argument("-d", "--designspace", metavar="DESIGNSPACE", type=str)
586    group.add_argument(
587        "-l",
588        "--locations",
589        metavar="LOCATION",
590        nargs="+",
591        help="Master locations as comma-separate coordinates. One must be all zeros.",
592    )
593
594    args = parser.parse_args(args)
595
596    configLogger(level=args.loglevel)
597    from pprint import pprint
598
599    if args.designspace:
600        from fontTools.designspaceLib import DesignSpaceDocument
601
602        doc = DesignSpaceDocument()
603        doc.read(args.designspace)
604        locs = [s.location for s in doc.sources]
605        print("Original locations:")
606        pprint(locs)
607        doc.normalize()
608        print("Normalized locations:")
609        locs = [s.location for s in doc.sources]
610        pprint(locs)
611    else:
612        axes = [chr(c) for c in range(ord("A"), ord("Z") + 1)]
613        locs = [
614            dict(zip(axes, (float(v) for v in s.split(",")))) for s in args.locations
615        ]
616
617    model = VariationModel(locs)
618    print("Sorted locations:")
619    pprint(model.locations)
620    print("Supports:")
621    pprint(model.supports)
622
623
624if __name__ == "__main__":
625    import doctest, sys
626
627    if len(sys.argv) > 1:
628        sys.exit(main())
629
630    sys.exit(doctest.testmod().failed)
631