Coverage for /usr/lib/python3/dist-packages/fontTools/varLib/models.py: 11%

303 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-14 15:55 +0200

1"""Variation fonts interpolation models.""" 

2 

3__all__ = [ 

4 "normalizeValue", 

5 "normalizeLocation", 

6 "supportScalar", 

7 "piecewiseLinearMap", 

8 "VariationModel", 

9] 

10 

11from fontTools.misc.roundTools import noRound 

12from .errors import VariationModelError 

13 

14 

15def nonNone(lst): 

16 return [l for l in lst if l is not None] 

17 

18 

19def allNone(lst): 

20 return all(l is None for l in lst) 

21 

22 

23def allEqualTo(ref, lst, mapper=None): 

24 if mapper is None: 

25 return all(ref == item for item in lst) 

26 

27 mapped = mapper(ref) 

28 return all(mapped == mapper(item) for item in lst) 

29 

30 

31def allEqual(lst, mapper=None): 

32 if not lst: 

33 return True 

34 it = iter(lst) 

35 try: 

36 first = next(it) 

37 except StopIteration: 

38 return True 

39 return allEqualTo(first, it, mapper=mapper) 

40 

41 

42def subList(truth, lst): 

43 assert len(truth) == len(lst) 

44 return [l for l, t in zip(lst, truth) if t] 

45 

46 

47def normalizeValue(v, triple, extrapolate=False): 

48 """Normalizes value based on a min/default/max triple. 

49 

50 >>> normalizeValue(400, (100, 400, 900)) 

51 0.0 

52 >>> normalizeValue(100, (100, 400, 900)) 

53 -1.0 

54 >>> normalizeValue(650, (100, 400, 900)) 

55 0.5 

56 """ 

57 lower, default, upper = triple 

58 if not (lower <= default <= upper): 

59 raise ValueError( 

60 f"Invalid axis values, must be minimum, default, maximum: " 

61 f"{lower:3.3f}, {default:3.3f}, {upper:3.3f}" 

62 ) 

63 if not extrapolate: 

64 v = max(min(v, upper), lower) 

65 

66 if v == default or lower == upper: 

67 return 0.0 

68 

69 if (v < default and lower != default) or (v > default and upper == default): 

70 return (v - default) / (default - lower) 

71 else: 

72 assert (v > default and upper != default) or ( 

73 v < default and lower == default 

74 ), f"Ooops... v={v}, triple=({lower}, {default}, {upper})" 

75 return (v - default) / (upper - default) 

76 

77 

78def normalizeLocation(location, axes, extrapolate=False): 

79 """Normalizes location based on axis min/default/max values from axes. 

80 

81 >>> axes = {"wght": (100, 400, 900)} 

82 >>> normalizeLocation({"wght": 400}, axes) 

83 {'wght': 0.0} 

84 >>> normalizeLocation({"wght": 100}, axes) 

85 {'wght': -1.0} 

86 >>> normalizeLocation({"wght": 900}, axes) 

87 {'wght': 1.0} 

88 >>> normalizeLocation({"wght": 650}, axes) 

89 {'wght': 0.5} 

90 >>> normalizeLocation({"wght": 1000}, axes) 

91 {'wght': 1.0} 

92 >>> normalizeLocation({"wght": 0}, axes) 

93 {'wght': -1.0} 

94 >>> axes = {"wght": (0, 0, 1000)} 

95 >>> normalizeLocation({"wght": 0}, axes) 

96 {'wght': 0.0} 

97 >>> normalizeLocation({"wght": -1}, axes) 

98 {'wght': 0.0} 

99 >>> normalizeLocation({"wght": 1000}, axes) 

100 {'wght': 1.0} 

101 >>> normalizeLocation({"wght": 500}, axes) 

102 {'wght': 0.5} 

103 >>> normalizeLocation({"wght": 1001}, axes) 

104 {'wght': 1.0} 

105 >>> axes = {"wght": (0, 1000, 1000)} 

106 >>> normalizeLocation({"wght": 0}, axes) 

107 {'wght': -1.0} 

108 >>> normalizeLocation({"wght": -1}, axes) 

109 {'wght': -1.0} 

110 >>> normalizeLocation({"wght": 500}, axes) 

111 {'wght': -0.5} 

112 >>> normalizeLocation({"wght": 1000}, axes) 

113 {'wght': 0.0} 

114 >>> normalizeLocation({"wght": 1001}, axes) 

115 {'wght': 0.0} 

116 """ 

