diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 6d0e00ee1..5c96fd01c 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -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?" ) diff --git a/Lib/fontTools/varLib/avar.py b/Lib/fontTools/varLib/avar.py index 60f0d7e70..164a4a805 100644 --- a/Lib/fontTools/varLib/avar.py +++ b/Lib/fontTools/varLib/avar.py @@ -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.") diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index fadfa242c..52433a66a 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -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 diff --git a/Tests/varLib/avar_test.py b/Tests/varLib/avar_test.py new file mode 100644 index 000000000..92f7b3a7d --- /dev/null +++ b/Tests/varLib/avar_test.py @@ -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