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