117 out = {} 

118 for tag, triple in axes.items(): 

119 v = location.get(tag, triple[1]) 

120 out[tag] = normalizeValue(v, triple, extrapolate=extrapolate) 

121 return out 

122 

123 

124def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None): 

125 """Returns the scalar multiplier at location, for a master 

126 with support. If ot is True, then a peak value of zero 

127 for support of an axis means "axis does not participate". That 

128 is how OpenType Variation Font technology works. 

129 

130 If extrapolate is True, axisRanges must be a dict that maps axis 

131 names to (axisMin, axisMax) tuples. 

132 

133 >>> supportScalar({}, {}) 

134 1.0 

135 >>> supportScalar({'wght':.2}, {}) 

136 1.0 

137 >>> supportScalar({'wght':.2}, {'wght':(0,2,3)}) 

138 0.1 

139 >>> supportScalar({'wght':2.5}, {'wght':(0,2,4)}) 

140 0.75 

141 >>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)}) 

142 0.75 

143 >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)}, ot=False) 

144 0.375 

145 >>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)}) 

146 0.75 

147 >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)}) 

148 0.75 

149 >>> supportScalar({'wght':3}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)}) 

150 -1.0 

151 >>> supportScalar({'wght':-1}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)}) 

152 -1.0 

153 >>> supportScalar({'wght':3}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)}) 

154 1.5 

155 >>> supportScalar({'wght':-1}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)}) 

156 -0.5 

157 """ 

158 if extrapolate and axisRanges is None: 

159 raise TypeError("axisRanges must be passed when extrapolate is True") 

160 scalar = 1.0 

161 for axis, (lower, peak, upper) in support.items(): 

162 if ot: 

163 # OpenType-specific case handling 

164 if peak == 0.0: 

165 continue 

166 if lower > peak or peak > upper: 

167 continue 

168 if lower < 0.0 and upper > 0.0: 

169 continue 

170 v = location.get(axis, 0.0) 

171 else: 

172 assert axis in location 

173 v = location[axis] 

174 if v == peak: 

175 continue 

176 

177 if extrapolate: 

178 axisMin, axisMax = axisRanges[axis] 

179 if v < axisMin and lower <= axisMin: 

180 if peak <= axisMin and peak < upper: 

181 scalar *= (v - upper) / (peak - upper) 

182 continue 

183 elif axisMin < peak: 

184 scalar *= (v - lower) / (peak - lower) 

185 continue 

186 elif axisMax < v and axisMax <= upper: 

187 if axisMax <= peak and lower < peak: 

188 scalar *= (v - lower) / (peak - lower) 

189 continue 

190 elif peak < axisMax: 

191 scalar *= (v - upper) / (peak - upper) 

192 continue 

193 

194 if v <= lower or upper <= v: 

195 scalar = 0.0 

196 break 

197 

198 if v < peak: 

199 scalar *= (v - lower) / (peak - lower) 

200 else: # v > peak 

201 scalar *= (v - upper) / (peak - upper) 

202 return scalar 

203 

204 

205class VariationModel(object): 

206 """Locations must have the base master at the origin (ie. 0). 

207 

208 If the extrapolate argument is set to True, then values are extrapolated 

209 outside the axis range. 

210 

211 >>> from pprint import pprint 

212 >>> locations = [ \ 

213 {'wght':100}, \ 

214 {'wght':-100}, \ 

215 {'wght':-180}, \ 

216 {'wdth':+.3}, \ 

217 {'wght':+120,'wdth':.3}, \ 

218 {'wght':+120,'wdth':.2}, \ 

219 {}, \ 

220 {'wght':+180,'wdth':.3}, \ 

221 {'wght':+180}, \ 

222 ] 

223 >>> model = VariationModel(locations, axisOrder=['wght']) 

224 >>> pprint(model.locations) 

225 [{}, 

226 {'wght': -100}, 

227 {'wght': -180}, 

228 {'wght': 100}, 

229 {'wght': 180}, 

230 {'wdth': 0.3}, 

231 {'wdth': 0.3, 'wght': 180}, 

232 {'wdth': 0.3, 'wght': 120}, 

233 {'wdth': 0.2, 'wght': 120}] 

234 >>> pprint(model.deltaWeights) 

235 [{}, 

236 {0: 1.0}, 

237 {0: 1.0}, 

238 {0: 1.0}, 

239 {0: 1.0}, 

240 {0: 1.0}, 

241 {0: 1.0, 4: 1.0, 5: 1.0}, 

242 {0: 1.0, 3: 0.75, 4: 0.25, 5: 1.0, 6: 0.6666666666666666}, 

243 {0: 1.0, 

244 3: 0.75, 

245 4: 0.25, 

246 5: 0.6666666666666667, 

247 6: 0.4444444444444445, 

248 7: 0.6666666666666667}] 

249 """ 

