From 5f083bdf2e210326099c1d0dd2fa3d46860fcd64 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 28 Mar 2019 17:41:58 +0000 Subject: [PATCH 1/3] refactor instantiateItemVariationStore for better test-ability The function now takes a VarStore instance, the fvar axes and a partial location, and returns an array of delta-sets to be applied to the default instance. The algorithm is now more similar to the one used for instantiating the tuple variation store. Tests are coming soon. --- Lib/fontTools/varLib/instancer.py | 200 ++++++++++++++++-------------- 1 file changed, 109 insertions(+), 91 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 59bb84b7e..3bf46c989 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -17,9 +17,7 @@ from fontTools.varLib.models import supportScalar, normalizeValue, piecewiseLine from fontTools.varLib.iup import iup_delta from fontTools.ttLib import TTFont from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates -from fontTools.varLib.varStore import VarStoreInstancer from fontTools.varLib.mvar import MVAR_ENTRIES -import collections from copy import deepcopy import logging import os @@ -132,125 +130,106 @@ def instantiateCvar(varfont, location): del varfont["cvar"] -def setMvarDeltas(varfont, location): +def setMvarDeltas(varfont, deltaArray): log.info("Setting MVAR deltas") mvar = varfont["MVAR"].table - fvar = varfont["fvar"] - varStoreInstancer = VarStoreInstancer(mvar.VarStore, fvar.axes, location) records = mvar.ValueRecord - # accumulate applicable deltas as floats and only round at the end - deltas = collections.defaultdict(float) for rec in records: mvarTag = rec.ValueTag if mvarTag not in MVAR_ENTRIES: continue tableTag, itemName = MVAR_ENTRIES[mvarTag] - deltas[(tableTag, itemName)] += varStoreInstancer[rec.VarIdx] - - for (tableTag, itemName), delta in deltas.items(): - setattr( - varfont[tableTag], - itemName, - getattr(varfont[tableTag], itemName) + otRound(delta), - ) + 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") - # First instantiate to new position without modifying MVAR table - setMvarDeltas(varfont, location) varStore = varfont["MVAR"].table.VarStore - instantiateItemVariationStore(varStore, varfont["fvar"].axes, location) + 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 instantiateItemVariationStore(varStore, fvarAxes, location): - regionsToBeRemoved = set() - regionScalars = {} - pinnedAxes = set(location.keys()) - fvarAxisIndices = { - axis.axisTag: index - for index, axis in enumerate(fvarAxes) - if axis.axisTag in pinnedAxes +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 } - for regionIndex, region in enumerate(varStore.VarRegionList.Region): - # collect set of axisTags which have influence: peak != 0 - regionAxes = set( - axis - for axis, (start, peak, end) in region.get_support(fvarAxes).items() - if peak != 0 + 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 + ] ) - pinnedRegionAxes = regionAxes & pinnedAxes - if not pinnedRegionAxes: - # A region where none of the axes having effect are pinned - continue - if len(pinnedRegionAxes) == len(regionAxes): - # All the axes having effect in this region are being pinned so - # remove it - regionsToBeRemoved.add(regionIndex) - else: - # compute the scalar support of the axes to be pinned - pinnedSupport = { - axis: support - for axis, support in region.get_support(fvarAxes).items() - if axis in pinnedRegionAxes - } - pinnedScalar = supportScalar(location, pinnedSupport) - if pinnedScalar == 0.0: - # no influence, drop this region - regionsToBeRemoved.add(regionIndex) - continue - elif pinnedScalar != 1.0: - # This region will be retained but the deltas will be scaled - regionScalars[regionIndex] = pinnedScalar + return deltaSets - for axisTag in pinnedRegionAxes: - # For all pinnedRegionAxes make their influence null by setting - # PeakCoord to 0. - axis = region.VarRegionAxis[fvarAxisIndices[axisTag]] - axis.StartCoord, axis.PeakCoord, axis.EndCoord = (0, 0, 0) +def _subsetVarStoreRegions(varStore, regionIndices): + # drop regions not in regionIndices newVarDatas = [] - for vardata in varStore.VarData: - varRegionIndex = vardata.VarRegionIndex - if regionsToBeRemoved.issuperset(varRegionIndex): + for varData in varStore.VarData: + if regionIndices.isdisjoint(varData.VarRegionIndex): # drop VarData subtable if we remove all the regions referenced by it continue - # Apply scalars for regions to be retained. - for item in vardata.Item: - for column, delta in enumerate(item): - regionIndex = varRegionIndex[column] - if regionIndex in regionScalars: - scalar = regionScalars[regionIndex] - item[column] = otRound(delta * scalar) + # 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) - if not regionsToBeRemoved.isdisjoint(varRegionIndex): - # from each deltaset row, delete columns corresponding to the regions to - # be deleted - newItems = [] - for item in vardata.Item: - newItems.append( - [ - delta - for column, delta in enumerate(item) - if varRegionIndex[column] not in regionsToBeRemoved - ] - ) - vardata.Item = newItems - vardata.ItemCount = len(newItems) - # prune VarRegionIndex from the regions to be deleted - vardata.VarRegionIndex = [ - ri for ri in vardata.VarRegionIndex if ri not in regionsToBeRemoved - ] - vardata.VarRegionCount = len(vardata.VarRegionIndex) - vardata.calculateNumShorts() - newVarDatas.append(vardata) + # recalculate NumShorts, reordering columns as necessary + varData.optimize() + newVarDatas.append(varData) varStore.VarData = newVarDatas varStore.VarDataCount = len(varStore.VarData) @@ -258,6 +237,45 @@ def instantiateItemVariationStore(varStore, fvarAxes, location): 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( From a8853997d52b41ae3b059787d8790169d8bd499f Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 28 Mar 2019 19:32:05 +0000 Subject: [PATCH 2/3] instancer_test: start adding tests for instantiateItemVariationStore helpers --- Tests/varLib/instancer_test.py | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 14204b903..c28362431 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -3,6 +3,7 @@ from fontTools.misc.py23 import * from fontTools.ttLib import TTFont from fontTools.varLib import instancer from fontTools.varLib.mvar import MVAR_ENTRIES +from fontTools.varLib import builder import os import pytest @@ -211,3 +212,63 @@ class InstantiateMvarTest(object): assert getattr(varfont[table_tag], item_name) == expected_value assert "MVAR" not in varfont + + +class InstantiateItemVariationStoreTest(object): + def test_getVarRegionAxes(self): + axisOrder = ["wght", "wdth", "opsz"] + regionAxes = {"wdth": (-1.0, -1.0, 0.0), "wght": (0.0, 1.0, 1.0)} + region = builder.buildVarRegion(regionAxes, axisOrder) + fvarAxes = [SimpleNamespace(axisTag=tag) for tag in axisOrder] + + result = instancer._getVarRegionAxes(region, fvarAxes) + + assert { + axisTag: (axis.StartCoord, axis.PeakCoord, axis.EndCoord) + for axisTag, axis in result.items() + } == regionAxes + + @pytest.mark.parametrize( + "location, regionAxes, expected", + [ + ({"wght": 0.5}, {"wght": (0.0, 1.0, 1.0)}, 0.5), + ({"wght": 0.5}, {"wght": (0.0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0.0)}, 0.5), + ( + {"wght": 0.5, "wdth": -0.5}, + {"wght": (0.0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0.0)}, + 0.25, + ), + ({"wght": 0.5, "wdth": -0.5}, {"wght": (0.0, 1.0, 1.0)}, 0.5), + ({"wght": 0.5}, {"wdth": (-1.0, -1.0, 1.0)}, 1.0), + ], + ) + def test_getVarRegionScalar(self, location, regionAxes, expected): + varRegionAxes = { + axisTag: builder.buildVarRegionAxis(support) + for axisTag, support in regionAxes.items() + } + + assert instancer._getVarRegionScalar(location, varRegionAxes) == expected + + def test_scaleVarDataDeltas(self): + regionScalars = [0.0, 0.5, 1.0] + varData = builder.buildVarData( + [1, 0], [[100, 200], [-100, -200]], optimize=False + ) + + instancer._scaleVarDataDeltas(varData, regionScalars) + + assert varData.Item == [[50, 0], [-50, 0]] + + def test_getVarDataDeltasForRegions(self): + varData = builder.buildVarData( + [1, 0], [[33.5, 67.9], [-100, -200]], optimize=False + ) + + assert instancer._getVarDataDeltasForRegions(varData, {1}) == [[33.5], [-100]] + assert instancer._getVarDataDeltasForRegions(varData, {0}) == [[67.9], [-200]] + assert instancer._getVarDataDeltasForRegions(varData, set()) == [[], []] + assert instancer._getVarDataDeltasForRegions(varData, {1}, rounded=True) == [ + [34], + [-100], + ] From 8e9fac123c368a3da411e60d4ba138210eef7a05 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 29 Mar 2019 13:00:27 +0000 Subject: [PATCH 3/3] instancer_test: add more unit tests for instantiateItemVariationStore --- Tests/varLib/instancer_test.py | 120 ++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index c28362431..a8492e225 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1,6 +1,7 @@ from __future__ import print_function, division, absolute_import from fontTools.misc.py23 import * -from fontTools.ttLib import TTFont +from fontTools import ttLib +from fontTools.ttLib.tables import _f_v_a_r from fontTools.varLib import instancer from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib import builder @@ -13,7 +14,7 @@ TESTDATA = os.path.join(os.path.dirname(__file__), "data") @pytest.fixture def varfont(): - f = TTFont() + f = ttLib.TTFont() f.importXML(os.path.join(TESTDATA, "PartialInstancerTest-VF.ttx")) return f @@ -272,3 +273,118 @@ class InstantiateItemVariationStoreTest(object): [34], [-100], ] + + def test_subsetVarStoreRegions(self): + regionList = builder.buildVarRegionList( + [ + {"wght": (0, 0.5, 1)}, + {"wght": (0.5, 1, 1)}, + {"wdth": (-1, -1, 0)}, + {"wght": (0, 0.5, 1), "wdth": (-1, -1, 0)}, + {"wght": (0.5, 1, 1), "wdth": (-1, -1, 0)}, + ], + ["wght", "wdth"], + ) + varData1 = builder.buildVarData([0, 1, 2, 4], [[0, 1, 2, 3], [4, 5, 6, 7]]) + varData2 = builder.buildVarData([2, 3, 1], [[8, 9, 10], [11, 12, 13]]) + varStore = builder.buildVarStore(regionList, [varData1, varData2]) + + instancer._subsetVarStoreRegions(varStore, {0, 4}) + + assert ( + varStore.VarRegionList.RegionCount + == len(varStore.VarRegionList.Region) + == 2 + ) + axis00 = varStore.VarRegionList.Region[0].VarRegionAxis[0] + assert (axis00.StartCoord, axis00.PeakCoord, axis00.EndCoord) == (0, 0.5, 1) + axis01 = varStore.VarRegionList.Region[0].VarRegionAxis[1] + assert (axis01.StartCoord, axis01.PeakCoord, axis01.EndCoord) == (0, 0, 0) + axis10 = varStore.VarRegionList.Region[1].VarRegionAxis[0] + assert (axis10.StartCoord, axis10.PeakCoord, axis10.EndCoord) == (0.5, 1, 1) + axis11 = varStore.VarRegionList.Region[1].VarRegionAxis[1] + assert (axis11.StartCoord, axis11.PeakCoord, axis11.EndCoord) == (-1, -1, 0) + + assert varStore.VarDataCount == len(varStore.VarData) == 1 + assert varStore.VarData[0].VarRegionCount == 2 + assert varStore.VarData[0].VarRegionIndex == [0, 1] + assert varStore.VarData[0].Item == [[0, 3], [4, 7]] + assert varStore.VarData[0].NumShorts == 0 + + @pytest.fixture + def fvarAxes(self): + wght = _f_v_a_r.Axis() + wght.axisTag = Tag("wght") + wght.minValue = 100 + wght.defaultValue = 400 + wght.maxValue = 900 + wdth = _f_v_a_r.Axis() + wdth.axisTag = Tag("wdth") + wdth.minValue = 70 + wdth.defaultValue = 100 + wdth.maxValue = 100 + return [wght, wdth] + + @pytest.fixture + def varStore(self): + return builder.buildVarStore( + builder.buildVarRegionList( + [ + {"wght": (-1.0, -1.0, 0)}, + {"wght": (0, 0.5, 1.0)}, + {"wght": (0.5, 1.0, 1.0)}, + {"wdth": (-1.0, -1.0, 0)}, + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, + {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, + {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, + ], + ["wght", "wdth"], + ), + [ + builder.buildVarData([0, 1, 2], [[100, 100, 100], [100, 100, 100]]), + builder.buildVarData( + [3, 4, 5, 6], [[100, 100, 100, 100], [100, 100, 100, 100]] + ), + ], + ) + + @pytest.mark.parametrize( + "location, expected_deltas, num_regions, num_vardatas", + [ + ({"wght": 0}, [[[0, 0, 0], [0, 0, 0]], [[], []]], 1, 1), + ({"wght": 0.25}, [[[0, 50, 0], [0, 50, 0]], [[], []]], 2, 1), + ({"wdth": 0}, [[[], []], [[0], [0]]], 3, 1), + ({"wdth": -0.75}, [[[], []], [[75], [75]]], 6, 2), + ( + {"wght": 0, "wdth": 0}, + [[[0, 0, 0], [0, 0, 0]], [[0, 0, 0, 0], [0, 0, 0, 0]]], + 0, + 0, + ), + ( + {"wght": 0.25, "wdth": 0}, + [[[0, 50, 0], [0, 50, 0]], [[0, 0, 0, 0], [0, 0, 0, 0]]], + 0, + 0, + ), + ( + {"wght": 0, "wdth": -0.75}, + [[[0, 0, 0], [0, 0, 0]], [[75, 0, 0, 0], [75, 0, 0, 0]]], + 0, + 0, + ), + ], + ) + def test_instantiate_default_deltas( + self, varStore, fvarAxes, location, expected_deltas, num_regions, num_vardatas + ): + defaultDeltas = instancer.instantiateItemVariationStore( + varStore, fvarAxes, location + ) + + # from fontTools.misc.testTools import getXML + # print("\n".join(getXML(varStore.toXML, ttFont=None))) + + assert defaultDeltas == expected_deltas + assert varStore.VarRegionList.RegionCount == num_regions + assert varStore.VarDataCount == num_vardatas