Spaces:
Sleeping
Sleeping
""" | |
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 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) | |