250 

251 def __init__(self, locations, axisOrder=None, extrapolate=False): 

252 if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations): 

253 raise VariationModelError("Locations must be unique.") 

254 

255 self.origLocations = locations 

256 self.axisOrder = axisOrder if axisOrder is not None else [] 

257 self.extrapolate = extrapolate 

258 self.axisRanges = self.computeAxisRanges(locations) if extrapolate else None 

259 

260 locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations] 

261 keyFunc = self.getMasterLocationsSortKeyFunc( 

262 locations, axisOrder=self.axisOrder 

263 ) 

264 self.locations = sorted(locations, key=keyFunc) 

265 

266 # Mapping from user's master order to our master order 

267 self.mapping = [self.locations.index(l) for l in locations] 

268 self.reverseMapping = [locations.index(l) for l in self.locations] 

269 

270 self._computeMasterSupports() 

271 self._subModels = {} 

272 

273 def getSubModel(self, items): 

274 if None not in items: 

275 return self, items 

276 key = tuple(v is not None for v in items) 

277 subModel = self._subModels.get(key) 

278 if subModel is None: 

279 subModel = VariationModel(subList(key, self.origLocations), self.axisOrder) 

280 self._subModels[key] = subModel 

281 return subModel, subList(key, items) 

282 

283 @staticmethod 

284 def computeAxisRanges(locations): 

285 axisRanges = {} 

286 allAxes = {axis for loc in locations for axis in loc.keys()} 

287 for loc in locations: 

288 for axis in allAxes: 

289 value = loc.get(axis, 0) 

290 axisMin, axisMax = axisRanges.get(axis, (value, value)) 

291 axisRanges[axis] = min(value, axisMin), max(value, axisMax) 

292 return axisRanges 

293 

294 @staticmethod 

295 def getMasterLocationsSortKeyFunc(locations, axisOrder=[]): 

296 if {} not in locations: 

297 raise VariationModelError("Base master not found.") 

298 axisPoints = {} 

299 for loc in locations: 

300 if len(loc) != 1: 

301 continue 

302 axis = next(iter(loc)) 

303 value = loc[axis] 

304 if axis not in axisPoints: 

305 axisPoints[axis] = {0.0} 

306 assert ( 

307 value not in axisPoints[axis] 

308 ), 'Value "%s" in axisPoints["%s"] --> %s' % (value, axis, axisPoints) 

309 axisPoints[axis].add(value) 

310 

311 def getKey(axisPoints, axisOrder): 

312 def sign(v): 

313 return -1 if v < 0 else +1 if v > 0 else 0 

314 

315 def key(loc): 

316 rank = len(loc) 

317 onPointAxes = [ 

318 axis 

319 for axis, value in loc.items() 

320 if axis in axisPoints and value in axisPoints[axis] 

321 ] 

322 orderedAxes = [axis for axis in axisOrder if axis in loc] 

323 orderedAxes.extend( 

324 [axis for axis in sorted(loc.keys()) if axis not in axisOrder] 

325 ) 

326 return ( 

327 rank, # First, order by increasing rank 

328 -len(onPointAxes), # Next, by decreasing number of onPoint axes 

329 tuple( 

330 axisOrder.index(axis) if axis in axisOrder else 0x10000 

331 for axis in orderedAxes 

332 ), # Next, by known axes 

333 tuple(orderedAxes), # Next, by all axes 

334 tuple( 

335 sign(loc[axis]) for axis in orderedAxes 

336 ), # Next, by signs of axis values 

337 tuple( 

338 abs(loc[axis]) for axis in orderedAxes 

339 ), # Next, by absolute value of axis values 

340 ) 

