models.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  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. """Return a sub-model and the items that are not None.
  237. The sub-model is necessary for working with the subset
  238. of items when some are None.
  239. The sub-model is cached."""
  240. if None not in items:
  241. return self, items
  242. key = tuple(v is not None for v in items)
  243. subModel = self._subModels.get(key)
  244. if subModel is None:
  245. subModel = VariationModel(subList(key, self.origLocations), self.axisOrder)
  246. self._subModels[key] = subModel
  247. return subModel, subList(key, items)
  248. @staticmethod
  249. def computeAxisRanges(locations):
  250. axisRanges = {}
  251. allAxes = {axis for loc in locations for axis in loc.keys()}
  252. for loc in locations:
  253. for axis in allAxes:
  254. value = loc.get(axis, 0)
  255. axisMin, axisMax = axisRanges.get(axis, (value, value))
  256. axisRanges[axis] = min(value, axisMin), max(value, axisMax)
  257. return axisRanges
  258. @staticmethod
  259. def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
  260. if {} not in locations:
  261. raise VariationModelError("Base master not found.")
  262. axisPoints = {}
  263. for loc in locations:
  264. if len(loc) != 1:
  265. continue
  266. axis = next(iter(loc))
  267. value = loc[axis]
  268. if axis not in axisPoints:
  269. axisPoints[axis] = {0.0}
  270. assert (
  271. value not in axisPoints[axis]
  272. ), 'Value "%s" in axisPoints["%s"] --> %s' % (value, axis, axisPoints)
  273. axisPoints[axis].add(value)
  274. def getKey(axisPoints, axisOrder):
  275. def sign(v):
  276. return -1 if v < 0 else +1 if v > 0 else 0
  277. def key(loc):
  278. rank = len(loc)
  279. onPointAxes = [
  280. axis
  281. for axis, value in loc.items()
  282. if axis in axisPoints and value in axisPoints[axis]
  283. ]
  284. orderedAxes = [axis for axis in axisOrder if axis in loc]
  285. orderedAxes.extend(
  286. [axis for axis in sorted(loc.keys()) if axis not in axisOrder]
  287. )
  288. return (
  289. rank, # First, order by increasing rank
  290. -len(onPointAxes), # Next, by decreasing number of onPoint axes
  291. tuple(
  292. axisOrder.index(axis) if axis in axisOrder else 0x10000
  293. for axis in orderedAxes
  294. ), # Next, by known axes
  295. tuple(orderedAxes), # Next, by all axes
  296. tuple(
  297. sign(loc[axis]) for axis in orderedAxes
  298. ), # Next, by signs of axis values
  299. tuple(
  300. abs(loc[axis]) for axis in orderedAxes
  301. ), # Next, by absolute value of axis values
  302. )
  303. return key
  304. ret = getKey(axisPoints, axisOrder)
  305. return ret
  306. def reorderMasters(self, master_list, mapping):
  307. # For changing the master data order without
  308. # recomputing supports and deltaWeights.
  309. new_list = [master_list[idx] for idx in mapping]
  310. self.origLocations = [self.origLocations[idx] for idx in mapping]
  311. locations = [
  312. {k: v for k, v in loc.items() if v != 0.0} for loc in self.origLocations
  313. ]
  314. self.mapping = [self.locations.index(l) for l in locations]
  315. self.reverseMapping = [locations.index(l) for l in self.locations]
  316. self._subModels = {}
  317. return new_list
  318. def _computeMasterSupports(self):
  319. self.supports = []
  320. regions = self._locationsToRegions()
  321. for i, region in enumerate(regions):
  322. locAxes = set(region.keys())
  323. # Walk over previous masters now
  324. for prev_region in regions[:i]:
  325. # Master with extra axes do not participte
  326. if set(prev_region.keys()) != locAxes:
  327. continue
  328. # If it's NOT in the current box, it does not participate
  329. relevant = True
  330. for axis, (lower, peak, upper) in region.items():
  331. if not (
  332. prev_region[axis][1] == peak
  333. or lower < prev_region[axis][1] < upper
  334. ):
  335. relevant = False
  336. break
  337. if not relevant:
  338. continue
  339. # Split the box for new master; split in whatever direction
  340. # that has largest range ratio.
  341. #
  342. # For symmetry, we actually cut across multiple axes
  343. # if they have the largest, equal, ratio.
  344. # https://github.com/fonttools/fonttools/commit/7ee81c8821671157968b097f3e55309a1faa511e#commitcomment-31054804
  345. bestAxes = {}
  346. bestRatio = -1
  347. for axis in prev_region.keys():
  348. val = prev_region[axis][1]
  349. assert axis in region
  350. lower, locV, upper = region[axis]
  351. newLower, newUpper = lower, upper
  352. if val < locV:
  353. newLower = val
  354. ratio = (val - locV) / (lower - locV)
  355. elif locV < val:
  356. newUpper = val
  357. ratio = (val - locV) / (upper - locV)
  358. else: # val == locV
  359. # Can't split box in this direction.
  360. continue
  361. if ratio > bestRatio:
  362. bestAxes = {}
  363. bestRatio = ratio
  364. if ratio == bestRatio:
  365. bestAxes[axis] = (newLower, locV, newUpper)
  366. for axis, triple in bestAxes.items():
  367. region[axis] = triple
  368. self.supports.append(region)
  369. self._computeDeltaWeights()
  370. def _locationsToRegions(self):
  371. locations = self.locations
  372. # Compute min/max across each axis, use it as total range.
  373. # TODO Take this as input from outside?
  374. minV = {}
  375. maxV = {}
  376. for l in locations:
  377. for k, v in l.items():
  378. minV[k] = min(v, minV.get(k, v))
  379. maxV[k] = max(v, maxV.get(k, v))
  380. regions = []
  381. for loc in locations:
  382. region = {}
  383. for axis, locV in loc.items():
  384. if locV > 0:
  385. region[axis] = (0, locV, maxV[axis])
  386. else:
  387. region[axis] = (minV[axis], locV, 0)
  388. regions.append(region)
  389. return regions
  390. def _computeDeltaWeights(self):
  391. self.deltaWeights = []
  392. for i, loc in enumerate(self.locations):
  393. deltaWeight = {}
  394. # Walk over previous masters now, populate deltaWeight
  395. for j, support in enumerate(self.supports[:i]):
  396. scalar = supportScalar(loc, support)
  397. if scalar:
  398. deltaWeight[j] = scalar
  399. self.deltaWeights.append(deltaWeight)
  400. def getDeltas(self, masterValues, *, round=noRound):
  401. assert len(masterValues) == len(self.deltaWeights)
  402. mapping = self.reverseMapping
  403. out = []
  404. for i, weights in enumerate(self.deltaWeights):
  405. delta = masterValues[mapping[i]]
  406. for j, weight in weights.items():
  407. if weight == 1:
  408. delta -= out[j]
  409. else:
  410. delta -= out[j] * weight
  411. out.append(round(delta))
  412. return out
  413. def getDeltasAndSupports(self, items, *, round=noRound):
  414. model, items = self.getSubModel(items)
  415. return model.getDeltas(items, round=round), model.supports
  416. def getScalars(self, loc):
  417. """Return scalars for each delta, for the given location.
  418. If interpolating many master-values at the same location,
  419. this function allows speed up by fetching the scalars once
  420. and using them with interpolateFromMastersAndScalars()."""
  421. return [
  422. supportScalar(
  423. loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges
  424. )
  425. for support in self.supports
  426. ]
  427. def getMasterScalars(self, targetLocation):
  428. """Return multipliers for each master, 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 interpolateFromValuesAndScalars().
  432. Note that the scalars used in interpolateFromMastersAndScalars(),
  433. are *not* the same as the ones returned here. They are the result
  434. of getScalars()."""
  435. out = self.getScalars(targetLocation)
  436. for i, weights in reversed(list(enumerate(self.deltaWeights))):
  437. for j, weight in weights.items():
  438. out[j] -= out[i] * weight
  439. out = [out[self.mapping[i]] for i in range(len(out))]
  440. return out
  441. @staticmethod
  442. def interpolateFromValuesAndScalars(values, scalars):
  443. """Interpolate from values and scalars coefficients.
  444. If the values are master-values, then the scalars should be
  445. fetched from getMasterScalars().
  446. If the values are deltas, then the scalars should be fetched
  447. from getScalars(); in which case this is the same as
  448. interpolateFromDeltasAndScalars().
  449. """
  450. v = None
  451. assert len(values) == len(scalars)
  452. for value, scalar in zip(values, scalars):
  453. if not scalar:
  454. continue
  455. contribution = value * scalar
  456. if v is None:
  457. v = contribution
  458. else:
  459. v += contribution
  460. return v
  461. @staticmethod
  462. def interpolateFromDeltasAndScalars(deltas, scalars):
  463. """Interpolate from deltas and scalars fetched from getScalars()."""
  464. return VariationModel.interpolateFromValuesAndScalars(deltas, scalars)
  465. def interpolateFromDeltas(self, loc, deltas):
  466. """Interpolate from deltas, at location loc."""
  467. scalars = self.getScalars(loc)
  468. return self.interpolateFromDeltasAndScalars(deltas, scalars)
  469. def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
  470. """Interpolate from master-values, at location loc."""
  471. scalars = self.getMasterScalars(loc)
  472. return self.interpolateFromValuesAndScalars(masterValues, scalars)
  473. def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
  474. """Interpolate from master-values, and scalars fetched from
  475. getScalars(), which is useful when you want to interpolate
  476. multiple master-values with the same location."""
  477. deltas = self.getDeltas(masterValues, round=round)
  478. return self.interpolateFromDeltasAndScalars(deltas, scalars)
  479. def piecewiseLinearMap(v, mapping):
  480. keys = mapping.keys()
  481. if not keys:
  482. return v
  483. if v in keys:
  484. return mapping[v]
  485. k = min(keys)
  486. if v < k:
  487. return v + mapping[k] - k
  488. k = max(keys)
  489. if v > k:
  490. return v + mapping[k] - k
  491. # Interpolate
  492. a = max(k for k in keys if k < v)
  493. b = min(k for k in keys if k > v)
  494. va = mapping[a]
  495. vb = mapping[b]
  496. return va + (vb - va) * (v - a) / (b - a)
  497. def main(args=None):
  498. """Normalize locations on a given designspace"""
  499. from fontTools import configLogger
  500. import argparse
  501. parser = argparse.ArgumentParser(
  502. "fonttools varLib.models",
  503. description=main.__doc__,
  504. )
  505. parser.add_argument(
  506. "--loglevel",
  507. metavar="LEVEL",
  508. default="INFO",
  509. help="Logging level (defaults to INFO)",
  510. )
  511. group = parser.add_mutually_exclusive_group(required=True)
  512. group.add_argument("-d", "--designspace", metavar="DESIGNSPACE", type=str)
  513. group.add_argument(
  514. "-l",
  515. "--locations",
  516. metavar="LOCATION",
  517. nargs="+",
  518. help="Master locations as comma-separate coordinates. One must be all zeros.",
  519. )
  520. args = parser.parse_args(args)
  521. configLogger(level=args.loglevel)
  522. from pprint import pprint
  523. if args.designspace:
  524. from fontTools.designspaceLib import DesignSpaceDocument
  525. doc = DesignSpaceDocument()
  526. doc.read(args.designspace)
  527. locs = [s.location for s in doc.sources]
  528. print("Original locations:")
  529. pprint(locs)
  530. doc.normalize()
  531. print("Normalized locations:")
  532. locs = [s.location for s in doc.sources]
  533. pprint(locs)
  534. else:
  535. axes = [chr(c) for c in range(ord("A"), ord("Z") + 1)]
  536. locs = [
  537. dict(zip(axes, (float(v) for v in s.split(",")))) for s in args.locations
  538. ]
  539. model = VariationModel(locs)
  540. print("Sorted locations:")
  541. pprint(model.locations)
  542. print("Supports:")
  543. pprint(model.supports)
  544. if __name__ == "__main__":
  545. import doctest, sys
  546. if len(sys.argv) > 1:
  547. sys.exit(main())
  548. sys.exit(doctest.testmod().failed)