Merge pull request #1626 from anthrotype/instancer-feature-vars

instancer: bugfixes and tests for instantiateFeatureVariations
This commit is contained in:
Cosimo Lupo 2019-05-31 10:50:41 +01:00 committed by GitHub
commit 333ec85985
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 342 additions and 16 deletions

View File

@ -283,6 +283,7 @@ def addFeatureVariationsRaw(font, conditionalSubstitutions):
rvrnFeature = buildFeatureRecord('rvrn', []) rvrnFeature = buildFeatureRecord('rvrn', [])
gsub.FeatureList.FeatureRecord.append(rvrnFeature) gsub.FeatureList.FeatureRecord.append(rvrnFeature)
gsub.FeatureList.FeatureCount = len(gsub.FeatureList.FeatureRecord)
sortFeatureList(gsub) sortFeatureList(gsub)
rvrnFeatureIndex = gsub.FeatureList.FeatureRecord.index(rvrnFeature) rvrnFeatureIndex = gsub.FeatureList.FeatureRecord.index(rvrnFeature)
@ -346,6 +347,7 @@ def buildGSUB():
srec.Script.DefaultLangSys = langrec.LangSys srec.Script.DefaultLangSys = langrec.LangSys
gsub.ScriptList.ScriptRecord.append(srec) gsub.ScriptList.ScriptRecord.append(srec)
gsub.ScriptList.ScriptCount = 1
gsub.FeatureVariations = None gsub.FeatureVariations = None
return fontTable return fontTable
@ -380,6 +382,7 @@ def buildSubstitutionLookups(gsub, allSubstitutions):
lookup = buildLookup([buildSingleSubstSubtable(substMap)]) lookup = buildLookup([buildSingleSubstSubtable(substMap)])
gsub.LookupList.Lookup.append(lookup) gsub.LookupList.Lookup.append(lookup)
assert gsub.LookupList.Lookup[lookupMap[subst]] is lookup assert gsub.LookupList.Lookup[lookupMap[subst]] is lookup
gsub.LookupList.LookupCount = len(gsub.LookupList.Lookup)
return lookupMap return lookupMap
@ -397,6 +400,7 @@ def buildFeatureRecord(featureTag, lookupListIndices):
fr.FeatureTag = featureTag fr.FeatureTag = featureTag
fr.Feature = ot.Feature() fr.Feature = ot.Feature()
fr.Feature.LookupListIndex = lookupListIndices fr.Feature.LookupListIndex = lookupListIndices
fr.Feature.populateDefaults()
return fr return fr

View File

