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