341 

342 return key 

343 

344 ret = getKey(axisPoints, axisOrder) 

345 return ret 

346 

347 def reorderMasters(self, master_list, mapping): 

348 # For changing the master data order without 

349 # recomputing supports and deltaWeights. 

350 new_list = [master_list[idx] for idx in mapping] 

351 self.origLocations = [self.origLocations[idx] for idx in mapping] 

352 locations = [ 

353 {k: v for k, v in loc.items() if v != 0.0} for loc in self.origLocations 

354 ] 

355 self.mapping = [self.locations.index(l) for l in locations] 

356 self.reverseMapping = [locations.index(l) for l in self.locations] 

357 self._subModels = {} 

358 return new_list 

359 

360 def _computeMasterSupports(self): 

361 self.supports = [] 

362 regions = self._locationsToRegions() 

363 for i, region in enumerate(regions): 

364 locAxes = set(region.keys()) 

365 # Walk over previous masters now 

366 for prev_region in regions[:i]: 

367 # Master with extra axes do not participte 

368 if set(prev_region.keys()) != locAxes: 

369 continue 

370 # If it's NOT in the current box, it does not participate 

371 relevant = True 

372 for axis, (lower, peak, upper) in region.items(): 

373 if not ( 

374 prev_region[axis][1] == peak 

375 or lower < prev_region[axis][1] < upper 

376 ): 

377 relevant = False 

378 break 

379 if not relevant: 

380 continue 

381 

382 # Split the box for new master; split in whatever direction 

383 # that has largest range ratio. 

384 # 

385 # For symmetry, we actually cut across multiple axes 

386 # if they have the largest, equal, ratio. 

387 # https://github.com/fonttools/fonttools/commit/7ee81c8821671157968b097f3e55309a1faa511e#commitcomment-31054804 

388 

389 bestAxes = {} 

390 bestRatio = -1 

391 for axis in prev_region.keys(): 

392 val = prev_region[axis][1] 

393 assert axis in region 

394 lower, locV, upper = region[axis] 

395 newLower, newUpper = lower, upper 

396 if val < locV: 

397 newLower = val 

398 ratio = (val - locV) / (lower - locV) 

399 elif locV < val: 

400 newUpper = val 

401 ratio = (val - locV) / (upper - locV) 

402 else: # val == locV 

403 # Can't split box in this direction. 

404 continue 

405 if ratio > bestRatio: 

406 bestAxes = {} 

407 bestRatio = ratio 

408 if ratio == bestRatio: 

409 bestAxes[axis] = (newLower, locV, newUpper) 

410 

411 for axis, triple in bestAxes.items(): 

412 region[axis] = triple 

413 self.supports.append(region) 

414 self._computeDeltaWeights() 

415 

416 def _locationsToRegions(self): 

417 locations = self.locations 

418 # Compute min/max across each axis, use it as total range. 

419 # TODO Take this as input from outside? 

420 minV = {} 

421 maxV = {} 

422 for l in locations: 

423 for k, v in l.items(): 

424 minV[k] = min(v, minV.get(k, v)) 

425 maxV[k] = max(v, maxV.get(k, v)) 

426 

427 regions = [] 

428 for loc in locations: 

429 region = {} 

430 for axis, locV in loc.items(): 

431 if locV > 0: 

432 region[axis] = (0, locV, maxV[axis]) 

433 else: 

434 region[axis] = (minV[axis], locV, 0) 

435 regions.append(region) 

436 return regions 

437 

438 def _computeDeltaWeights(self): 

439 self.deltaWeights = [] 

440 for i, loc in enumerate(self.locations): 

441 deltaWeight = {} 

442 # Walk over previous masters now, populate deltaWeight 

443 for j, support in enumerate(self.supports[:i]): 

444 scalar = supportScalar(loc, support) 

445 if scalar: 

446 deltaWeight[j] = scalar 

447 self.deltaWeights.append(deltaWeight) 

448 

449 def getDeltas(self, masterValues, *, round=noRound): 

450 assert len(masterValues) == len(self.deltaWeights) 

451 mapping = self.reverseMapping 

452 out = [] 

453 for i, weights in enumerate(self.deltaWeights): 

