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