mutator.py 19 KB

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