| """ |
| 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] += 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() 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 '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).") |
| 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) |
| |
| 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) |