unbuild.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. from fontTools.varLib.models import VariationModel
  2. from fontTools.varLib.varStore import VarStoreInstancer
  3. from fontTools.misc.fixedTools import fixedToFloat as fi2fl
  4. from itertools import product
  5. import sys
  6. def _denormalize(v, axis):
  7. if v >= 0:
  8. return axis.defaultValue + v * (axis.maxValue - axis.defaultValue)
  9. else:
  10. return axis.defaultValue + v * (axis.defaultValue - axis.minValue)
  11. def _pruneLocations(locations, poles, axisTags):
  12. # Now we have all the input locations, find which ones are
  13. # not needed and remove them.
  14. # Note: This algorithm is heavily tied to how VariationModel
  15. # is implemented. It assumes that input was extracted from
  16. # VariationModel-generated object, like an ItemVariationStore
  17. # created by fontmake using varLib.models.VariationModel.
  18. # Some CoPilot blabbering:
  19. # I *think* I can prove that this algorithm is correct, but
  20. # I'm not 100% sure. It's possible that there are edge cases
  21. # where this algorithm will fail. I'm not sure how to prove
  22. # that it's correct, but I'm also not sure how to prove that
  23. # it's incorrect. I'm not sure how to write a test case that
  24. # would prove that it's incorrect. I'm not sure how to write
  25. # a test case that would prove that it's correct.
  26. model = VariationModel(locations, axisTags)
  27. modelMapping = model.mapping
  28. modelSupports = model.supports
  29. pins = {tuple(k.items()): None for k in poles}
  30. for location in poles:
  31. i = locations.index(location)
  32. i = modelMapping[i]
  33. support = modelSupports[i]
  34. supportAxes = set(support.keys())
  35. for axisTag, (minV, _, maxV) in support.items():
  36. for v in (minV, maxV):
  37. if v in (-1, 0, 1):
  38. continue
  39. for pin in pins.keys():
  40. pinLocation = dict(pin)
  41. pinAxes = set(pinLocation.keys())
  42. if pinAxes != supportAxes:
  43. continue
  44. if axisTag not in pinAxes:
  45. continue
  46. if pinLocation[axisTag] == v:
  47. break
  48. else:
  49. # No pin found. Go through the previous masters
  50. # and find a suitable pin. Going backwards is
  51. # better because it can find a pin that is close
  52. # to the pole in more dimensions, and reducing
  53. # the total number of pins needed.
  54. for candidateIdx in range(i - 1, -1, -1):
  55. candidate = modelSupports[candidateIdx]
  56. candidateAxes = set(candidate.keys())
  57. if candidateAxes != supportAxes:
  58. continue
  59. if axisTag not in candidateAxes:
  60. continue
  61. candidate = {
  62. k: defaultV for k, (_, defaultV, _) in candidate.items()
  63. }
  64. if candidate[axisTag] == v:
  65. pins[tuple(candidate.items())] = None
  66. break
  67. else:
  68. assert False, "No pin found"
  69. return [dict(t) for t in pins.keys()]
  70. def mappings_from_avar(font, denormalize=True):
  71. fvarAxes = font["fvar"].axes
  72. axisMap = {a.axisTag: a for a in fvarAxes}
  73. axisTags = [a.axisTag for a in fvarAxes]
  74. axisIndexes = {a.axisTag: i for i, a in enumerate(fvarAxes)}
  75. if "avar" not in font:
  76. return {}, []
  77. avar = font["avar"]
  78. axisMaps = {
  79. tag: seg
  80. for tag, seg in avar.segments.items()
  81. if seg and seg != {-1: -1, 0: 0, 1: 1}
  82. }
  83. mappings = []
  84. if getattr(avar, "majorVersion", 1) == 2:
  85. varStore = avar.table.VarStore
  86. regions = varStore.VarRegionList.Region
  87. # Find all the input locations; this finds "poles", that are
  88. # locations of the peaks, and "corners", that are locations
  89. # of the corners of the regions. These two sets of locations
  90. # together constitute inputLocations to consider.
  91. poles = {(): None} # Just using it as an ordered set
  92. inputLocations = set({()})
  93. for varData in varStore.VarData:
  94. regionIndices = varData.VarRegionIndex
  95. for regionIndex in regionIndices:
  96. peakLocation = []
  97. corners = []
  98. region = regions[regionIndex]
  99. for axisIndex, axis in enumerate(region.VarRegionAxis):
  100. if axis.PeakCoord == 0:
  101. continue
  102. axisTag = axisTags[axisIndex]
  103. peakLocation.append((axisTag, axis.PeakCoord))
  104. corner = []
  105. if axis.StartCoord != 0:
  106. corner.append((axisTag, axis.StartCoord))
  107. if axis.EndCoord != 0:
  108. corner.append((axisTag, axis.EndCoord))
  109. corners.append(corner)
  110. corners = set(product(*corners))
  111. peakLocation = tuple(peakLocation)
  112. poles[peakLocation] = None
  113. inputLocations.add(peakLocation)
  114. inputLocations.update(corners)
  115. # Sort them by number of axes, then by axis order
  116. inputLocations = [
  117. dict(t)
  118. for t in sorted(
  119. inputLocations,
  120. key=lambda t: (len(t), tuple(axisIndexes[tag] for tag, _ in t)),
  121. )
  122. ]
  123. poles = [dict(t) for t in poles.keys()]
  124. inputLocations = _pruneLocations(inputLocations, list(poles), axisTags)
  125. # Find the output locations, at input locations
  126. varIdxMap = avar.table.VarIdxMap
  127. instancer = VarStoreInstancer(varStore, fvarAxes)
  128. for location in inputLocations:
  129. instancer.setLocation(location)
  130. outputLocation = {}
  131. for axisIndex, axisTag in enumerate(axisTags):
  132. varIdx = axisIndex
  133. if varIdxMap is not None:
  134. varIdx = varIdxMap[varIdx]
  135. delta = instancer[varIdx]
  136. if delta != 0:
  137. v = location.get(axisTag, 0)
  138. v = v + fi2fl(delta, 14)
  139. # See https://github.com/fonttools/fonttools/pull/3598#issuecomment-2266082009
  140. # v = max(-1, min(1, v))
  141. outputLocation[axisTag] = v
  142. mappings.append((location, outputLocation))
  143. # Remove base master we added, if it maps to the default location
  144. assert mappings[0][0] == {}
  145. if mappings[0][1] == {}:
  146. mappings.pop(0)
  147. if denormalize:
  148. for tag, seg in axisMaps.items():
  149. if tag not in axisMap:
  150. raise ValueError(f"Unknown axis tag {tag}")
  151. denorm = lambda v: _denormalize(v, axisMap[tag])
  152. axisMaps[tag] = {denorm(k): denorm(v) for k, v in seg.items()}
  153. for i, (inputLoc, outputLoc) in enumerate(mappings):
  154. inputLoc = {
  155. tag: _denormalize(val, axisMap[tag]) for tag, val in inputLoc.items()
  156. }
  157. outputLoc = {
  158. tag: _denormalize(val, axisMap[tag]) for tag, val in outputLoc.items()
  159. }
  160. mappings[i] = (inputLoc, outputLoc)
  161. return axisMaps, mappings
  162. def unbuild(font, f=sys.stdout):
  163. fvar = font["fvar"]
  164. axes = fvar.axes
  165. segments, mappings = mappings_from_avar(font)
  166. if "name" in font:
  167. name = font["name"]
  168. axisNames = {
  169. axis.axisTag: name.getDebugName(axis.axisNameID) or axis.axisTag
  170. for axis in axes
  171. }
  172. else:
  173. axisNames = {a.axisTag: a.axisTag for a in axes}
  174. print("<?xml version='1.0' encoding='UTF-8'?>", file=f)
  175. print('<designspace format="5.1">', file=f)
  176. print(" <axes>", file=f)
  177. for axis in axes:
  178. axisName = axisNames[axis.axisTag]
  179. triplet = (axis.minValue, axis.defaultValue, axis.maxValue)
  180. triplet = [int(v) if v == int(v) else v for v in triplet]
  181. axisMap = segments.get(axis.axisTag)
  182. closing = "/>" if axisMap is None else ">"
  183. print(
  184. f' <axis tag="{axis.axisTag}" name="{axisName}" minimum="{triplet[0]}" maximum="{triplet[2]}" default="{triplet[1]}"{closing}',
  185. file=f,
  186. )
  187. if axisMap is not None:
  188. for k in sorted(axisMap.keys()):
  189. v = axisMap[k]
  190. k = int(k) if k == int(k) else k
  191. v = int(v) if v == int(v) else v
  192. print(f' <map input="{k}" output="{v}"/>', file=f)
  193. print(" </axis>", file=f)
  194. if mappings:
  195. print(" <mappings>", file=f)
  196. for inputLoc, outputLoc in mappings:
  197. print(" <mapping>", file=f)
  198. print(" <input>", file=f)
  199. for tag in sorted(inputLoc.keys()):
  200. v = inputLoc[tag]
  201. v = int(v) if v == int(v) else v
  202. print(
  203. f' <dimension name="{axisNames[tag]}" xvalue="{v}"/>',
  204. file=f,
  205. )
  206. print(" </input>", file=f)
  207. print(" <output>", file=f)
  208. for tag in sorted(outputLoc.keys()):
  209. v = outputLoc[tag]
  210. v = int(v) if v == int(v) else v
  211. print(
  212. f' <dimension name="{axisNames[tag]}" xvalue="{v}"/>',
  213. file=f,
  214. )
  215. print(" </output>", file=f)
  216. print(" </mapping>", file=f)
  217. print(" </mappings>", file=f)
  218. print(" </axes>", file=f)
  219. print("</designspace>", file=f)
  220. def main(args=None):
  221. """Print `avar` table as a designspace snippet."""
  222. if args is None:
  223. args = sys.argv[1:]
  224. from fontTools.ttLib import TTFont
  225. import argparse
  226. parser = argparse.ArgumentParser(
  227. "fonttools varLib.avar.unbuild",
  228. description="Print `avar` table as a designspace snippet.",
  229. )
  230. parser.add_argument("font", metavar="varfont.ttf", help="Variable-font file.")
  231. options = parser.parse_args(args)
  232. font = TTFont(options.font)
  233. if "fvar" not in font:
  234. print("Not a variable font.", file=sys.stderr)
  235. return 1
  236. unbuild(font)
  237. if __name__ == "__main__":
  238. import sys
  239. sys.exit(main())