models.py 22 KB

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