mutator.py 19 KB

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