avar.py 9.4 KB

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