@ -18,6 +18,9 @@ from fontTools.ttLib import TTFont
from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools.ttLib.tables.TupleVariation import TupleVariation
from fontTools.ttLib.tables import _g_l_y_f from fontTools.ttLib.tables import _g_l_y_f
from fontTools import varLib from fontTools import varLib
# we import the `subset` module because we use the `prune_lookups` method on the GSUB
# table class, and that method is only defined dynamically upon importing `subset`
from fontTools import subset # noqa: F401
from fontTools.varLib import builder from fontTools.varLib import builder
from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.mvar import MVAR_ENTRIES
from fontTools.varLib.merger import MutatorMerger from fontTools.varLib.merger import MutatorMerger
@ -30,7 +33,7 @@ import os
import re import re
log = logging.getLogger("fontTools.varlib.instancer") log = logging.getLogger("fontTools.varLib.instancer")
def instantiateTupleVariationStore(variations, location, origCoords=None, endPts=None): def instantiateTupleVariationStore(variations, location, origCoords=None, endPts=None):
@ -408,34 +411,74 @@ def instantiateFeatureVariations(varfont, location):
_instantiateFeatureVariations( _instantiateFeatureVariations(
varfont[tableTag].table, varfont["fvar"].axes, location varfont[tableTag].table, varfont["fvar"].axes, location
) )
# remove unreferenced lookups
varfont[tableTag].prune_lookups()
def _featureVariationRecordIsUnique(rec, seen):
conditionSet = []
for cond in rec.ConditionSet.ConditionTable:
if cond.Format != 1:
# can't tell whether this is duplicate, assume is unique
return True
conditionSet.append(
(cond.AxisIndex, cond.FilterRangeMinValue, cond.FilterRangeMaxValue)
)
# besides the set of conditions, we also include the FeatureTableSubstitution
# version to identify unique FeatureVariationRecords, even though only one
# version is currently defined. It's theoretically possible that multiple
# records with same conditions but different substitution table version be
# present in the same font for backward compatibility.
recordKey = frozenset([rec.FeatureTableSubstitution.Version] + conditionSet)
if recordKey in seen:
return False
else:
seen.add(recordKey) # side effect
return True
def _instantiateFeatureVariations(table, fvarAxes, location): def _instantiateFeatureVariations(table, fvarAxes, location):
newRecords = []
pinnedAxes = set(location.keys()) pinnedAxes = set(location.keys())
axisOrder = [axis.axisTag for axis in fvarAxes if axis.axisTag not in pinnedAxes]
axisIndexMap = {axisTag: axisOrder.index(axisTag) for axisTag in axisOrder}
featureVariationApplied = False featureVariationApplied = False
for record in table.FeatureVariations.FeatureVariationRecord: uniqueRecords = set()
newRecords = []
for i, record in enumerate(table.FeatureVariations.FeatureVariationRecord):
retainRecord = True retainRecord = True
applies = True applies = True
newConditions = [] newConditions = []
for condition in record.ConditionSet.ConditionTable: for j, condition in enumerate(record.ConditionSet.ConditionTable):
axisIdx = condition.AxisIndex if condition.Format == 1:
axisTag = fvarAxes[axisIdx].axisTag axisIdx = condition.AxisIndex
if condition.Format == 1 and axisTag in pinnedAxes: axisTag = fvarAxes[axisIdx].axisTag
minValue = condition.FilterRangeMinValue if axisTag in pinnedAxes:
maxValue = condition.FilterRangeMaxValue minValue = condition.FilterRangeMinValue
v = location[axisTag] maxValue = condition.FilterRangeMaxValue
if not (minValue <= v <= maxValue): v = location[axisTag]
# condition not met so remove entire record if not (minValue <= v <= maxValue):
retainRecord = False # condition not met so remove entire record
break retainRecord = applies = False
break
else:
# axis not pinned, keep condition with remapped axis index
applies = False
condition.AxisIndex = axisIndexMap[axisTag]
newConditions.append(condition)
else: else:
log.warning(
"Condition table {0} of FeatureVariationRecord {1} has "
"unsupported format ({2}); ignored".format(j, i, condition.Format)
)
applies = False applies = False
newConditions.append(condition) newConditions.append(condition)
if retainRecord and newConditions: if retainRecord and newConditions:
record.ConditionSet.ConditionTable = newConditions record.ConditionSet.ConditionTable = newConditions
newRecords.append(record) if _featureVariationRecordIsUnique(record, uniqueRecords):
newRecords.append(record)
if applies and not featureVariationApplied: if applies and not featureVariationApplied:
assert record.FeatureTableSubstitution.Version == 0x00010000 assert record.FeatureTableSubstitution.Version == 0x00010000
@ -446,6 +489,7 @@ def _instantiateFeatureVariations(table, fvarAxes, location):
if newRecords: if newRecords:
table.FeatureVariations.FeatureVariationRecord = newRecords table.FeatureVariations.FeatureVariationRecord = newRecords
table.FeatureVariations.FeatureVariationCount = len(newRecords)
else: else:
del table.FeatureVariations del table.FeatureVariations
@ -644,7 +688,7 @@ def normalize(value, triple, avar_mapping):
def normalizeAxisLimits(varfont, axis_limits): def normalizeAxisLimits(varfont, axis_limits):
fvar = varfont["fvar"] fvar = varfont["fvar"]
bad_limits = axis_limits.keys() - {a.axisTag for a in fvar.axes} bad_limits = set(axis_limits.keys()).difference(a.axisTag for a in fvar.axes)
if bad_limits: if bad_limits:
raise ValueError("Cannot limit: {} not present in fvar".format(bad_limits)) raise ValueError("Cannot limit: {} not present in fvar".format(bad_limits))

View File

