Merge pull request #3598 from fonttools/avar2-reconstruct-mappings

[varLib.avar] Reconstruct mappings from binary
This commit is contained in:
Behdad Esfahbod 2024-08-30 11:04:20 -06:00 committed by GitHub
commit ce534ac74a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 290 additions and 6 deletions

View File

@ -869,7 +869,7 @@ def _add_COLR(font, model, master_fonts, axisTags, colr_layer_reuse=True):
colr.VarIndexMap = builder.buildDeltaSetIndexMap(varIdxes)
def load_designspace(designspace, log_enabled=True):
def load_designspace(designspace, log_enabled=True, *, require_sources=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
@ -878,7 +878,7 @@ def load_designspace(designspace, log_enabled=True):
ds = DesignSpaceDocument.fromfile(designspace)
masters = ds.sources
if not masters:
if require_sources and not masters:
raise VarLibValidationError("Designspace must have at least one source.")
instances = ds.instances
@ -978,7 +978,7 @@ def load_designspace(designspace, log_enabled=True):
"More than one base master found in Designspace."
)
base_idx = i
if base_idx is None:
if require_sources and base_idx is None:
raise VarLibValidationError(
"Base master not found; no master at default location?"
)

View File

@ -1,10 +1,187 @@
from fontTools.varLib import _add_avar, load_designspace
from fontTools.varLib.models import VariationModel
from fontTools.varLib.varStore import VarStoreInstancer
from fontTools.misc.fixedTools import fixedToFloat as fi2fl
from fontTools.misc.cliTools import makeOutputFileName
from itertools import product
import logging
log = logging.getLogger("fontTools.varLib.avar")
def _denormalize(v, axis):
if v >= 0:
return axis.defaultValue + v * (axis.maxValue - axis.defaultValue)
else:
return axis.defaultValue + v * (axis.defaultValue - axis.minValue)
def _pruneLocations(locations, poles, axisTags):
# Now we have all the input locations, find which ones are
# not needed and remove them.
# Note: This algorithm is heavily tied to how VariationModel
# is implemented. It assumes that input was extracted from
# VariationModel-generated object, like an ItemVariationStore
# created by fontmake using varLib.models.VariationModel.
# Some CoPilot blabbering:
# I *think* I can prove that this algorithm is correct, but
# I'm not 100% sure. It's possible that there are edge cases
# where this algorithm will fail. I'm not sure how to prove
# that it's correct, but I'm also not sure how to prove that
# it's incorrect. I'm not sure how to write a test case that
# would prove that it's incorrect. I'm not sure how to write
# a test case that would prove that it's correct.
model = VariationModel(locations, axisTags)
modelMapping = model.mapping
modelSupports = model.supports
pins = {tuple(k.items()): None for k in poles}
for location in poles:
i = locations.index(location)
i = modelMapping[i]
support = modelSupports[i]
supportAxes = set(support.keys())
for axisTag, (minV, _, maxV) in support.items():
for v in (minV, maxV):
if v in (-1, 0, 1):
continue
for pin in pins.keys():
pinLocation = dict(pin)
pinAxes = set(pinLocation.keys())
if pinAxes != supportAxes:
continue
if axisTag not in pinAxes:
continue
if pinLocation[axisTag] == v:
break
else:
# No pin found. Go through the previous masters
# and find a suitable pin. Going backwards is
# better because it can find a pin that is close
# to the pole in more dimensions, and reducing
# the total number of pins needed.
for candidateIdx in range(i - 1, -1, -1):
candidate = modelSupports[candidateIdx]
candidateAxes = set(candidate.keys())
if candidateAxes != supportAxes:
continue
if axisTag not in candidateAxes:
continue
candidate = {
k: defaultV for k, (_, defaultV, _) in candidate.items()
}
if candidate[axisTag] == v:
pins[tuple(candidate.items())] = None
break
else:
assert False, "No pin found"
return [dict(t) for t in pins.keys()]
def mappings_from_avar(font, denormalize=True):
fvarAxes = font["fvar"].axes
axisMap = {a.axisTag: a for a in fvarAxes}
axisTags = [a.axisTag for a in fvarAxes]
axisIndexes = {a.axisTag: i for i, a in enumerate(fvarAxes)}
if "avar" not in font:
return {}, {}
avar = font["avar"]
axisMaps = {
tag: seg
for tag, seg in avar.segments.items()
if seg and seg != {-1: -1, 0: 0, 1: 1}
}
mappings = []
if getattr(avar, "majorVersion", 1) == 2:
varStore = avar.table.VarStore
regions = varStore.VarRegionList.Region
# Find all the input locations; this finds "poles", that are
# locations of the peaks, and "corners", that are locations
# of the corners of the regions. These two sets of locations
# together constitute inputLocations to consider.
poles = {(): None} # Just using it as an ordered set
inputLocations = set({()})
for varData in varStore.VarData:
regionIndices = varData.VarRegionIndex
for regionIndex in regionIndices:
peakLocation = []
corners = []
region = regions[regionIndex]
for axisIndex, axis in enumerate(region.VarRegionAxis):
if axis.PeakCoord == 0:
continue
axisTag = axisTags[axisIndex]
peakLocation.append((axisTag, axis.PeakCoord))
corner = []
if axis.StartCoord != 0:
corner.append((axisTag, axis.StartCoord))
if axis.EndCoord != 0:
corner.append((axisTag, axis.EndCoord))
corners.append(corner)
corners = set(product(*corners))
peakLocation = tuple(peakLocation)
poles[peakLocation] = None
inputLocations.add(peakLocation)
inputLocations.update(corners)
# Sort them by number of axes, then by axis order
inputLocations = [
dict(t)
for t in sorted(
inputLocations,
key=lambda t: (len(t), tuple(axisIndexes[tag] for tag, _ in t)),
)
]
poles = [dict(t) for t in poles.keys()]
inputLocations = _pruneLocations(inputLocations, list(poles), axisTags)
# Find the output locations, at input locations
varIdxMap = avar.table.VarIdxMap
instancer = VarStoreInstancer(varStore, fvarAxes)
for location in inputLocations:
instancer.setLocation(location)
outputLocation = {}
for axisIndex, axisTag in enumerate(axisTags):
varIdx = axisIndex
if varIdxMap is not None:
varIdx = varIdxMap[varIdx]
delta = instancer[varIdx]
if delta != 0:
v = location.get(axisTag, 0)
v = v + fi2fl(delta, 14)
# See https://github.com/fonttools/fonttools/pull/3598#issuecomment-2266082009
# v = max(-1, min(1, v))
outputLocation[axisTag] = v
mappings.append((location, outputLocation))
# Remove base master we added, if it maps to the default location
assert mappings[0][0] == {}
if mappings[0][1] == {}:
mappings.pop(0)
if denormalize:
for tag, seg in axisMaps.items():
if tag not in axisMap:
raise ValueError(f"Unknown axis tag {tag}")
denorm = lambda v: _denormalize(v, axisMap[tag])
axisMaps[tag] = {denorm(k): denorm(v) for k, v in seg.items()}
for i, (inputLoc, outputLoc) in enumerate(mappings):
inputLoc = {
tag: _denormalize(val, axisMap[tag]) for tag, val in inputLoc.items()
}
outputLoc = {
tag: _denormalize(val, axisMap[tag]) for tag, val in outputLoc.items()
}
mappings[i] = (inputLoc, outputLoc)
return axisMaps, mappings
def main(args=None):
"""Add `avar` table from designspace file to variable font."""
@ -24,7 +201,11 @@ def main(args=None):
)
parser.add_argument("font", metavar="varfont.ttf", help="Variable-font file.")
parser.add_argument(
"designspace", metavar="family.designspace", help="Designspace file."
"designspace",
metavar="family.designspace",
help="Designspace file.",
nargs="?",
default=None,
)
parser.add_argument(
"-o",
@ -45,9 +226,18 @@ def main(args=None):
log.error("Not a variable font.")
return 1
if options.designspace is None:
from pprint import pprint
segments, mappings = mappings_from_avar(font)
pprint(segments)
pprint(mappings)
print(len(mappings), "mappings")
return
axisTags = [a.axisTag for a in font["fvar"].axes]
ds = load_designspace(options.designspace)
ds = load_designspace(options.designspace, require_sources=False)
if "avar" in font:
log.warning("avar table already present, overwriting.")

View File

@ -386,7 +386,7 @@ class VariationModel(object):
locAxes = set(region.keys())
# Walk over previous masters now
for prev_region in regions[:i]:
# Master with extra axes do not participte
# Master with different axes do not participte
if set(prev_region.keys()) != locAxes:
continue
# If it's NOT in the current box, it does not participate

94
Tests/varLib/avar_test.py Normal file
View File

@ -0,0 +1,94 @@
from fontTools.ttLib import TTFont
from fontTools.varLib.models import VariationModel
from fontTools.varLib.avar import _pruneLocations, mappings_from_avar
import os
import unittest
import pytest
TESTS = [
(
[
{"wght": 1},
{"wght": 0.5},
],
[
{"wght": 0.5},
],
[
{"wght": 0.5},
],
),
(
[
{"wght": 1, "wdth": 1},
{"wght": 0.5, "wdth": 1},
],
[
{"wght": 1, "wdth": 1},
],
[
{"wght": 1, "wdth": 1},
{"wght": 0.5, "wdth": 1},
],
),
(
[
{"wght": 1},
{"wdth": 1},
{"wght": 0.5, "wdth": 0.5},
],
[
{"wght": 0.5, "wdth": 0.5},
],
[
{"wght": 0.5, "wdth": 0.5},
],
),
]
@pytest.mark.parametrize("locations, poles, expected", TESTS)
def test_pruneLocations(locations, poles, expected):
axisTags = set()
for location in locations:
axisTags.update(location.keys())
axisTags = sorted(axisTags)
locations = [{}] + locations
pruned = _pruneLocations(locations, poles, axisTags)
assert pruned == expected, (pruned, expected)
@pytest.mark.parametrize("locations, poles, expected", TESTS)
def test_roundtrip(locations, poles, expected):
axisTags = set()
for location in locations:
axisTags.update(location.keys())
axisTags = sorted(axisTags)
locations = [{}] + locations
expected = [{}] + expected
model1 = VariationModel(locations, axisTags)
model2 = VariationModel(expected, axisTags)
for location in poles:
i = model1.locations.index(location)
support1 = model1.supports[i]
i = model2.locations.index(location)
support2 = model2.supports[i]
assert support1 == support2, (support1, support2)
def test_mappings_from_avar():
CWD = os.path.abspath(os.path.dirname(__file__))
DATADIR = os.path.join(CWD, "..", "ttLib", "tables", "data")
varfont_path = os.path.join(DATADIR, "Amstelvar-avar2.subset.ttf")
font = TTFont(varfont_path)
mappings = mappings_from_avar(font)
assert len(mappings) == 2, mappings