mutator.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  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. glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
  181. if glyf[name].isComposite() or glyf[name].isVarComposite()
  182. else 0,
  183. name,
  184. ),
  185. )
  186. for glyphname in glyphnames:
  187. variations = gvar.variations[glyphname]
  188. coordinates, _ = glyf._getCoordinatesAndControls(
  189. glyphname, hMetrics, vMetrics
  190. )
  191. origCoords, endPts = None, None
  192. for var in variations:
  193. scalar = supportScalar(loc, var.axes)
  194. if not scalar:
  195. continue
  196. delta = var.coordinates
  197. if None in delta:
  198. if origCoords is None:
  199. origCoords, g = glyf._getCoordinatesAndControls(
  200. glyphname, hMetrics, vMetrics
  201. )
  202. delta = iup_delta(delta, origCoords, g.endPts)
  203. coordinates += GlyphCoordinates(delta) * scalar
  204. glyf._setCoordinates(glyphname, coordinates, hMetrics, vMetrics)
  205. else:
  206. glyf = None
  207. if "DSIG" in varfont:
  208. del varfont["DSIG"]
  209. if "cvar" in varfont:
  210. log.info("Mutating cvt/cvar tables")
  211. cvar = varfont["cvar"]
  212. cvt = varfont["cvt "]
  213. deltas = {}
  214. for var in cvar.variations:
  215. scalar = supportScalar(loc, var.axes)
  216. if not scalar:
  217. continue
  218. for i, c in enumerate(var.coordinates):
  219. if c is not None:
  220. deltas[i] = deltas.get(i, 0) + scalar * c
  221. for i, delta in deltas.items():
  222. cvt[i] += otRound(delta)
  223. if "CFF2" in varfont:
  224. log.info("Mutating CFF2 table")
  225. glyphOrder = varfont.getGlyphOrder()
  226. CFF2 = varfont["CFF2"]
  227. topDict = CFF2.cff.topDictIndex[0]
  228. vsInstancer = VarStoreInstancer(topDict.VarStore.otVarStore, fvar.axes, loc)
  229. interpolateFromDeltas = vsInstancer.interpolateFromDeltas
  230. interpolate_cff2_PrivateDict(topDict, interpolateFromDeltas)
  231. CFF2.desubroutinize()
  232. interpolate_cff2_charstrings(topDict, interpolateFromDeltas, glyphOrder)
  233. interpolate_cff2_metrics(varfont, topDict, glyphOrder, loc)
  234. del topDict.rawDict["VarStore"]
  235. del topDict.VarStore
  236. if "MVAR" in varfont:
  237. log.info("Mutating MVAR table")
  238. mvar = varfont["MVAR"].table
  239. varStoreInstancer = VarStoreInstancer(mvar.VarStore, fvar.axes, loc)
  240. records = mvar.ValueRecord
  241. for rec in records:
  242. mvarTag = rec.ValueTag
  243. if mvarTag not in MVAR_ENTRIES:
  244. continue
  245. tableTag, itemName = MVAR_ENTRIES[mvarTag]
  246. delta = otRound(varStoreInstancer[rec.VarIdx])
  247. if not delta:
  248. continue
  249. setattr(
  250. varfont[tableTag],
  251. itemName,
  252. getattr(varfont[tableTag], itemName) + delta,
  253. )
  254. log.info("Mutating FeatureVariations")
  255. for tableTag in "GSUB", "GPOS":
  256. if not tableTag in varfont:
  257. continue
  258. table = varfont[tableTag].table
  259. if not getattr(table, "FeatureVariations", None):
  260. continue
  261. variations = table.FeatureVariations
  262. for record in variations.FeatureVariationRecord:
  263. applies = True
  264. for condition in record.ConditionSet.ConditionTable:
  265. if condition.Format == 1:
  266. axisIdx = condition.AxisIndex
  267. axisTag = fvar.axes[axisIdx].axisTag
  268. Min = condition.FilterRangeMinValue
  269. Max = condition.FilterRangeMaxValue
  270. v = loc[axisTag]
  271. if not (Min <= v <= Max):
  272. applies = False
  273. else:
  274. applies = False
  275. if not applies:
  276. break
  277. if applies:
  278. assert record.FeatureTableSubstitution.Version == 0x00010000
  279. for rec in record.FeatureTableSubstitution.SubstitutionRecord:
  280. table.FeatureList.FeatureRecord[
  281. rec.FeatureIndex
  282. ].Feature = rec.Feature
  283. break
  284. del table.FeatureVariations
  285. if "GDEF" in varfont and varfont["GDEF"].table.Version >= 0x00010003:
  286. log.info("Mutating GDEF/GPOS/GSUB tables")
  287. gdef = varfont["GDEF"].table
  288. instancer = VarStoreInstancer(gdef.VarStore, fvar.axes, loc)
  289. merger = MutatorMerger(varfont, instancer)
  290. merger.mergeTables(varfont, [varfont], ["GDEF", "GPOS"])
  291. # Downgrade GDEF.
  292. del gdef.VarStore
  293. gdef.Version = 0x00010002
  294. if gdef.MarkGlyphSetsDef is None:
  295. del gdef.MarkGlyphSetsDef
  296. gdef.Version = 0x00010000
  297. if not (
  298. gdef.LigCaretList
  299. or gdef.MarkAttachClassDef
  300. or gdef.GlyphClassDef
  301. or gdef.AttachList
  302. or (gdef.Version >= 0x00010002 and gdef.MarkGlyphSetsDef)
  303. ):
  304. del varfont["GDEF"]
  305. addidef = False
  306. if glyf:
  307. for glyph in glyf.glyphs.values():
  308. if hasattr(glyph, "program"):
  309. instructions = glyph.program.getAssembly()
  310. # If GETVARIATION opcode is used in bytecode of any glyph add IDEF
  311. addidef = any(op.startswith("GETVARIATION") for op in instructions)
  312. if addidef:
  313. break
  314. if overlap:
  315. for glyph_name in glyf.keys():
  316. glyph = glyf[glyph_name]
  317. # Set OVERLAP_COMPOUND bit for compound glyphs
  318. if glyph.isComposite():
  319. glyph.components[0].flags |= OVERLAP_COMPOUND
  320. # Set OVERLAP_SIMPLE bit for simple glyphs
  321. elif glyph.numberOfContours > 0:
  322. glyph.flags[0] |= flagOverlapSimple
  323. if addidef:
  324. log.info("Adding IDEF to fpgm table for GETVARIATION opcode")
  325. asm = []
  326. if "fpgm" in varfont:
  327. fpgm = varfont["fpgm"]
  328. asm = fpgm.program.getAssembly()
  329. else:
  330. fpgm = newTable("fpgm")
  331. fpgm.program = ttProgram.Program()
  332. varfont["fpgm"] = fpgm
  333. asm.append("PUSHB[000] 145")
  334. asm.append("IDEF[ ]")
  335. args = [str(len(loc))]
  336. for a in fvar.axes:
  337. args.append(str(floatToFixed(loc[a.axisTag], 14)))
  338. asm.append("NPUSHW[ ] " + " ".join(args))
  339. asm.append("ENDF[ ]")
  340. fpgm.program.fromAssembly(asm)
  341. # Change maxp attributes as IDEF is added
  342. if "maxp" in varfont:
  343. maxp = varfont["maxp"]
  344. setattr(
  345. maxp, "maxInstructionDefs", 1 + getattr(maxp, "maxInstructionDefs", 0)
  346. )
  347. setattr(
  348. maxp,
  349. "maxStackElements",
  350. max(len(loc), getattr(maxp, "maxStackElements", 0)),
  351. )
  352. if "name" in varfont:
  353. log.info("Pruning name table")
  354. exclude = {a.axisNameID for a in fvar.axes}
  355. for i in fvar.instances:
  356. exclude.add(i.subfamilyNameID)
  357. exclude.add(i.postscriptNameID)
  358. if "ltag" in varfont:
  359. # Drop the whole 'ltag' table if all its language tags are referenced by
  360. # name records to be pruned.
  361. # TODO: prune unused ltag tags and re-enumerate langIDs accordingly
  362. excludedUnicodeLangIDs = [
  363. n.langID
  364. for n in varfont["name"].names
  365. if n.nameID in exclude and n.platformID == 0 and n.langID != 0xFFFF
  366. ]
  367. if set(excludedUnicodeLangIDs) == set(range(len((varfont["ltag"].tags)))):
  368. del varfont["ltag"]
  369. varfont["name"].names[:] = [
  370. n for n in varfont["name"].names if n.nameID not in exclude
  371. ]
  372. if "wght" in location and "OS/2" in varfont:
  373. varfont["OS/2"].usWeightClass = otRound(max(1, min(location["wght"], 1000)))
  374. if "wdth" in location:
  375. wdth = location["wdth"]
  376. for percent, widthClass in sorted(OS2_WIDTH_CLASS_VALUES.items()):
  377. if wdth < percent:
  378. varfont["OS/2"].usWidthClass = widthClass
  379. break
  380. else:
  381. varfont["OS/2"].usWidthClass = 9
  382. if "slnt" in location and "post" in varfont:
  383. varfont["post"].italicAngle = max(-90, min(location["slnt"], 90))
  384. log.info("Removing variable tables")
  385. for tag in ("avar", "cvar", "fvar", "gvar", "HVAR", "MVAR", "VVAR", "STAT"):
  386. if tag in varfont:
  387. del varfont[tag]
  388. return varfont
  389. def main(args=None):
  390. """Instantiate a variation font"""
  391. from fontTools import configLogger
  392. import argparse
  393. parser = argparse.ArgumentParser(
  394. "fonttools varLib.mutator", description="Instantiate a variable font"
  395. )
  396. parser.add_argument("input", metavar="INPUT.ttf", help="Input variable TTF file.")
  397. parser.add_argument(
  398. "locargs",
  399. metavar="AXIS=LOC",
  400. nargs="*",
  401. help="List of space separated locations. A location consist in "
  402. "the name of a variation axis, followed by '=' and a number. E.g.: "
  403. " wght=700 wdth=80. The default is the location of the base master.",
  404. )
  405. parser.add_argument(
  406. "-o",
  407. "--output",
  408. metavar="OUTPUT.ttf",
  409. default=None,
  410. help="Output instance TTF file (default: INPUT-instance.ttf).",
  411. )
  412. parser.add_argument(
  413. "--no-recalc-timestamp",
  414. dest="recalc_timestamp",
  415. action="store_false",
  416. help="Don't set the output font's timestamp to the current time.",
  417. )
  418. logging_group = parser.add_mutually_exclusive_group(required=False)
  419. logging_group.add_argument(
  420. "-v", "--verbose", action="store_true", help="Run more verbosely."
  421. )
  422. logging_group.add_argument(
  423. "-q", "--quiet", action="store_true", help="Turn verbosity off."
  424. )
  425. parser.add_argument(
  426. "--no-overlap",
  427. dest="overlap",
  428. action="store_false",
  429. help="Don't set OVERLAP_SIMPLE/OVERLAP_COMPOUND glyf flags.",
  430. )
  431. options = parser.parse_args(args)
  432. varfilename = options.input
  433. outfile = (
  434. os.path.splitext(varfilename)[0] + "-instance.ttf"
  435. if not options.output
  436. else options.output
  437. )
  438. configLogger(
  439. level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO")
  440. )
  441. loc = {}
  442. for arg in options.locargs:
  443. try:
  444. tag, val = arg.split("=")
  445. assert len(tag) <= 4
  446. loc[tag.ljust(4)] = float(val)
  447. except (ValueError, AssertionError):
  448. parser.error("invalid location argument format: %r" % arg)
  449. log.info("Location: %s", loc)
  450. log.info("Loading variable font")
  451. varfont = TTFont(varfilename, recalcTimestamp=options.recalc_timestamp)
  452. instantiateVariableFont(varfont, loc, inplace=True, overlap=options.overlap)
  453. log.info("Saving instance font %s", outfile)
  454. varfont.save(outfile)
  455. if __name__ == "__main__":
  456. import sys
  457. if len(sys.argv) > 1:
  458. sys.exit(main())
  459. import doctest
  460. sys.exit(doctest.testmod().failed)