Coverage for /usr/lib/python3/dist-packages/fontTools/varLib/models.py: 11%
303 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
1"""Variation fonts interpolation models."""
3__all__ = [
4 "normalizeValue",
5 "normalizeLocation",
6 "supportScalar",
7 "piecewiseLinearMap",
8 "VariationModel",
9]
11from fontTools.misc.roundTools import noRound
12from .errors import VariationModelError
15def nonNone(lst):
16 return [l for l in lst if l is not None]
19def allNone(lst):
20 return all(l is None for l in lst)
23def allEqualTo(ref, lst, mapper=None):
24 if mapper is None:
25 return all(ref == item for item in lst)
27 mapped = mapper(ref)
28 return all(mapped == mapper(item) for item in lst)
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)
42def subList(truth, lst):
43 assert len(truth) == len(lst)
44 return [l for l, t in zip(lst, truth) if t]
47def normalizeValue(v, triple, extrapolate=False):
48 """Normalizes value based on a min/default/max triple.
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)
66 if v == default or lower == upper:
67 return 0.0
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)
78def normalizeLocation(location, axes, extrapolate=False):
79 """Normalizes location based on axis min/default/max values from axes.
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
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.
130 If extrapolate is True, axisRanges must be a dict that maps axis
131 names to (axisMin, axisMax) tuples.
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
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
194 if v <= lower or upper <= v:
195 scalar = 0.0
196 break
198 if v < peak:
199 scalar *= (v - lower) / (peak - lower)
200 else: # v > peak
201 scalar *= (v - upper) / (peak - upper)
202 return scalar
205class VariationModel(object):
206 """Locations must have the base master at the origin (ie. 0).
208 If the extrapolate argument is set to True, then values are extrapolated
209 outside the axis range.
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 """
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.")
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
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)
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]
270 self._computeMasterSupports()
271 self._subModels = {}
273 def getSubModel(self, items):
274 if None not in items:
275 return self, items
276 key = tuple(v is not None for v in items)
277 subModel = self._subModels.get(key)
278 if subModel is None:
279 subModel = VariationModel(subList(key, self.origLocations), self.axisOrder)
280 self._subModels[key] = subModel
281 return subModel, subList(key, items)
283 @staticmethod
284 def computeAxisRanges(locations):
285 axisRanges = {}
286 allAxes = {axis for loc in locations for axis in loc.keys()}
287 for loc in locations:
288 for axis in allAxes:
289 value = loc.get(axis, 0)
290 axisMin, axisMax = axisRanges.get(axis, (value, value))
291 axisRanges[axis] = min(value, axisMin), max(value, axisMax)
292 return axisRanges
294 @staticmethod
295 def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
296 if {} not in locations:
297 raise VariationModelError("Base master not found.")
298 axisPoints = {}
299 for loc in locations:
300 if len(loc) != 1:
301 continue
302 axis = next(iter(loc))
303 value = loc[axis]
304 if axis not in axisPoints:
305 axisPoints[axis] = {0.0}
306 assert (
307 value not in axisPoints[axis]
308 ), 'Value "%s" in axisPoints["%s"] --> %s' % (value, axis, axisPoints)
309 axisPoints[axis].add(value)
311 def getKey(axisPoints, axisOrder):
312 def sign(v):
313 return -1 if v < 0 else +1 if v > 0 else 0
315 def key(loc):
316 rank = len(loc)
317 onPointAxes = [
318 axis
319 for axis, value in loc.items()
320 if axis in axisPoints and value in axisPoints[axis]
321 ]
322 orderedAxes = [axis for axis in axisOrder if axis in loc]
323 orderedAxes.extend(
324 [axis for axis in sorted(loc.keys()) if axis not in axisOrder]
325 )
326 return (
327 rank, # First, order by increasing rank
328 -len(onPointAxes), # Next, by decreasing number of onPoint axes
329 tuple(
330 axisOrder.index(axis) if axis in axisOrder else 0x10000
331 for axis in orderedAxes
332 ), # Next, by known axes
333 tuple(orderedAxes), # Next, by all axes
334 tuple(
335 sign(loc[axis]) for axis in orderedAxes
336 ), # Next, by signs of axis values
337 tuple(
338 abs(loc[axis]) for axis in orderedAxes
339 ), # Next, by absolute value of axis values
340 )
342 return key
344 ret = getKey(axisPoints, axisOrder)
345 return ret
347 def reorderMasters(self, master_list, mapping):
348 # For changing the master data order without
349 # recomputing supports and deltaWeights.
350 new_list = [master_list[idx] for idx in mapping]
351 self.origLocations = [self.origLocations[idx] for idx in mapping]
352 locations = [
353 {k: v for k, v in loc.items() if v != 0.0} for loc in self.origLocations
354 ]
355 self.mapping = [self.locations.index(l) for l in locations]
356 self.reverseMapping = [locations.index(l) for l in self.locations]
357 self._subModels = {}
358 return new_list
360 def _computeMasterSupports(self):
361 self.supports = []
362 regions = self._locationsToRegions()
363 for i, region in enumerate(regions):
364 locAxes = set(region.keys())
365 # Walk over previous masters now
366 for prev_region in regions[:i]:
367 # Master with extra axes do not participte
368 if set(prev_region.keys()) != locAxes:
369 continue
370 # If it's NOT in the current box, it does not participate
371 relevant = True
372 for axis, (lower, peak, upper) in region.items():
373 if not (
374 prev_region[axis][1] == peak
375 or lower < prev_region[axis][1] < upper
376 ):
377 relevant = False
378 break
379 if not relevant:
380 continue
382 # Split the box for new master; split in whatever direction
383 # that has largest range ratio.
384 #
385 # For symmetry, we actually cut across multiple axes
386 # if they have the largest, equal, ratio.
387 # https://github.com/fonttools/fonttools/commit/7ee81c8821671157968b097f3e55309a1faa511e#commitcomment-31054804
389 bestAxes = {}
390 bestRatio = -1
391 for axis in prev_region.keys():
392 val = prev_region[axis][1]
393 assert axis in region
394 lower, locV, upper = region[axis]
395 newLower, newUpper = lower, upper
396 if val < locV:
397 newLower = val
398 ratio = (val - locV) / (lower - locV)
399 elif locV < val:
400 newUpper = val
401 ratio = (val - locV) / (upper - locV)
402 else: # val == locV
403 # Can't split box in this direction.
404 continue
405 if ratio > bestRatio:
406 bestAxes = {}
407 bestRatio = ratio
408 if ratio == bestRatio:
409 bestAxes[axis] = (newLower, locV, newUpper)
411 for axis, triple in bestAxes.items():
412 region[axis] = triple
413 self.supports.append(region)
414 self._computeDeltaWeights()
416 def _locationsToRegions(self):
417 locations = self.locations
418 # Compute min/max across each axis, use it as total range.
419 # TODO Take this as input from outside?
420 minV = {}
421 maxV = {}
422 for l in locations:
423 for k, v in l.items():
424 minV[k] = min(v, minV.get(k, v))
425 maxV[k] = max(v, maxV.get(k, v))
427 regions = []
428 for loc in locations:
429 region = {}
430 for axis, locV in loc.items():
431 if locV > 0:
432 region[axis] = (0, locV, maxV[axis])
433 else:
434 region[axis] = (minV[axis], locV, 0)
435 regions.append(region)
436 return regions
438 def _computeDeltaWeights(self):
439 self.deltaWeights = []
440 for i, loc in enumerate(self.locations):
441 deltaWeight = {}
442 # Walk over previous masters now, populate deltaWeight
443 for j, support in enumerate(self.supports[:i]):
444 scalar = supportScalar(loc, support)
445 if scalar:
446 deltaWeight[j] = scalar
447 self.deltaWeights.append(deltaWeight)
449 def getDeltas(self, masterValues, *, round=noRound):
450 assert len(masterValues) == len(self.deltaWeights)
451 mapping = self.reverseMapping
452 out = []
453 for i, weights in enumerate(self.deltaWeights):
454 delta = masterValues[mapping[i]]
455 for j, weight in weights.items():
456 if weight == 1:
457 delta -= out[j]
458 else:
459 delta -= out[j] * weight
460 out.append(round(delta))
461 return out
463 def getDeltasAndSupports(self, items, *, round=noRound):
464 model, items = self.getSubModel(items)
465 return model.getDeltas(items, round=round), model.supports
467 def getScalars(self, loc):
468 return [
469 supportScalar(
470 loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges
471 )
472 for support in self.supports
473 ]
475 @staticmethod
476 def interpolateFromDeltasAndScalars(deltas, scalars):
477 v = None
478 assert len(deltas) == len(scalars)
479 for delta, scalar in zip(deltas, scalars):
480 if not scalar:
481 continue
482 contribution = delta * scalar
483 if v is None:
484 v = contribution
485 else:
486 v += contribution
487 return v
489 def interpolateFromDeltas(self, loc, deltas):
490 scalars = self.getScalars(loc)
491 return self.interpolateFromDeltasAndScalars(deltas, scalars)
493 def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
494 deltas = self.getDeltas(masterValues, round=round)
495 return self.interpolateFromDeltas(loc, deltas)
497 def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
498 deltas = self.getDeltas(masterValues, round=round)
499 return self.interpolateFromDeltasAndScalars(deltas, scalars)
502def piecewiseLinearMap(v, mapping):
503 keys = mapping.keys()
504 if not keys:
505 return v
506 if v in keys:
507 return mapping[v]
508 k = min(keys)
509 if v < k:
510 return v + mapping[k] - k
511 k = max(keys)
512 if v > k:
513 return v + mapping[k] - k
514 # Interpolate
515 a = max(k for k in keys if k < v)
516 b = min(k for k in keys if k > v)
517 va = mapping[a]
518 vb = mapping[b]
519 return va + (vb - va) * (v - a) / (b - a)
522def main(args=None):
523 """Normalize locations on a given designspace"""
524 from fontTools import configLogger
525 import argparse
527 parser = argparse.ArgumentParser(
528 "fonttools varLib.models",
529 description=main.__doc__,
530 )
531 parser.add_argument(
532 "--loglevel",
533 metavar="LEVEL",
534 default="INFO",
535 help="Logging level (defaults to INFO)",
536 )
538 group = parser.add_mutually_exclusive_group(required=True)
539 group.add_argument("-d", "--designspace", metavar="DESIGNSPACE", type=str)
540 group.add_argument(
541 "-l",
542 "--locations",
543 metavar="LOCATION",
544 nargs="+",
545 help="Master locations as comma-separate coordinates. One must be all zeros.",
546 )
548 args = parser.parse_args(args)
550 configLogger(level=args.loglevel)
551 from pprint import pprint
553 if args.designspace:
554 from fontTools.designspaceLib import DesignSpaceDocument
556 doc = DesignSpaceDocument()
557 doc.read(args.designspace)
558 locs = [s.location for s in doc.sources]
559 print("Original locations:")
560 pprint(locs)
561 doc.normalize()
562 print("Normalized locations:")
563 locs = [s.location for s in doc.sources]
564 pprint(locs)
565 else:
566 axes = [chr(c) for c in range(ord("A"), ord("Z") + 1)]
567 locs = [
568 dict(zip(axes, (float(v) for v in s.split(",")))) for s in args.locations
569 ]
571 model = VariationModel(locs)
572 print("Sorted locations:")
573 pprint(model.locations)
574 print("Supports:")
575 pprint(model.supports)
578if __name__ == "__main__":
579 import doctest, sys
581 if len(sys.argv) > 1:
582 sys.exit(main())
584 sys.exit(doctest.testmod().failed)