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