@ -10,7 +10,9 @@ from fontTools import varLib
from fontTools.varLib import instancer from fontTools.varLib import instancer
from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.mvar import MVAR_ENTRIES
from fontTools.varLib import builder from fontTools.varLib import builder
from fontTools.varLib import featureVars
from fontTools.varLib import models from fontTools.varLib import models
from fontTools.misc.loggingTools import CapturingLogHandler
import collections import collections
from copy import deepcopy from copy import deepcopy
import os import os
@ -1146,3 +1148,279 @@ class InstantiateVariableFontTest(object):
expected = _get_expected_instance_ttx(400, 100) expected = _get_expected_instance_ttx(400, 100)
assert _dump_ttx(instance) == expected assert _dump_ttx(instance) == expected
def _conditionSetAsDict(conditionSet, axisOrder):
result = {}
for cond in conditionSet.ConditionTable:
assert cond.Format == 1
axisTag = axisOrder[cond.AxisIndex]
result[axisTag] = (cond.FilterRangeMinValue, cond.FilterRangeMaxValue)
return result
def _getSubstitutions(gsub, lookupIndices):
subs = {}
for index, lookup in enumerate(gsub.LookupList.Lookup):
if index in lookupIndices:
for subtable in lookup.SubTable:
subs.update(subtable.mapping)
return subs
def makeFeatureVarsFont(conditionalSubstitutions):
axes = set()
glyphs = set()
for region, substitutions in conditionalSubstitutions:
for box in region:
axes.update(box.keys())
glyphs.update(*substitutions.items())
varfont = ttLib.TTFont()
varfont.setGlyphOrder(sorted(glyphs))
fvar = varfont["fvar"] = ttLib.newTable("fvar")
fvar.axes = []
for axisTag in sorted(axes):
axis = _f_v_a_r.Axis()
axis.axisTag = Tag(axisTag)
fvar.axes.append(axis)
featureVars.addFeatureVariations(varfont, conditionalSubstitutions)
return varfont
class InstantiateFeatureVariationsTest(object):
@pytest.mark.parametrize(
"location, appliedSubs, expectedRecords",
[
({"wght": 0}, {}, [({"cntr": (0.75, 1.0)}, {"uni0041": "uni0061"})]),
(
{"wght": -1.0},
{},
[
({"cntr": (0, 0.25)}, {"uni0061": "uni0041"}),
({"cntr": (0.75, 1.0)}, {"uni0041": "uni0061"}),
],
),
(
{"wght": 1.0},
{"uni0024": "uni0024.nostroke"},
[
(
{"cntr": (0.75, 1.0)},
{"uni0024": "uni0024.nostroke", "uni0041": "uni0061"},
)
],
),
(
{"cntr": 0},
{},
[
({"wght": (-1.0, -0.45654)}, {"uni0061": "uni0041"}),
({"wght": (0.20886, 1.0)}, {"uni0024": "uni0024.nostroke"}),
],
),
(
{"cntr": 1.0},
{"uni0041": "uni0061"},
[
(
{"wght": (0.20886, 1.0)},
{"uni0024": "uni0024.nostroke", "uni0041": "uni0061"},
)
],
),
],
)
def test_partial_instance(self, location, appliedSubs, expectedRecords):
font = makeFeatureVarsFont(
[
([{"wght": (0.20886, 1.0)}], {"uni0024": "uni0024.nostroke"}),
([{"cntr": (0.75, 1.0)}], {"uni0041": "uni0061"}),
(
[{"wght": (-1.0, -0.45654), "cntr": (0, 0.25)}],
{"uni0061": "uni0041"},
),
]
)
instancer.instantiateFeatureVariations(font, location)
gsub = font["GSUB"].table
featureVariations = gsub.FeatureVariations
assert featureVariations.FeatureVariationCount == len(expectedRecords)
axisOrder = [a.axisTag for a in font["fvar"].axes if a.axisTag not in location]
for i, (expectedConditionSet, expectedSubs) in enumerate(expectedRecords):
rec = featureVariations.FeatureVariationRecord[i]
conditionSet = _conditionSetAsDict(rec.ConditionSet, axisOrder)
assert conditionSet == expectedConditionSet
subsRecord = rec.FeatureTableSubstitution.SubstitutionRecord[0]
lookupIndices = subsRecord.Feature.LookupListIndex
substitutions = _getSubstitutions(gsub, lookupIndices)
assert substitutions == expectedSubs
appliedLookupIndices = gsub.FeatureList.FeatureRecord[0].Feature.LookupListIndex
assert _getSubstitutions(gsub, appliedLookupIndices) == appliedSubs
@pytest.mark.parametrize(
"location, appliedSubs",
[
({"wght": 0, "cntr": 0}, None),
({"wght": -1.0, "cntr": 0}, {"uni0061": "uni0041"}),
({"wght": 1.0, "cntr": 0}, {"uni0024": "uni0024.nostroke"}),
({"wght": 0.0, "cntr": 1.0}, {"uni0041": "uni0061"}),
(
{"wght": 1.0, "cntr": 1.0},
{"uni0041": "uni0061", "uni0024": "uni0024.nostroke"},
),
({"wght": -1.0, "cntr": 0.3}, None),
],
)
def test_full_instance(self, location, appliedSubs):
font = makeFeatureVarsFont(
[
([{"wght": (0.20886, 1.0)}], {"uni0024": "uni0024.nostroke"}),
([{"cntr": (0.75, 1.0)}], {"uni0041": "uni0061"}),
(
[{"wght": (-1.0, -0.45654), "cntr": (0, 0.25)}],
{"uni0061": "uni0041"},
),
]
)
instancer.instantiateFeatureVariations(font, location)
gsub = font["GSUB"].table
assert not hasattr(gsub, "FeatureVariations")
if appliedSubs:
lookupIndices = gsub.FeatureList.FeatureRecord[0].Feature.LookupListIndex
assert _getSubstitutions(gsub, lookupIndices) == appliedSubs
else:
assert not gsub.FeatureList.FeatureRecord
def test_unsupported_condition_format(self):
font = makeFeatureVarsFont(
[
(
[{"wdth": (-1.0, -0.5), "wght": (0.5, 1.0)}],
{"dollar": "dollar.nostroke"},
)
]
)
featureVariations = font["GSUB"].table.FeatureVariations
rec1 = featureVariations.FeatureVariationRecord[0]
assert len(rec1.ConditionSet.ConditionTable) == 2
rec1.ConditionSet.ConditionTable[0].Format = 2
with CapturingLogHandler("fontTools.varLib.instancer", "WARNING") as captor:
instancer.instantiateFeatureVariations(font, {"wdth": 0})
captor.assertRegex(
r"Condition table 0 of FeatureVariationRecord 0 "
r"has unsupported format \(2\); ignored"
)
# check that record with unsupported condition format (but whose other
# conditions do not reference pinned axes) is kept as is
featureVariations = font["GSUB"].table.FeatureVariations
assert featureVariations.FeatureVariationRecord[0] is rec1
assert len(rec1.ConditionSet.ConditionTable) == 2
assert rec1.ConditionSet.ConditionTable[0].Format == 2
@pytest.mark.parametrize(
"limits, expected",
[
(["wght=400", "wdth=100"], {"wght": 400, "wdth": 100}),
(["wght=400:900"], {"wght": (400, 900)}),
(["slnt=11.4"], {"slnt": 11.4}),
(["ABCD=drop"], {"ABCD": None}),
],
)
def test_parseLimits(limits, expected):
assert instancer.parseLimits(limits) == expected
@pytest.mark.parametrize(
"limits", [["abcde=123", "=0", "wght=:", "wght=1:", "wght=abcd", "wght=x:y"]]
)
def test_parseLimits_invalid(limits):
with pytest.raises(ValueError, match="invalid location format"):
instancer.parseLimits(limits)
def test_normalizeAxisLimits_tuple(varfont):
normalized = instancer.normalizeAxisLimits(varfont, {"wght": (100, 400)})
assert normalized == {"wght": (-1.0, 0)}
def test_normalizeAxisLimits_no_avar(varfont):
del varfont["avar"]
normalized = instancer.normalizeAxisLimits(varfont, {"wght": (500, 600)})
assert normalized["wght"] == pytest.approx((0.2, 0.4), 1e-4)
def test_normalizeAxisLimits_missing_from_fvar(varfont):
with pytest.raises(ValueError, match="not present in fvar"):
instancer.normalizeAxisLimits(varfont, {"ZZZZ": 1000})
def test_sanityCheckVariableTables(varfont):
font = ttLib.TTFont()
with pytest.raises(ValueError, match="Missing required table fvar"):
instancer.sanityCheckVariableTables(font)
del varfont["glyf"]
with pytest.raises(ValueError, match="Can't have gvar without glyf"):
instancer.sanityCheckVariableTables(varfont)
def test_main(varfont, tmpdir):
fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf")
varfont.save(fontfile)
args = [fontfile, "wght=400"]
# exits without errors
assert instancer.main(args) is None
def test_main_exit_nonexistent_file(capsys):
with pytest.raises(SystemExit):
instancer.main([""])
captured = capsys.readouterr()
assert "No such file ''" in captured.err
def test_main_exit_invalid_location(varfont, tmpdir, capsys):
fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf")
varfont.save(fontfile)
with pytest.raises(SystemExit):
instancer.main([fontfile, "wght:100"])
captured = capsys.readouterr()
assert "invalid location format" in captured.err
def test_main_exit_multiple_limits(varfont, tmpdir, capsys):
fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf")
varfont.save(fontfile)
with pytest.raises(SystemExit):
instancer.main([fontfile, "wght=400", "wght=90"])
captured = capsys.readouterr()
assert "Specified multiple limits for the same axis" in captured.err