models.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  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, *, validate=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. if validate:
  97. assert set(location.keys()) <= set(axes.keys()), set(location.keys()) - set(
  98. axes.keys()
  99. )
  100. out = {}
  101. for tag, triple in axes.items():
  102. v = location.get(tag, triple[1])
  103. out[tag] = normalizeValue(v, triple, extrapolate=extrapolate)
  104. return out
  105. def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None):
  106. """Returns the scalar multiplier at location, for a master
  107. with support. If ot is True, then a peak value of zero
  108. for support of an axis means "axis does not participate". That
  109. is how OpenType Variation Font technology works.
  110. If extrapolate is True, axisRanges must be a dict that maps axis
  111. names to (axisMin, axisMax) tuples.
  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. >>> supportScalar({'wght':3}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
  129. -1.0
  130. >>> supportScalar({'wght':-1}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
  131. -1.0
  132. >>> supportScalar({'wght':3}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
  133. 1.5
  134. >>> supportScalar({'wght':-1}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
  135. -0.5
  136. """
  137. if extrapolate and axisRanges is None:
  138. raise TypeError("axisRanges must be passed when extrapolate is True")
  139. scalar = 1.0
  140. for axis, (lower, peak, upper) in support.items():
  141. if ot:
  142. # OpenType-specific case handling
  143. if peak == 0.0:
  144. continue
  145. if lower > peak or peak > upper:
  146. continue
  147. if lower < 0.0 and upper > 0.0:
  148. continue
  149. v = location.get(axis, 0.0)
  150. else:
  151. assert axis in location
  152. v = location[axis]
  153. if v == peak:
  154. continue
  155. if extrapolate:
  156. axisMin, axisMax = axisRanges[axis]
  157. if v < axisMin and lower <= axisMin:
  158. if peak <= axisMin and peak < upper:
  159. scalar *= (v - upper) / (peak - upper)
  160. continue
  161. elif axisMin < peak:
  162. scalar *= (v - lower) / (peak - lower)
  163. continue
  164. elif axisMax < v and axisMax <= upper:
  165. if axisMax <= peak and lower < peak:
  166. scalar *= (v - lower) / (peak - lower)
  167. continue
  168. elif peak < axisMax:
  169. scalar *= (v - upper) / (peak - upper)
  170. continue
  171. if v <= lower or upper <= v:
  172. scalar = 0.0
  173. break
  174. if v < peak:
  175. scalar *= (v - lower) / (peak - lower)
  176. else: # v > peak
  177. scalar *= (v - upper) / (peak - upper)
  178. return scalar
  179. class VariationModel(object):
  180. """Locations must have the base master at the origin (ie. 0).
  181. If axis-ranges are not provided, values are assumed to be normalized to
  182. the range [-1, 1].
  183. If the extrapolate argument is set to True, then values are extrapolated
  184. outside the axis range.
  185. >>> from pprint import pprint
  186. >>> axisRanges = {'wght': (-180, +180), 'wdth': (-1, +1)}
  187. >>> locations = [ \
  188. {'wght':100}, \
  189. {'wght':-100}, \
  190. {'wght':-180}, \
  191. {'wdth':+.3}, \
  192. {'wght':+120,'wdth':.3}, \
  193. {'wght':+120,'wdth':.2}, \
  194. {}, \
  195. {'wght':+180,'wdth':.3}, \
  196. {'wght':+180}, \
  197. ]
  198. >>> model = VariationModel(locations, axisOrder=['wght'], axisRanges=axisRanges)
  199. >>> pprint(model.locations)
  200. [{},
  201. {'wght': -100},
  202. {'wght': -180},
  203. {'wght': 100},
  204. {'wght': 180},
  205. {'wdth': 0.3},
  206. {'wdth': 0.3, 'wght': 180},
  207. {'wdth': 0.3, 'wght': 120},
  208. {'wdth': 0.2, 'wght': 120}]
  209. >>> pprint(model.deltaWeights)
  210. [{},
  211. {0: 1.0},
  212. {0: 1.0},
  213. {0: 1.0},
  214. {0: 1.0},
  215. {0: 1.0},
  216. {0: 1.0, 4: 1.0, 5: 1.0},
  217. {0: 1.0, 3: 0.75, 4: 0.25, 5: 1.0, 6: 0.6666666666666666},
  218. {0: 1.0,
  219. 3: 0.75,
  220. 4: 0.25,
  221. 5: 0.6666666666666667,
  222. 6: 0.4444444444444445,
  223. 7: 0.6666666666666667}]
  224. """
  225. def __init__(
  226. self, locations, axisOrder=None, extrapolate=False, *, axisRanges=None
  227. ):
  228. if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations):
  229. raise VariationModelError("Locations must be unique.")
  230. self.origLocations = locations
  231. self.axisOrder = axisOrder if axisOrder is not None else []
  232. self.extrapolate = extrapolate
  233. if axisRanges is None:
  234. if extrapolate:
  235. axisRanges = self.computeAxisRanges(locations)
  236. else:
  237. allAxes = {axis for loc in locations for axis in loc.keys()}
  238. axisRanges = {axis: (-1, 1) for axis in allAxes}
  239. self.axisRanges = axisRanges
  240. locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations]
  241. keyFunc = self.getMasterLocationsSortKeyFunc(
  242. locations, axisOrder=self.axisOrder
  243. )
  244. self.locations = sorted(locations, key=keyFunc)
  245. # Mapping from user's master order to our master order
  246. self.mapping = [self.locations.index(l) for l in locations]
  247. self.reverseMapping = [locations.index(l) for l in self.locations]
  248. self._computeMasterSupports()
  249. self._subModels = {}
  250. def getSubModel(self, items):
  251. """Return a sub-model and the items that are not None.
  252. The sub-model is necessary for working with the subset
  253. of items when some are None.
  254. The sub-model is cached."""
  255. if None not in items:
  256. return self, items
  257. key = tuple(v is not None for v in items)
  258. subModel = self._subModels.get(key)
  259. if subModel is None:
  260. subModel = VariationModel(subList(key, self.origLocations), self.axisOrder)
  261. self._subModels[key] = subModel
  262. return subModel, subList(key, items)
  263. @staticmethod
  264. def computeAxisRanges(locations):
  265. axisRanges = {}
  266. allAxes = {axis for loc in locations for axis in loc.keys()}
  267. for loc in locations:
  268. for axis in allAxes:
  269. value = loc.get(axis, 0)
  270. axisMin, axisMax = axisRanges.get(axis, (value, value))
  271. axisRanges[axis] = min(value, axisMin), max(value, axisMax)
  272. return axisRanges
  273. @staticmethod
  274. def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
  275. if {} not in locations:
  276. raise VariationModelError("Base master not found.")
  277. axisPoints = {}
  278. for loc in locations:
  279. if len(loc) != 1:
  280. continue
  281. axis = next(iter(loc))
  282. value = loc[axis]
  283. if axis not in axisPoints:
  284. axisPoints[axis] = {0.0}
  285. assert (
  286. value not in axisPoints[axis]
  287. ), 'Value "%s" in axisPoints["%s"] --> %s' % (value, axis, axisPoints)
  288. axisPoints[axis].add(value)
  289. def getKey(axisPoints, axisOrder):
  290. def sign(v):
  291. return -1 if v < 0 else +1 if v > 0 else 0
  292. def key(loc):
  293. rank = len(loc)
  294. onPointAxes = [
  295. axis
  296. for axis, value in loc.items()
  297. if axis in axisPoints and value in axisPoints[axis]
  298. ]
  299. orderedAxes = [axis for axis in axisOrder if axis in loc]
  300. orderedAxes.extend(
  301. [axis for axis in sorted(loc.keys()) if axis not in axisOrder]
  302. )
  303. return (
  304. rank, # First, order by increasing rank
  305. -len(onPointAxes), # Next, by decreasing number of onPoint axes
  306. tuple(
  307. axisOrder.index(axis) if axis in axisOrder else 0x10000
  308. for axis in orderedAxes
  309. ), # Next, by known axes
  310. tuple(orderedAxes), # Next, by all axes
  311. tuple(
  312. sign(loc[axis]) for axis in orderedAxes
  313. ), # Next, by signs of axis values
  314. tuple(
  315. abs(loc[axis]) for axis in orderedAxes
  316. ), # Next, by absolute value of axis values
  317. )
  318. return key
  319. ret = getKey(axisPoints, axisOrder)
  320. return ret
  321. def reorderMasters(self, master_list, mapping):
  322. # For changing the master data order without
  323. # recomputing supports and deltaWeights.
  324. new_list = [master_list[idx] for idx in mapping]
  325. self.origLocations = [self.origLocations[idx] for idx in mapping]
  326. locations = [
  327. {k: v for k, v in loc.items() if v != 0.0} for loc in self.origLocations
  328. ]
  329. self.mapping = [self.locations.index(l) for l in locations]
  330. self.reverseMapping = [locations.index(l) for l in self.locations]
  331. self._subModels = {}
  332. return new_list
  333. def _computeMasterSupports(self):
  334. self.supports = []
  335. regions = self._locationsToRegions()
  336. for i, region in enumerate(regions):
  337. locAxes = set(region.keys())
  338. # Walk over previous masters now
  339. for prev_region in regions[:i]:
  340. # Master with different axes do not participte
  341. if set(prev_region.keys()) != locAxes:
  342. continue
  343. # If it's NOT in the current box, it does not participate
  344. relevant = True
  345. for axis, (lower, peak, upper) in region.items():
  346. if not (
  347. prev_region[axis][1] == peak
  348. or lower < prev_region[axis][1] < upper
  349. ):
  350. relevant = False
  351. break
  352. if not relevant:
  353. continue
  354. # Split the box for new master; split in whatever direction
  355. # that has largest range ratio.
  356. #
  357. # For symmetry, we actually cut across multiple axes
  358. # if they have the largest, equal, ratio.
  359. # https://github.com/fonttools/fonttools/commit/7ee81c8821671157968b097f3e55309a1faa511e#commitcomment-31054804
  360. bestAxes = {}
  361. bestRatio = -1
  362. for axis in prev_region.keys():
  363. val = prev_region[axis][1]
  364. assert axis in region
  365. lower, locV, upper = region[axis]
  366. newLower, newUpper = lower, upper
  367. if val < locV:
  368. newLower = val
  369. ratio = (val - locV) / (lower - locV)
  370. elif locV < val:
  371. newUpper = val
  372. ratio = (val - locV) / (upper - locV)
  373. else: # val == locV
  374. # Can't split box in this direction.
  375. continue
  376. if ratio > bestRatio:
  377. bestAxes = {}
  378. bestRatio = ratio
  379. if ratio == bestRatio:
  380. bestAxes[axis] = (newLower, locV, newUpper)
  381. for axis, triple in bestAxes.items():
  382. region[axis] = triple
  383. self.supports.append(region)
  384. self._computeDeltaWeights()
  385. def _locationsToRegions(self):
  386. locations = self.locations
  387. axisRanges = self.axisRanges
  388. regions = []
  389. for loc in locations:
  390. region = {}
  391. for axis, locV in loc.items():
  392. if locV > 0:
  393. region[axis] = (0, locV, axisRanges[axis][1])
  394. else:
  395. region[axis] = (axisRanges[axis][0], locV, 0)
  396. regions.append(region)
  397. return regions
  398. def _computeDeltaWeights(self):
  399. self.deltaWeights = []
  400. for i, loc in enumerate(self.locations):
  401. deltaWeight = {}
  402. # Walk over previous masters now, populate deltaWeight
  403. for j, support in enumerate(self.supports[:i]):
  404. scalar = supportScalar(loc, support)
  405. if scalar:
  406. deltaWeight[j] = scalar
  407. self.deltaWeights.append(deltaWeight)
  408. def getDeltas(self, masterValues, *, round=noRound):
  409. assert len(masterValues) == len(self.deltaWeights), (
  410. len(masterValues),
  411. len(self.deltaWeights),
  412. )
  413. mapping = self.reverseMapping
  414. out = []
  415. for i, weights in enumerate(self.deltaWeights):
  416. delta = masterValues[mapping[i]]
  417. for j, weight in weights.items():
  418. if weight == 1:
  419. delta -= out[j]
  420. else:
  421. delta -= out[j] * weight
  422. out.append(round(delta))
  423. return out
  424. def getDeltasAndSupports(self, items, *, round=noRound):
  425. model, items = self.getSubModel(items)
  426. return model.getDeltas(items, round=round), model.supports
  427. def getScalars(self, loc):
  428. """Return scalars for each delta, for the given location.
  429. If interpolating many master-values at the same location,
  430. this function allows speed up by fetching the scalars once
  431. and using them with interpolateFromMastersAndScalars()."""
  432. return [
  433. supportScalar(
  434. loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges
  435. )
  436. for support in self.supports
  437. ]
  438. def getMasterScalars(self, targetLocation):
  439. """Return multipliers for each master, for the given location.
  440. If interpolating many master-values at the same location,
  441. this function allows speed up by fetching the scalars once
  442. and using them with interpolateFromValuesAndScalars().
  443. Note that the scalars used in interpolateFromMastersAndScalars(),
  444. are *not* the same as the ones returned here. They are the result
  445. of getScalars()."""
  446. out = self.getScalars(targetLocation)
  447. for i, weights in reversed(list(enumerate(self.deltaWeights))):
  448. for j, weight in weights.items():
  449. out[j] -= out[i] * weight
  450. out = [out[self.mapping[i]] for i in range(len(out))]
  451. return out
  452. @staticmethod
  453. def interpolateFromValuesAndScalars(values, scalars):
  454. """Interpolate from values and scalars coefficients.
  455. If the values are master-values, then the scalars should be
  456. fetched from getMasterScalars().
  457. If the values are deltas, then the scalars should be fetched
  458. from getScalars(); in which case this is the same as
  459. interpolateFromDeltasAndScalars().
  460. """
  461. v = None
  462. assert len(values) == len(scalars)
  463. for value, scalar in zip(values, scalars):
  464. if not scalar:
  465. continue
  466. contribution = value * scalar
  467. if v is None:
  468. v = contribution
  469. else:
  470. v += contribution
  471. return v
  472. @staticmethod
  473. def interpolateFromDeltasAndScalars(deltas, scalars):
  474. """Interpolate from deltas and scalars fetched from getScalars()."""
  475. return VariationModel.interpolateFromValuesAndScalars(deltas, scalars)
  476. def interpolateFromDeltas(self, loc, deltas):
  477. """Interpolate from deltas, at location loc."""
  478. scalars = self.getScalars(loc)
  479. return self.interpolateFromDeltasAndScalars(deltas, scalars)
  480. def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
  481. """Interpolate from master-values, at location loc."""
  482. scalars = self.getMasterScalars(loc)
  483. return self.interpolateFromValuesAndScalars(masterValues, scalars)
  484. def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
  485. """Interpolate from master-values, and scalars fetched from
  486. getScalars(), which is useful when you want to interpolate
  487. multiple master-values with the same location."""
  488. deltas = self.getDeltas(masterValues, round=round)
  489. return self.interpolateFromDeltasAndScalars(deltas, scalars)
  490. def piecewiseLinearMap(v, mapping):
  491. keys = mapping.keys()
  492. if not keys:
  493. return v
  494. if v in keys:
  495. return mapping[v]
  496. k = min(keys)
  497. if v < k:
  498. return v + mapping[k] - k
  499. k = max(keys)
  500. if v > k:
  501. return v + mapping[k] - k
  502. # Interpolate
  503. a = max(k for k in keys if k < v)
  504. b = min(k for k in keys if k > v)
  505. va = mapping[a]
  506. vb = mapping[b]
  507. return va + (vb - va) * (v - a) / (b - a)
  508. def main(args=None):
  509. """Normalize locations on a given designspace"""
  510. from fontTools import configLogger
  511. import argparse
  512. parser = argparse.ArgumentParser(
  513. "fonttools varLib.models",
  514. description=main.__doc__,
  515. )
  516. parser.add_argument(
  517. "--loglevel",
  518. metavar="LEVEL",
  519. default="INFO",
  520. help="Logging level (defaults to INFO)",
  521. )
  522. group = parser.add_mutually_exclusive_group(required=True)
  523. group.add_argument("-d", "--designspace", metavar="DESIGNSPACE", type=str)
  524. group.add_argument(
  525. "-l",
  526. "--locations",
  527. metavar="LOCATION",
  528. nargs="+",
  529. help="Master locations as comma-separate coordinates. One must be all zeros.",
  530. )
  531. args = parser.parse_args(args)
  532. configLogger(level=args.loglevel)
  533. from pprint import pprint
  534. if args.designspace:
  535. from fontTools.designspaceLib import DesignSpaceDocument
  536. doc = DesignSpaceDocument()
  537. doc.read(args.designspace)
  538. locs = [s.location for s in doc.sources]
  539. print("Original locations:")
  540. pprint(locs)
  541. doc.normalize()
  542. print("Normalized locations:")
  543. locs = [s.location for s in doc.sources]
  544. pprint(locs)
  545. else:
  546. axes = [chr(c) for c in range(ord("A"), ord("Z") + 1)]
  547. locs = [
  548. dict(zip(axes, (float(v) for v in s.split(",")))) for s in args.locations
  549. ]
  550. model = VariationModel(locs)
  551. print("Sorted locations:")
  552. pprint(model.locations)
  553. print("Supports:")
  554. pprint(model.supports)
  555. if __name__ == "__main__":
  556. import doctest, sys
  557. if len(sys.argv) > 1:
  558. sys.exit(main())
  559. sys.exit(doctest.testmod().failed)