mutator.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. """
  2. Instantiate a variation font. Run, eg:
  3. .. code-block:: sh
  4. $ fonttools varLib.mutator ./NotoSansArabic-VF.ttf wght=140 wdth=85
  5. """
  6. from fontTools.misc.fixedTools import floatToFixedToFloat, floatToFixed
  7. from fontTools.misc.roundTools import otRound
  8. from fontTools.pens.boundsPen import BoundsPen
  9. from fontTools.ttLib import TTFont, newTable
  10. from fontTools.ttLib.tables import ttProgram
  11. from fontTools.ttLib.tables._g_l_y_f import (
  12. GlyphCoordinates,
  13. flagOverlapSimple,
  14. OVERLAP_COMPOUND,
  15. )
  16. from fontTools.varLib.models import (
  17. supportScalar,
  18. normalizeLocation,
  19. piecewiseLinearMap,
  20. )
  21. from fontTools.varLib.merger import MutatorMerger
  22. from fontTools.varLib.varStore import VarStoreInstancer
  23. from fontTools.varLib.mvar import MVAR_ENTRIES
  24. from fontTools.varLib.iup import iup_delta
  25. import fontTools.subset.cff
  26. import os.path
  27. import logging
  28. from io import BytesIO
  29. log = logging.getLogger("fontTools.varlib.mutator")
  30. # map 'wdth' axis (1..200) to OS/2.usWidthClass (1..9), rounding to closest
  31. OS2_WIDTH_CLASS_VALUES = {}
  32. percents = [50.0, 62.5, 75.0, 87.5, 100.0, 112.5, 125.0, 150.0, 200.0]
  33. for i, (prev, curr) in enumerate(zip(percents[:-1], percents[1:]), start=1):
  34. half = (prev + curr) / 2
  35. OS2_WIDTH_CLASS_VALUES[half] = i
  36. def interpolate_cff2_PrivateDict(topDict, interpolateFromDeltas):
  37. pd_blend_lists = (
  38. "BlueValues",
  39. "OtherBlues",
  40. "FamilyBlues",
  41. "FamilyOtherBlues",
  42. "StemSnapH",
  43. "StemSnapV",
  44. )
  45. pd_blend_values = ("BlueScale", "BlueShift", "BlueFuzz", "StdHW", "StdVW")
  46. for fontDict in topDict.FDArray:
  47. pd = fontDict.Private
  48. vsindex = pd.vsindex if (hasattr(pd, "vsindex")) else 0
  49. for key, value in pd.rawDict.items():
  50. if (key in pd_blend_values) and isinstance(value, list):
  51. delta = interpolateFromDeltas(vsindex, value[1:])
  52. pd.rawDict[key] = otRound(value[0] + delta)
  53. elif (key in pd_blend_lists) and isinstance(value[0], list):
  54. """If any argument in a BlueValues list is a blend list,
  55. then they all are. The first value of each list is an
  56. absolute value. The delta tuples are calculated from
  57. relative master values, hence we need to append all the
  58. deltas to date to each successive absolute value."""
  59. delta = 0
  60. for i, val_list in enumerate(value):
  61. delta += otRound(interpolateFromDeltas(vsindex, val_list[1:]))
  62. value[i] = val_list[0] + delta
  63. def interpolate_cff2_charstrings(topDict, interpolateFromDeltas, glyphOrder):
  64. charstrings = topDict.CharStrings
  65. for gname in glyphOrder:
  66. # Interpolate charstring
  67. # e.g replace blend op args with regular args,
  68. # and use and discard vsindex op.
  69. charstring = charstrings[gname]
  70. new_program = []
  71. vsindex = 0
  72. last_i = 0
  73. for i, token in enumerate(charstring.program):
  74. if token == "vsindex":
  75. vsindex = charstring.program[i - 1]
  76. if last_i != 0:
  77. new_program.extend(charstring.program[last_i : i - 1])
  78. last_i = i + 1
  79. elif token == "blend":
  80. num_regions = charstring.getNumRegions(vsindex)
  81. numMasters = 1 + num_regions
  82. num_args = charstring.program[i - 1]
  83. # The program list starting at program[i] is now:
  84. # ..args for following operations
  85. # num_args values from the default font
  86. # num_args tuples, each with numMasters-1 delta values
  87. # num_blend_args
  88. # 'blend'
  89. argi = i - (num_args * numMasters + 1)
  90. end_args = tuplei = argi + num_args
  91. while argi < end_args:
  92. next_ti = tuplei + num_regions
  93. deltas = charstring.program[tuplei:next_ti]
  94. delta = interpolateFromDeltas(vsindex, deltas)
  95. charstring.program[argi] += otRound(delta)
  96. tuplei = next_ti
  97. argi += 1
  98. new_program.extend(charstring.program[last_i:end_args])
  99. last_i = i + 1
  100. if last_i != 0:
  101. new_program.extend(charstring.program[last_i:])
  102. charstring.program = new_program
  103. def interpolate_cff2_metrics(varfont, topDict, glyphOrder, loc):
  104. """Unlike TrueType glyphs, neither advance width nor bounding box
  105. info is stored in a CFF2 charstring. The width data exists only in
  106. the hmtx and HVAR tables. Since LSB data cannot be interpolated
  107. reliably from the master LSB values in the hmtx table, we traverse
  108. the charstring to determine the actual bound box."""
  109. charstrings = topDict.CharStrings
  110. boundsPen = BoundsPen(glyphOrder)
  111. hmtx = varfont["hmtx"]
  112. hvar_table = None
  113. if "HVAR" in varfont:
  114. hvar_table = varfont["HVAR"].table
  115. fvar = varfont["fvar"]
  116. varStoreInstancer = VarStoreInstancer(hvar_table.VarStore, fvar.axes, loc)
  117. for gid, gname in enumerate(glyphOrder):
  118. entry = list(hmtx[gname])
  119. # get width delta.
  120. if hvar_table:
  121. if hvar_table.AdvWidthMap:
  122. width_idx = hvar_table.AdvWidthMap.mapping[gname]
  123. else:
  124. width_idx = gid
  125. width_delta = otRound(varStoreInstancer[width_idx])
  126. else:
  127. width_delta = 0
  128. # get LSB.
  129. boundsPen.init()
  130. charstring = charstrings[gname]
  131. charstring.draw(boundsPen)
  132. if boundsPen.bounds is None:
  133. # Happens with non-marking glyphs
  134. lsb_delta = 0
  135. else:
  136. lsb = otRound(boundsPen.bounds[0])
  137. lsb_delta = entry[1] - lsb
  138. if lsb_delta or width_delta:
  139. if width_delta:
  140. entry[0] = max(0, entry[0] + width_delta)
  141. if lsb_delta:
  142. entry[1] = lsb
  143. hmtx[gname] = tuple(entry)
  144. def instantiateVariableFont(varfont, location, inplace=False, overlap=True):
  145. """Generate a static instance from a variable TTFont and a dictionary
  146. defining the desired location along the variable font's axes.
  147. The location values must be specified as user-space coordinates, e.g.:
  148. .. code-block::
  149. {'wght': 400, 'wdth': 100}
  150. By default, a new TTFont object is returned. If ``inplace`` is True, the
  151. input varfont is modified and reduced to a static font.
  152. When the overlap parameter is defined as True,
  153. OVERLAP_SIMPLE and OVERLAP_COMPOUND bits are set to 1. See
  154. https://docs.microsoft.com/en-us/typography/opentype/spec/glyf
  155. """
  156. if not inplace:
  157. # make a copy to leave input varfont unmodified
  158. stream = BytesIO()
  159. varfont.save(stream)
  160. stream.seek(0)
  161. varfont = TTFont(stream)
  162. fvar = varfont["fvar"]
  163. axes = {a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in fvar.axes}
  164. loc = normalizeLocation(location, axes)
  165. if "avar" in varfont:
  166. maps = varfont["avar"].segments
  167. loc = {k: piecewiseLinearMap(v, maps[k]) for k, v in loc.items()}
  168. # Quantize to F2Dot14, to avoid surprise interpolations.
  169. loc = {k: floatToFixedToFloat(v, 14) for k, v in loc.items()}
  170. # Location is normalized now
  171. log.info("Normalized location: %s", loc)
  172. if "gvar" in varfont:
  173. log.info("Mutating glyf/gvar tables")
  174. gvar = varfont["gvar"]
  175. glyf = varfont["glyf"]
  176. hMetrics = varfont["hmtx"].metrics
  177. vMetrics = getattr(varfont.get("vmtx"), "metrics", None)
  178. # get list of glyph names in gvar sorted by component depth
  179. glyphnames = sorted(
  180. gvar.variations.keys(),
  181. key=lambda name: (
  182. (
  183. glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
  184. if glyf[name].isComposite()
  185. else 0
  186. ),
  187. name,
  188. ),
  189. )
  190. for glyphname in glyphnames:
  191. variations = gvar.variations[glyphname]
  192. coordinates, _ = glyf._getCoordinatesAndControls(
  193. glyphname, hMetrics, vMetrics
  194. )
  195. origCoords, endPts = None, None
  196. for var in variations:
  197. scalar = supportScalar(loc, var.axes)
  198. if not scalar:
  199. continue
  200. delta = var.coordinates
  201. if None in delta:
  202. if origCoords is None:
  203. origCoords, g = glyf._getCoordinatesAndControls(
  204. glyphname, hMetrics, vMetrics
  205. )
  206. delta = iup_delta(delta, origCoords, g.endPts)
  207. coordinates += GlyphCoordinates(delta) * scalar
  208. glyf._setCoordinates(glyphname, coordinates, hMetrics, vMetrics)
  209. else:
  210. glyf = None
  211. if "DSIG" in varfont:
  212. del varfont["DSIG"]
  213. if "cvar" in varfont:
  214. log.info("Mutating cvt/cvar tables")
  215. cvar = varfont["cvar"]
  216. cvt = varfont["cvt "]
  217. deltas = {}
  218. for var in cvar.variations:
  219. scalar = supportScalar(loc, var.axes)
  220. if not scalar:
  221. continue
  222. for i, c in enumerate(var.coordinates):
  223. if c is not None:
  224. deltas[i] = deltas.get(i, 0) + scalar * c
  225. for i, delta in deltas.items():
  226. cvt[i] += otRound(delta)
  227. if "CFF2" in varfont:
  228. log.info("Mutating CFF2 table")
  229. glyphOrder = varfont.getGlyphOrder()
  230. CFF2 = varfont["CFF2"]
  231. topDict = CFF2.cff.topDictIndex[0]
  232. vsInstancer = VarStoreInstancer(topDict.VarStore.otVarStore, fvar.axes, loc)
  233. interpolateFromDeltas = vsInstancer.interpolateFromDeltas
  234. interpolate_cff2_PrivateDict(topDict, interpolateFromDeltas)
  235. CFF2.desubroutinize()
  236. interpolate_cff2_charstrings(topDict, interpolateFromDeltas, glyphOrder)
  237. interpolate_cff2_metrics(varfont, topDict, glyphOrder, loc)
  238. del topDict.rawDict["VarStore"]
  239. del topDict.VarStore
  240. if "MVAR" in varfont:
  241. log.info("Mutating MVAR table")
  242. mvar = varfont["MVAR"].table
  243. varStoreInstancer = VarStoreInstancer(mvar.VarStore, fvar.axes, loc)
  244. records = mvar.ValueRecord
  245. for rec in records:
  246. mvarTag = rec.ValueTag
  247. if mvarTag not in MVAR_ENTRIES:
  248. continue
  249. tableTag, itemName = MVAR_ENTRIES[mvarTag]
  250. delta = otRound(varStoreInstancer[rec.VarIdx])
  251. if not delta:
  252. continue
  253. setattr(
  254. varfont[tableTag],
  255. itemName,
  256. getattr(varfont[tableTag], itemName) + delta,
  257. )
  258. log.info("Mutating FeatureVariations")
  259. for tableTag in "GSUB", "GPOS":
  260. if not tableTag in varfont:
  261. continue
  262. table = varfont[tableTag].table
  263. if not getattr(table, "FeatureVariations", None):
  264. continue
  265. variations = table.FeatureVariations
  266. for record in variations.FeatureVariationRecord:
  267. applies = True
  268. for condition in record.ConditionSet.ConditionTable:
  269. if condition.Format == 1:
  270. axisIdx = condition.AxisIndex
  271. axisTag = fvar.axes[axisIdx].axisTag
  272. Min = condition.FilterRangeMinValue
  273. Max = condition.FilterRangeMaxValue
  274. v = loc[axisTag]
  275. if not (Min <= v <= Max):
  276. applies = False
  277. else:
  278. applies = False
  279. if not applies:
  280. break
  281. if applies:
  282. assert record.FeatureTableSubstitution.Version == 0x00010000
  283. for rec in record.FeatureTableSubstitution.SubstitutionRecord:
  284. table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature = (
  285. rec.Feature
  286. )
  287. break
  288. del table.FeatureVariations
  289. if "GDEF" in varfont and varfont["GDEF"].table.Version >= 0x00010003:
  290. log.info("Mutating GDEF/GPOS/GSUB tables")
  291. gdef = varfont["GDEF"].table
  292. instancer = VarStoreInstancer(gdef.VarStore, fvar.axes, loc)
  293. merger = MutatorMerger(varfont, instancer)
  294. merger.mergeTables(varfont, [varfont], ["GDEF", "GPOS"])
  295. # Downgrade GDEF.
  296. del gdef.VarStore
  297. gdef.Version = 0x00010002
  298. if gdef.MarkGlyphSetsDef is None:
  299. del gdef.MarkGlyphSetsDef
  300. gdef.Version = 0x00010000
  301. if not (
  302. gdef.LigCaretList
  303. or gdef.MarkAttachClassDef
  304. or gdef.GlyphClassDef
  305. or gdef.AttachList
  306. or (gdef.Version >= 0x00010002 and gdef.MarkGlyphSetsDef)
  307. ):
  308. del varfont["GDEF"]
  309. addidef = False
  310. if glyf:
  311. for glyph in glyf.glyphs.values():
  312. if hasattr(glyph, "program"):
  313. instructions = glyph.program.getAssembly()
  314. # If GETVARIATION opcode is used in bytecode of any glyph add IDEF
  315. addidef = any(op.startswith("GETVARIATION") for op in instructions)
  316. if addidef:
  317. break
  318. if overlap:
  319. for glyph_name in glyf.keys():
  320. glyph = glyf[glyph_name]
  321. # Set OVERLAP_COMPOUND bit for compound glyphs
  322. if glyph.isComposite():
  323. glyph.components[0].flags |= OVERLAP_COMPOUND
  324. # Set OVERLAP_SIMPLE bit for simple glyphs
  325. elif glyph.numberOfContours > 0:
  326. glyph.flags[0] |= flagOverlapSimple
  327. if addidef:
  328. log.info("Adding IDEF to fpgm table for GETVARIATION opcode")
  329. asm = []
  330. if "fpgm" in varfont:
  331. fpgm = varfont["fpgm"]
  332. asm = fpgm.program.getAssembly()
  333. else:
  334. fpgm = newTable("fpgm")
  335. fpgm.program = ttProgram.Program()
  336. varfont["fpgm"] = fpgm
  337. asm.append("PUSHB[000] 145")
  338. asm.append("IDEF[ ]")
  339. args = [str(len(loc))]
  340. for a in fvar.axes:
  341. args.append(str(floatToFixed(loc[a.axisTag], 14)))
  342. asm.append("NPUSHW[ ] " + " ".join(args))
  343. asm.append("ENDF[ ]")
  344. fpgm.program.fromAssembly(asm)
  345. # Change maxp attributes as IDEF is added
  346. if "maxp" in varfont:
  347. maxp = varfont["maxp"]
  348. setattr(
  349. maxp, "maxInstructionDefs", 1 + getattr(maxp, "maxInstructionDefs", 0)
  350. )
  351. setattr(
  352. maxp,
  353. "maxStackElements",
  354. max(len(loc), getattr(maxp, "maxStackElements", 0)),
  355. )
  356. if "name" in varfont:
  357. log.info("Pruning name table")
  358. exclude = {a.axisNameID for a in fvar.axes}
  359. for i in fvar.instances:
  360. exclude.add(i.subfamilyNameID)
  361. exclude.add(i.postscriptNameID)
  362. if "ltag" in varfont:
  363. # Drop the whole 'ltag' table if all its language tags are referenced by
  364. # name records to be pruned.
  365. # TODO: prune unused ltag tags and re-enumerate langIDs accordingly
  366. excludedUnicodeLangIDs = [
  367. n.langID
  368. for n in varfont["name"].names
  369. if n.nameID in exclude and n.platformID == 0 and n.langID != 0xFFFF
  370. ]
  371. if set(excludedUnicodeLangIDs) == set(range(len((varfont["ltag"].tags)))):
  372. del varfont["ltag"]
  373. varfont["name"].names[:] = [
  374. n
  375. for n in varfont["name"].names
  376. if n.nameID < 256 or n.nameID not in exclude
  377. ]
  378. if "wght" in location and "OS/2" in varfont:
  379. varfont["OS/2"].usWeightClass = otRound(max(1, min(location["wght"], 1000)))
  380. if "wdth" in location:
  381. wdth = location["wdth"]
  382. for percent, widthClass in sorted(OS2_WIDTH_CLASS_VALUES.items()):
  383. if wdth < percent:
  384. varfont["OS/2"].usWidthClass = widthClass
  385. break
  386. else:
  387. varfont["OS/2"].usWidthClass = 9
  388. if "slnt" in location and "post" in varfont:
  389. varfont["post"].italicAngle = max(-90, min(location["slnt"], 90))
  390. log.info("Removing variable tables")
  391. for tag in ("avar", "cvar", "fvar", "gvar", "HVAR", "MVAR", "VVAR", "STAT"):
  392. if tag in varfont:
  393. del varfont[tag]
  394. return varfont
  395. def main(args=None):
  396. """Instantiate a variation font"""
  397. from fontTools import configLogger
  398. import argparse
  399. parser = argparse.ArgumentParser(
  400. "fonttools varLib.mutator", description="Instantiate a variable font"
  401. )
  402. parser.add_argument("input", metavar="INPUT.ttf", help="Input variable TTF file.")
  403. parser.add_argument(
  404. "locargs",
  405. metavar="AXIS=LOC",
  406. nargs="*",
  407. help="List of space separated locations. A location consist in "
  408. "the name of a variation axis, followed by '=' and a number. E.g.: "
  409. " wght=700 wdth=80. The default is the location of the base master.",
  410. )
  411. parser.add_argument(
  412. "-o",
  413. "--output",
  414. metavar="OUTPUT.ttf",
  415. default=None,
  416. help="Output instance TTF file (default: INPUT-instance.ttf).",
  417. )
  418. parser.add_argument(
  419. "--no-recalc-timestamp",
  420. dest="recalc_timestamp",
  421. action="store_false",
  422. help="Don't set the output font's timestamp to the current time.",
  423. )
  424. logging_group = parser.add_mutually_exclusive_group(required=False)
  425. logging_group.add_argument(
  426. "-v", "--verbose", action="store_true", help="Run more verbosely."
  427. )
  428. logging_group.add_argument(
  429. "-q", "--quiet", action="store_true", help="Turn verbosity off."
  430. )
  431. parser.add_argument(
  432. "--no-overlap",
  433. dest="overlap",
  434. action="store_false",
  435. help="Don't set OVERLAP_SIMPLE/OVERLAP_COMPOUND glyf flags.",
  436. )
  437. options = parser.parse_args(args)
  438. varfilename = options.input
  439. outfile = (
  440. os.path.splitext(varfilename)[0] + "-instance.ttf"
  441. if not options.output
  442. else options.output
  443. )
  444. configLogger(
  445. level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO")
  446. )
  447. loc = {}
  448. for arg in options.locargs:
  449. try:
  450. tag, val = arg.split("=")
  451. assert len(tag) <= 4
  452. loc[tag.ljust(4)] = float(val)
  453. except (ValueError, AssertionError):
  454. parser.error("invalid location argument format: %r" % arg)
  455. log.info("Location: %s", loc)
  456. log.info("Loading variable font")
  457. varfont = TTFont(varfilename, recalcTimestamp=options.recalc_timestamp)
  458. instantiateVariableFont(varfont, loc, inplace=True, overlap=options.overlap)
  459. log.info("Saving instance font %s", outfile)
  460. varfont.save(outfile)
  461. if __name__ == "__main__":
  462. import sys
  463. if len(sys.argv) > 1:
  464. sys.exit(main())
  465. import doctest
  466. sys.exit(doctest.testmod().failed)