454 delta = masterValues[mapping[i]] 

455 for j, weight in weights.items(): 

456 if weight == 1: 

457 delta -= out[j] 

458 else: 

459 delta -= out[j] * weight 

460 out.append(round(delta)) 

461 return out 

462 

463 def getDeltasAndSupports(self, items, *, round=noRound): 

464 model, items = self.getSubModel(items) 

465 return model.getDeltas(items, round=round), model.supports 

466 

467 def getScalars(self, loc): 

468 return [ 

469 supportScalar( 

470 loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges 

471 ) 

472 for support in self.supports 

473 ] 

474 

475 @staticmethod 

476 def interpolateFromDeltasAndScalars(deltas, scalars): 

477 v = None 

478 assert len(deltas) == len(scalars) 

479 for delta, scalar in zip(deltas, scalars): 

480 if not scalar: 

481 continue 

482 contribution = delta * scalar 

483 if v is None: 

484 v = contribution 

485 else: 

486 v += contribution 

487 return v 

488 

489 def interpolateFromDeltas(self, loc, deltas): 

490 scalars = self.getScalars(loc) 

491 return self.interpolateFromDeltasAndScalars(deltas, scalars) 

492 

493 def interpolateFromMasters(self, loc, masterValues, *, round=noRound): 

494 deltas = self.getDeltas(masterValues, round=round) 

495 return self.interpolateFromDeltas(loc, deltas) 

496 

497 def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound): 

498 deltas = self.getDeltas(masterValues, round=round) 

499 return self.interpolateFromDeltasAndScalars(deltas, scalars) 

500 

501 

502def piecewiseLinearMap(v, mapping): 

503 keys = mapping.keys() 

504 if not keys: 

505 return v 

506 if v in keys: 

507 return mapping[v] 

508 k = min(keys) 

509 if v < k: 

510 return v + mapping[k] - k 

511 k = max(keys) 

512 if v > k: 

513 return v + mapping[k] - k 

514 # Interpolate 

515 a = max(k for k in keys if k < v) 

516 b = min(k for k in keys if k > v) 

517 va = mapping[a] 

518 vb = mapping[b] 

519 return va + (vb - va) * (v - a) / (b - a) 

520 

521 

522def main(args=None): 

523 """Normalize locations on a given designspace""" 

524 from fontTools import configLogger 

525 import argparse 

526 

527 parser = argparse.ArgumentParser( 

528 "fonttools varLib.models", 

529 description=main.__doc__, 

530 ) 

531 parser.add_argument( 

532 "--loglevel", 

533 metavar="LEVEL", 

534 default="INFO", 

535 help="Logging level (defaults to INFO)", 

536 ) 

537 

538 group = parser.add_mutually_exclusive_group(required=True) 

539 group.add_argument("-d", "--designspace", metavar="DESIGNSPACE", type=str) 

540 group.add_argument( 

541 "-l", 

542 "--locations", 

543 metavar="LOCATION", 

544 nargs="+", 

545 help="Master locations as comma-separate coordinates. One must be all zeros.", 

546 ) 

547 

548 args = parser.parse_args(args) 

549 

550 configLogger(level=args.loglevel) 

551 from pprint import pprint 

552 

553 if args.designspace: 

554 from fontTools.designspaceLib import DesignSpaceDocument 

555 

556 doc = DesignSpaceDocument() 

557 doc.read(args.designspace) 

558 locs = [s.location for s in doc.sources] 

559 print("Original locations:") 

560 pprint(locs) 

561 doc.normalize() 

562 print("Normalized locations:") 

563 locs = [s.location for s in doc.sources] 

564 pprint(locs) 

565 else: 

566 axes = [chr(c) for c in range(ord("A"), ord("Z") + 1)] 

567 locs = [ 

568 dict(zip(axes, (float(v) for v in s.split(",")))) for s in args.locations 

569 ] 

570 

571 model = VariationModel(locs) 

572 print("Sorted locations:") 

573 pprint(model.locations) 

574 print("Supports:") 

575 pprint(model.supports) 

576 

577 

578if __name__ == "__main__": 

579 import doctest, sys 

580 

581 if len(sys.argv) > 1: 

582 sys.exit(main()) 

583 

584 sys.exit(doctest.testmod().failed)