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