models.py 19 KB

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