123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- """
- Instantiate a variation font. Run, eg:
- $ fonttools varLib.mutator ./NotoSansArabic-VF.ttf wght=140 wdth=85
- """
- from fontTools.misc.fixedTools import floatToFixedToFloat, floatToFixed
- from fontTools.misc.roundTools import otRound
- from fontTools.pens.boundsPen import BoundsPen
- from fontTools.ttLib import TTFont, newTable
- from fontTools.ttLib.tables import ttProgram
- from fontTools.ttLib.tables._g_l_y_f import (
- GlyphCoordinates,
- flagOverlapSimple,
- OVERLAP_COMPOUND,
- )
- from fontTools.varLib.models import (
- supportScalar,
- normalizeLocation,
- piecewiseLinearMap,
- )
- from fontTools.varLib.merger import MutatorMerger
- from fontTools.varLib.varStore import VarStoreInstancer
- from fontTools.varLib.mvar import MVAR_ENTRIES
- from fontTools.varLib.iup import iup_delta
- import fontTools.subset.cff
- import os.path
- import logging
- from io import BytesIO
- log = logging.getLogger("fontTools.varlib.mutator")
- # map 'wdth' axis (1..200) to OS/2.usWidthClass (1..9), rounding to closest
- OS2_WIDTH_CLASS_VALUES = {}
- percents = [50.0, 62.5, 75.0, 87.5, 100.0, 112.5, 125.0, 150.0, 200.0]
- for i, (prev, curr) in enumerate(zip(percents[:-1], percents[1:]), start=1):
- half = (prev + curr) / 2
- OS2_WIDTH_CLASS_VALUES[half] = i
- def interpolate_cff2_PrivateDict(topDict, interpolateFromDeltas):
- pd_blend_lists = (
- "BlueValues",
- "OtherBlues",
- "FamilyBlues",
- "FamilyOtherBlues",
- "StemSnapH",
- "StemSnapV",
- )
- pd_blend_values = ("BlueScale", "BlueShift", "BlueFuzz", "StdHW", "StdVW")
- for fontDict in topDict.FDArray:
- pd = fontDict.Private
- vsindex = pd.vsindex if (hasattr(pd, "vsindex")) else 0
- for key, value in pd.rawDict.items():
- if (key in pd_blend_values) and isinstance(value, list):
- delta = interpolateFromDeltas(vsindex, value[1:])
- pd.rawDict[key] = otRound(value[0] + delta)
- elif (key in pd_blend_lists) and isinstance(value[0], list):
- """If any argument in a BlueValues list is a blend list,
- then they all are. The first value of each list is an
- absolute value. The delta tuples are calculated from
- relative master values, hence we need to append all the
- deltas to date to each successive absolute value."""
- delta = 0
- for i, val_list in enumerate(value):
- delta += otRound(interpolateFromDeltas(vsindex, val_list[1:]))
- value[i] = val_list[0] + delta
- def interpolate_cff2_charstrings(topDict, interpolateFromDeltas, glyphOrder):
- charstrings = topDict.CharStrings
- for gname in glyphOrder:
- # Interpolate charstring
- # e.g replace blend op args with regular args,
- # and use and discard vsindex op.
- charstring = charstrings[gname]
- new_program = []
- vsindex = 0
- last_i = 0
- for i, token in enumerate(charstring.program):
- if token == "vsindex":
- vsindex = charstring.program[i - 1]
- if last_i != 0:
- new_program.extend(charstring.program[last_i : i - 1])
- last_i = i + 1
- elif token == "blend":
- num_regions = charstring.getNumRegions(vsindex)
- numMasters = 1 + num_regions
- num_args = charstring.program[i - 1]
- # The program list starting at program[i] is now:
- # ..args for following operations
- # num_args values from the default font
- # num_args tuples, each with numMasters-1 delta values
- # num_blend_args
- # 'blend'
- argi = i - (num_args * numMasters + 1)
- end_args = tuplei = argi + num_args
- while argi < end_args:
- next_ti = tuplei + num_regions
- deltas = charstring.program[tuplei:next_ti]
- delta = interpolateFromDeltas(vsindex, deltas)
- charstring.program[argi] += otRound(delta)
- tuplei = next_ti
- argi += 1
- new_program.extend(charstring.program[last_i:end_args])
- last_i = i + 1
- if last_i != 0:
- new_program.extend(charstring.program[last_i:])
- charstring.program = new_program
- def interpolate_cff2_metrics(varfont, topDict, glyphOrder, loc):
- """Unlike TrueType glyphs, neither advance width nor bounding box
- info is stored in a CFF2 charstring. The width data exists only in
- the hmtx and HVAR tables. Since LSB data cannot be interpolated
- reliably from the master LSB values in the hmtx table, we traverse
- the charstring to determine the actual bound box."""
- charstrings = topDict.CharStrings
- boundsPen = BoundsPen(glyphOrder)
- hmtx = varfont["hmtx"]
- hvar_table = None
- if "HVAR" in varfont:
- hvar_table = varfont["HVAR"].table
- fvar = varfont["fvar"]
- varStoreInstancer = VarStoreInstancer(hvar_table.VarStore, fvar.axes, loc)
- for gid, gname in enumerate(glyphOrder):
- entry = list(hmtx[gname])
- # get width delta.
- if hvar_table:
- if hvar_table.AdvWidthMap:
- width_idx = hvar_table.AdvWidthMap.mapping[gname]
- else:
- width_idx = gid
- width_delta = otRound(varStoreInstancer[width_idx])
- else:
- width_delta = 0
- # get LSB.
- boundsPen.init()
- charstring = charstrings[gname]
- charstring.draw(boundsPen)
- if boundsPen.bounds is None:
- # Happens with non-marking glyphs
- lsb_delta = 0
- else:
- lsb = otRound(boundsPen.bounds[0])
- lsb_delta = entry[1] - lsb
- if lsb_delta or width_delta:
- if width_delta:
- entry[0] = max(0, entry[0] + width_delta)
- if lsb_delta:
- entry[1] = lsb
- hmtx[gname] = tuple(entry)
- def instantiateVariableFont(varfont, location, inplace=False, overlap=True):
- """Generate a static instance from a variable TTFont and a dictionary
- defining the desired location along the variable font's axes.
- The location values must be specified as user-space coordinates, e.g.:
- {'wght': 400, 'wdth': 100}
- By default, a new TTFont object is returned. If ``inplace`` is True, the
- input varfont is modified and reduced to a static font.
- When the overlap parameter is defined as True,
- OVERLAP_SIMPLE and OVERLAP_COMPOUND bits are set to 1. See
- https://docs.microsoft.com/en-us/typography/opentype/spec/glyf
- """
- if not inplace:
- # make a copy to leave input varfont unmodified
- stream = BytesIO()
- varfont.save(stream)
- stream.seek(0)
- varfont = TTFont(stream)
- fvar = varfont["fvar"]
- axes = {a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in fvar.axes}
- loc = normalizeLocation(location, axes)
- if "avar" in varfont:
- maps = varfont["avar"].segments
- loc = {k: piecewiseLinearMap(v, maps[k]) for k, v in loc.items()}
- # Quantize to F2Dot14, to avoid surprise interpolations.
- loc = {k: floatToFixedToFloat(v, 14) for k, v in loc.items()}
- # Location is normalized now
- log.info("Normalized location: %s", loc)
- if "gvar" in varfont:
- log.info("Mutating glyf/gvar tables")
- gvar = varfont["gvar"]
- glyf = varfont["glyf"]
- hMetrics = varfont["hmtx"].metrics
- vMetrics = getattr(varfont.get("vmtx"), "metrics", None)
- # get list of glyph names in gvar sorted by component depth
- glyphnames = sorted(
- gvar.variations.keys(),
- key=lambda name: (
- glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
- if glyf[name].isComposite() or glyf[name].isVarComposite()
- else 0,
- name,
- ),
- )
- for glyphname in glyphnames:
- variations = gvar.variations[glyphname]
- coordinates, _ = glyf._getCoordinatesAndControls(
- glyphname, hMetrics, vMetrics
- )
- origCoords, endPts = None, None
- for var in variations:
- scalar = supportScalar(loc, var.axes)
- if not scalar:
- continue
- delta = var.coordinates
- if None in delta:
- if origCoords is None:
- origCoords, g = glyf._getCoordinatesAndControls(
- glyphname, hMetrics, vMetrics
- )
- delta = iup_delta(delta, origCoords, g.endPts)
- coordinates += GlyphCoordinates(delta) * scalar
- glyf._setCoordinates(glyphname, coordinates, hMetrics, vMetrics)
- else:
- glyf = None
- if "DSIG" in varfont:
- del varfont["DSIG"]
- if "cvar" in varfont:
- log.info("Mutating cvt/cvar tables")
- cvar = varfont["cvar"]
- cvt = varfont["cvt "]
- deltas = {}
- for var in cvar.variations:
- scalar = supportScalar(loc, var.axes)
- if not scalar:
- continue
- for i, c in enumerate(var.coordinates):
- if c is not None:
- deltas[i] = deltas.get(i, 0) + scalar * c
- for i, delta in deltas.items():
- cvt[i] += otRound(delta)
- if "CFF2" in varfont:
- log.info("Mutating CFF2 table")
- glyphOrder = varfont.getGlyphOrder()
- CFF2 = varfont["CFF2"]
- topDict = CFF2.cff.topDictIndex[0]
- vsInstancer = VarStoreInstancer(topDict.VarStore.otVarStore, fvar.axes, loc)
- interpolateFromDeltas = vsInstancer.interpolateFromDeltas
- interpolate_cff2_PrivateDict(topDict, interpolateFromDeltas)
- CFF2.desubroutinize()
- interpolate_cff2_charstrings(topDict, interpolateFromDeltas, glyphOrder)
- interpolate_cff2_metrics(varfont, topDict, glyphOrder, loc)
- del topDict.rawDict["VarStore"]
- del topDict.VarStore
- if "MVAR" in varfont:
- log.info("Mutating MVAR table")
- mvar = varfont["MVAR"].table
- varStoreInstancer = VarStoreInstancer(mvar.VarStore, fvar.axes, loc)
- records = mvar.ValueRecord
- for rec in records:
- mvarTag = rec.ValueTag
- if mvarTag not in MVAR_ENTRIES:
- continue
- tableTag, itemName = MVAR_ENTRIES[mvarTag]
- delta = otRound(varStoreInstancer[rec.VarIdx])
- if not delta:
- continue
- setattr(
- varfont[tableTag],
- itemName,
- getattr(varfont[tableTag], itemName) + delta,
- )
- log.info("Mutating FeatureVariations")
- for tableTag in "GSUB", "GPOS":
- if not tableTag in varfont:
- continue
- table = varfont[tableTag].table
- if not getattr(table, "FeatureVariations", None):
- continue
- variations = table.FeatureVariations
- for record in variations.FeatureVariationRecord:
- applies = True
- for condition in record.ConditionSet.ConditionTable:
- if condition.Format == 1:
- axisIdx = condition.AxisIndex
- axisTag = fvar.axes[axisIdx].axisTag
- Min = condition.FilterRangeMinValue
- Max = condition.FilterRangeMaxValue
- v = loc[axisTag]
- if not (Min <= v <= Max):
- applies = False
- else:
- applies = False
- if not applies:
- break
- if applies:
- assert record.FeatureTableSubstitution.Version == 0x00010000
- for rec in record.FeatureTableSubstitution.SubstitutionRecord:
- table.FeatureList.FeatureRecord[
- rec.FeatureIndex
- ].Feature = rec.Feature
- break
- del table.FeatureVariations
- if "GDEF" in varfont and varfont["GDEF"].table.Version >= 0x00010003:
- log.info("Mutating GDEF/GPOS/GSUB tables")
- gdef = varfont["GDEF"].table
- instancer = VarStoreInstancer(gdef.VarStore, fvar.axes, loc)
- merger = MutatorMerger(varfont, instancer)
- merger.mergeTables(varfont, [varfont], ["GDEF", "GPOS"])
- # Downgrade GDEF.
- del gdef.VarStore
- gdef.Version = 0x00010002
- if gdef.MarkGlyphSetsDef is None:
- del gdef.MarkGlyphSetsDef
- gdef.Version = 0x00010000
- if not (
- gdef.LigCaretList
- or gdef.MarkAttachClassDef
- or gdef.GlyphClassDef
- or gdef.AttachList
- or (gdef.Version >= 0x00010002 and gdef.MarkGlyphSetsDef)
- ):
- del varfont["GDEF"]
- addidef = False
- if glyf:
- for glyph in glyf.glyphs.values():
- if hasattr(glyph, "program"):
- instructions = glyph.program.getAssembly()
- # If GETVARIATION opcode is used in bytecode of any glyph add IDEF
- addidef = any(op.startswith("GETVARIATION") for op in instructions)
- if addidef:
- break
- if overlap:
- for glyph_name in glyf.keys():
- glyph = glyf[glyph_name]
- # Set OVERLAP_COMPOUND bit for compound glyphs
- if glyph.isComposite():
- glyph.components[0].flags |= OVERLAP_COMPOUND
- # Set OVERLAP_SIMPLE bit for simple glyphs
- elif glyph.numberOfContours > 0:
- glyph.flags[0] |= flagOverlapSimple
- if addidef:
- log.info("Adding IDEF to fpgm table for GETVARIATION opcode")
- asm = []
- if "fpgm" in varfont:
- fpgm = varfont["fpgm"]
- asm = fpgm.program.getAssembly()
- else:
- fpgm = newTable("fpgm")
- fpgm.program = ttProgram.Program()
- varfont["fpgm"] = fpgm
- asm.append("PUSHB[000] 145")
- asm.append("IDEF[ ]")
- args = [str(len(loc))]
- for a in fvar.axes:
- args.append(str(floatToFixed(loc[a.axisTag], 14)))
- asm.append("NPUSHW[ ] " + " ".join(args))
- asm.append("ENDF[ ]")
- fpgm.program.fromAssembly(asm)
- # Change maxp attributes as IDEF is added
- if "maxp" in varfont:
- maxp = varfont["maxp"]
- setattr(
- maxp, "maxInstructionDefs", 1 + getattr(maxp, "maxInstructionDefs", 0)
- )
- setattr(
- maxp,
- "maxStackElements",
- max(len(loc), getattr(maxp, "maxStackElements", 0)),
- )
- if "name" in varfont:
- log.info("Pruning name table")
- exclude = {a.axisNameID for a in fvar.axes}
- for i in fvar.instances:
- exclude.add(i.subfamilyNameID)
- exclude.add(i.postscriptNameID)
- if "ltag" in varfont:
- # Drop the whole 'ltag' table if all its language tags are referenced by
- # name records to be pruned.
- # TODO: prune unused ltag tags and re-enumerate langIDs accordingly
- excludedUnicodeLangIDs = [
- n.langID
- for n in varfont["name"].names
- if n.nameID in exclude and n.platformID == 0 and n.langID != 0xFFFF
- ]
- if set(excludedUnicodeLangIDs) == set(range(len((varfont["ltag"].tags)))):
- del varfont["ltag"]
- varfont["name"].names[:] = [
- n for n in varfont["name"].names if n.nameID not in exclude
- ]
- if "wght" in location and "OS/2" in varfont:
- varfont["OS/2"].usWeightClass = otRound(max(1, min(location["wght"], 1000)))
- if "wdth" in location:
- wdth = location["wdth"]
- for percent, widthClass in sorted(OS2_WIDTH_CLASS_VALUES.items()):
- if wdth < percent:
- varfont["OS/2"].usWidthClass = widthClass
- break
- else:
- varfont["OS/2"].usWidthClass = 9
- if "slnt" in location and "post" in varfont:
- varfont["post"].italicAngle = max(-90, min(location["slnt"], 90))
- log.info("Removing variable tables")
- for tag in ("avar", "cvar", "fvar", "gvar", "HVAR", "MVAR", "VVAR", "STAT"):
- if tag in varfont:
- del varfont[tag]
- return varfont
- def main(args=None):
- """Instantiate a variation font"""
- from fontTools import configLogger
- import argparse
- parser = argparse.ArgumentParser(
- "fonttools varLib.mutator", description="Instantiate a variable font"
- )
- parser.add_argument("input", metavar="INPUT.ttf", help="Input variable TTF file.")
- parser.add_argument(
- "locargs",
- metavar="AXIS=LOC",
- nargs="*",
- help="List of space separated locations. A location consist in "
- "the name of a variation axis, followed by '=' and a number. E.g.: "
- " wght=700 wdth=80. The default is the location of the base master.",
- )
- parser.add_argument(
- "-o",
- "--output",
- metavar="OUTPUT.ttf",
- default=None,
- help="Output instance TTF file (default: INPUT-instance.ttf).",
- )
- parser.add_argument(
- "--no-recalc-timestamp",
- dest="recalc_timestamp",
- action="store_false",
- help="Don't set the output font's timestamp to the current time.",
- )
- logging_group = parser.add_mutually_exclusive_group(required=False)
- logging_group.add_argument(
- "-v", "--verbose", action="store_true", help="Run more verbosely."
- )
- logging_group.add_argument(
- "-q", "--quiet", action="store_true", help="Turn verbosity off."
- )
- parser.add_argument(
- "--no-overlap",
- dest="overlap",
- action="store_false",
- help="Don't set OVERLAP_SIMPLE/OVERLAP_COMPOUND glyf flags.",
- )
- options = parser.parse_args(args)
- varfilename = options.input
- outfile = (
- os.path.splitext(varfilename)[0] + "-instance.ttf"
- if not options.output
- else options.output
- )
- configLogger(
- level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO")
- )
- loc = {}
- for arg in options.locargs:
- try:
- tag, val = arg.split("=")
- assert len(tag) <= 4
- loc[tag.ljust(4)] = float(val)
- except (ValueError, AssertionError):
- parser.error("invalid location argument format: %r" % arg)
- log.info("Location: %s", loc)
- log.info("Loading variable font")
- varfont = TTFont(varfilename, recalcTimestamp=options.recalc_timestamp)
- instantiateVariableFont(varfont, loc, inplace=True, overlap=options.overlap)
- log.info("Saving instance font %s", outfile)
- varfont.save(outfile)
- if __name__ == "__main__":
- import sys
- if len(sys.argv) > 1:
- sys.exit(main())
- import doctest
- sys.exit(doctest.testmod().failed)
|