fonttools/Lib/fontTools/varLib/instancer.py
Cosimo Lupo f220d36df1
instancer: merge TupleVariations left with same axes after pinning
The instantiateTupleVariationStore function now groups TupleVariation
tables that have the same axes 'tents', then merges them into a single
TupleVariation by summing their deltas. The rounding to integer happens
after summing the scaled deltas as floats, to reduce off-by-one errors.

To be able to sum gvar TupleVariation, it needs to calculate the inferred
deltas so it now takes two optional lists (origCoords and endPts) that
are passed on to iup_delta function. These only make sense for gvar
type of TupleVariation, of course, and are unused for cvar tuples.

It also run iup_delta_optimize on the gvar deltas that are left after
partial instancing and whose inferred deltas had to be interpolated.
This can be disabled with --no-optimize CLI option.

Also added calcInferredDeltas and optimize methods to TupleVariation
class, which use functions from varLib.iup module, plus tests
that exercise them.
2019-04-04 15:11:23 +01:00

521 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):
varGroups = 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
var.scaleDeltas(scalar)
# group TupleVariations by overlapping "tents" (can be empty if all the axes
# were instanced)
axes = tuple(var.axes.items())
if axes in varGroups:
varGroups[axes].append(var)
else:
varGroups[axes] = [var]
defaultDeltas = None
newVariations = []
for axes, varGroup in varGroups.items():
var = varGroup.pop(0)
# merge TupleVariations having the same (or none) axes
if varGroup:
var.sumDeltas(varGroup, origCoords, endPts)
if axes is ():
# if no axis is left in the TupleVariation, we drop it and its deltas
# will be later added to the default instance; we need to interpolate
# any inferred (i.e. None) deltas to be able to sum the coordinates
if origCoords is not None:
var.calcInferredDeltas(origCoords, endPts)
defaultDeltas = var.coordinates
else:
var.roundDeltas()
newVariations.append(var)
variations[:] = newVariations
return defaultDeltas or []
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] += otRound(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")
varStore = varfont["MVAR"].table.VarStore
fvarAxes = varfont["fvar"].axes
defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, location)
setMvarDeltas(varfont, defaultDeltas)
if not varStore.VarRegionList.Region:
# 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
newVarDatas = []
for varData in varStore.VarData:
if regionIndices.isdisjoint(varData.VarRegionIndex):
# drop VarData subtable if we remove all the regions referenced by it
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()
newVarDatas.append(varData)
varStore.VarData = newVarDatas
varStore.VarDataCount = len(varStore.VarData)
# 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)
return defaultDeltaArray
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())