fonttools/Lib/fontTools/varLib/instancer.py
Cosimo Lupo 4a7ab3fee2
instancer: use VarStore.optimize() and remap MVAR records' VarIdx
in test font, add additional VarData subtable in MVAR.VarStore and check it gets merged after optimizing.
2019-04-16 11:23:39 +01:00

522 lines
18 KiB
Python

""" Partially instantiate a variable font.
This is similar to fontTools.varLib.mutator, but instead of creating full
instances (i.e. static fonts) from variable fonts, it creates "partial"
variable fonts, only containing a subset of the variation space.
For example, if you wish to pin the width axis to a given location while
keeping the rest of the axes, you can do:
$ fonttools varLib.instancer ./NotoSans-VF.ttf wdth=85
NOTE: The module is experimental and both the API and the CLI *will* change.
"""
from __future__ import print_function, division, absolute_import
from fontTools.misc.py23 import *
from fontTools.misc.fixedTools import floatToFixedToFloat, otRound
from fontTools.varLib.models import supportScalar, normalizeValue, piecewiseLinearMap
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates
from fontTools.varLib.mvar import MVAR_ENTRIES
import collections
from copy import deepcopy
import logging
import os
import re
log = logging.getLogger("fontTools.varlib.instancer")
def instantiateTupleVariationStore(variations, location, origCoords=None, endPts=None):
newVariations = collections.OrderedDict()
for var in variations:
# Compute the scalar support of the axes to be pinned at the desired location,
# excluding any axes that we are not pinning.
# If a TupleVariation doesn't mention an axis, it implies that the axis peak
# is 0 (i.e. the axis does not participate).
support = {axis: var.axes.pop(axis, (-1, 0, +1)) for axis in location}
scalar = supportScalar(location, support)
if scalar == 0.0:
# no influence, drop the TupleVariation
continue
# compute inferred deltas only for gvar ('origCoords' is None for cvar)
if origCoords is not None:
var.calcInferredDeltas(origCoords, endPts)
var.scaleDeltas(scalar)
# merge TupleVariations with overlapping "tents"
axes = tuple(var.axes.items())
if axes in newVariations:
newVariations[axes] += var
else:
newVariations[axes] = var
for var in newVariations.values():
var.roundDeltas()
# drop TupleVariation if all axes have been pinned (var.axes.items() is empty);
# its deltas will be added to the default instance's coordinates
defaultVar = newVariations.pop(tuple(), None)
variations[:] = list(newVariations.values())
return defaultVar.coordinates if defaultVar is not None else []
def instantiateGvarGlyph(varfont, glyphname, location, optimize=True):
glyf = varfont["glyf"]
coordinates, ctrl = glyf.getCoordinatesAndControls(glyphname, varfont)
endPts = ctrl.endPts
gvar = varfont["gvar"]
tupleVarStore = gvar.variations[glyphname]
defaultDeltas = instantiateTupleVariationStore(
tupleVarStore, location, coordinates, endPts
)
if defaultDeltas:
coordinates += GlyphCoordinates(defaultDeltas)
# this will also set the hmtx advance widths and sidebearings from
# the fourth-last and third-last phantom points (and glyph.xMin)
glyf.setCoordinates(glyphname, coordinates, varfont)
if not tupleVarStore:
del gvar.variations[glyphname]
return
if optimize:
isComposite = glyf[glyphname].isComposite()
for var in tupleVarStore:
var.optimize(coordinates, endPts, isComposite)
def instantiateGvar(varfont, location, optimize=True):
log.info("Instantiating glyf/gvar tables")
gvar = varfont["gvar"]
glyf = varfont["glyf"]
# Get list of glyph names in gvar sorted by component depth.
# If a composite glyph is processed before its base glyph, the bounds may
# be calculated incorrectly because deltas haven't been applied to the
# base glyph yet.
glyphnames = sorted(
gvar.variations.keys(),
key=lambda name: (
glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
if glyf[name].isComposite()
else 0,
name,
),
)
for glyphname in glyphnames:
instantiateGvarGlyph(varfont, glyphname, location, optimize=optimize)
if not gvar.variations:
del varfont["gvar"]
def setCvarDeltas(cvt, deltas):
for i, delta in enumerate(deltas):
if delta is not None:
cvt[i] += delta
def instantiateCvar(varfont, location):
log.info("Instantiating cvt/cvar tables")
cvar = varfont["cvar"]
defaultDeltas = instantiateTupleVariationStore(cvar.variations, location)
if defaultDeltas:
setCvarDeltas(varfont["cvt "], defaultDeltas)
if not cvar.variations:
del varfont["cvar"]
def setMvarDeltas(varfont, deltaArray):
log.info("Setting MVAR deltas")
mvar = varfont["MVAR"].table
records = mvar.ValueRecord
for rec in records:
mvarTag = rec.ValueTag
if mvarTag not in MVAR_ENTRIES:
continue
tableTag, itemName = MVAR_ENTRIES[mvarTag]
varDataIndex = rec.VarIdx >> 16
itemIndex = rec.VarIdx & 0xFFFF
deltaRow = deltaArray[varDataIndex][itemIndex]
delta = sum(deltaRow)
if delta != 0:
setattr(
varfont[tableTag],
itemName,
getattr(varfont[tableTag], itemName) + otRound(delta),
)
def instantiateMvar(varfont, location):
log.info("Instantiating MVAR table")
mvar = varfont["MVAR"].table
fvarAxes = varfont["fvar"].axes
defaultDeltas, varIndexMapping = instantiateItemVariationStore(
mvar.VarStore, fvarAxes, location
)
setMvarDeltas(varfont, defaultDeltas)
if varIndexMapping:
for rec in mvar.ValueRecord:
rec.VarIdx = varIndexMapping[rec.VarIdx]
else:
# Delete table if no more regions left.
del varfont["MVAR"]
def _getVarRegionAxes(region, fvarAxes):
# map fvar axes tags to VarRegionAxis in VarStore region, excluding axes that
# don't participate (peak == 0)
axes = {}
assert len(fvarAxes) == len(region.VarRegionAxis)
for fvarAxis, regionAxis in zip(fvarAxes, region.VarRegionAxis):
if regionAxis.PeakCoord != 0:
axes[fvarAxis.axisTag] = regionAxis
return axes
def _getVarRegionScalar(location, regionAxes):
# compute partial product of per-axis scalars at location, excluding the axes
# that are not pinned
pinnedAxes = {
axisTag: (axis.StartCoord, axis.PeakCoord, axis.EndCoord)
for axisTag, axis in regionAxes.items()
if axisTag in location
}
return supportScalar(location, pinnedAxes)
def _scaleVarDataDeltas(varData, regionScalars):
# multiply all varData deltas in-place by the corresponding region scalar
varRegionCount = len(varData.VarRegionIndex)
scalars = [regionScalars[regionIndex] for regionIndex in varData.VarRegionIndex]
for item in varData.Item:
assert len(item) == varRegionCount
item[:] = [delta * scalar for delta, scalar in zip(item, scalars)]
def _getVarDataDeltasForRegions(varData, regionIndices, rounded=False):
# Get only the deltas that correspond to the given regions (optionally, rounded).
# Returns: list of lists of float
varRegionIndices = varData.VarRegionIndex
deltaSets = []
for item in varData.Item:
deltaSets.append(
[
delta if not rounded else otRound(delta)
for regionIndex, delta in zip(varRegionIndices, item)
if regionIndex in regionIndices
]
)
return deltaSets
def _subsetVarStoreRegions(varStore, regionIndices):
# drop regions not in regionIndices
for varData in varStore.VarData:
if regionIndices.isdisjoint(varData.VarRegionIndex):
# empty VarData subtable if we remove all the regions referenced by it
varData.Item = [[] for _ in range(varData.ItemCount)]
varData.VarRegionIndex = []
varData.VarRegionCount = varData.NumShorts = 0
continue
# only retain delta-set columns that correspond to the given regions
varData.Item = _getVarDataDeltasForRegions(varData, regionIndices, rounded=True)
varData.VarRegionIndex = [
ri for ri in varData.VarRegionIndex if ri in regionIndices
]
varData.VarRegionCount = len(varData.VarRegionIndex)
# recalculate NumShorts, reordering columns as necessary
varData.optimize()
# remove unused regions from VarRegionList
varStore.prune_regions()
def instantiateItemVariationStore(varStore, fvarAxes, location):
regions = [
_getVarRegionAxes(reg, fvarAxes) for reg in varStore.VarRegionList.Region
]
# for each region, compute the scalar support of the axes to be pinned at the
# desired location, and scale the deltas accordingly
regionScalars = [_getVarRegionScalar(location, axes) for axes in regions]
for varData in varStore.VarData:
_scaleVarDataDeltas(varData, regionScalars)
# disable the pinned axes by setting PeakCoord to 0
for axes in regions:
for axisTag, axis in axes.items():
if axisTag in location:
axis.StartCoord, axis.PeakCoord, axis.EndCoord = (0, 0, 0)
# If all axes in a region are pinned, its deltas are added to the default instance
defaultRegionIndices = {
regionIndex
for regionIndex, axes in enumerate(regions)
if all(axis.PeakCoord == 0 for axis in axes.values())
}
# Collect the default deltas into a two-dimension array, with outer/inner indices
# corresponding to a VarData subtable and a deltaset row within that table.
defaultDeltaArray = [
_getVarDataDeltasForRegions(varData, defaultRegionIndices)
for varData in varStore.VarData
]
# drop default regions, or those whose influence at the pinned location is 0
newRegionIndices = {
regionIndex
for regionIndex in range(len(varStore.VarRegionList.Region))
if regionIndex not in defaultRegionIndices and regionScalars[regionIndex] != 0
}
_subsetVarStoreRegions(varStore, newRegionIndices)
if varStore.VarRegionList.Region:
# optimize VarStore, and get a map from old to new VarIdx after optimization
varIndexMapping = varStore.optimize()
else:
varIndexMapping = None # VarStore is empty
return defaultDeltaArray, varIndexMapping
def instantiateFeatureVariations(varfont, location):
for tableTag in ("GPOS", "GSUB"):
if tableTag not in varfont or not hasattr(
varfont[tableTag].table, "FeatureVariations"
):
continue
log.info("Instantiating FeatureVariations of %s table", tableTag)
_instantiateFeatureVariations(
varfont[tableTag].table, varfont["fvar"].axes, location
)
def _instantiateFeatureVariations(table, fvarAxes, location):
newRecords = []
pinnedAxes = set(location.keys())
featureVariationApplied = False
for record in table.FeatureVariations.FeatureVariationRecord:
retainRecord = True
applies = True
newConditions = []
for condition in record.ConditionSet.ConditionTable:
axisIdx = condition.AxisIndex
axisTag = fvarAxes[axisIdx].axisTag
if condition.Format == 1 and axisTag in pinnedAxes:
minValue = condition.FilterRangeMinValue
maxValue = condition.FilterRangeMaxValue
v = location[axisTag]
if not (minValue <= v <= maxValue):
# condition not met so remove entire record
retainRecord = False
break
else:
applies = False
newConditions.append(condition)
if retainRecord and newConditions:
record.ConditionSet.ConditionTable = newConditions
newRecords.append(record)
if applies and not featureVariationApplied:
assert record.FeatureTableSubstitution.Version == 0x00010000
for rec in record.FeatureTableSubstitution.SubstitutionRecord:
table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature = rec.Feature
# Set variations only once
featureVariationApplied = True
if newRecords:
table.FeatureVariations.FeatureVariationRecord = newRecords
else:
del table.FeatureVariations
def normalize(value, triple, avar_mapping):
value = normalizeValue(value, triple)
if avar_mapping:
value = piecewiseLinearMap(value, avar_mapping)
# Quantize to F2Dot14, to avoid surprise interpolations.
return floatToFixedToFloat(value, 14)
def normalizeAxisLimits(varfont, axis_limits):
fvar = varfont["fvar"]
bad_limits = axis_limits.keys() - {a.axisTag for a in fvar.axes}
if bad_limits:
raise ValueError("Cannot limit: {} not present in fvar".format(bad_limits))
axes = {
a.axisTag: (a.minValue, a.defaultValue, a.maxValue)
for a in fvar.axes
if a.axisTag in axis_limits
}
avar_segments = {}
if "avar" in varfont:
avar_segments = varfont["avar"].segments
for axis_tag, triple in axes.items():
avar_mapping = avar_segments.get(axis_tag, None)
value = axis_limits[axis_tag]
if isinstance(value, tuple):
axis_limits[axis_tag] = tuple(
normalize(v, triple, avar_mapping) for v in axis_limits[axis_tag]
)
else:
axis_limits[axis_tag] = normalize(value, triple, avar_mapping)
def sanityCheckVariableTables(varfont):
if "fvar" not in varfont:
raise ValueError("Missing required table fvar")
if "gvar" in varfont:
if "glyf" not in varfont:
raise ValueError("Can't have gvar without glyf")
def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True):
sanityCheckVariableTables(varfont)
if not inplace:
varfont = deepcopy(varfont)
normalizeAxisLimits(varfont, axis_limits)
log.info("Normalized limits: %s", axis_limits)
# TODO Remove this check once ranges are supported
if any(isinstance(v, tuple) for v in axis_limits.values()):
raise NotImplementedError("Axes range limits are not supported yet")
if "gvar" in varfont:
instantiateGvar(varfont, axis_limits, optimize=optimize)
if "cvar" in varfont:
instantiateCvar(varfont, axis_limits)
if "MVAR" in varfont:
instantiateMvar(varfont, axis_limits)
instantiateFeatureVariations(varfont, axis_limits)
# TODO: actually process HVAR instead of dropping it
del varfont["HVAR"]
return varfont
def parseLimits(limits):
result = {}
for limit_string in limits:
match = re.match(r"^(\w{1,4})=([^:]+)(?:[:](.+))?$", limit_string)
if not match:
raise ValueError("invalid location format: %r" % limit_string)
tag = match.group(1).ljust(4)
lbound = float(match.group(2))
ubound = lbound
if match.group(3):
ubound = float(match.group(3))
if lbound != ubound:
result[tag] = (lbound, ubound)
else:
result[tag] = lbound
return result
def parseArgs(args):
"""Parse argv.
Returns:
3-tuple (infile, outfile, axis_limits)
axis_limits is either a Dict[str, int], for pinning variation axes to specific
coordinates along those axes; or a Dict[str, Tuple(int, int)], meaning limit
this axis to min/max range.
Axes locations are in user-space coordinates, as defined in the "fvar" table.
"""
from fontTools import configLogger
import argparse
parser = argparse.ArgumentParser(
"fonttools varLib.instancer",
description="Partially 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 tag of a variation axis, followed by '=' and a number or"
"number:number. E.g.: wdth=100 or wght=75.0:125.0",
)
parser.add_argument(
"-o",
"--output",
metavar="OUTPUT.ttf",
default=None,
help="Output instance TTF file (default: INPUT-instance.ttf).",
)
parser.add_argument(
"--no-optimize",
dest="optimize",
action="store_false",
help="do not perform IUP optimization on the remaining gvar TupleVariations",
)
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)
infile = options.input
outfile = (
os.path.splitext(infile)[0] + "-instance.ttf"
if not options.output
else options.output
)
configLogger(
level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO")
)
axis_limits = parseLimits(options.locargs)
if len(axis_limits) != len(options.locargs):
raise ValueError("Specified multiple limits for the same axis")
return (infile, outfile, axis_limits, options)
def main(args=None):
infile, outfile, axis_limits, options = parseArgs(args)
log.info("Restricting axes: %s", axis_limits)
log.info("Loading variable font")
varfont = TTFont(infile)
instantiateVariableFont(
varfont, axis_limits, inplace=True, optimize=options.optimize
)
log.info("Saving partial variable font %s", outfile)
varfont.save(outfile)
if __name__ == "__main__":
import sys
sys.exit(main())