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