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