|
""" |
|
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") |
|
|
|
|
|
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: |
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
|
|
|
|
|
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]) |
|
|
|
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 |
|
|
|
|
|
boundsPen.init() |
|
charstring = charstrings[gname] |
|
charstring.draw(boundsPen) |
|
if boundsPen.bounds is None: |
|
|
|
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: |
|
|
|
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()} |
|
|
|
loc = {k: floatToFixedToFloat(v, 14) for k, v in loc.items()} |
|
|
|
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) |
|
|
|
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"]) |
|
|
|
|
|
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() |
|
|
|
addidef = any(op.startswith("GETVARIATION") for op in instructions) |
|
if addidef: |
|
break |
|
if overlap: |
|
for glyph_name in glyf.keys(): |
|
glyph = glyf[glyph_name] |
|
|
|
if glyph.isComposite(): |
|
glyph.components[0].flags |= OVERLAP_COMPOUND |
|
|
|
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) |
|
|
|
|
|
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: |
|
|
|
|
|
|
|
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) |
|
|