Spaces:
Paused
Paused
| """ | |
| Instantiate a variation font. Run, eg: | |
| .. code-block:: sh | |
| $ 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.: | |
| .. code-block:: | |
| {'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() | |
| 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 < 256 or 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) | |