1496 lines
52 KiB
Python
1496 lines
52 KiB
Python
|
"""
|
||
|
Module for dealing with 'gvar'-style font variations, also known as run-time
|
||
|
interpolation.
|
||
|
|
||
|
The ideas here are very similar to MutatorMath. There is even code to read
|
||
|
MutatorMath .designspace files in the varLib.designspace module.
|
||
|
|
||
|
For now, if you run this file on a designspace file, it tries to find
|
||
|
ttf-interpolatable files for the masters and build a variable-font from
|
||
|
them. Such ttf-interpolatable and designspace files can be generated from
|
||
|
a Glyphs source, eg., using noto-source as an example:
|
||
|
|
||
|
$ fontmake -o ttf-interpolatable -g NotoSansArabic-MM.glyphs
|
||
|
|
||
|
Then you can make a variable-font this way:
|
||
|
|
||
|
$ fonttools varLib master_ufo/NotoSansArabic.designspace
|
||
|
|
||
|
API *will* change in near future.
|
||
|
"""
|
||
|
|
||
|
from typing import List
|
||
|
from fontTools.misc.vector import Vector
|
||
|
from fontTools.misc.roundTools import noRound, otRound
|
||
|
from fontTools.misc.fixedTools import floatToFixed as fl2fi
|
||
|
from fontTools.misc.textTools import Tag, tostr
|
||
|
from fontTools.ttLib import TTFont, newTable
|
||
|
from fontTools.ttLib.tables._f_v_a_r import Axis, NamedInstance
|
||
|
from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates, dropImpliedOnCurvePoints
|
||
|
from fontTools.ttLib.tables.ttProgram import Program
|
||
|
from fontTools.ttLib.tables.TupleVariation import TupleVariation
|
||
|
from fontTools.ttLib.tables import otTables as ot
|
||
|
from fontTools.ttLib.tables.otBase import OTTableWriter
|
||
|
from fontTools.varLib import builder, models, varStore
|
||
|
from fontTools.varLib.merger import VariationMerger, COLRVariationMerger
|
||
|
from fontTools.varLib.mvar import MVAR_ENTRIES
|
||
|
from fontTools.varLib.iup import iup_delta_optimize
|
||
|
from fontTools.varLib.featureVars import addFeatureVariations
|
||
|
from fontTools.designspaceLib import DesignSpaceDocument, InstanceDescriptor
|
||
|
from fontTools.designspaceLib.split import splitInterpolable, splitVariableFonts
|
||
|
from fontTools.varLib.stat import buildVFStatTable
|
||
|
from fontTools.colorLib.builder import buildColrV1
|
||
|
from fontTools.colorLib.unbuilder import unbuildColrV1
|
||
|
from functools import partial
|
||
|
from collections import OrderedDict, defaultdict, namedtuple
|
||
|
import os.path
|
||
|
import logging
|
||
|
from copy import deepcopy
|
||
|
from pprint import pformat
|
||
|
from re import fullmatch
|
||
|
from .errors import VarLibError, VarLibValidationError
|
||
|
|
||
|
log = logging.getLogger("fontTools.varLib")
|
||
|
|
||
|
# This is a lib key for the designspace document. The value should be
|
||
|
# a comma-separated list of OpenType feature tag(s), to be used as the
|
||
|
# FeatureVariations feature.
|
||
|
# If present, the DesignSpace <rules processing="..."> flag is ignored.
|
||
|
FEAVAR_FEATURETAG_LIB_KEY = "com.github.fonttools.varLib.featureVarsFeatureTag"
|
||
|
|
||
|
#
|
||
|
# Creation routines
|
||
|
#
|
||
|
|
||
|
|
||
|
def _add_fvar(font, axes, instances: List[InstanceDescriptor]):
|
||
|
"""
|
||
|
Add 'fvar' table to font.
|
||
|
|
||
|
axes is an ordered dictionary of DesignspaceAxis objects.
|
||
|
|
||
|
instances is list of dictionary objects with 'location', 'stylename',
|
||
|
and possibly 'postscriptfontname' entries.
|
||
|
"""
|
||
|
|
||
|
assert axes
|
||
|
assert isinstance(axes, OrderedDict)
|
||
|
|
||
|
log.info("Generating fvar")
|
||
|
|
||
|
fvar = newTable("fvar")
|
||
|
nameTable = font["name"]
|
||
|
|
||
|
for a in axes.values():
|
||
|
axis = Axis()
|
||
|
axis.axisTag = Tag(a.tag)
|
||
|
# TODO Skip axes that have no variation.
|
||
|
axis.minValue, axis.defaultValue, axis.maxValue = (
|
||
|
a.minimum,
|
||
|
a.default,
|
||
|
a.maximum,
|
||
|
)
|
||
|
axis.axisNameID = nameTable.addMultilingualName(
|
||
|
a.labelNames, font, minNameID=256
|
||
|
)
|
||
|
axis.flags = int(a.hidden)
|
||
|
fvar.axes.append(axis)
|
||
|
|
||
|
for instance in instances:
|
||
|
# Filter out discrete axis locations
|
||
|
coordinates = {
|
||
|
name: value for name, value in instance.location.items() if name in axes
|
||
|
}
|
||
|
|
||
|
if "en" not in instance.localisedStyleName:
|
||
|
if not instance.styleName:
|
||
|
raise VarLibValidationError(
|
||
|
f"Instance at location '{coordinates}' must have a default English "
|
||
|
"style name ('stylename' attribute on the instance element or a "
|
||
|
"stylename element with an 'xml:lang=\"en\"' attribute)."
|
||
|
)
|
||
|
localisedStyleName = dict(instance.localisedStyleName)
|
||
|
localisedStyleName["en"] = tostr(instance.styleName)
|
||
|
else:
|
||
|
localisedStyleName = instance.localisedStyleName
|
||
|
|
||
|
psname = instance.postScriptFontName
|
||
|
|
||
|
inst = NamedInstance()
|
||
|
inst.subfamilyNameID = nameTable.addMultilingualName(localisedStyleName)
|
||
|
if psname is not None:
|
||
|
psname = tostr(psname)
|
||
|
inst.postscriptNameID = nameTable.addName(psname)
|
||
|
inst.coordinates = {
|
||
|
axes[k].tag: axes[k].map_backward(v) for k, v in coordinates.items()
|
||
|
}
|
||
|
# inst.coordinates = {axes[k].tag:v for k,v in coordinates.items()}
|
||
|
fvar.instances.append(inst)
|
||
|
|
||
|
assert "fvar" not in font
|
||
|
font["fvar"] = fvar
|
||
|
|
||
|
return fvar
|
||
|
|
||
|
|
||
|
def _add_avar(font, axes, mappings, axisTags):
|
||
|
"""
|
||
|
Add 'avar' table to font.
|
||
|
|
||
|
axes is an ordered dictionary of AxisDescriptor objects.
|
||
|
"""
|
||
|
|
||
|
assert axes
|
||
|
assert isinstance(axes, OrderedDict)
|
||
|
|
||
|
log.info("Generating avar")
|
||
|
|
||
|
avar = newTable("avar")
|
||
|
|
||
|
interesting = False
|
||
|
vals_triples = {}
|
||
|
for axis in axes.values():
|
||
|
# Currently, some rasterizers require that the default value maps
|
||
|
# (-1 to -1, 0 to 0, and 1 to 1) be present for all the segment
|
||
|
# maps, even when the default normalization mapping for the axis
|
||
|
# was not modified.
|
||
|
# https://github.com/googlei18n/fontmake/issues/295
|
||
|
# https://github.com/fonttools/fonttools/issues/1011
|
||
|
# TODO(anthrotype) revert this (and 19c4b37) when issue is fixed
|
||
|
curve = avar.segments[axis.tag] = {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}
|
||
|
|
||
|
keys_triple = (axis.minimum, axis.default, axis.maximum)
|
||
|
vals_triple = tuple(axis.map_forward(v) for v in keys_triple)
|
||
|
vals_triples[axis.tag] = vals_triple
|
||
|
|
||
|
if not axis.map:
|
||
|
continue
|
||
|
|
||
|
items = sorted(axis.map)
|
||
|
keys = [item[0] for item in items]
|
||
|
vals = [item[1] for item in items]
|
||
|
|
||
|
# Current avar requirements. We don't have to enforce
|
||
|
# these on the designer and can deduce some ourselves,
|
||
|
# but for now just enforce them.
|
||
|
if axis.minimum != min(keys):
|
||
|
raise VarLibValidationError(
|
||
|
f"Axis '{axis.name}': there must be a mapping for the axis minimum "
|
||
|
f"value {axis.minimum} and it must be the lowest input mapping value."
|
||
|
)
|
||
|
if axis.maximum != max(keys):
|
||
|
raise VarLibValidationError(
|
||
|
f"Axis '{axis.name}': there must be a mapping for the axis maximum "
|
||
|
f"value {axis.maximum} and it must be the highest input mapping value."
|
||
|
)
|
||
|
if axis.default not in keys:
|
||
|
raise VarLibValidationError(
|
||
|
f"Axis '{axis.name}': there must be a mapping for the axis default "
|
||
|
f"value {axis.default}."
|
||
|
)
|
||
|
# No duplicate input values (output values can be >= their preceeding value).
|
||
|
if len(set(keys)) != len(keys):
|
||
|
raise VarLibValidationError(
|
||
|
f"Axis '{axis.name}': All axis mapping input='...' values must be "
|
||
|
"unique, but we found duplicates."
|
||
|
)
|
||
|
# Ascending values
|
||
|
if sorted(vals) != vals:
|
||
|
raise VarLibValidationError(
|
||
|
f"Axis '{axis.name}': mapping output values must be in ascending order."
|
||
|
)
|
||
|
|
||
|
keys = [models.normalizeValue(v, keys_triple) for v in keys]
|
||
|
vals = [models.normalizeValue(v, vals_triple) for v in vals]
|
||
|
|
||
|
if all(k == v for k, v in zip(keys, vals)):
|
||
|
continue
|
||
|
interesting = True
|
||
|
|
||
|
curve.update(zip(keys, vals))
|
||
|
|
||
|
assert 0.0 in curve and curve[0.0] == 0.0
|
||
|
assert -1.0 not in curve or curve[-1.0] == -1.0
|
||
|
assert +1.0 not in curve or curve[+1.0] == +1.0
|
||
|
# curve.update({-1.0: -1.0, 0.0: 0.0, 1.0: 1.0})
|
||
|
|
||
|
if mappings:
|
||
|
interesting = True
|
||
|
|
||
|
inputLocations = [
|
||
|
{
|
||
|
axes[name].tag: models.normalizeValue(v, vals_triples[axes[name].tag])
|
||
|
for name, v in mapping.inputLocation.items()
|
||
|
}
|
||
|
for mapping in mappings
|
||
|
]
|
||
|
outputLocations = [
|
||
|
{
|
||
|
axes[name].tag: models.normalizeValue(v, vals_triples[axes[name].tag])
|
||
|
for name, v in mapping.outputLocation.items()
|
||
|
}
|
||
|
for mapping in mappings
|
||
|
]
|
||
|
assert len(inputLocations) == len(outputLocations)
|
||
|
|
||
|
# If base-master is missing, insert it at zero location.
|
||
|
if not any(all(v == 0 for k, v in loc.items()) for loc in inputLocations):
|
||
|
inputLocations.insert(0, {})
|
||
|
outputLocations.insert(0, {})
|
||
|
|
||
|
model = models.VariationModel(inputLocations, axisTags)
|
||
|
storeBuilder = varStore.OnlineVarStoreBuilder(axisTags)
|
||
|
storeBuilder.setModel(model)
|
||
|
varIdxes = {}
|
||
|
for tag in axisTags:
|
||
|
masterValues = []
|
||
|
for vo, vi in zip(outputLocations, inputLocations):
|
||
|
if tag not in vo:
|
||
|
masterValues.append(0)
|
||
|
continue
|
||
|
v = vo[tag] - vi.get(tag, 0)
|
||
|
masterValues.append(fl2fi(v, 14))
|
||
|
varIdxes[tag] = storeBuilder.storeMasters(masterValues)[1]
|
||
|
|
||
|
store = storeBuilder.finish()
|
||
|
optimized = store.optimize()
|
||
|
varIdxes = {axis: optimized[value] for axis, value in varIdxes.items()}
|
||
|
|
||
|
varIdxMap = builder.buildDeltaSetIndexMap(varIdxes[t] for t in axisTags)
|
||
|
|
||
|
avar.majorVersion = 2
|
||
|
avar.table = ot.avar()
|
||
|
avar.table.VarIdxMap = varIdxMap
|
||
|
avar.table.VarStore = store
|
||
|
|
||
|
assert "avar" not in font
|
||
|
if not interesting:
|
||
|
log.info("No need for avar")
|
||
|
avar = None
|
||
|
else:
|
||
|
font["avar"] = avar
|
||
|
|
||
|
return avar
|
||
|
|
||
|
|
||
|
def _add_stat(font):
|
||
|
# Note: this function only gets called by old code that calls `build()`
|
||
|
# directly. Newer code that wants to benefit from STAT data from the
|
||
|
# designspace should call `build_many()`
|
||
|
|
||
|
if "STAT" in font:
|
||
|
return
|
||
|
|
||
|
from ..otlLib.builder import buildStatTable
|
||
|
|
||
|
fvarTable = font["fvar"]
|
||
|
axes = [dict(tag=a.axisTag, name=a.axisNameID) for a in fvarTable.axes]
|
||
|
buildStatTable(font, axes)
|
||
|
|
||
|
|
||
|
_MasterData = namedtuple("_MasterData", ["glyf", "hMetrics", "vMetrics"])
|
||
|
|
||
|
|
||
|
def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True):
|
||
|
if tolerance < 0:
|
||
|
raise ValueError("`tolerance` must be a positive number.")
|
||
|
|
||
|
log.info("Generating gvar")
|
||
|
assert "gvar" not in font
|
||
|
gvar = font["gvar"] = newTable("gvar")
|
||
|
glyf = font["glyf"]
|
||
|
defaultMasterIndex = masterModel.reverseMapping[0]
|
||
|
|
||
|
master_datas = [
|
||
|
_MasterData(
|
||
|
m["glyf"], m["hmtx"].metrics, getattr(m.get("vmtx"), "metrics", None)
|
||
|
)
|
||
|
for m in master_ttfs
|
||
|
]
|
||
|
|
||
|
for glyph in font.getGlyphOrder():
|
||
|
log.debug("building gvar for glyph '%s'", glyph)
|
||
|
isComposite = glyf[glyph].isComposite()
|
||
|
|
||
|
allData = [
|
||
|
m.glyf._getCoordinatesAndControls(glyph, m.hMetrics, m.vMetrics)
|
||
|
for m in master_datas
|
||
|
]
|
||
|
|
||
|
if allData[defaultMasterIndex][1].numberOfContours != 0:
|
||
|
# If the default master is not empty, interpret empty non-default masters
|
||
|
# as missing glyphs from a sparse master
|
||
|
allData = [
|
||
|
d if d is not None and d[1].numberOfContours != 0 else None
|
||
|
for d in allData
|
||
|
]
|
||
|
|
||
|
model, allData = masterModel.getSubModel(allData)
|
||
|
|
||
|
allCoords = [d[0] for d in allData]
|
||
|
allControls = [d[1] for d in allData]
|
||
|
control = allControls[0]
|
||
|
if not models.allEqual(allControls):
|
||
|
log.warning("glyph %s has incompatible masters; skipping" % glyph)
|
||
|
continue
|
||
|
del allControls
|
||
|
|
||
|
# Update gvar
|
||
|
gvar.variations[glyph] = []
|
||
|
deltas = model.getDeltas(
|
||
|
allCoords, round=partial(GlyphCoordinates.__round__, round=round)
|
||
|
)
|
||
|
supports = model.supports
|
||
|
assert len(deltas) == len(supports)
|
||
|
|
||
|
# Prepare for IUP optimization
|
||
|
origCoords = deltas[0]
|
||
|
endPts = control.endPts
|
||
|
|
||
|
for i, (delta, support) in enumerate(zip(deltas[1:], supports[1:])):
|
||
|
if all(v == 0 for v in delta.array) and not isComposite:
|
||
|
continue
|
||
|
var = TupleVariation(support, delta)
|
||
|
if optimize:
|
||
|
delta_opt = iup_delta_optimize(
|
||
|
delta, origCoords, endPts, tolerance=tolerance
|
||
|
)
|
||
|
|
||
|
if None in delta_opt:
|
||
|
"""In composite glyphs, there should be one 0 entry
|
||
|
to make sure the gvar entry is written to the font.
|
||
|
|
||
|
This is to work around an issue with macOS 10.14 and can be
|
||
|
removed once the behaviour of macOS is changed.
|
||
|
|
||
|
https://github.com/fonttools/fonttools/issues/1381
|
||
|
"""
|
||
|
if all(d is None for d in delta_opt):
|
||
|
delta_opt = [(0, 0)] + [None] * (len(delta_opt) - 1)
|
||
|
# Use "optimized" version only if smaller...
|
||
|
var_opt = TupleVariation(support, delta_opt)
|
||
|
|
||
|
axis_tags = sorted(
|
||
|
support.keys()
|
||
|
) # Shouldn't matter that this is different from fvar...?
|
||
|
tupleData, auxData = var.compile(axis_tags)
|
||
|
unoptimized_len = len(tupleData) + len(auxData)
|
||
|
tupleData, auxData = var_opt.compile(axis_tags)
|
||
|
optimized_len = len(tupleData) + len(auxData)
|
||
|
|
||
|
if optimized_len < unoptimized_len:
|
||
|
var = var_opt
|
||
|
|
||
|
gvar.variations[glyph].append(var)
|
||
|
|
||
|
|
||
|
def _remove_TTHinting(font):
|
||
|
for tag in ("cvar", "cvt ", "fpgm", "prep"):
|
||
|
if tag in font:
|
||
|
del font[tag]
|
||
|
maxp = font["maxp"]
|
||
|
for attr in (
|
||
|
"maxTwilightPoints",
|
||
|
"maxStorage",
|
||
|
"maxFunctionDefs",
|
||
|
"maxInstructionDefs",
|
||
|
"maxStackElements",
|
||
|
"maxSizeOfInstructions",
|
||
|
):
|
||
|
setattr(maxp, attr, 0)
|
||
|
maxp.maxZones = 1
|
||
|
font["glyf"].removeHinting()
|
||
|
# TODO: Modify gasp table to deactivate gridfitting for all ranges?
|
||
|
|
||
|
|
||
|
def _merge_TTHinting(font, masterModel, master_ttfs):
|
||
|
log.info("Merging TT hinting")
|
||
|
assert "cvar" not in font
|
||
|
|
||
|
# Check that the existing hinting is compatible
|
||
|
|
||
|
# fpgm and prep table
|
||
|
|
||
|
for tag in ("fpgm", "prep"):
|
||
|
all_pgms = [m[tag].program for m in master_ttfs if tag in m]
|
||
|
if not all_pgms:
|
||
|
continue
|
||
|
font_pgm = getattr(font.get(tag), "program", None)
|
||
|
if any(pgm != font_pgm for pgm in all_pgms):
|
||
|
log.warning(
|
||
|
"Masters have incompatible %s tables, hinting is discarded." % tag
|
||
|
)
|
||
|
_remove_TTHinting(font)
|
||
|
return
|
||
|
|
||
|
# glyf table
|
||
|
|
||
|
font_glyf = font["glyf"]
|
||
|
master_glyfs = [m["glyf"] for m in master_ttfs]
|
||
|
for name, glyph in font_glyf.glyphs.items():
|
||
|
all_pgms = [getattr(glyf.get(name), "program", None) for glyf in master_glyfs]
|
||
|
if not any(all_pgms):
|
||
|
continue
|
||
|
glyph.expand(font_glyf)
|
||
|
font_pgm = getattr(glyph, "program", None)
|
||
|
if any(pgm != font_pgm for pgm in all_pgms if pgm):
|
||
|
log.warning(
|
||
|
"Masters have incompatible glyph programs in glyph '%s', hinting is discarded."
|
||
|
% name
|
||
|
)
|
||
|
# TODO Only drop hinting from this glyph.
|
||
|
_remove_TTHinting(font)
|
||
|
return
|
||
|
|
||
|
# cvt table
|
||
|
|
||
|
all_cvs = [Vector(m["cvt "].values) if "cvt " in m else None for m in master_ttfs]
|
||
|
|
||
|
nonNone_cvs = models.nonNone(all_cvs)
|
||
|
if not nonNone_cvs:
|
||
|
# There is no cvt table to make a cvar table from, we're done here.
|
||
|
return
|
||
|
|
||
|
if not models.allEqual(len(c) for c in nonNone_cvs):
|
||
|
log.warning("Masters have incompatible cvt tables, hinting is discarded.")
|
||
|
_remove_TTHinting(font)
|
||
|
return
|
||
|
|
||
|
variations = []
|
||
|
deltas, supports = masterModel.getDeltasAndSupports(
|
||
|
all_cvs, round=round
|
||
|
) # builtin round calls into Vector.__round__, which uses builtin round as we like
|
||
|
for i, (delta, support) in enumerate(zip(deltas[1:], supports[1:])):
|
||
|
if all(v == 0 for v in delta):
|
||
|
continue
|
||
|
var = TupleVariation(support, delta)
|
||
|
variations.append(var)
|
||
|
|
||
|
# We can build the cvar table now.
|
||
|
if variations:
|
||
|
cvar = font["cvar"] = newTable("cvar")
|
||
|
cvar.version = 1
|
||
|
cvar.variations = variations
|
||
|
|
||
|
|
||
|
_MetricsFields = namedtuple(
|
||
|
"_MetricsFields",
|
||
|
["tableTag", "metricsTag", "sb1", "sb2", "advMapping", "vOrigMapping"],
|
||
|
)
|
||
|
|
||
|
HVAR_FIELDS = _MetricsFields(
|
||
|
tableTag="HVAR",
|
||
|
metricsTag="hmtx",
|
||
|
sb1="LsbMap",
|
||
|
sb2="RsbMap",
|
||
|
advMapping="AdvWidthMap",
|
||
|
vOrigMapping=None,
|
||
|
)
|
||
|
|
||
|
VVAR_FIELDS = _MetricsFields(
|
||
|
tableTag="VVAR",
|
||
|
metricsTag="vmtx",
|
||
|
sb1="TsbMap",
|
||
|
sb2="BsbMap",
|
||
|
advMapping="AdvHeightMap",
|
||
|
vOrigMapping="VOrgMap",
|
||
|
)
|
||
|
|
||
|
|
||
|
def _add_HVAR(font, masterModel, master_ttfs, axisTags):
|
||
|
_add_VHVAR(font, masterModel, master_ttfs, axisTags, HVAR_FIELDS)
|
||
|
|
||
|
|
||
|
def _add_VVAR(font, masterModel, master_ttfs, axisTags):
|
||
|
_add_VHVAR(font, masterModel, master_ttfs, axisTags, VVAR_FIELDS)
|
||
|
|
||
|
|
||
|
def _add_VHVAR(font, masterModel, master_ttfs, axisTags, tableFields):
|
||
|
tableTag = tableFields.tableTag
|
||
|
assert tableTag not in font
|
||
|
log.info("Generating " + tableTag)
|
||
|
VHVAR = newTable(tableTag)
|
||
|
tableClass = getattr(ot, tableTag)
|
||
|
vhvar = VHVAR.table = tableClass()
|
||
|
vhvar.Version = 0x00010000
|
||
|
|
||
|
glyphOrder = font.getGlyphOrder()
|
||
|
|
||
|
# Build list of source font advance widths for each glyph
|
||
|
metricsTag = tableFields.metricsTag
|
||
|
advMetricses = [m[metricsTag].metrics for m in master_ttfs]
|
||
|
|
||
|
# Build list of source font vertical origin coords for each glyph
|
||
|
if tableTag == "VVAR" and "VORG" in master_ttfs[0]:
|
||
|
vOrigMetricses = [m["VORG"].VOriginRecords for m in master_ttfs]
|
||
|
defaultYOrigs = [m["VORG"].defaultVertOriginY for m in master_ttfs]
|
||
|
vOrigMetricses = list(zip(vOrigMetricses, defaultYOrigs))
|
||
|
else:
|
||
|
vOrigMetricses = None
|
||
|
|
||
|
metricsStore, advanceMapping, vOrigMapping = _get_advance_metrics(
|
||
|
font,
|
||
|
masterModel,
|
||
|
master_ttfs,
|
||
|
axisTags,
|
||
|
glyphOrder,
|
||
|
advMetricses,
|
||
|
vOrigMetricses,
|
||
|
)
|
||
|
|
||
|
vhvar.VarStore = metricsStore
|
||
|
if advanceMapping is None:
|
||
|
setattr(vhvar, tableFields.advMapping, None)
|
||
|
else:
|
||
|
setattr(vhvar, tableFields.advMapping, advanceMapping)
|
||
|
if vOrigMapping is not None:
|
||
|
setattr(vhvar, tableFields.vOrigMapping, vOrigMapping)
|
||
|
setattr(vhvar, tableFields.sb1, None)
|
||
|
setattr(vhvar, tableFields.sb2, None)
|
||
|
|
||
|
font[tableTag] = VHVAR
|
||
|
return
|
||
|
|
||
|
|
||
|
def _get_advance_metrics(
|
||
|
font,
|
||
|
masterModel,
|
||
|
master_ttfs,
|
||
|
axisTags,
|
||
|
glyphOrder,
|
||
|
advMetricses,
|
||
|
vOrigMetricses=None,
|
||
|
):
|
||
|
vhAdvanceDeltasAndSupports = {}
|
||
|
vOrigDeltasAndSupports = {}
|
||
|
# HACK: we treat width 65535 as a sentinel value to signal that a glyph
|
||
|
# from a non-default master should not participate in computing {H,V}VAR,
|
||
|
# as if it were missing. Allows to variate other glyph-related data independently
|
||
|
# from glyph metrics
|
||
|
sparse_advance = 0xFFFF
|
||
|
for glyph in glyphOrder:
|
||
|
vhAdvances = [
|
||
|
(
|
||
|
metrics[glyph][0]
|
||
|
if glyph in metrics and metrics[glyph][0] != sparse_advance
|
||
|
else None
|
||
|
)
|
||
|
for metrics in advMetricses
|
||
|
]
|
||
|
vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(
|
||
|
vhAdvances, round=round
|
||
|
)
|
||
|
|
||
|
singleModel = models.allEqual(id(v[1]) for v in vhAdvanceDeltasAndSupports.values())
|
||
|
|
||
|
if vOrigMetricses:
|
||
|
singleModel = False
|
||
|
for glyph in glyphOrder:
|
||
|
# We need to supply a vOrigs tuple with non-None default values
|
||
|
# for each glyph. vOrigMetricses contains values only for those
|
||
|
# glyphs which have a non-default vOrig.
|
||
|
vOrigs = [
|
||
|
metrics[glyph] if glyph in metrics else defaultVOrig
|
||
|
for metrics, defaultVOrig in vOrigMetricses
|
||
|
]
|
||
|
vOrigDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(
|
||
|
vOrigs, round=round
|
||
|
)
|
||
|
|
||
|
directStore = None
|
||
|
if singleModel:
|
||
|
# Build direct mapping
|
||
|
supports = next(iter(vhAdvanceDeltasAndSupports.values()))[1][1:]
|
||
|
varTupleList = builder.buildVarRegionList(supports, axisTags)
|
||
|
varTupleIndexes = list(range(len(supports)))
|
||
|
varData = builder.buildVarData(varTupleIndexes, [], optimize=False)
|
||
|
for glyphName in glyphOrder:
|
||
|
varData.addItem(vhAdvanceDeltasAndSupports[glyphName][0], round=noRound)
|
||
|
varData.optimize()
|
||
|
directStore = builder.buildVarStore(varTupleList, [varData])
|
||
|
|
||
|
# Build optimized indirect mapping
|
||
|
storeBuilder = varStore.OnlineVarStoreBuilder(axisTags)
|
||
|
advMapping = {}
|
||
|
for glyphName in glyphOrder:
|
||
|
deltas, supports = vhAdvanceDeltasAndSupports[glyphName]
|
||
|
storeBuilder.setSupports(supports)
|
||
|
advMapping[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound)
|
||
|
|
||
|
if vOrigMetricses:
|
||
|
vOrigMap = {}
|
||
|
for glyphName in glyphOrder:
|
||
|
deltas, supports = vOrigDeltasAndSupports[glyphName]
|
||
|
storeBuilder.setSupports(supports)
|
||
|
vOrigMap[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound)
|
||
|
|
||
|
indirectStore = storeBuilder.finish()
|
||
|
mapping2 = indirectStore.optimize(use_NO_VARIATION_INDEX=False)
|
||
|
advMapping = [mapping2[advMapping[g]] for g in glyphOrder]
|
||
|
advanceMapping = builder.buildVarIdxMap(advMapping, glyphOrder)
|
||
|
|
||
|
if vOrigMetricses:
|
||
|
vOrigMap = [mapping2[vOrigMap[g]] for g in glyphOrder]
|
||
|
|
||
|
useDirect = False
|
||
|
vOrigMapping = None
|
||
|
if directStore:
|
||
|
# Compile both, see which is more compact
|
||
|
|
||
|
writer = OTTableWriter()
|
||
|
directStore.compile(writer, font)
|
||
|
directSize = len(writer.getAllData())
|
||
|
|
||
|
writer = OTTableWriter()
|
||
|
indirectStore.compile(writer, font)
|
||
|
advanceMapping.compile(writer, font)
|
||
|
indirectSize = len(writer.getAllData())
|
||
|
|
||
|
useDirect = directSize < indirectSize
|
||
|
|
||
|
if useDirect:
|
||
|
metricsStore = directStore
|
||
|
advanceMapping = None
|
||
|
else:
|
||
|
metricsStore = indirectStore
|
||
|
if vOrigMetricses:
|
||
|
vOrigMapping = builder.buildVarIdxMap(vOrigMap, glyphOrder)
|
||
|
|
||
|
return metricsStore, advanceMapping, vOrigMapping
|
||
|
|
||
|
|
||
|
def _add_MVAR(font, masterModel, master_ttfs, axisTags):
|
||
|
log.info("Generating MVAR")
|
||
|
|
||
|
store_builder = varStore.OnlineVarStoreBuilder(axisTags)
|
||
|
|
||
|
records = []
|
||
|
lastTableTag = None
|
||
|
fontTable = None
|
||
|
tables = None
|
||
|
# HACK: we need to special-case post.underlineThickness and .underlinePosition
|
||
|
# and unilaterally/arbitrarily define a sentinel value to distinguish the case
|
||
|
# when a post table is present in a given master simply because that's where
|
||
|
# the glyph names in TrueType must be stored, but the underline values are not
|
||
|
# meant to be used for building MVAR's deltas. The value of -0x8000 (-36768)
|
||
|
# the minimum FWord (int16) value, was chosen for its unlikelyhood to appear
|
||
|
# in real-world underline position/thickness values.
|
||
|
specialTags = {"unds": -0x8000, "undo": -0x8000}
|
||
|
|
||
|
for tag, (tableTag, itemName) in sorted(MVAR_ENTRIES.items(), key=lambda kv: kv[1]):
|
||
|
# For each tag, fetch the associated table from all fonts (or not when we are
|
||
|
# still looking at a tag from the same tables) and set up the variation model
|
||
|
# for them.
|
||
|
if tableTag != lastTableTag:
|
||
|
tables = fontTable = None
|
||
|
if tableTag in font:
|
||
|
fontTable = font[tableTag]
|
||
|
tables = []
|
||
|
for master in master_ttfs:
|
||
|
if tableTag not in master or (
|
||
|
tag in specialTags
|
||
|
and getattr(master[tableTag], itemName) == specialTags[tag]
|
||
|
):
|
||
|
tables.append(None)
|
||
|
else:
|
||
|
tables.append(master[tableTag])
|
||
|
model, tables = masterModel.getSubModel(tables)
|
||
|
store_builder.setModel(model)
|
||
|
lastTableTag = tableTag
|
||
|
|
||
|
if tables is None: # Tag not applicable to the master font.
|
||
|
continue
|
||
|
|
||
|
# TODO support gasp entries
|
||
|
|
||
|
master_values = [getattr(table, itemName) for table in tables]
|
||
|
if models.allEqual(master_values):
|
||
|
base, varIdx = master_values[0], None
|
||
|
else:
|
||
|
base, varIdx = store_builder.storeMasters(master_values)
|
||
|
setattr(fontTable, itemName, base)
|
||
|
|
||
|
if varIdx is None:
|
||
|
continue
|
||
|
log.info(" %s: %s.%s %s", tag, tableTag, itemName, master_values)
|
||
|
rec = ot.MetricsValueRecord()
|
||
|
rec.ValueTag = tag
|
||
|
rec.VarIdx = varIdx
|
||
|
records.append(rec)
|
||
|
|
||
|
assert "MVAR" not in font
|
||
|
if records:
|
||
|
store = store_builder.finish()
|
||
|
# Optimize
|
||
|
mapping = store.optimize()
|
||
|
for rec in records:
|
||
|
rec.VarIdx = mapping[rec.VarIdx]
|
||
|
|
||
|
MVAR = font["MVAR"] = newTable("MVAR")
|
||
|
mvar = MVAR.table = ot.MVAR()
|
||
|
mvar.Version = 0x00010000
|
||
|
mvar.Reserved = 0
|
||
|
mvar.VarStore = store
|
||
|
# XXX these should not be hard-coded but computed automatically
|
||
|
mvar.ValueRecordSize = 8
|
||
|
mvar.ValueRecordCount = len(records)
|
||
|
mvar.ValueRecord = sorted(records, key=lambda r: r.ValueTag)
|
||
|
|
||
|
|
||
|
def _add_BASE(font, masterModel, master_ttfs, axisTags):
|
||
|
log.info("Generating BASE")
|
||
|
|
||
|
merger = VariationMerger(masterModel, axisTags, font)
|
||
|
merger.mergeTables(font, master_ttfs, ["BASE"])
|
||
|
store = merger.store_builder.finish()
|
||
|
|
||
|
if not store:
|
||
|
return
|
||
|
base = font["BASE"].table
|
||
|
assert base.Version == 0x00010000
|
||
|
base.Version = 0x00010001
|
||
|
base.VarStore = store
|
||
|
|
||
|
|
||
|
def _merge_OTL(font, model, master_fonts, axisTags):
|
||
|
otl_tags = ["GSUB", "GDEF", "GPOS"]
|
||
|
if not any(tag in font for tag in otl_tags):
|
||
|
return
|
||
|
|
||
|
log.info("Merging OpenType Layout tables")
|
||
|
merger = VariationMerger(model, axisTags, font)
|
||
|
|
||
|
merger.mergeTables(font, master_fonts, otl_tags)
|
||
|
store = merger.store_builder.finish()
|
||
|
if not store:
|
||
|
return
|
||
|
try:
|
||
|
GDEF = font["GDEF"].table
|
||
|
assert GDEF.Version <= 0x00010002
|
||
|
except KeyError:
|
||
|
font["GDEF"] = newTable("GDEF")
|
||
|
GDEFTable = font["GDEF"] = newTable("GDEF")
|
||
|
GDEF = GDEFTable.table = ot.GDEF()
|
||
|
GDEF.GlyphClassDef = None
|
||
|
GDEF.AttachList = None
|
||
|
GDEF.LigCaretList = None
|
||
|
GDEF.MarkAttachClassDef = None
|
||
|
GDEF.MarkGlyphSetsDef = None
|
||
|
|
||
|
GDEF.Version = 0x00010003
|
||
|
GDEF.VarStore = store
|
||
|
|
||
|
# Optimize
|
||
|
varidx_map = store.optimize()
|
||
|
GDEF.remap_device_varidxes(varidx_map)
|
||
|
if "GPOS" in font:
|
||
|
font["GPOS"].table.remap_device_varidxes(varidx_map)
|
||
|
|
||
|
|
||
|
def _add_GSUB_feature_variations(
|
||
|
font, axes, internal_axis_supports, rules, featureTags
|
||
|
):
|
||
|
def normalize(name, value):
|
||
|
return models.normalizeLocation({name: value}, internal_axis_supports)[name]
|
||
|
|
||
|
log.info("Generating GSUB FeatureVariations")
|
||
|
|
||
|
axis_tags = {name: axis.tag for name, axis in axes.items()}
|
||
|
|
||
|
conditional_subs = []
|
||
|
for rule in rules:
|
||
|
region = []
|
||
|
for conditions in rule.conditionSets:
|
||
|
space = {}
|
||
|
for condition in conditions:
|
||
|
axis_name = condition["name"]
|
||
|
if condition["minimum"] is not None:
|
||
|
minimum = normalize(axis_name, condition["minimum"])
|
||
|
else:
|
||
|
minimum = -1.0
|
||
|
if condition["maximum"] is not None:
|
||
|
maximum = normalize(axis_name, condition["maximum"])
|
||
|
else:
|
||
|
maximum = 1.0
|
||
|
tag = axis_tags[axis_name]
|
||
|
space[tag] = (minimum, maximum)
|
||
|
region.append(space)
|
||
|
|
||
|
subs = {k: v for k, v in rule.subs}
|
||
|
|
||
|
conditional_subs.append((region, subs))
|
||
|
|
||
|
addFeatureVariations(font, conditional_subs, featureTags)
|
||
|
|
||
|
|
||
|
_DesignSpaceData = namedtuple(
|
||
|
"_DesignSpaceData",
|
||
|
[
|
||
|
"axes",
|
||
|
"axisMappings",
|
||
|
"internal_axis_supports",
|
||
|
"base_idx",
|
||
|
"normalized_master_locs",
|
||
|
"masters",
|
||
|
"instances",
|
||
|
"rules",
|
||
|
"rulesProcessingLast",
|
||
|
"lib",
|
||
|
],
|
||
|
)
|
||
|
|
||
|
|
||
|
def _add_CFF2(varFont, model, master_fonts):
|
||
|
from .cff import merge_region_fonts
|
||
|
|
||
|
glyphOrder = varFont.getGlyphOrder()
|
||
|
if "CFF2" not in varFont:
|
||
|
from .cff import convertCFFtoCFF2
|
||
|
|
||
|
convertCFFtoCFF2(varFont)
|
||
|
ordered_fonts_list = model.reorderMasters(master_fonts, model.reverseMapping)
|
||
|
# re-ordering the master list simplifies building the CFF2 data item lists.
|
||
|
merge_region_fonts(varFont, model, ordered_fonts_list, glyphOrder)
|
||
|
|
||
|
|
||
|
def _add_COLR(font, model, master_fonts, axisTags, colr_layer_reuse=True):
|
||
|
merger = COLRVariationMerger(
|
||
|
model, axisTags, font, allowLayerReuse=colr_layer_reuse
|
||
|
)
|
||
|
merger.mergeTables(font, master_fonts)
|
||
|
store = merger.store_builder.finish()
|
||
|
|
||
|
colr = font["COLR"].table
|
||
|
if store:
|
||
|
mapping = store.optimize()
|
||
|
colr.VarStore = store
|
||
|
varIdxes = [mapping[v] for v in merger.varIdxes]
|
||
|
colr.VarIndexMap = builder.buildDeltaSetIndexMap(varIdxes)
|
||
|
|
||
|
|
||
|
def load_designspace(designspace, log_enabled=True):
|
||
|
# TODO: remove this and always assume 'designspace' is a DesignSpaceDocument,
|
||
|
# never a file path, as that's already handled by caller
|
||
|
if hasattr(designspace, "sources"): # Assume a DesignspaceDocument
|
||
|
ds = designspace
|
||
|
else: # Assume a file path
|
||
|
ds = DesignSpaceDocument.fromfile(designspace)
|
||
|
|
||
|
masters = ds.sources
|
||
|
if not masters:
|
||
|
raise VarLibValidationError("Designspace must have at least one source.")
|
||
|
instances = ds.instances
|
||
|
|
||
|
# TODO: Use fontTools.designspaceLib.tagForAxisName instead.
|
||
|
standard_axis_map = OrderedDict(
|
||
|
[
|
||
|
("weight", ("wght", {"en": "Weight"})),
|
||
|
("width", ("wdth", {"en": "Width"})),
|
||
|
("slant", ("slnt", {"en": "Slant"})),
|
||
|
("optical", ("opsz", {"en": "Optical Size"})),
|
||
|
("italic", ("ital", {"en": "Italic"})),
|
||
|
]
|
||
|
)
|
||
|
|
||
|
# Setup axes
|
||
|
if not ds.axes:
|
||
|
raise VarLibValidationError(f"Designspace must have at least one axis.")
|
||
|
|
||
|
axes = OrderedDict()
|
||
|
for axis_index, axis in enumerate(ds.axes):
|
||
|
axis_name = axis.name
|
||
|
if not axis_name:
|
||
|
if not axis.tag:
|
||
|
raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.")
|
||
|
axis_name = axis.name = axis.tag
|
||
|
|
||
|
if axis_name in standard_axis_map:
|
||
|
if axis.tag is None:
|
||
|
axis.tag = standard_axis_map[axis_name][0]
|
||
|
if not axis.labelNames:
|
||
|
axis.labelNames.update(standard_axis_map[axis_name][1])
|
||
|
else:
|
||
|
if not axis.tag:
|
||
|
raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.")
|
||
|
if not axis.labelNames:
|
||
|
axis.labelNames["en"] = tostr(axis_name)
|
||
|
|
||
|
axes[axis_name] = axis
|
||
|
if log_enabled:
|
||
|
log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()]))
|
||
|
|
||
|
axisMappings = ds.axisMappings
|
||
|
if axisMappings and log_enabled:
|
||
|
log.info("Mappings:\n%s", pformat(axisMappings))
|
||
|
|
||
|
# Check all master and instance locations are valid and fill in defaults
|
||
|
for obj in masters + instances:
|
||
|
obj_name = obj.name or obj.styleName or ""
|
||
|
loc = obj.getFullDesignLocation(ds)
|
||
|
obj.designLocation = loc
|
||
|
if loc is None:
|
||
|
raise VarLibValidationError(
|
||
|
f"Source or instance '{obj_name}' has no location."
|
||
|
)
|
||
|
for axis_name in loc.keys():
|
||
|
if axis_name not in axes:
|
||
|
raise VarLibValidationError(
|
||
|
f"Location axis '{axis_name}' unknown for '{obj_name}'."
|
||
|
)
|
||
|
for axis_name, axis in axes.items():
|
||
|
v = axis.map_backward(loc[axis_name])
|
||
|
if not (axis.minimum <= v <= axis.maximum):
|
||
|
raise VarLibValidationError(
|
||
|
f"Source or instance '{obj_name}' has out-of-range location "
|
||
|
f"for axis '{axis_name}': is mapped to {v} but must be in "
|
||
|
f"mapped range [{axis.minimum}..{axis.maximum}] (NOTE: all "
|
||
|
"values are in user-space)."
|
||
|
)
|
||
|
|
||
|
# Normalize master locations
|
||
|
|
||
|
internal_master_locs = [o.getFullDesignLocation(ds) for o in masters]
|
||
|
if log_enabled:
|
||
|
log.info("Internal master locations:\n%s", pformat(internal_master_locs))
|
||
|
|
||
|
# TODO This mapping should ideally be moved closer to logic in _add_fvar/avar
|
||
|
internal_axis_supports = {}
|
||
|
for axis in axes.values():
|
||
|
triple = (axis.minimum, axis.default, axis.maximum)
|
||
|
internal_axis_supports[axis.name] = [axis.map_forward(v) for v in triple]
|
||
|
if log_enabled:
|
||
|
log.info("Internal axis supports:\n%s", pformat(internal_axis_supports))
|
||
|
|
||
|
normalized_master_locs = [
|
||
|
models.normalizeLocation(m, internal_axis_supports)
|
||
|
for m in internal_master_locs
|
||
|
]
|
||
|
if log_enabled:
|
||
|
log.info("Normalized master locations:\n%s", pformat(normalized_master_locs))
|
||
|
|
||
|
# Find base master
|
||
|
base_idx = None
|
||
|
for i, m in enumerate(normalized_master_locs):
|
||
|
if all(v == 0 for v in m.values()):
|
||
|
if base_idx is not None:
|
||
|
raise VarLibValidationError(
|
||
|
"More than one base master found in Designspace."
|
||
|
)
|
||
|
base_idx = i
|
||
|
if base_idx is None:
|
||
|
raise VarLibValidationError(
|
||
|
"Base master not found; no master at default location?"
|
||
|
)
|
||
|
if log_enabled:
|
||
|
log.info("Index of base master: %s", base_idx)
|
||
|
|
||
|
return _DesignSpaceData(
|
||
|
axes,
|
||
|
axisMappings,
|
||
|
internal_axis_supports,
|
||
|
base_idx,
|
||
|
normalized_master_locs,
|
||
|
masters,
|
||
|
instances,
|
||
|
ds.rules,
|
||
|
ds.rulesProcessingLast,
|
||
|
ds.lib,
|
||
|
)
|
||
|
|
||
|
|
||
|
# https://docs.microsoft.com/en-us/typography/opentype/spec/os2#uswidthclass
|
||
|
WDTH_VALUE_TO_OS2_WIDTH_CLASS = {
|
||
|
50: 1,
|
||
|
62.5: 2,
|
||
|
75: 3,
|
||
|
87.5: 4,
|
||
|
100: 5,
|
||
|
112.5: 6,
|
||
|
125: 7,
|
||
|
150: 8,
|
||
|
200: 9,
|
||
|
}
|
||
|
|
||
|
|
||
|
def set_default_weight_width_slant(font, location):
|
||
|
if "OS/2" in font:
|
||
|
if "wght" in location:
|
||
|
weight_class = otRound(max(1, min(location["wght"], 1000)))
|
||
|
if font["OS/2"].usWeightClass != weight_class:
|
||
|
log.info("Setting OS/2.usWeightClass = %s", weight_class)
|
||
|
font["OS/2"].usWeightClass = weight_class
|
||
|
|
||
|
if "wdth" in location:
|
||
|
# map 'wdth' axis (50..200) to OS/2.usWidthClass (1..9), rounding to closest
|
||
|
widthValue = min(max(location["wdth"], 50), 200)
|
||
|
widthClass = otRound(
|
||
|
models.piecewiseLinearMap(widthValue, WDTH_VALUE_TO_OS2_WIDTH_CLASS)
|
||
|
)
|
||
|
if font["OS/2"].usWidthClass != widthClass:
|
||
|
log.info("Setting OS/2.usWidthClass = %s", widthClass)
|
||
|
font["OS/2"].usWidthClass = widthClass
|
||
|
|
||
|
if "slnt" in location and "post" in font:
|
||
|
italicAngle = max(-90, min(location["slnt"], 90))
|
||
|
if font["post"].italicAngle != italicAngle:
|
||
|
log.info("Setting post.italicAngle = %s", italicAngle)
|
||
|
font["post"].italicAngle = italicAngle
|
||
|
|
||
|
|
||
|
def drop_implied_oncurve_points(*masters: TTFont) -> int:
|
||
|
"""Drop impliable on-curve points from all the simple glyphs in masters.
|
||
|
|
||
|
In TrueType glyf outlines, on-curve points can be implied when they are located
|
||
|
exactly at the midpoint of the line connecting two consecutive off-curve points.
|
||
|
|
||
|
The input masters' glyf tables are assumed to contain same-named glyphs that are
|
||
|
interpolatable. Oncurve points are only dropped if they can be implied for all
|
||
|
the masters. The fonts are modified in-place.
|
||
|
|
||
|
Args:
|
||
|
masters: The TTFont(s) to modify
|
||
|
|
||
|
Returns:
|
||
|
The total number of points that were dropped if any.
|
||
|
|
||
|
Reference:
|
||
|
https://developer.apple.com/fonts/TrueType-Reference-Manual/RM01/Chap1.html
|
||
|
"""
|
||
|
|
||
|
count = 0
|
||
|
glyph_masters = defaultdict(list)
|
||
|
# multiple DS source may point to the same TTFont object and we want to
|
||
|
# avoid processing the same glyph twice as they are modified in-place
|
||
|
for font in {id(m): m for m in masters}.values():
|
||
|
glyf = font["glyf"]
|
||
|
for glyphName in glyf.keys():
|
||
|
glyph_masters[glyphName].append(glyf[glyphName])
|
||
|
count = 0
|
||
|
for glyphName, glyphs in glyph_masters.items():
|
||
|
try:
|
||
|
dropped = dropImpliedOnCurvePoints(*glyphs)
|
||
|
except ValueError as e:
|
||
|
# we don't fail for incompatible glyphs in _add_gvar so we shouldn't here
|
||
|
log.warning("Failed to drop implied oncurves for %r: %s", glyphName, e)
|
||
|
else:
|
||
|
count += len(dropped)
|
||
|
return count
|
||
|
|
||
|
|
||
|
def build_many(
|
||
|
designspace: DesignSpaceDocument,
|
||
|
master_finder=lambda s: s,
|
||
|
exclude=[],
|
||
|
optimize=True,
|
||
|
skip_vf=lambda vf_name: False,
|
||
|
colr_layer_reuse=True,
|
||
|
drop_implied_oncurves=False,
|
||
|
):
|
||
|
"""
|
||
|
Build variable fonts from a designspace file, version 5 which can define
|
||
|
several VFs, or version 4 which has implicitly one VF covering the whole doc.
|
||
|
|
||
|
If master_finder is set, it should be a callable that takes master
|
||
|
filename as found in designspace file and map it to master font
|
||
|
binary as to be opened (eg. .ttf or .otf).
|
||
|
|
||
|
skip_vf can be used to skip building some of the variable fonts defined in
|
||
|
the input designspace. It's a predicate that takes as argument the name
|
||
|
of the variable font and returns `bool`.
|
||
|
|
||
|
Always returns a Dict[str, TTFont] keyed by VariableFontDescriptor.name
|
||
|
"""
|
||
|
res = {}
|
||
|
# varLib.build (used further below) by default only builds an incomplete 'STAT'
|
||
|
# with an empty AxisValueArray--unless the VF inherited 'STAT' from its base master.
|
||
|
# Designspace version 5 can also be used to define 'STAT' labels or customize
|
||
|
# axes ordering, etc. To avoid overwriting a pre-existing 'STAT' or redoing the
|
||
|
# same work twice, here we check if designspace contains any 'STAT' info before
|
||
|
# proceeding to call buildVFStatTable for each VF.
|
||
|
# https://github.com/fonttools/fonttools/pull/3024
|
||
|
# https://github.com/fonttools/fonttools/issues/3045
|
||
|
doBuildStatFromDSv5 = (
|
||
|
"STAT" not in exclude
|
||
|
and designspace.formatTuple >= (5, 0)
|
||
|
and (
|
||
|
any(a.axisLabels or a.axisOrdering is not None for a in designspace.axes)
|
||
|
or designspace.locationLabels
|
||
|
)
|
||
|
)
|
||
|
for _location, subDoc in splitInterpolable(designspace):
|
||
|
for name, vfDoc in splitVariableFonts(subDoc):
|
||
|
if skip_vf(name):
|
||
|
log.debug(f"Skipping variable TTF font: {name}")
|
||
|
continue
|
||
|
vf = build(
|
||
|
vfDoc,
|
||
|
master_finder,
|
||
|
exclude=exclude,
|
||
|
optimize=optimize,
|
||
|
colr_layer_reuse=colr_layer_reuse,
|
||
|
drop_implied_oncurves=drop_implied_oncurves,
|
||
|
)[0]
|
||
|
if doBuildStatFromDSv5:
|
||
|
buildVFStatTable(vf, designspace, name)
|
||
|
res[name] = vf
|
||
|
return res
|
||
|
|
||
|
|
||
|
def build(
|
||
|
designspace,
|
||
|
master_finder=lambda s: s,
|
||
|
exclude=[],
|
||
|
optimize=True,
|
||
|
colr_layer_reuse=True,
|
||
|
drop_implied_oncurves=False,
|
||
|
):
|
||
|
"""
|
||
|
Build variation font from a designspace file.
|
||
|
|
||
|
If master_finder is set, it should be a callable that takes master
|
||
|
filename as found in designspace file and map it to master font
|
||
|
binary as to be opened (eg. .ttf or .otf).
|
||
|
"""
|
||
|
if hasattr(designspace, "sources"): # Assume a DesignspaceDocument
|
||
|
pass
|
||
|
else: # Assume a file path
|
||
|
designspace = DesignSpaceDocument.fromfile(designspace)
|
||
|
|
||
|
ds = load_designspace(designspace)
|
||
|
log.info("Building variable font")
|
||
|
|
||
|
log.info("Loading master fonts")
|
||
|
master_fonts = load_masters(designspace, master_finder)
|
||
|
|
||
|
# TODO: 'master_ttfs' is unused except for return value, remove later
|
||
|
master_ttfs = []
|
||
|
for master in master_fonts:
|
||
|
try:
|
||
|
master_ttfs.append(master.reader.file.name)
|
||
|
except AttributeError:
|
||
|
master_ttfs.append(None) # in-memory fonts have no path
|
||
|
|
||
|
if drop_implied_oncurves and "glyf" in master_fonts[ds.base_idx]:
|
||
|
drop_count = drop_implied_oncurve_points(*master_fonts)
|
||
|
log.info(
|
||
|
"Dropped %s on-curve points from simple glyphs in the 'glyf' table",
|
||
|
drop_count,
|
||
|
)
|
||
|
|
||
|
# Copy the base master to work from it
|
||
|
vf = deepcopy(master_fonts[ds.base_idx])
|
||
|
|
||
|
if "DSIG" in vf:
|
||
|
del vf["DSIG"]
|
||
|
|
||
|
# TODO append masters as named-instances as well; needs .designspace change.
|
||
|
fvar = _add_fvar(vf, ds.axes, ds.instances)
|
||
|
if "STAT" not in exclude:
|
||
|
_add_stat(vf)
|
||
|
|
||
|
# Map from axis names to axis tags...
|
||
|
normalized_master_locs = [
|
||
|
{ds.axes[k].tag: v for k, v in loc.items()} for loc in ds.normalized_master_locs
|
||
|
]
|
||
|
# From here on, we use fvar axes only
|
||
|
axisTags = [axis.axisTag for axis in fvar.axes]
|
||
|
|
||
|
# Assume single-model for now.
|
||
|
model = models.VariationModel(normalized_master_locs, axisOrder=axisTags)
|
||
|
assert 0 == model.mapping[ds.base_idx]
|
||
|
|
||
|
log.info("Building variations tables")
|
||
|
if "avar" not in exclude:
|
||
|
_add_avar(vf, ds.axes, ds.axisMappings, axisTags)
|
||
|
if "BASE" not in exclude and "BASE" in vf:
|
||
|
_add_BASE(vf, model, master_fonts, axisTags)
|
||
|
if "MVAR" not in exclude:
|
||
|
_add_MVAR(vf, model, master_fonts, axisTags)
|
||
|
if "HVAR" not in exclude:
|
||
|
_add_HVAR(vf, model, master_fonts, axisTags)
|
||
|
if "VVAR" not in exclude and "vmtx" in vf:
|
||
|
_add_VVAR(vf, model, master_fonts, axisTags)
|
||
|
if "GDEF" not in exclude or "GPOS" not in exclude:
|
||
|
_merge_OTL(vf, model, master_fonts, axisTags)
|
||
|
if "gvar" not in exclude and "glyf" in vf:
|
||
|
_add_gvar(vf, model, master_fonts, optimize=optimize)
|
||
|
if "cvar" not in exclude and "glyf" in vf:
|
||
|
_merge_TTHinting(vf, model, master_fonts)
|
||
|
if "GSUB" not in exclude and ds.rules:
|
||
|
featureTags = _feature_variations_tags(ds)
|
||
|
_add_GSUB_feature_variations(
|
||
|
vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTags
|
||
|
)
|
||
|
if "CFF2" not in exclude and ("CFF " in vf or "CFF2" in vf):
|
||
|
_add_CFF2(vf, model, master_fonts)
|
||
|
if "post" in vf:
|
||
|
# set 'post' to format 2 to keep the glyph names dropped from CFF2
|
||
|
post = vf["post"]
|
||
|
if post.formatType != 2.0:
|
||
|
post.formatType = 2.0
|
||
|
post.extraNames = []
|
||
|
post.mapping = {}
|
||
|
if "COLR" not in exclude and "COLR" in vf and vf["COLR"].version > 0:
|
||
|
_add_COLR(vf, model, master_fonts, axisTags, colr_layer_reuse)
|
||
|
|
||
|
set_default_weight_width_slant(
|
||
|
vf, location={axis.axisTag: axis.defaultValue for axis in vf["fvar"].axes}
|
||
|
)
|
||
|
|
||
|
for tag in exclude:
|
||
|
if tag in vf:
|
||
|
del vf[tag]
|
||
|
|
||
|
# TODO: Only return vf for 4.0+, the rest is unused.
|
||
|
return vf, model, master_ttfs
|
||
|
|
||
|
|
||
|
def _open_font(path, master_finder=lambda s: s):
|
||
|
# load TTFont masters from given 'path': this can be either a .TTX or an
|
||
|
# OpenType binary font; or if neither of these, try use the 'master_finder'
|
||
|
# callable to resolve the path to a valid .TTX or OpenType font binary.
|
||
|
from fontTools.ttx import guessFileType
|
||
|
|
||
|
master_path = os.path.normpath(path)
|
||
|
tp = guessFileType(master_path)
|
||
|
if tp is None:
|
||
|
# not an OpenType binary/ttx, fall back to the master finder.
|
||
|
master_path = master_finder(master_path)
|
||
|
tp = guessFileType(master_path)
|
||
|
if tp in ("TTX", "OTX"):
|
||
|
font = TTFont()
|
||
|
font.importXML(master_path)
|
||
|
elif tp in ("TTF", "OTF", "WOFF", "WOFF2"):
|
||
|
font = TTFont(master_path)
|
||
|
else:
|
||
|
raise VarLibValidationError("Invalid master path: %r" % master_path)
|
||
|
return font
|
||
|
|
||
|
|
||
|
def load_masters(designspace, master_finder=lambda s: s):
|
||
|
"""Ensure that all SourceDescriptor.font attributes have an appropriate TTFont
|
||
|
object loaded, or else open TTFont objects from the SourceDescriptor.path
|
||
|
attributes.
|
||
|
|
||
|
The paths can point to either an OpenType font, a TTX file, or a UFO. In the
|
||
|
latter case, use the provided master_finder callable to map from UFO paths to
|
||
|
the respective master font binaries (e.g. .ttf, .otf or .ttx).
|
||
|
|
||
|
Return list of master TTFont objects in the same order they are listed in the
|
||
|
DesignSpaceDocument.
|
||
|
"""
|
||
|
for master in designspace.sources:
|
||
|
# If a SourceDescriptor has a layer name, demand that the compiled TTFont
|
||
|
# be supplied by the caller. This spares us from modifying MasterFinder.
|
||
|
if master.layerName and master.font is None:
|
||
|
raise VarLibValidationError(
|
||
|
f"Designspace source '{master.name or '<Unknown>'}' specified a "
|
||
|
"layer name but lacks the required TTFont object in the 'font' "
|
||
|
"attribute."
|
||
|
)
|
||
|
|
||
|
return designspace.loadSourceFonts(_open_font, master_finder=master_finder)
|
||
|
|
||
|
|
||
|
class MasterFinder(object):
|
||
|
def __init__(self, template):
|
||
|
self.template = template
|
||
|
|
||
|
def __call__(self, src_path):
|
||
|
fullname = os.path.abspath(src_path)
|
||
|
dirname, basename = os.path.split(fullname)
|
||
|
stem, ext = os.path.splitext(basename)
|
||
|
path = self.template.format(
|
||
|
fullname=fullname,
|
||
|
dirname=dirname,
|
||
|
basename=basename,
|
||
|
stem=stem,
|
||
|
ext=ext,
|
||
|
)
|
||
|
return os.path.normpath(path)
|
||
|
|
||
|
|
||
|
def _feature_variations_tags(ds):
|
||
|
raw_tags = ds.lib.get(
|
||
|
FEAVAR_FEATURETAG_LIB_KEY,
|
||
|
"rclt" if ds.rulesProcessingLast else "rvrn",
|
||
|
)
|
||
|
return sorted({t.strip() for t in raw_tags.split(",")})
|
||
|
|
||
|
|
||
|
def addGSUBFeatureVariations(vf, designspace, featureTags=(), *, log_enabled=False):
|
||
|
"""Add GSUB FeatureVariations table to variable font, based on DesignSpace rules.
|
||
|
|
||
|
Args:
|
||
|
vf: A TTFont object representing the variable font.
|
||
|
designspace: A DesignSpaceDocument object.
|
||
|
featureTags: Optional feature tag(s) to use for the FeatureVariations records.
|
||
|
If unset, the key 'com.github.fonttools.varLib.featureVarsFeatureTag' is
|
||
|
looked up in the DS <lib> and used; otherwise the default is 'rclt' if
|
||
|
the <rules processing="last"> attribute is set, else 'rvrn'.
|
||
|
See <https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#rules-element>
|
||
|
log_enabled: If True, log info about DS axes and sources. Default is False, as
|
||
|
the same info may have already been logged as part of varLib.build.
|
||
|
"""
|
||
|
ds = load_designspace(designspace, log_enabled=log_enabled)
|
||
|
if not ds.rules:
|
||
|
return
|
||
|
if not featureTags:
|
||
|
featureTags = _feature_variations_tags(ds)
|
||
|
_add_GSUB_feature_variations(
|
||
|
vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTags
|
||
|
)
|
||
|
|
||
|
|
||
|
def main(args=None):
|
||
|
"""Build variable fonts from a designspace file and masters"""
|
||
|
from argparse import ArgumentParser
|
||
|
from fontTools import configLogger
|
||
|
|
||
|
parser = ArgumentParser(prog="varLib", description=main.__doc__)
|
||
|
parser.add_argument("designspace")
|
||
|
output_group = parser.add_mutually_exclusive_group()
|
||
|
output_group.add_argument(
|
||
|
"-o", metavar="OUTPUTFILE", dest="outfile", default=None, help="output file"
|
||
|
)
|
||
|
output_group.add_argument(
|
||
|
"-d",
|
||
|
"--output-dir",
|
||
|
metavar="OUTPUTDIR",
|
||
|
default=None,
|
||
|
help="output dir (default: same as input designspace file)",
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
"-x",
|
||
|
metavar="TAG",
|
||
|
dest="exclude",
|
||
|
action="append",
|
||
|
default=[],
|
||
|
help="exclude table",
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
"--disable-iup",
|
||
|
dest="optimize",
|
||
|
action="store_false",
|
||
|
help="do not perform IUP optimization",
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
"--no-colr-layer-reuse",
|
||
|
dest="colr_layer_reuse",
|
||
|
action="store_false",
|
||
|
help="do not rebuild variable COLR table to optimize COLR layer reuse",
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
"--drop-implied-oncurves",
|
||
|
action="store_true",
|
||
|
help=(
|
||
|
"drop on-curve points that can be implied when exactly in the middle of "
|
||
|
"two off-curve points (only applies to TrueType fonts)"
|
||
|
),
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
"--master-finder",
|
||
|
default="master_ttf_interpolatable/{stem}.ttf",
|
||
|
help=(
|
||
|
"templated string used for finding binary font "
|
||
|
"files given the source file names defined in the "
|
||
|
"designspace document. The following special strings "
|
||
|
"are defined: {fullname} is the absolute source file "
|
||
|
"name; {basename} is the file name without its "
|
||
|
"directory; {stem} is the basename without the file "
|
||
|
"extension; {ext} is the source file extension; "
|
||
|
"{dirname} is the directory of the absolute file "
|
||
|
'name. The default value is "%(default)s".'
|
||
|
),
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
"--variable-fonts",
|
||
|
default=".*",
|
||
|
metavar="VF_NAME",
|
||
|
help=(
|
||
|
"Filter the list of variable fonts produced from the input "
|
||
|
"Designspace v5 file. By default all listed variable fonts are "
|
||
|
"generated. To generate a specific variable font (or variable fonts) "
|
||
|
'that match a given "name" attribute, you can pass as argument '
|
||
|
"the full name or a regular expression. E.g.: --variable-fonts "
|
||
|
'"MyFontVF_WeightOnly"; or --variable-fonts "MyFontVFItalic_.*".'
|
||
|
),
|
||
|
)
|
||
|
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."
|
||
|
)
|
||
|
options = parser.parse_args(args)
|
||
|
|
||
|
configLogger(
|
||
|
level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO")
|
||
|
)
|
||
|
|
||
|
designspace_filename = options.designspace
|
||
|
designspace = DesignSpaceDocument.fromfile(designspace_filename)
|
||
|
|
||
|
vf_descriptors = designspace.getVariableFonts()
|
||
|
if not vf_descriptors:
|
||
|
parser.error(f"No variable fonts in given designspace {designspace.path!r}")
|
||
|
|
||
|
vfs_to_build = []
|
||
|
for vf in vf_descriptors:
|
||
|
# Skip variable fonts that do not match the user's inclusion regex if given.
|
||
|
if not fullmatch(options.variable_fonts, vf.name):
|
||
|
continue
|
||
|
vfs_to_build.append(vf)
|
||
|
|
||
|
if not vfs_to_build:
|
||
|
parser.error(f"No variable fonts matching {options.variable_fonts!r}")
|
||
|
|
||
|
if options.outfile is not None and len(vfs_to_build) > 1:
|
||
|
parser.error(
|
||
|
"can't specify -o because there are multiple VFs to build; "
|
||
|
"use --output-dir, or select a single VF with --variable-fonts"
|
||
|
)
|
||
|
|
||
|
output_dir = options.output_dir
|
||
|
if output_dir is None:
|
||
|
output_dir = os.path.dirname(designspace_filename)
|
||
|
|
||
|
vf_name_to_output_path = {}
|
||
|
if len(vfs_to_build) == 1 and options.outfile is not None:
|
||
|
vf_name_to_output_path[vfs_to_build[0].name] = options.outfile
|
||
|
else:
|
||
|
for vf in vfs_to_build:
|
||
|
filename = vf.filename if vf.filename is not None else vf.name + ".{ext}"
|
||
|
vf_name_to_output_path[vf.name] = os.path.join(output_dir, filename)
|
||
|
|
||
|
finder = MasterFinder(options.master_finder)
|
||
|
|
||
|
vfs = build_many(
|
||
|
designspace,
|
||
|
finder,
|
||
|
exclude=options.exclude,
|
||
|
optimize=options.optimize,
|
||
|
colr_layer_reuse=options.colr_layer_reuse,
|
||
|
drop_implied_oncurves=options.drop_implied_oncurves,
|
||
|
)
|
||
|
|
||
|
for vf_name, vf in vfs.items():
|
||
|
ext = "otf" if vf.sfntVersion == "OTTO" else "ttf"
|
||
|
output_path = vf_name_to_output_path[vf_name].format(ext=ext)
|
||
|
output_dir = os.path.dirname(output_path)
|
||
|
if output_dir:
|
||
|
os.makedirs(output_dir, exist_ok=True)
|
||
|
log.info("Saving variation font %s", output_path)
|
||
|
vf.save(output_path)
|
||
|
|
||
|
|
||
|
if __name__ == "__main__":
|
||
|
import sys
|
||
|
|
||
|
if len(sys.argv) > 1:
|
||
|
sys.exit(main())
|
||
|
import doctest
|
||
|
|
||
|
sys.exit(doctest.testmod().failed)
|