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