From b6501a940612b1e4edfd4df8808d8549f1e05392 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 6 Mar 2019 17:43:28 -0800 Subject: [PATCH 001/127] added WIP fontTools.varLib.partialInstancer module can only partially instantiate gvar for now --- Lib/fontTools/varLib/partialInstancer.py | 196 +++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 Lib/fontTools/varLib/partialInstancer.py diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py new file mode 100644 index 000000000..107ffea0d --- /dev/null +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -0,0 +1,196 @@ +""" 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.partialInstancer ./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 +from fontTools.varLib import _GetCoordinates, _SetCoordinates +from fontTools.varLib.models import ( + supportScalar, + normalizeLocation, + piecewiseLinearMap, +) +from fontTools.varLib.iup import iup_delta +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates +import os +import logging + + +log = logging.getLogger("fontTools.varlib.partialInstancer") + + +def instantiateGvarGlyph(varfont, location, glyphname): + gvar = varfont["gvar"] + variations = gvar.variations[glyphname] + coordinates, _ = _GetCoordinates(varfont, glyphname) + origCoords, endPts = None, None + newVariations = [] + pinnedAxes = set(location.keys()) + defaultModified = False + for var in variations: + tupleAxes = set(var.axes.keys()) + pinnedTupleAxes = tupleAxes & pinnedAxes + if not pinnedTupleAxes: + # A tuple for only axes being kept is untouched + newVariations.append(var) + continue + else: + # compute influence at pinned location only for the pinned axes + pinnedAxesSupport = {a: var.axes[a] for a in pinnedTupleAxes} + scalar = supportScalar(location, pinnedAxesSupport) + if not scalar: + # no influence (default value or out of range); drop tuple + continue + deltas = var.coordinates + hasUntouchedPoints = None in deltas + if hasUntouchedPoints: + if origCoords is None: + origCoords, control = _GetCoordinates(varfont, glyphname) + numberOfContours = control[0] + isComposite = numberOfContours == -1 + if isComposite: + endPts = list(range(len(control[1]))) + else: + endPts = control[1] + deltas = iup_delta(deltas, origCoords, endPts) + scaledDeltas = GlyphCoordinates(deltas) * scalar + if tupleAxes.issubset(pinnedAxes): + # A tuple for only axes being pinned is discarded, and + # it's contribution is reflected into the base outlines + coordinates += scaledDeltas + defaultModified = True + else: + # A tuple for some axes being pinned has to be adjusted + var.coordinates = scaledDeltas + for axis in pinnedTupleAxes: + del var.axes[axis] + newVariations.append(var) + if defaultModified: + _SetCoordinates(varfont, glyphname, coordinates) + gvar.variations[glyphname] = newVariations + + +def instantiateGvar(varfont, location): + 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, location, glyphname) + + +def instantiateVariableFont(varfont, location, inplace=False): + if not inplace: + varfont = deepcopy(varfont) + + fvar = varfont['fvar'] + pinnedAxes = { + a.axisTag: (a.minValue, a.defaultValue, a.maxValue) + for a in fvar.axes + if a.axisTag in location + } + if not pinnedAxes: + return varfont # nothing to do + + location = normalizeLocation(location, pinnedAxes) + if 'avar' in varfont: + # 'warp' the default normalization using avar + maps = varfont['avar'].segments + location = { + axis: piecewiseLinearMap(v, maps[axis]) for axis, v in location.items() + } + # Quantize to F2Dot14, to avoid surprise interpolations. + location = {axis: floatToFixedToFloat(v, 14) for axis, v in location.items()} + # Location is normalized now + log.info("Normalized location: %s", location) + + if "gvar" in varfont: + instantiateGvar(varfont, location) + + # TODO: actually process HVAR instead of dropping it + del varfont["HVAR"] + + return varfont + + +def main(args=None): + from fontTools import configLogger + import argparse + + parser = argparse.ArgumentParser( + "fonttools varLib.partialInstancer", + 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 name of a variation axis, followed by '=' and a number. E.g.: " + " wdth=100") + parser.add_argument( + "-o", "--output", metavar="OUTPUT.ttf", default=None, + help="Output instance TTF file (default: INPUT-instance.ttf).") + 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) + + varfilename = options.input + outfile = ( + os.path.splitext(varfilename)[0] + '-partial.ttf' + if not options.output else options.output) + configLogger( + level=( + "DEBUG" if options.verbose + else "ERROR" if options.quiet + else "INFO" + ) + ) + + loc = {} + for arg in options.locargs: + try: + tag, val = arg.split('=') + assert len(tag) <= 4 + loc[tag.ljust(4)] = float(val) + except (ValueError, AssertionError): + parser.error("invalid location argument format: %r" % arg) + log.info("Location: %s", loc) + + log.info("Loading variable font") + varfont = TTFont(varfilename) + + instantiateVariableFont(varfont, loc, inplace=True) + + log.info("Saving partial variable font %s", outfile) + varfont.save(outfile) + + +if __name__ == "__main__": + import sys + sys.exit(main()) From ced09ff3fd68ac66988629cfc2c584d86c9892af Mon Sep 17 00:00:00 2001 From: Rod Sheeter Date: Wed, 6 Mar 2019 21:54:15 -0800 Subject: [PATCH 002/127] Makes life easier if ranged limits are wired from start --- Lib/fontTools/varLib/partialInstancer.py | 111 +++++++++++++++-------- 1 file changed, 71 insertions(+), 40 deletions(-) diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py index 107ffea0d..9911a1f23 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -16,14 +16,15 @@ from fontTools.misc.fixedTools import floatToFixedToFloat from fontTools.varLib import _GetCoordinates, _SetCoordinates from fontTools.varLib.models import ( supportScalar, - normalizeLocation, + normalizeValue, piecewiseLinearMap, ) from fontTools.varLib.iup import iup_delta from fontTools.ttLib import TTFont from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates -import os import logging +import os +import re log = logging.getLogger("fontTools.varlib.partialInstancer") @@ -101,33 +102,42 @@ def instantiateGvar(varfont, location): instantiateGvarGlyph(varfont, location, glyphname) -def instantiateVariableFont(varfont, location, inplace=False): +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) + axis_limits[axis_tag] = tuple(normalize(v, triple, avar_mapping) + for v in axis_limits[axis_tag]) + +def instantiateVariableFont(varfont, axis_limits, inplace=False): if not inplace: varfont = deepcopy(varfont) + normalizeAxisLimits(varfont, axis_limits) - fvar = varfont['fvar'] - pinnedAxes = { - a.axisTag: (a.minValue, a.defaultValue, a.maxValue) - for a in fvar.axes - if a.axisTag in location - } - if not pinnedAxes: - return varfont # nothing to do - - location = normalizeLocation(location, pinnedAxes) - if 'avar' in varfont: - # 'warp' the default normalization using avar - maps = varfont['avar'].segments - location = { - axis: piecewiseLinearMap(v, maps[axis]) for axis, v in location.items() - } - # Quantize to F2Dot14, to avoid surprise interpolations. - location = {axis: floatToFixedToFloat(v, 14) for axis, v in location.items()} - # Location is normalized now - log.info("Normalized location: %s", location) + log.info("Normalized limits: %s", axis_limits) if "gvar" in varfont: - instantiateGvar(varfont, location) + # TODO: support range, stop dropping max value + axis_limits = {tag: minv for tag, (minv, maxv) in axis_limits.items()} + print(axis_limits) + instantiateGvar(varfont, axis_limits) # TODO: actually process HVAR instead of dropping it del varfont["HVAR"] @@ -135,7 +145,28 @@ def instantiateVariableFont(varfont, location, inplace=False): return varfont -def main(args=None): +def parseLimits(limits): + result = {} + for limit_string in limits: + match = re.match(r'^(\w{1,4})=([^:]+)(?:[:](.+))?$', limit_string) + if not match: + parser.error("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)) + result[tag] = (lbound, ubound) + return result + + +def parseArgs(args): + """Parse argv. + + Returns: + 3-tuple (infile, outfile, axis_limits) + axis_limits is a map axis_tag:(min,max), meaning limit this axis to + range.""" from fontTools import configLogger import argparse @@ -148,8 +179,8 @@ def main(args=None): parser.add_argument( "locargs", metavar="AXIS=LOC", nargs="*", help="List of space separated locations. A location consist in " - "the name of a variation axis, followed by '=' and a number. E.g.: " - " wdth=100") + "the name 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).") @@ -160,9 +191,9 @@ def main(args=None): "-q", "--quiet", action="store_true", help="Turn verbosity off.") options = parser.parse_args(args) - varfilename = options.input + infile = options.input outfile = ( - os.path.splitext(varfilename)[0] + '-partial.ttf' + os.path.splitext(infile)[0] + '-partial.ttf' if not options.output else options.output) configLogger( level=( @@ -172,20 +203,20 @@ def main(args=None): ) ) - loc = {} - for arg in options.locargs: - try: - tag, val = arg.split('=') - assert len(tag) <= 4 - loc[tag.ljust(4)] = float(val) - except (ValueError, AssertionError): - parser.error("invalid location argument format: %r" % arg) - log.info("Location: %s", loc) + 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) + + +def main(args=None): + infile, outfile, axis_limits = parseArgs(args) + log.info("Restricting axes: %s", axis_limits) log.info("Loading variable font") - varfont = TTFont(varfilename) + varfont = TTFont(infile) - instantiateVariableFont(varfont, loc, inplace=True) + instantiateVariableFont(varfont, axis_limits, inplace=True) log.info("Saving partial variable font %s", outfile) varfont.save(outfile) From aa59dc92cf638d3252a467eaf057ac3efd9682f4 Mon Sep 17 00:00:00 2001 From: Rod Sheeter Date: Wed, 6 Mar 2019 21:58:58 -0800 Subject: [PATCH 003/127] better error when pointed at a non-variable font --- Lib/fontTools/varLib/partialInstancer.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py index 9911a1f23..a5a56f2ef 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -126,13 +126,25 @@ def normalizeAxisLimits(varfont, axis_limits): axis_limits[axis_tag] = tuple(normalize(v, triple, avar_mapping) for v in axis_limits[axis_tag]) + +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): + sanityCheckVariableTables(varfont) + if not inplace: varfont = deepcopy(varfont) normalizeAxisLimits(varfont, axis_limits) log.info("Normalized limits: %s", axis_limits) + + if "gvar" in varfont: # TODO: support range, stop dropping max value axis_limits = {tag: minv for tag, (minv, maxv) in axis_limits.items()} From 3c69682a16ba4619cf71f2253214410faff7a6ff Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 7 Mar 2019 19:18:14 -0800 Subject: [PATCH 004/127] partialInstancer: run black autoformatter --- Lib/fontTools/varLib/partialInstancer.py | 84 +++++++++++++----------- 1 file changed, 46 insertions(+), 38 deletions(-) diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py index a5a56f2ef..2251cfc95 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -14,11 +14,7 @@ from __future__ import print_function, division, absolute_import from fontTools.misc.py23 import * from fontTools.misc.fixedTools import floatToFixedToFloat from fontTools.varLib import _GetCoordinates, _SetCoordinates -from fontTools.varLib.models import ( - supportScalar, - normalizeValue, - piecewiseLinearMap, -) +from fontTools.varLib.models import supportScalar, normalizeValue, piecewiseLinearMap from fontTools.varLib.iup import iup_delta from fontTools.ttLib import TTFont from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates @@ -84,8 +80,8 @@ def instantiateGvarGlyph(varfont, location, glyphname): def instantiateGvar(varfont, location): log.info("Instantiating glyf/gvar tables") - gvar = varfont['gvar'] - glyf = varfont['glyf'] + 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 @@ -94,9 +90,10 @@ def instantiateGvar(varfont, location): gvar.variations.keys(), key=lambda name: ( glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth - if glyf[name].isComposite() else 0, - name - ) + if glyf[name].isComposite() + else 0, + name, + ), ) for glyphname in glyphnames: instantiateGvarGlyph(varfont, location, glyphname) @@ -109,22 +106,27 @@ def normalize(value, triple, avar_mapping): # Quantize to F2Dot14, to avoid surprise interpolations. return floatToFixedToFloat(value, 14) + def normalizeAxisLimits(varfont, axis_limits): - fvar = varfont['fvar'] + 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)) + 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} + 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 + if "avar" in varfont: + avar_segments = varfont["avar"].segments for axis_tag, triple in axes.items(): avar_mapping = avar_segments.get(axis_tag, None) - axis_limits[axis_tag] = tuple(normalize(v, triple, avar_mapping) - for v in axis_limits[axis_tag]) + axis_limits[axis_tag] = tuple( + normalize(v, triple, avar_mapping) for v in axis_limits[axis_tag] + ) def sanityCheckVariableTables(varfont): @@ -134,6 +136,7 @@ def sanityCheckVariableTables(varfont): if "glyf" not in varfont: raise ValueError("Can't have gvar without glyf") + def instantiateVariableFont(varfont, axis_limits, inplace=False): sanityCheckVariableTables(varfont) @@ -143,8 +146,6 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False): log.info("Normalized limits: %s", axis_limits) - - if "gvar" in varfont: # TODO: support range, stop dropping max value axis_limits = {tag: minv for tag, (minv, maxv) in axis_limits.items()} @@ -160,7 +161,7 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False): def parseLimits(limits): result = {} for limit_string in limits: - match = re.match(r'^(\w{1,4})=([^:]+)(?:[:](.+))?$', limit_string) + match = re.match(r"^(\w{1,4})=([^:]+)(?:[:](.+))?$", limit_string) if not match: parser.error("invalid location format: %r" % limit_string) tag = match.group(1).ljust(4) @@ -184,40 +185,46 @@ def parseArgs(args): parser = argparse.ArgumentParser( "fonttools varLib.partialInstancer", - description="Partially instantiate a variable font" + description="Partially instantiate a variable font", ) + parser.add_argument("input", metavar="INPUT.ttf", help="Input variable TTF file.") parser.add_argument( - "input", metavar="INPUT.ttf", help="Input variable TTF file.") - parser.add_argument( - "locargs", metavar="AXIS=LOC", nargs="*", + "locargs", + metavar="AXIS=LOC", + nargs="*", help="List of space separated locations. A location consist in " "the name of a variation axis, followed by '=' and a number or" - "number:number. E.g.: wdth=100 or wght=75.0:125.0") + "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).") + "-o", + "--output", + metavar="OUTPUT.ttf", + default=None, + help="Output instance TTF file (default: INPUT-instance.ttf).", + ) logging_group = parser.add_mutually_exclusive_group(required=False) logging_group.add_argument( - "-v", "--verbose", action="store_true", help="Run more verbosely.") + "-v", "--verbose", action="store_true", help="Run more verbosely." + ) logging_group.add_argument( - "-q", "--quiet", action="store_true", help="Turn verbosity off.") + "-q", "--quiet", action="store_true", help="Turn verbosity off." + ) options = parser.parse_args(args) infile = options.input outfile = ( - os.path.splitext(infile)[0] + '-partial.ttf' - if not options.output else options.output) + os.path.splitext(infile)[0] + "-partial.ttf" + if not options.output + else options.output + ) configLogger( - level=( - "DEBUG" if options.verbose - else "ERROR" if options.quiet - else "INFO" - ) + 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') + raise ValueError("Specified multiple limits for the same axis") return (infile, outfile, axis_limits) @@ -236,4 +243,5 @@ def main(args=None): if __name__ == "__main__": import sys + sys.exit(main()) From 90815e83c7da165f34b2f008b837635a52092fc4 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 8 Mar 2019 10:28:24 -0800 Subject: [PATCH 005/127] remove stray print --- Lib/fontTools/varLib/partialInstancer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py index 2251cfc95..33ed9c499 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -149,7 +149,6 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False): if "gvar" in varfont: # TODO: support range, stop dropping max value axis_limits = {tag: minv for tag, (minv, maxv) in axis_limits.items()} - print(axis_limits) instantiateGvar(varfont, axis_limits) # TODO: actually process HVAR instead of dropping it From 19ccffcd8a9787ce08bda58a127c651cafb68848 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 8 Mar 2019 10:37:11 -0800 Subject: [PATCH 006/127] set default output filename to '-instance.ttf' like mutator.py --- Lib/fontTools/varLib/partialInstancer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py index 33ed9c499..4d12759a6 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -213,7 +213,7 @@ def parseArgs(args): infile = options.input outfile = ( - os.path.splitext(infile)[0] + "-partial.ttf" + os.path.splitext(infile)[0] + "-instance.ttf" if not options.output else options.output ) From 742b1d784aa9c206d9eda5339525d4396c178311 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 8 Mar 2019 15:56:32 -0800 Subject: [PATCH 007/127] gvar: minor whitespace mixed tab/spaces freak out my vim --- Lib/fontTools/ttLib/tables/_g_v_a_r.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/_g_v_a_r.py b/Lib/fontTools/ttLib/tables/_g_v_a_r.py index 608b6a2d9..d24de2e36 100644 --- a/Lib/fontTools/ttLib/tables/_g_v_a_r.py +++ b/Lib/fontTools/ttLib/tables/_g_v_a_r.py @@ -210,8 +210,9 @@ def compileGlyph_(variations, pointCount, axisTags, sharedCoordIndices): variations, pointCount, axisTags, sharedCoordIndices) if tupleVariationCount == 0: return b"" - result = (struct.pack(">HH", tupleVariationCount, 4 + len(tuples)) + - tuples + data) + result = ( + struct.pack(">HH", tupleVariationCount, 4 + len(tuples)) + tuples + data + ) if len(result) % 2 != 0: result = result + b"\0" # padding return result @@ -222,6 +223,8 @@ def decompileGlyph_(pointCount, sharedTuples, axisTags, data): return [] tupleVariationCount, offsetToData = struct.unpack(">HH", data[:4]) dataPos = offsetToData - return tv.decompileTupleVariationStore("gvar", axisTags, - tupleVariationCount, pointCount, - sharedTuples, data, 4, offsetToData) + return tv.decompileTupleVariationStore( + "gvar", axisTags, + tupleVariationCount, pointCount, + sharedTuples, data, 4, offsetToData + ) From 2658b081d250a000fbf239067fcc9a4af195db9a Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 8 Mar 2019 16:24:13 -0800 Subject: [PATCH 008/127] add missing deepcopy import --- Lib/fontTools/varLib/partialInstancer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py index 4d12759a6..4bd0caea5 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -18,6 +18,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 copy import deepcopy import logging import os import re From b4fd0e5ca0c81617e3f8ea1153b0fb02d471219f Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 11 Mar 2019 15:50:16 +0000 Subject: [PATCH 009/127] varLib: move _{Get,Set}Coordinates to methods of glyf table class --- Lib/fontTools/ttLib/tables/_g_l_y_f.py | 145 +++++++++++++++++++++++ Lib/fontTools/varLib/__init__.py | 102 +--------------- Lib/fontTools/varLib/mutator.py | 10 +- Lib/fontTools/varLib/partialInstancer.py | 18 +-- 4 files changed, 158 insertions(+), 117 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/_g_l_y_f.py b/Lib/fontTools/ttLib/tables/_g_l_y_f.py index 8b3605047..f39a146b5 100644 --- a/Lib/fontTools/ttLib/tables/_g_l_y_f.py +++ b/Lib/fontTools/ttLib/tables/_g_l_y_f.py @@ -243,6 +243,151 @@ class table__g_l_y_f(DefaultTable.DefaultTable): assert len(self.glyphOrder) == len(self.glyphs) return len(self.glyphs) + def getPhantomPoints(self, glyphName, ttFont, defaultVerticalOrigin=None): + """Compute the four "phantom points" for the given glyph from its bounding box + and the horizontal and vertical advance widths and sidebearings stored in the + ttFont's "hmtx" and "vmtx" tables. + + If the ttFont doesn't contain a "vmtx" table, the hhea.ascent is used as the + vertical origin, and the head.unitsPerEm as the vertical advance. + + The "defaultVerticalOrigin" (Optional[int]) is used when the ttFont contains + neither a "vmtx" nor an "hhea" table. + + https://docs.microsoft.com/en-us/typography/opentype/spec/tt_instructing_glyphs#phantoms + """ + glyph = self[glyphName] + assert glyphName in ttFont["hmtx"].metrics, ttFont["hmtx"].metrics + horizontalAdvanceWidth, leftSideBearing = ttFont["hmtx"].metrics[glyphName] + if not hasattr(glyph, 'xMin'): + glyph.recalcBounds(self) + leftSideX = glyph.xMin - leftSideBearing + rightSideX = leftSideX + horizontalAdvanceWidth + if "vmtx" in ttFont: + verticalAdvanceWidth, topSideBearing = ttFont["vmtx"].metrics[glyphName] + topSideY = topSideBearing + glyph.yMax + else: + # without vmtx, use ascent as vertical origin and UPEM as vertical advance + # like HarfBuzz does + verticalAdvanceWidth = ttFont["head"].unitsPerEm + try: + topSideY = ttFont["hhea"].ascent + except KeyError: + # sparse masters may not contain an hhea table; use the ascent + # of the default master as the vertical origin + assert defaultVerticalOrigin is not None + topSideY = defaultVerticalOrigin + bottomSideY = topSideY - verticalAdvanceWidth + return [ + (leftSideX, 0), + (rightSideX, 0), + (0, topSideY), + (0, bottomSideY), + ] + + def getCoordinatesAndControls(self, glyphName, ttFont, defaultVerticalOrigin=None): + """Return glyph coordinates and controls as expected by "gvar" table. + + The coordinates includes four "phantom points" for the glyph metrics, + as mandated by the "gvar" spec. + + The glyph controls is a namedtuple with the following attributes: + - numberOfContours: -1 for composite glyphs. + - endPts: list of indices of end points for each contour in simple + glyphs, or component indices in composite glyphs (used for IUP + optimization). + - flags: array of contour point flags for simple glyphs, or component + flags for composite glyphs. + - components: list of base glyph names (str) for each component in + composite glyphs (None for simple glyphs). + + The "ttFont" and "defaultVerticalOrigin" args are used to compute the + "phantom points" (see "getPhantomPoints" method). + + Return None if the requested glyphName is not present. + """ + if glyphName not in self.glyphs: + return None + glyph = self[glyphName] + if glyph.isComposite(): + coords = GlyphCoordinates( + [(getattr(c, 'x', 0), getattr(c, 'y', 0)) for c in glyph.components] + ) + controls = _GlyphControls( + numberOfContours=glyph.numberOfContours, + endPts=list(range(len(glyph.components))), + flags=[c.flags for c in glyph.components], + components=[c.glyphName for c in glyph.components], + ) + else: + coords, endPts, flags = glyph.getCoordinates(self) + coords = coords.copy() + controls = _GlyphControls( + numberOfContours=glyph.numberOfContours, + endPts=endPts, + flags=flags, + components=None, + ) + # Add phantom points for (left, right, top, bottom) positions. + phantomPoints = self.getPhantomPoints( + glyphName, ttFont, defaultVerticalOrigin=defaultVerticalOrigin + ) + coords.extend(phantomPoints) + return coords, controls + + def setCoordinates(self, glyphName, coord, ttFont): + """Set coordinates and metrics for the given glyph. + + "coord" is an array of GlyphCoordinates which must include the four + "phantom points". + + Only the horizontal advance and sidebearings in "hmtx" table are updated + from the first two phantom points. The last two phantom points for + vertical typesetting are currently ignored. + """ + # TODO: Create new glyph if not already present + assert glyphName in self.glyphs + glyph = self[glyphName] + + # Handle phantom points for (left, right, top, bottom) positions. + assert len(coord) >= 4 + if not hasattr(glyph, 'xMin'): + glyph.recalcBounds(self) + leftSideX = coord[-4][0] + rightSideX = coord[-3][0] + topSideY = coord[-2][1] + bottomSideY = coord[-1][1] + + for _ in range(4): + del coord[-1] + + if glyph.isComposite(): + assert len(coord) == len(glyph.components) + for p,comp in zip(coord, glyph.components): + if hasattr(comp, 'x'): + comp.x,comp.y = p + elif glyph.numberOfContours is 0: + assert len(coord) == 0 + else: + assert len(coord) == len(glyph.coordinates) + glyph.coordinates = coord + + glyph.recalcBounds(self) + + horizontalAdvanceWidth = otRound(rightSideX - leftSideX) + if horizontalAdvanceWidth < 0: + # unlikely, but it can happen, see: + # https://github.com/fonttools/fonttools/pull/1198 + horizontalAdvanceWidth = 0 + leftSideBearing = otRound(glyph.xMin - leftSideX) + # TODO Handle vertical metrics? + ttFont["hmtx"].metrics[glyphName] = horizontalAdvanceWidth, leftSideBearing + + +_GlyphControls = namedtuple( + "_GlyphControls", "numberOfContours endPts flags components" +) + glyphHeaderFormat = """ > # big endian diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 0543ee375..38b543f9c 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -210,102 +210,6 @@ def _add_stat(font, axes): stat.ElidedFallbackNameID = 2 -def _get_phantom_points(font, glyphName, defaultVerticalOrigin=None): - glyf = font["glyf"] - glyph = glyf[glyphName] - horizontalAdvanceWidth, leftSideBearing = font["hmtx"].metrics[glyphName] - if not hasattr(glyph, 'xMin'): - glyph.recalcBounds(glyf) - leftSideX = glyph.xMin - leftSideBearing - rightSideX = leftSideX + horizontalAdvanceWidth - if "vmtx" in font: - verticalAdvanceWidth, topSideBearing = font["vmtx"].metrics[glyphName] - topSideY = topSideBearing + glyph.yMax - else: - # without vmtx, use ascent as vertical origin and UPEM as vertical advance - # like HarfBuzz does - verticalAdvanceWidth = font["head"].unitsPerEm - try: - topSideY = font["hhea"].ascent - except KeyError: - # sparse masters may not contain an hhea table; use the ascent - # of the default master as the vertical origin - assert defaultVerticalOrigin is not None - topSideY = defaultVerticalOrigin - bottomSideY = topSideY - verticalAdvanceWidth - return [ - (leftSideX, 0), - (rightSideX, 0), - (0, topSideY), - (0, bottomSideY), - ] - - -# TODO Move to glyf or gvar table proper -def _GetCoordinates(font, glyphName, defaultVerticalOrigin=None): - """font, glyphName --> glyph coordinates as expected by "gvar" table - - The result includes four "phantom points" for the glyph metrics, - as mandated by the "gvar" spec. - """ - glyf = font["glyf"] - if glyphName not in glyf.glyphs: return None - glyph = glyf[glyphName] - if glyph.isComposite(): - coord = GlyphCoordinates([(getattr(c, 'x', 0),getattr(c, 'y', 0)) for c in glyph.components]) - control = (glyph.numberOfContours,[c.glyphName for c in glyph.components]) - else: - allData = glyph.getCoordinates(glyf) - coord = allData[0] - control = (glyph.numberOfContours,)+allData[1:] - - # Add phantom points for (left, right, top, bottom) positions. - phantomPoints = _get_phantom_points(font, glyphName, defaultVerticalOrigin) - coord = coord.copy() - coord.extend(phantomPoints) - - return coord, control - -# TODO Move to glyf or gvar table proper -def _SetCoordinates(font, glyphName, coord): - glyf = font["glyf"] - assert glyphName in glyf.glyphs - glyph = glyf[glyphName] - - # Handle phantom points for (left, right, top, bottom) positions. - assert len(coord) >= 4 - if not hasattr(glyph, 'xMin'): - glyph.recalcBounds(glyf) - leftSideX = coord[-4][0] - rightSideX = coord[-3][0] - topSideY = coord[-2][1] - bottomSideY = coord[-1][1] - - for _ in range(4): - del coord[-1] - - if glyph.isComposite(): - assert len(coord) == len(glyph.components) - for p,comp in zip(coord, glyph.components): - if hasattr(comp, 'x'): - comp.x,comp.y = p - elif glyph.numberOfContours is 0: - assert len(coord) == 0 - else: - assert len(coord) == len(glyph.coordinates) - glyph.coordinates = coord - - glyph.recalcBounds(glyf) - - horizontalAdvanceWidth = otRound(rightSideX - leftSideX) - if horizontalAdvanceWidth < 0: - # unlikely, but it can happen, see: - # https://github.com/fonttools/fonttools/pull/1198 - horizontalAdvanceWidth = 0 - leftSideBearing = otRound(glyph.xMin - leftSideX) - # XXX Handle vertical - font["hmtx"].metrics[glyphName] = horizontalAdvanceWidth, leftSideBearing - def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): assert tolerance >= 0 @@ -320,13 +224,13 @@ def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): glyf = font['glyf'] # use hhea.ascent of base master as default vertical origin when vmtx is missing - defaultVerticalOrigin = font['hhea'].ascent + baseAscent = font['hhea'].ascent for glyph in font.getGlyphOrder(): isComposite = glyf[glyph].isComposite() allData = [ - _GetCoordinates(m, glyph, defaultVerticalOrigin=defaultVerticalOrigin) + m["glyf"].getCoordinatesAndControls(glyph, m, defaultVerticalOrigin=baseAscent) for m in master_ttfs ] model, allData = masterModel.getSubModel(allData) @@ -347,7 +251,7 @@ def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): # Prepare for IUP optimization origCoords = deltas[0] - endPts = control[1] if control[0] >= 1 else list(range(len(control[1]))) + endPts = control.endPts for i,(delta,support) in enumerate(zip(deltas[1:], supports[1:])): if all(abs(v) <= tolerance for v in delta.array) and not isComposite: diff --git a/Lib/fontTools/varLib/mutator.py b/Lib/fontTools/varLib/mutator.py index de612365f..780313e3b 100644 --- a/Lib/fontTools/varLib/mutator.py +++ b/Lib/fontTools/varLib/mutator.py @@ -10,7 +10,6 @@ from fontTools.pens.boundsPen import BoundsPen from fontTools.ttLib import TTFont, newTable from fontTools.ttLib.tables import ttProgram from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates, flagOverlapSimple, OVERLAP_COMPOUND -from fontTools.varLib import _GetCoordinates, _SetCoordinates from fontTools.varLib.models import ( supportScalar, normalizeLocation, @@ -190,7 +189,7 @@ def instantiateVariableFont(varfont, location, inplace=False, overlap=True): name)) for glyphname in glyphnames: variations = gvar.variations[glyphname] - coordinates,_ = _GetCoordinates(varfont, glyphname) + coordinates, _ = glyf.getCoordinatesAndControls(glyphname, varfont) origCoords, endPts = None, None for var in variations: scalar = supportScalar(loc, var.axes) @@ -198,11 +197,10 @@ def instantiateVariableFont(varfont, location, inplace=False, overlap=True): delta = var.coordinates if None in delta: if origCoords is None: - origCoords,control = _GetCoordinates(varfont, glyphname) - endPts = control[1] if control[0] >= 1 else list(range(len(control[1]))) - delta = iup_delta(delta, origCoords, endPts) + origCoords, g = glyf.getCoordinatesAndControls(glyphname, varfont) + delta = iup_delta(delta, origCoords, g.endPts) coordinates += GlyphCoordinates(delta) * scalar - _SetCoordinates(varfont, glyphname, coordinates) + glyf.setCoordinates(glyphname, coordinates, varfont) else: glyf = None diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py index 4bd0caea5..e13f18888 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -13,7 +13,6 @@ 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 -from fontTools.varLib import _GetCoordinates, _SetCoordinates from fontTools.varLib.models import supportScalar, normalizeValue, piecewiseLinearMap from fontTools.varLib.iup import iup_delta from fontTools.ttLib import TTFont @@ -28,10 +27,11 @@ log = logging.getLogger("fontTools.varlib.partialInstancer") def instantiateGvarGlyph(varfont, location, glyphname): + glyf = varfont["glyf"] gvar = varfont["gvar"] variations = gvar.variations[glyphname] - coordinates, _ = _GetCoordinates(varfont, glyphname) - origCoords, endPts = None, None + coordinates, _ = glyf.getCoordinatesAndControls(glyphname, varfont) + origCoords = None newVariations = [] pinnedAxes = set(location.keys()) defaultModified = False @@ -53,14 +53,8 @@ def instantiateGvarGlyph(varfont, location, glyphname): hasUntouchedPoints = None in deltas if hasUntouchedPoints: if origCoords is None: - origCoords, control = _GetCoordinates(varfont, glyphname) - numberOfContours = control[0] - isComposite = numberOfContours == -1 - if isComposite: - endPts = list(range(len(control[1]))) - else: - endPts = control[1] - deltas = iup_delta(deltas, origCoords, endPts) + origCoords, g = glyf.getCoordinatesAndControls(glyphname, varfont) + deltas = iup_delta(deltas, origCoords, g.endPts) scaledDeltas = GlyphCoordinates(deltas) * scalar if tupleAxes.issubset(pinnedAxes): # A tuple for only axes being pinned is discarded, and @@ -74,7 +68,7 @@ def instantiateGvarGlyph(varfont, location, glyphname): del var.axes[axis] newVariations.append(var) if defaultModified: - _SetCoordinates(varfont, glyphname, coordinates) + glyf.setCoordinates(glyphname, coordinates, varfont) gvar.variations[glyphname] = newVariations From 355139db5a770b11b34b0fc960963e07bb35b7e9 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 12 Mar 2019 17:59:11 +0000 Subject: [PATCH 010/127] let input axis_limits be a map axis_tag:value as well as range tuples this way instanceVariableFont function can be used as drop-in replacement for mutator.instaceVariableFont (which only accepts single-point locations, not ranges) --- Lib/fontTools/varLib/partialInstancer.py | 29 ++++++++++++++++-------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py index e13f18888..68d0a450e 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -119,9 +119,13 @@ def normalizeAxisLimits(varfont, axis_limits): avar_segments = varfont["avar"].segments for axis_tag, triple in axes.items(): avar_mapping = avar_segments.get(axis_tag, None) - axis_limits[axis_tag] = tuple( - normalize(v, triple, avar_mapping) for v in axis_limits[axis_tag] - ) + 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): @@ -141,9 +145,11 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False): 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: - # TODO: support range, stop dropping max value - axis_limits = {tag: minv for tag, (minv, maxv) in axis_limits.items()} instantiateGvar(varfont, axis_limits) # TODO: actually process HVAR instead of dropping it @@ -163,7 +169,10 @@ def parseLimits(limits): ubound = lbound if match.group(3): ubound = float(match.group(3)) - result[tag] = (lbound, ubound) + if lbound != ubound: + result[tag] = (lbound, ubound) + else: + result[tag] = lbound return result @@ -172,8 +181,10 @@ def parseArgs(args): Returns: 3-tuple (infile, outfile, axis_limits) - axis_limits is a map axis_tag:(min,max), meaning limit this axis to - range.""" + 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. + """ from fontTools import configLogger import argparse @@ -187,7 +198,7 @@ def parseArgs(args): metavar="AXIS=LOC", nargs="*", help="List of space separated locations. A location consist in " - "the name of a variation axis, followed by '=' and a number or" + "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( From 3adcf8051c29279e452d667aa5e3577d74079bb4 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 12 Mar 2019 19:01:26 +0000 Subject: [PATCH 011/127] add glyf.getCoordinates method that only returns coordinates, and no controls --- Lib/fontTools/ttLib/tables/_g_l_y_f.py | 21 +++++++++++++++++++++ Lib/fontTools/varLib/mutator.py | 2 +- Lib/fontTools/varLib/partialInstancer.py | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/_g_l_y_f.py b/Lib/fontTools/ttLib/tables/_g_l_y_f.py index f39a146b5..466c32cf3 100644 --- a/Lib/fontTools/ttLib/tables/_g_l_y_f.py +++ b/Lib/fontTools/ttLib/tables/_g_l_y_f.py @@ -335,6 +335,27 @@ class table__g_l_y_f(DefaultTable.DefaultTable): coords.extend(phantomPoints) return coords, controls + def getCoordinates(self, glyphName, ttFont, defaultVerticalOrigin=None): + """Same as `getCoordinatesAndControls` but only returns coordinates array, + or None if the glyph is missing. + """ + if glyphName not in self.glyphs: + return None + glyph = self[glyphName] + if glyph.isComposite(): + coords = GlyphCoordinates( + [(getattr(c, 'x', 0), getattr(c, 'y', 0)) for c in glyph.components] + ) + else: + coords, _, _ = glyph.getCoordinates(self) + coords = coords.copy() + # Add phantom points for (left, right, top, bottom) positions. + phantomPoints = self.getPhantomPoints( + glyphName, ttFont, defaultVerticalOrigin=defaultVerticalOrigin + ) + coords.extend(phantomPoints) + return coords + def setCoordinates(self, glyphName, coord, ttFont): """Set coordinates and metrics for the given glyph. diff --git a/Lib/fontTools/varLib/mutator.py b/Lib/fontTools/varLib/mutator.py index 780313e3b..1524cff64 100644 --- a/Lib/fontTools/varLib/mutator.py +++ b/Lib/fontTools/varLib/mutator.py @@ -189,7 +189,7 @@ def instantiateVariableFont(varfont, location, inplace=False, overlap=True): name)) for glyphname in glyphnames: variations = gvar.variations[glyphname] - coordinates, _ = glyf.getCoordinatesAndControls(glyphname, varfont) + coordinates = glyf.getCoordinates(glyphname, varfont) origCoords, endPts = None, None for var in variations: scalar = supportScalar(loc, var.axes) diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py index 68d0a450e..3bc4f3c51 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -30,7 +30,7 @@ def instantiateGvarGlyph(varfont, location, glyphname): glyf = varfont["glyf"] gvar = varfont["gvar"] variations = gvar.variations[glyphname] - coordinates, _ = glyf.getCoordinatesAndControls(glyphname, varfont) + coordinates = glyf.getCoordinates(glyphname, varfont) origCoords = None newVariations = [] pinnedAxes = set(location.keys()) From 373d1b86f382c6b3ca28ad9c87bca913463aeab3 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 12 Mar 2019 19:02:14 +0000 Subject: [PATCH 012/127] clarify in docstring that input axis limits must be in user-space coordinates in case it wasn't obvious --- Lib/fontTools/varLib/partialInstancer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py index 3bc4f3c51..cf42e3013 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -184,6 +184,7 @@ def parseArgs(args): 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 From 126a2d9c38722e7396bd03a49efef1cce4a3c103 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 12 Mar 2019 19:44:33 +0000 Subject: [PATCH 013/127] Add partialInstancer_test.py and PartialInstancerTest-VF.ttx Currently tests the instantiateGvar function only. The test font contains two axes and a single glyph. I shall make add more complexity later. --- Tests/varLib/data/PartialInstancerTest-VF.ttx | 915 ++++++++++++++++++ Tests/varLib/partialInstancer_test.py | 92 ++ 2 files changed, 1007 insertions(+) create mode 100644 Tests/varLib/data/PartialInstancerTest-VF.ttx create mode 100644 Tests/varLib/partialInstancer_test.py diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/data/PartialInstancerTest-VF.ttx new file mode 100644 index 000000000..98fec4168 --- /dev/null +++ b/Tests/varLib/data/PartialInstancerTest-VF.ttx @@ -0,0 +1,915 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Weight + + + Width + + + Thin + + + ExtraLight + + + Light + + + Regular + + + Medium + + + SemiBold + + + Bold + + + ExtraBold + + + Black + + + SemiCondensed Thin + + + SemiCondensed ExtraLight + + + SemiCondensed Light + + + SemiCondensed + + + SemiCondensed Medium + + + SemiCondensed SemiBold + + + SemiCondensed Bold + + + SemiCondensed ExtraBold + + + SemiCondensed Black + + + Condensed Thin + + + Condensed ExtraLight + + + Condensed Light + + + Condensed + + + Condensed Medium + + + Condensed SemiBold + + + Condensed Bold + + + Condensed ExtraBold + + + Condensed Black + + + ExtraCondensed Thin + + + ExtraCondensed ExtraLight + + + ExtraCondensed Light + + + ExtraCondensed + + + ExtraCondensed Medium + + + ExtraCondensed SemiBold + + + ExtraCondensed Bold + + + ExtraCondensed ExtraBold + + + ExtraCondensed Black + + + Copyright 2015 Google Inc. All Rights Reserved. + + + Test Variable Font + + + Regular + + + 2.001;GOOG;TestVariableFont-Regular + + + Test Variable Font Regular + + + Version 2.001 + + + TestVariableFont-Regular + + + Noto is a trademark of Google Inc. + + + Monotype Imaging Inc. + + + Monotype Design Team + + + http://www.google.com/get/noto/ + + + http://www.monotype.com/studio + + + Weight + + + Width + + + Thin + + + ExtraLight + + + Light + + + Regular + + + Medium + + + SemiBold + + + Bold + + + ExtraBold + + + Black + + + SemiCondensed Thin + + + SemiCondensed ExtraLight + + + SemiCondensed Light + + + SemiCondensed + + + SemiCondensed Medium + + + SemiCondensed SemiBold + + + SemiCondensed Bold + + + SemiCondensed ExtraBold + + + SemiCondensed Black + + + Condensed Thin + + + Condensed ExtraLight + + + Condensed Light + + + Condensed + + + Condensed Medium + + + Condensed SemiBold + + + Condensed Bold + + + Condensed ExtraBold + + + Condensed Black + + + ExtraCondensed Thin + + + ExtraCondensed ExtraLight + + + ExtraCondensed Light + + + ExtraCondensed + + + ExtraCondensed Medium + + + ExtraCondensed SemiBold + + + ExtraCondensed Bold + + + ExtraCondensed ExtraBold + + + ExtraCondensed Black + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wght + 0x0 + 100.0 + 400.0 + 900.0 + 256 + + + + + wdth + 0x0 + 70.0 + 100.0 + 100.0 + 257 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/partialInstancer_test.py b/Tests/varLib/partialInstancer_test.py new file mode 100644 index 000000000..d251b7c3e --- /dev/null +++ b/Tests/varLib/partialInstancer_test.py @@ -0,0 +1,92 @@ +from __future__ import print_function, division, absolute_import +from fontTools.misc.py23 import * +from fontTools.ttLib import TTFont +from fontTools.varLib import partialInstancer as pi +import os +import pytest + + +TESTDATA = os.path.join(os.path.dirname(__file__), "data") + + +@pytest.fixture +def varfont(): + f = TTFont() + f.importXML(os.path.join(TESTDATA, "PartialInstancerTest-VF.ttx")) + return f + + +def _get_coordinates(varfont, glyphname): + # converts GlyphCoordinates to a list of (x, y) tuples, so that pytest's + # assert will give us a nicer diff + return list(varfont["glyf"].getCoordinates(glyphname, varfont)) + + +class InstantiateGvarTest(object): + @pytest.mark.parametrize("glyph_name", ["hyphen"]) + @pytest.mark.parametrize( + "location, expected", + [ + pytest.param( + {"wdth": -1.0}, + { + "hyphen": [ + (27, 229), + (27, 310), + (247, 310), + (247, 229), + (0, 0), + (274, 0), + (0, 1000), + (0, 0), + ] + }, + id="wdth=-1.0", + ), + pytest.param( + {"wdth": -0.5}, + { + "hyphen": [ + (33.5, 229), + (33.5, 308.5), + (264.5, 308.5), + (264.5, 229), + (0, 0), + (298, 0), + (0, 1000), + (0, 0), + ] + }, + id="wdth=-0.5", + ), + # an axis pinned at the default normalized location (0.0) means + # the default glyf outline stays the same + pytest.param( + {"wdth": 0.0}, + { + "hyphen": [ + (40, 229), + (40, 307), + (282, 307), + (282, 229), + (0, 0), + (322, 0), + (0, 1000), + (0, 0), + ] + }, + id="wdth=0.0", + ), + ], + ) + def test_pin_and_drop_axis(self, varfont, glyph_name, location, expected): + pi.instantiateGvar(varfont, location) + + assert _get_coordinates(varfont, glyph_name) == expected[glyph_name] + + # check that the pinned axis has been dropped from gvar + assert not any( + "wdth" in t.axes + for tuples in varfont["gvar"].variations.values() + for t in tuples + ) From d91caaf915a63a45271744b6687ee15ff25c3851 Mon Sep 17 00:00:00 2001 From: Nyshadh Reddy Rachamallu Date: Thu, 14 Mar 2019 10:59:15 -0400 Subject: [PATCH 014/127] Add cvar instantiation --- Lib/fontTools/varLib/partialInstancer.py | 49 +++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py index cf42e3013..41423623d 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -12,7 +12,7 @@ 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 +from fontTools.misc.fixedTools import floatToFixedToFloat, otRound from fontTools.varLib.models import supportScalar, normalizeValue, piecewiseLinearMap from fontTools.varLib.iup import iup_delta from fontTools.ttLib import TTFont @@ -94,6 +94,50 @@ def instantiateGvar(varfont, location): instantiateGvarGlyph(varfont, location, glyphname) +def instantiateCvar(varfont, location): + log.info("Instantiating cvt/cvar tables") + + cvar = varfont["cvar"] + cvt = varfont["cvt "] + pinnedAxes = set(location.keys()) + newVariations = [] + deltas = {} + for var in cvar.variations: + tupleAxes = set(var.axes.keys()) + pinnedTupleAxes = tupleAxes & pinnedAxes + if not pinnedTupleAxes: + # A tuple for only axes being kept is untouched + newVariations.append(var) + continue + else: + # compute influence at pinned location only for the pinned axes + pinnedAxesSupport = {a: var.axes[a] for a in pinnedTupleAxes} + scalar = supportScalar(location, pinnedAxesSupport) + if not scalar: + # no influence (default value or out of range); drop tuple + continue + if tupleAxes.issubset(pinnedAxes): + for i, c in enumerate(var.coordinates): + if c is not None: + # Compute deltas which need to be applied to values in cvt + deltas[i] = deltas.get(i, 0) + scalar * c + else: + # Apply influence to delta values + for i, d in enumerate(var.coordinates): + if d is not None: + var.coordinates[i] = otRound(d * scalar) + for axis in pinnedTupleAxes: + del var.axes[axis] + newVariations.append(var) + if deltas: + for i, delta in deltas.items(): + cvt[i] += otRound(delta) + if newVariations: + cvar.variations = newVariations + else: + del varfont["cvar"] + + def normalize(value, triple, avar_mapping): value = normalizeValue(value, triple) if avar_mapping: @@ -152,6 +196,9 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False): if "gvar" in varfont: instantiateGvar(varfont, axis_limits) + if "cvar" in varfont: + instantiateCvar(varfont, axis_limits) + # TODO: actually process HVAR instead of dropping it del varfont["HVAR"] From 677b540265fba7b4ecd0e219dcd0b477aef7151d Mon Sep 17 00:00:00 2001 From: Nyshadh Reddy Rachamallu Date: Tue, 19 Mar 2019 10:44:39 -0400 Subject: [PATCH 015/127] Add ItemVariationStore (and MVAR) instantiation --- Lib/fontTools/varLib/partialInstancer.py | 109 ++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/partialInstancer.py index 41423623d..f8d21542f 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/partialInstancer.py @@ -17,6 +17,8 @@ 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 from copy import deepcopy import logging import os @@ -25,6 +27,8 @@ import re log = logging.getLogger("fontTools.varlib.partialInstancer") +PEAK_COORD_INDEX = 1 + def instantiateGvarGlyph(varfont, location, glyphname): glyf = varfont["glyf"] @@ -135,7 +139,107 @@ def instantiateCvar(varfont, location): if newVariations: cvar.variations = newVariations else: - del varfont["cvar"] + del varfont["cvar"] + + +def setMvarDeltas(varfont, location): + log.info("Setting MVAR deltas") + + mvar = varfont["MVAR"].table + fvar = varfont["fvar"] + varStoreInstancer = VarStoreInstancer(mvar.VarStore, fvar.axes, location) + records = mvar.ValueRecord + for rec in records: + mvarTag = rec.ValueTag + if mvarTag not in MVAR_ENTRIES: + continue + tableTag, itemName = MVAR_ENTRIES[mvarTag] + delta = otRound(varStoreInstancer[rec.VarIdx]) + if not delta: + continue + setattr( + varfont[tableTag], itemName, getattr(varfont[tableTag], itemName) + delta + ) + + +def instantiateMvar(varfont, location): + log.info("Instantiating MVAR table") + # First instantiate to new position without modifying MVAR table + setMvarDeltas(varfont, location) + + instantiateItemVariationStore(varfont, "MVAR", location) + + +def instantiateItemVariationStore(varfont, tableName, location): + log.info("Instantiating ItemVariation store of %s table", tableName) + + table = varfont[tableName].table + fvar = varfont["fvar"] + newRegions = [] + regionInfluenceMap = {} + pinnedAxes = set(location.keys()) + for regionIndex, region in enumerate(table.VarStore.VarRegionList.Region): + # collect set of axisTags which have influence: peakCoord != 0 + regionAxes = set( + key + for key, value in region.get_support(fvar.axes).items() + if value[PEAK_COORD_INDEX] != 0 + ) + pinnedRegionAxes = regionAxes & pinnedAxes + if not pinnedRegionAxes: + # A region where none of the axes having effect are pinned + newRegions.append(region) + continue + if len(pinnedRegionAxes) == len(regionAxes): + # All the axes having effect in this region are being pinned so + # remove it + regionInfluenceMap.update({regionIndex: None}) + else: + # This region will be retained but the deltas have to be adjusted. + pinnedSupport = { + key: value + for key, value in enumerate(region.get_support(fvar.axes)) + if key in pinnedRegionAxes + } + pinnedScalar = supportScalar(location, pinnedSupport) + regionInfluenceMap.update({regionIndex: pinnedScalar}) + + for axisname in pinnedRegionAxes: + # For all pinnedRegionAxes make their influence null by setting + # PeakCoord to 0. + index = next( + index + for index, axis in enumerate(fvar.axes) + if axis.axisTag == axisname + ) + region.VarRegionAxis[index].PeakCoord = 0 + + newRegions.append(region) + + table.VarStore.VarRegionList.Region = newRegions + + if not table.VarStore.VarRegionList.Region: + # Delete table if no more regions left. + del varfont[tableName] + return + + # First apply scalars to deltas then remove deltas in reverse index order + if regionInfluenceMap: + regionsToBeRemoved = [ + regionIndex + for regionIndex, scalar in regionInfluenceMap.items() + if scalar is None + ] + for vardata in table.VarStore.VarData: + for regionIndex, scalar in regionInfluenceMap.items(): + if scalar is not None: + for item in vardata.Item: + item[regionIndex] = otRound(item[regionIndex] * scalar) + + for index in sorted(regionsToBeRemoved, reverse=True): + del vardata.VarRegionIndex[index] + for item in vardata.Item: + del item[index] def normalize(value, triple, avar_mapping): @@ -199,6 +303,9 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False): if "cvar" in varfont: instantiateCvar(varfont, axis_limits) + if "MVAR" in varfont: + instantiateMvar(varfont, axis_limits) + # TODO: actually process HVAR instead of dropping it del varfont["HVAR"] From 6281f87cb68a1f8bda43ea8e9179946040fe3ee6 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 21 Mar 2019 15:30:48 +0000 Subject: [PATCH 016/127] rename partialInstancer.py to instancer.py --- Lib/fontTools/varLib/{partialInstancer.py => instancer.py} | 6 +++--- .../varLib/{partialInstancer_test.py => instancer_test.py} | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) rename Lib/fontTools/varLib/{partialInstancer.py => instancer.py} (98%) rename Tests/varLib/{partialInstancer_test.py => instancer_test.py} (96%) diff --git a/Lib/fontTools/varLib/partialInstancer.py b/Lib/fontTools/varLib/instancer.py similarity index 98% rename from Lib/fontTools/varLib/partialInstancer.py rename to Lib/fontTools/varLib/instancer.py index f8d21542f..5b729585f 100644 --- a/Lib/fontTools/varLib/partialInstancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -6,7 +6,7 @@ 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.partialInstancer ./NotoSans-VF.ttf wdth=85 +$ fonttools varLib.instancer ./NotoSans-VF.ttf wdth=85 NOTE: The module is experimental and both the API and the CLI *will* change. """ @@ -25,7 +25,7 @@ import os import re -log = logging.getLogger("fontTools.varlib.partialInstancer") +log = logging.getLogger("fontTools.varlib.instancer") PEAK_COORD_INDEX = 1 @@ -344,7 +344,7 @@ def parseArgs(args): import argparse parser = argparse.ArgumentParser( - "fonttools varLib.partialInstancer", + "fonttools varLib.instancer", description="Partially instantiate a variable font", ) parser.add_argument("input", metavar="INPUT.ttf", help="Input variable TTF file.") diff --git a/Tests/varLib/partialInstancer_test.py b/Tests/varLib/instancer_test.py similarity index 96% rename from Tests/varLib/partialInstancer_test.py rename to Tests/varLib/instancer_test.py index d251b7c3e..22eb77a65 100644 --- a/Tests/varLib/partialInstancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1,7 +1,7 @@ from __future__ import print_function, division, absolute_import from fontTools.misc.py23 import * from fontTools.ttLib import TTFont -from fontTools.varLib import partialInstancer as pi +from fontTools.varLib import instancer import os import pytest @@ -80,7 +80,7 @@ class InstantiateGvarTest(object): ], ) def test_pin_and_drop_axis(self, varfont, glyph_name, location, expected): - pi.instantiateGvar(varfont, location) + instancer.instantiateGvar(varfont, location) assert _get_coordinates(varfont, glyph_name) == expected[glyph_name] From 18f8a30305683d280e2b2b147dd598cdb50a0cb3 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 22 Mar 2019 14:13:55 +0000 Subject: [PATCH 017/127] TupleVariation: add scaleDeltas and roundDeltas method --- Lib/fontTools/ttLib/tables/TupleVariation.py | 45 +++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/ttLib/tables/TupleVariation.py b/Lib/fontTools/ttLib/tables/TupleVariation.py index e2ceba4d1..5eee56e57 100644 --- a/Lib/fontTools/ttLib/tables/TupleVariation.py +++ b/Lib/fontTools/ttLib/tables/TupleVariation.py @@ -306,7 +306,7 @@ class TupleVariation(object): elif type(c) is int: deltaX.append(c) elif c is not None: - raise ValueError("invalid type of delta: %s" % type(c)) + raise TypeError("invalid type of delta: %s" % type(c)) return self.compileDeltaValues_(deltaX) + self.compileDeltaValues_(deltaY) @staticmethod @@ -446,6 +446,49 @@ class TupleVariation(object): size += axisCount * 4 return size + def scaleDeltas(self, scalar): + if scalar == 1.0: + return # no change + # check if deltas are (x, y) as in gvar, or single values as in cvar + firstDelta = next((c for c in self.coordinates if c is not None), None) + if firstDelta is None: + return # nothing to scale + if type(firstDelta) is tuple and len(firstDelta) == 2: + if scalar == 0: + self.coordinates = [(0, 0)] * len(self.coordinates) + else: + self.coordinates = [ + (d[0] * scalar, d[1] * scalar) if d is not None else None + for d in self.coordinates + ] + elif type(firstDelta) in (int, float): + if scalar == 0: + self.coordinates = [0] * len(self.coordinates) + else: + self.coordinates = [ + d * scalar if d is not None else None + for d in self.coordinates + ] + else: + raise TypeError("invalid type of delta: %s" % type(firstDelta)) + + def roundDeltas(self): + # check if deltas are (x, y) as in gvar, or single values as in cvar + firstDelta = next((c for c in self.coordinates if c is not None), None) + if firstDelta is None: + return # nothing to round + if type(firstDelta) is tuple and len(firstDelta) == 2: + self.coordinates = [ + (otRound(d[0]), otRound(d[1])) if d is not None else None + for d in self.coordinates + ] + elif type(firstDelta) in (int, float): + self.coordinates = [ + otRound(d) if d is not None else None for d in self.coordinates + ] + else: + raise TypeError("invalid type of delta: %s" % type(firstDelta)) + def decompileSharedTuples(axisTags, sharedTupleCount, data, offset): result = [] From 62c98b451ab6a12a01e9306e370e592dd2e91428 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 22 Mar 2019 14:15:53 +0000 Subject: [PATCH 018/127] instancer: share same instantiateTupleVariationStore for both gvar/cvar refactored code, hopefully simplifying things a bit. for cvar/cvt we do the rounding only at the end after we have summed the scaled deltas to avoid introducing unnecessary rounding errors. --- Lib/fontTools/varLib/instancer.py | 148 ++++++++++++++---------------- 1 file changed, 69 insertions(+), 79 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 5b729585f..2efa5c2d9 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -30,50 +30,58 @@ log = logging.getLogger("fontTools.varlib.instancer") PEAK_COORD_INDEX = 1 -def instantiateGvarGlyph(varfont, location, glyphname): +def instantiateTupleVariationStore(variations, location): + newVariations = [] + defaultDeltas = [] + 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 + elif scalar != 1.0: + var.scaleDeltas(scalar) + if not var.axes: + # if no axis is left in the TupleVariation, also drop it; its deltas + # will be folded into the neutral + defaultDeltas.append(var.coordinates) + else: + # keep the TupleVariation, and round the scaled deltas to integers + var.roundDeltas() + newVariations.append(var) + variations[:] = newVariations + return defaultDeltas + + +def setGvarGlyphDeltas(varfont, glyphname, deltasets): glyf = varfont["glyf"] - gvar = varfont["gvar"] - variations = gvar.variations[glyphname] coordinates = glyf.getCoordinates(glyphname, varfont) origCoords = None - newVariations = [] - pinnedAxes = set(location.keys()) - defaultModified = False - for var in variations: - tupleAxes = set(var.axes.keys()) - pinnedTupleAxes = tupleAxes & pinnedAxes - if not pinnedTupleAxes: - # A tuple for only axes being kept is untouched - newVariations.append(var) - continue - else: - # compute influence at pinned location only for the pinned axes - pinnedAxesSupport = {a: var.axes[a] for a in pinnedTupleAxes} - scalar = supportScalar(location, pinnedAxesSupport) - if not scalar: - # no influence (default value or out of range); drop tuple - continue - deltas = var.coordinates - hasUntouchedPoints = None in deltas - if hasUntouchedPoints: - if origCoords is None: - origCoords, g = glyf.getCoordinatesAndControls(glyphname, varfont) - deltas = iup_delta(deltas, origCoords, g.endPts) - scaledDeltas = GlyphCoordinates(deltas) * scalar - if tupleAxes.issubset(pinnedAxes): - # A tuple for only axes being pinned is discarded, and - # it's contribution is reflected into the base outlines - coordinates += scaledDeltas - defaultModified = True - else: - # A tuple for some axes being pinned has to be adjusted - var.coordinates = scaledDeltas - for axis in pinnedTupleAxes: - del var.axes[axis] - newVariations.append(var) - if defaultModified: - glyf.setCoordinates(glyphname, coordinates, varfont) - gvar.variations[glyphname] = newVariations + + for deltas in deltasets: + hasUntouchedPoints = None in deltas + if hasUntouchedPoints: + if origCoords is None: + origCoords, g = glyf.getCoordinatesAndControls(glyphname, varfont) + deltas = iup_delta(deltas, origCoords, g.endPts) + coordinates += GlyphCoordinates(deltas) + + glyf.setCoordinates(glyphname, coordinates, varfont) + + +def instantiateGvarGlyph(varfont, glyphname, location): + gvar = varfont["gvar"] + + defaultDeltas = instantiateTupleVariationStore(gvar.variations[glyphname], location) + if defaultDeltas: + setGvarGlyphDeltas(varfont, glyphname, defaultDeltas) + + if not gvar.variations[glyphname]: + del gvar.variations[glyphname] def instantiateGvar(varfont, location): @@ -95,50 +103,32 @@ def instantiateGvar(varfont, location): ), ) for glyphname in glyphnames: - instantiateGvarGlyph(varfont, location, glyphname) + instantiateGvarGlyph(varfont, glyphname, location) + + if not gvar.variations: + del varfont["gvar"] + + +def applyCvtDeltas(cvt, deltasets): + # copy cvt values internally represented as array.array("h") to a list, + # accumulating deltas (that may be float since we scaled them) and only + # do the rounding to integer once at the end to reduce rounding errors + values = list(cvt) + for deltas in deltasets: + for i, delta in enumerate(deltas): + if delta is not None: + values[i] += delta + for i, v in enumerate(values): + cvt[i] = otRound(v) def instantiateCvar(varfont, location): log.info("Instantiating cvt/cvar tables") - cvar = varfont["cvar"] cvt = varfont["cvt "] - pinnedAxes = set(location.keys()) - newVariations = [] - deltas = {} - for var in cvar.variations: - tupleAxes = set(var.axes.keys()) - pinnedTupleAxes = tupleAxes & pinnedAxes - if not pinnedTupleAxes: - # A tuple for only axes being kept is untouched - newVariations.append(var) - continue - else: - # compute influence at pinned location only for the pinned axes - pinnedAxesSupport = {a: var.axes[a] for a in pinnedTupleAxes} - scalar = supportScalar(location, pinnedAxesSupport) - if not scalar: - # no influence (default value or out of range); drop tuple - continue - if tupleAxes.issubset(pinnedAxes): - for i, c in enumerate(var.coordinates): - if c is not None: - # Compute deltas which need to be applied to values in cvt - deltas[i] = deltas.get(i, 0) + scalar * c - else: - # Apply influence to delta values - for i, d in enumerate(var.coordinates): - if d is not None: - var.coordinates[i] = otRound(d * scalar) - for axis in pinnedTupleAxes: - del var.axes[axis] - newVariations.append(var) - if deltas: - for i, delta in deltas.items(): - cvt[i] += otRound(delta) - if newVariations: - cvar.variations = newVariations - else: + defaultDeltas = instantiateTupleVariationStore(cvar.variations, location) + applyCvtDeltas(cvt, defaultDeltas) + if not cvar.variations: del varfont["cvar"] From 29c7a11f7705e6e271cb5a1b2a84f4105b4f93b8 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 22 Mar 2019 15:27:58 +0000 Subject: [PATCH 019/127] minor: fix 'NameError: parser not defined' --- Lib/fontTools/varLib/instancer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 2efa5c2d9..30966f234 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -307,7 +307,7 @@ def parseLimits(limits): for limit_string in limits: match = re.match(r"^(\w{1,4})=([^:]+)(?:[:](.+))?$", limit_string) if not match: - parser.error("invalid location format: %r" % limit_string) + raise ValueError("invalid location format: %r" % limit_string) tag = match.group(1).ljust(4) lbound = float(match.group(2)) ubound = lbound From fa57f7e931fe485f7eb367c4accc37bf582c9a0d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 22 Mar 2019 17:30:30 +0000 Subject: [PATCH 020/127] instancer: only round deltas if we did scale them --- Lib/fontTools/varLib/instancer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 30966f234..4c22000ac 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -51,7 +51,8 @@ def instantiateTupleVariationStore(variations, location): defaultDeltas.append(var.coordinates) else: # keep the TupleVariation, and round the scaled deltas to integers - var.roundDeltas() + if scalar != 1.0: + var.roundDeltas() newVariations.append(var) variations[:] = newVariations return defaultDeltas From bb667f68419ff47a5f3ef229afd9d3aec6c0e265 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 22 Mar 2019 17:32:05 +0000 Subject: [PATCH 021/127] rename applyCvtDeltas to setCvarDeltas for consistency with set{Gvar,Mvar}Deltas --- Lib/fontTools/varLib/instancer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 4c22000ac..57b2f5cfb 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -110,7 +110,7 @@ def instantiateGvar(varfont, location): del varfont["gvar"] -def applyCvtDeltas(cvt, deltasets): +def setCvarDeltas(cvt, deltasets): # copy cvt values internally represented as array.array("h") to a list, # accumulating deltas (that may be float since we scaled them) and only # do the rounding to integer once at the end to reduce rounding errors @@ -128,7 +128,7 @@ def instantiateCvar(varfont, location): cvar = varfont["cvar"] cvt = varfont["cvt "] defaultDeltas = instantiateTupleVariationStore(cvar.variations, location) - applyCvtDeltas(cvt, defaultDeltas) + setCvarDeltas(cvt, defaultDeltas) if not cvar.variations: del varfont["cvar"] From 05c22b9122ad451b980f45cf1fa91893904db207 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 25 Mar 2019 11:09:46 +0000 Subject: [PATCH 022/127] instancer_test: added tests for instantiateCvar --- Tests/varLib/data/PartialInstancerTest-VF.ttx | 25 ++++++++++++++-- Tests/varLib/instancer_test.py | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/data/PartialInstancerTest-VF.ttx index 98fec4168..ced219fe8 100644 --- a/Tests/varLib/data/PartialInstancerTest-VF.ttx +++ b/Tests/varLib/data/PartialInstancerTest-VF.ttx @@ -1,5 +1,5 @@ - + @@ -12,12 +12,12 @@ - + - + @@ -139,6 +139,13 @@ + + + + + + + @@ -617,6 +624,18 @@ + + + + + + + + + + + + diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 22eb77a65..4927ef3cb 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -90,3 +90,32 @@ class InstantiateGvarTest(object): for tuples in varfont["gvar"].variations.values() for t in tuples ) + + +class InstantiateCvarTest(object): + @pytest.mark.parametrize( + "location, expected", + [ + pytest.param({"wght": -1.0}, [500, -400, 150, 250], id="wght=-1.0"), + pytest.param({"wdth": -1.0}, [500, -400, 180, 200], id="wdth=-1.0"), + pytest.param({"wght": -0.5}, [500, -400, 165, 250], id="wght=-0.5"), + pytest.param({"wdth": -0.3}, [500, -400, 180, 240], id="wdth=-0.3"), + ], + ) + def test_pin_and_drop_axis(self, varfont, location, expected): + instancer.instantiateCvar(varfont, location) + + assert list(varfont["cvt "].values) == expected + + # check that the pinned axis has been dropped from gvar + pinned_axes = location.keys() + assert not any( + axis in t.axes for t in varfont["cvar"].variations for axis in pinned_axes + ) + + def test_full_instance(self, varfont): + instancer.instantiateCvar(varfont, {"wght": -0.5, "wdth": -0.5}) + + assert list(varfont["cvt "].values) == [500, -400, 165, 225] + + assert "cvar" not in varfont From eff5a6310d16ed69b459139924c1501fc54e5365 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 25 Mar 2019 12:22:42 +0000 Subject: [PATCH 023/127] instancer_test: adjust expected cvar test result --- Tests/varLib/instancer_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 4927ef3cb..f38cac63f 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -99,7 +99,7 @@ class InstantiateCvarTest(object): pytest.param({"wght": -1.0}, [500, -400, 150, 250], id="wght=-1.0"), pytest.param({"wdth": -1.0}, [500, -400, 180, 200], id="wdth=-1.0"), pytest.param({"wght": -0.5}, [500, -400, 165, 250], id="wght=-0.5"), - pytest.param({"wdth": -0.3}, [500, -400, 180, 240], id="wdth=-0.3"), + pytest.param({"wdth": -0.3}, [500, -400, 180, 235], id="wdth=-0.3"), ], ) def test_pin_and_drop_axis(self, varfont, location, expected): From 846e7b3ec4d2a56a8646dbeffd065c53d1698d22 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 25 Mar 2019 13:15:50 +0000 Subject: [PATCH 024/127] instancer: round MVAR deltas at the end and don't use enumerate() when iterating over region axes, as get_support() method returns a dict --- Lib/fontTools/varLib/instancer.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 57b2f5cfb..28a9ca467 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -19,6 +19,7 @@ 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 @@ -140,16 +141,20 @@ def setMvarDeltas(varfont, location): 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] - delta = otRound(varStoreInstancer[rec.VarIdx]) - if not delta: - continue + deltas[(tableTag, itemName)] += varStoreInstancer[rec.VarIdx] + + for (tableTag, itemName), delta in deltas.items(): setattr( - varfont[tableTag], itemName, getattr(varfont[tableTag], itemName) + delta + varfont[tableTag], + itemName, + getattr(varfont[tableTag], itemName) + otRound(delta), ) @@ -189,7 +194,7 @@ def instantiateItemVariationStore(varfont, tableName, location): # This region will be retained but the deltas have to be adjusted. pinnedSupport = { key: value - for key, value in enumerate(region.get_support(fvar.axes)) + for key, value in region.get_support(fvar.axes).items() if key in pinnedRegionAxes } pinnedScalar = supportScalar(location, pinnedSupport) From 012d80db6d8f5a55379cc3a5c2d7405d62c63316 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 25 Mar 2019 13:19:38 +0000 Subject: [PATCH 025/127] instancer_test: added tests for MVAR table I added an MVAR table to the PartialInstancer-VF.ttx test font with made-up deltas for OS/2.yStrikeoutSize, post.underlinePosition and post.underlineThickness. I defined 3 regions, one with only wght, one with only wdth, and one with both wdth and wght axes. --- Tests/varLib/data/PartialInstancerTest-VF.ttx | 78 +++++++++++++++- Tests/varLib/instancer_test.py | 88 +++++++++++++++++++ 2 files changed, 164 insertions(+), 2 deletions(-) diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/data/PartialInstancerTest-VF.ttx index ced219fe8..8e728fc0d 100644 --- a/Tests/varLib/data/PartialInstancerTest-VF.ttx +++ b/Tests/varLib/data/PartialInstancerTest-VF.ttx @@ -12,12 +12,12 @@ - + - + @@ -585,6 +585,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index f38cac63f..682ccf2a8 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -2,6 +2,7 @@ from __future__ import print_function, division, absolute_import from fontTools.misc.py23 import * from fontTools.ttLib import TTFont from fontTools.varLib import instancer +from fontTools.varLib.mvar import MVAR_ENTRIES import os import pytest @@ -119,3 +120,90 @@ class InstantiateCvarTest(object): assert list(varfont["cvt "].values) == [500, -400, 165, 225] assert "cvar" not in varfont + + +class InstantiateMvarTest(object): + @pytest.mark.parametrize( + "location, expected", + [ + pytest.param( + {"wght": 1.0}, {"strs": 100, "undo": -200, "unds": 150}, id="wght=1.0" + ), + pytest.param( + {"wght": 0.5}, {"strs": 75, "undo": -150, "unds": 100}, id="wght=0.5" + ), + pytest.param( + {"wght": 0.0}, {"strs": 50, "undo": -100, "unds": 50}, id="wght=0.0" + ), + pytest.param( + {"wdth": -1.0}, {"strs": 20, "undo": -100, "unds": 50}, id="wdth=-1.0" + ), + pytest.param( + {"wdth": -0.5}, {"strs": 35, "undo": -100, "unds": 50}, id="wdth=-0.5" + ), + pytest.param( + {"wdth": 0.0}, {"strs": 50, "undo": -100, "unds": 50}, id="wdth=0.0" + ), + ], + ) + def test_pin_and_drop_axis(self, varfont, location, expected): + mvar = varfont["MVAR"].table + # initially we have a single VarData with deltas associated with 3 regions: + # 1 with only wght, 1 with only wdth, and 1 with both wght and wdth. + assert len(mvar.VarStore.VarData) == 1 + assert mvar.VarStore.VarData[0].VarRegionCount == 3 + assert all(len(item) == 3 for item in mvar.VarStore.VarData[0].Item) + + instancer.instantiateMvar(varfont, location) + + for mvar_tag, expected_value in expected.items(): + table_tag, item_name = MVAR_ENTRIES[mvar_tag] + assert getattr(varfont[table_tag], item_name) == expected_value + + # check that the pinned axis does not influence any of the remaining regions + # in MVAR VarStore + pinned_axes = location.keys() + fvar = varfont["fvar"] + assert all( + support[instancer.PEAK_COORD_INDEX] == 0 + for region in mvar.VarStore.VarRegionList.Region + for axis, support in region.get_support(fvar.axes).items() + if axis in pinned_axes + ) + + # check that one region and accompanying deltas has been dropped + assert all(len(item) == 2 for item in mvar.VarStore.VarData[0].Item) + + @pytest.mark.parametrize( + "location, expected", + [ + pytest.param( + {"wght": 1.0, "wdth": 0.0}, + {"strs": 100, "undo": -200, "unds": 150}, + id="wght=1.0,wdth=0.0", + ), + pytest.param( + {"wght": 0.0, "wdth": -1.0}, + {"strs": 20, "undo": -100, "unds": 50}, + id="wght=0.0,wdth=-1.0", + ), + pytest.param( + {"wght": 0.5, "wdth": -0.5}, + {"strs": 55, "undo": -145, "unds": 95}, + id="wght=0.5,wdth=-0.5", + ), + pytest.param( + {"wght": 1.0, "wdth": -1.0}, + {"strs": 50, "undo": -180, "unds": 130}, + id="wght=0.5,wdth=-0.5", + ), + ], + ) + def test_full_instance(self, varfont, location, expected): + instancer.instantiateMvar(varfont, location) + + for mvar_tag, expected_value in expected.items(): + table_tag, item_name = MVAR_ENTRIES[mvar_tag] + assert getattr(varfont[table_tag], item_name) == expected_value + + assert "MVAR" not in varfont From 2b746d6e503f9c40cd796f57a291cfaa5e64c60c Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 25 Mar 2019 13:41:41 +0000 Subject: [PATCH 026/127] instancer: unpack axis (start, peak, end) tuple instead of indexing at PEAK_COORD_INDEX Makes it more readable --- Lib/fontTools/varLib/instancer.py | 16 +++++++--------- Tests/varLib/instancer_test.py | 4 ++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 28a9ca467..469cfd3f9 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -28,8 +28,6 @@ import re log = logging.getLogger("fontTools.varlib.instancer") -PEAK_COORD_INDEX = 1 - def instantiateTupleVariationStore(variations, location): newVariations = [] @@ -175,11 +173,11 @@ def instantiateItemVariationStore(varfont, tableName, location): regionInfluenceMap = {} pinnedAxes = set(location.keys()) for regionIndex, region in enumerate(table.VarStore.VarRegionList.Region): - # collect set of axisTags which have influence: peakCoord != 0 + # collect set of axisTags which have influence: peak != 0 regionAxes = set( - key - for key, value in region.get_support(fvar.axes).items() - if value[PEAK_COORD_INDEX] != 0 + axis + for axis, (start, peak, end) in region.get_support(fvar.axes).items() + if peak != 0 ) pinnedRegionAxes = regionAxes & pinnedAxes if not pinnedRegionAxes: @@ -193,9 +191,9 @@ def instantiateItemVariationStore(varfont, tableName, location): else: # This region will be retained but the deltas have to be adjusted. pinnedSupport = { - key: value - for key, value in region.get_support(fvar.axes).items() - if key in pinnedRegionAxes + axis: support + for axis, support in region.get_support(fvar.axes).items() + if axis in pinnedRegionAxes } pinnedScalar = supportScalar(location, pinnedSupport) regionInfluenceMap.update({regionIndex: pinnedScalar}) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 682ccf2a8..f0868adcc 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -165,9 +165,9 @@ class InstantiateMvarTest(object): pinned_axes = location.keys() fvar = varfont["fvar"] assert all( - support[instancer.PEAK_COORD_INDEX] == 0 + peak == 0 for region in mvar.VarStore.VarRegionList.Region - for axis, support in region.get_support(fvar.axes).items() + for axis, (start, peak, end) in region.get_support(fvar.axes).items() if axis in pinned_axes ) From dbad6da5c96df194493e6d87447b11f78b71bc3d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 25 Mar 2019 13:56:27 +0000 Subject: [PATCH 027/127] instancer: enumerate fvar axis indices only once then look them up fvar axis tags are unique, we can compute the mapping from tag to index once and reuse when we need them index from the tag. --- Lib/fontTools/varLib/instancer.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 469cfd3f9..97b3bc435 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -172,6 +172,11 @@ def instantiateItemVariationStore(varfont, tableName, location): newRegions = [] regionInfluenceMap = {} pinnedAxes = set(location.keys()) + fvarAxisIndices = { + axis.axisTag: index + for index, axis in enumerate(fvar.axes) + if axis.axisTag in pinnedAxes + } for regionIndex, region in enumerate(table.VarStore.VarRegionList.Region): # collect set of axisTags which have influence: peak != 0 regionAxes = set( @@ -198,14 +203,10 @@ def instantiateItemVariationStore(varfont, tableName, location): pinnedScalar = supportScalar(location, pinnedSupport) regionInfluenceMap.update({regionIndex: pinnedScalar}) - for axisname in pinnedRegionAxes: + for axis in pinnedRegionAxes: # For all pinnedRegionAxes make their influence null by setting # PeakCoord to 0. - index = next( - index - for index, axis in enumerate(fvar.axes) - if axis.axisTag == axisname - ) + index = fvarAxisIndices[axis] region.VarRegionAxis[index].PeakCoord = 0 newRegions.append(region) From 158501734508697cf8dfe73dc9f297cd42f96824 Mon Sep 17 00:00:00 2001 From: Nyshadh Reddy Rachamallu Date: Mon, 25 Mar 2019 16:14:57 -0400 Subject: [PATCH 028/127] Add FeatureVariation instantiation (for GSUB, GPOS) --- Lib/fontTools/varLib/instancer.py | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 97b3bc435..5d49711dd 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -237,6 +237,50 @@ def instantiateItemVariationStore(varfont, tableName, location): del item[index] +def instantiateFeatureVariations(varfont, tableTag, location): + table = varfont[tableTag].table + if not hasattr(table, "FeatureVariations"): + log.info("No FeatureVariations in %s", tableTag) + return + + log.info("Instantiating FeatureVariations of %s table", tableTag) + variations = table.FeatureVariations + fvar = varfont["fvar"] + newRecords = [] + pinnedAxes = set(location.keys()) + featureVariationApplied = False + for record in variations.FeatureVariationRecord: + retainRecord = True + applies = True + newConditions = [] + for condition in record.ConditionSet.ConditionTable: + axisIdx = condition.AxisIndex + axisTag = fvar.axes[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 + table.FeatureVariations.FeatureVariationRecord = newRecords if newRecords else None + + def normalize(value, triple, avar_mapping): value = normalizeValue(value, triple) if avar_mapping: @@ -301,6 +345,12 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False): if "MVAR" in varfont: instantiateMvar(varfont, axis_limits) + if "GSUB" in varfont: + instantiateFeatureVariationStore(varfont, "GSUB", axis_limits) + + if "GPOS" in varfont: + instantiateFeatureVariationStore(varfont, "GPOS", axis_limits) + # TODO: actually process HVAR instead of dropping it del varfont["HVAR"] From f3aa8d5c90871167c2af017a2043e4d0e4023f1b Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 26 Mar 2019 09:57:52 +0000 Subject: [PATCH 029/127] fix instantiateFeatureVarationStore, was renamed instantiateFeatureVariations --- Lib/fontTools/varLib/instancer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 5d49711dd..b8b18bb71 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -346,10 +346,10 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False): instantiateMvar(varfont, axis_limits) if "GSUB" in varfont: - instantiateFeatureVariationStore(varfont, "GSUB", axis_limits) + instantiateFeatureVariations(varfont, "GSUB", axis_limits) if "GPOS" in varfont: - instantiateFeatureVariationStore(varfont, "GPOS", axis_limits) + instantiateFeatureVariations(varfont, "GPOS", axis_limits) # TODO: actually process HVAR instead of dropping it del varfont["HVAR"] From c9a00f4ad07b713444b3715aa39de989329d0a73 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 26 Mar 2019 10:14:16 +0000 Subject: [PATCH 030/127] minor refactoring of instantiateFeatureVariations to make it easier to test --- Lib/fontTools/varLib/instancer.py | 36 +++++++++++++++++-------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index b8b18bb71..b6bc2dd1e 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -237,25 +237,29 @@ def instantiateItemVariationStore(varfont, tableName, location): del item[index] -def instantiateFeatureVariations(varfont, tableTag, location): - table = varfont[tableTag].table - if not hasattr(table, "FeatureVariations"): - log.info("No FeatureVariations in %s", tableTag) - return +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 + ) - log.info("Instantiating FeatureVariations of %s table", tableTag) - variations = table.FeatureVariations - fvar = varfont["fvar"] + +def _instantiateFeatureVariations(table, fvarAxes, location): newRecords = [] pinnedAxes = set(location.keys()) featureVariationApplied = False - for record in variations.FeatureVariationRecord: + for record in table.FeatureVariations.FeatureVariationRecord: retainRecord = True applies = True newConditions = [] for condition in record.ConditionSet.ConditionTable: axisIdx = condition.AxisIndex - axisTag = fvar.axes[axisIdx].axisTag + axisTag = fvarAxes[axisIdx].axisTag if condition.Format == 1 and axisTag in pinnedAxes: minValue = condition.FilterRangeMinValue maxValue = condition.FilterRangeMaxValue @@ -278,7 +282,11 @@ def instantiateFeatureVariations(varfont, tableTag, location): table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature = rec.Feature # Set variations only once featureVariationApplied = True - table.FeatureVariations.FeatureVariationRecord = newRecords if newRecords else None + + if newRecords: + table.FeatureVariations.FeatureVariationRecord = newRecords + else: + del table.FeatureVariations def normalize(value, triple, avar_mapping): @@ -345,11 +353,7 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False): if "MVAR" in varfont: instantiateMvar(varfont, axis_limits) - if "GSUB" in varfont: - instantiateFeatureVariations(varfont, "GSUB", axis_limits) - - if "GPOS" in varfont: - instantiateFeatureVariations(varfont, "GPOS", axis_limits) + instantiateFeatureVariations(varfont, axis_limits) # TODO: actually process HVAR instead of dropping it del varfont["HVAR"] From 38fc6b6611bdfe520db910a3a832f49be3407fdc Mon Sep 17 00:00:00 2001 From: Nyshadh Reddy Rachamallu Date: Mon, 25 Mar 2019 18:37:44 -0400 Subject: [PATCH 031/127] Bug fix for ItemVariationStore instantiation. --- Lib/fontTools/varLib/instancer.py | 44 +++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index b6bc2dd1e..ae0203f21 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -21,6 +21,7 @@ from fontTools.varLib.varStore import VarStoreInstancer from fontTools.varLib.mvar import MVAR_ENTRIES import collections from copy import deepcopy +import bisect import logging import os import re @@ -218,23 +219,44 @@ def instantiateItemVariationStore(varfont, tableName, location): del varfont[tableName] return - # First apply scalars to deltas then remove deltas in reverse index order + # Start modifying deltas. if regionInfluenceMap: - regionsToBeRemoved = [ - regionIndex - for regionIndex, scalar in regionInfluenceMap.items() - if scalar is None - ] + regionsToBeRemoved = sorted( + [ + regionIndex + for regionIndex, scalar in regionInfluenceMap.items() + if scalar is None + ] + ) for vardata in table.VarStore.VarData: + varRegionIndexMapping = {v: k for k, v in enumerate(vardata.VarRegionIndex)} + # Apply scalars for regions to be retained. for regionIndex, scalar in regionInfluenceMap.items(): if scalar is not None: + varRegionIndex = varRegionIndexMapping[regionIndex] for item in vardata.Item: - item[regionIndex] = otRound(item[regionIndex] * scalar) + item[varRegionIndex] = otRound(item[varRegionIndex] * scalar) - for index in sorted(regionsToBeRemoved, reverse=True): - del vardata.VarRegionIndex[index] - for item in vardata.Item: - del item[index] + if regionsToBeRemoved: + # Delete deltas (in reverse order) for regions to be removed. + for regionIndex in sorted( + regionsToBeRemoved, + key=lambda x: varRegionIndexMapping[x], + reverse=True, + ): + varRegionIndex = varRegionIndexMapping[regionIndex] + for item in vardata.Item: + del item[varRegionIndex] + + # Adjust VarRegionIndex since we are deleting regions. + newVarRegionIndex = [] + for varRegionIndex in vardata.VarRegionIndex: + if varRegionIndex not in regionsToBeRemoved: + newVarRegionIndex.append( + varRegionIndex + - bisect.bisect_left(regionsToBeRemoved, varRegionIndex) + ) + vardata.VarRegionIndex = newVarRegionIndex def instantiateFeatureVariations(varfont, location): From 0b432533696afa4cb80adfa17beb6dfe44c7975e Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 26 Mar 2019 13:48:54 +0000 Subject: [PATCH 032/127] refactor and simplify instantiateItemVariationStore we can reuse the prune_regions method defined in varStore.py to update the VarRegionList. also update the counts at the end (will be done automatically on compile anyway). --- Lib/fontTools/varLib/instancer.py | 107 ++++++++++++++---------------- 1 file changed, 48 insertions(+), 59 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index ae0203f21..b7f32d3e5 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -21,7 +21,6 @@ from fontTools.varLib.varStore import VarStoreInstancer from fontTools.varLib.mvar import MVAR_ENTRIES import collections from copy import deepcopy -import bisect import logging import os import re @@ -162,47 +161,47 @@ def instantiateMvar(varfont, location): # First instantiate to new position without modifying MVAR table setMvarDeltas(varfont, location) - instantiateItemVariationStore(varfont, "MVAR", location) + varStore = varfont["MVAR"].table.VarStore + instantiateItemVariationStore(varStore, varfont["fvar"].axes, location) + + if not varStore.VarRegionList.Region: + # Delete table if no more regions left. + del varfont["MVAR"] -def instantiateItemVariationStore(varfont, tableName, location): - log.info("Instantiating ItemVariation store of %s table", tableName) - - table = varfont[tableName].table - fvar = varfont["fvar"] - newRegions = [] - regionInfluenceMap = {} +def instantiateItemVariationStore(varStore, fvarAxes, location): + regionsToBeRemoved = set() + regionScalars = {} pinnedAxes = set(location.keys()) fvarAxisIndices = { axis.axisTag: index - for index, axis in enumerate(fvar.axes) + for index, axis in enumerate(fvarAxes) if axis.axisTag in pinnedAxes } - for regionIndex, region in enumerate(table.VarStore.VarRegionList.Region): + 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(fvar.axes).items() + for axis, (start, peak, end) in region.get_support(fvarAxes).items() if peak != 0 ) pinnedRegionAxes = regionAxes & pinnedAxes if not pinnedRegionAxes: # A region where none of the axes having effect are pinned - newRegions.append(region) continue if len(pinnedRegionAxes) == len(regionAxes): # All the axes having effect in this region are being pinned so # remove it - regionInfluenceMap.update({regionIndex: None}) + regionsToBeRemoved.add(regionIndex) else: # This region will be retained but the deltas have to be adjusted. pinnedSupport = { axis: support - for axis, support in region.get_support(fvar.axes).items() + for axis, support in region.get_support(fvarAxes).items() if axis in pinnedRegionAxes } pinnedScalar = supportScalar(location, pinnedSupport) - regionInfluenceMap.update({regionIndex: pinnedScalar}) + regionScalars[regionIndex] = pinnedScalar for axis in pinnedRegionAxes: # For all pinnedRegionAxes make their influence null by setting @@ -210,53 +209,43 @@ def instantiateItemVariationStore(varfont, tableName, location): index = fvarAxisIndices[axis] region.VarRegionAxis[index].PeakCoord = 0 - newRegions.append(region) + for vardata in varStore.VarData: + reverseVarRegionIndex = [ + vardata.VarRegionIndex.index(ri) for ri in vardata.VarRegionIndex + ] + # Apply scalars for regions to be retained. + for regionIndex, scalar in regionScalars.items(): + varRegionIndex = reverseVarRegionIndex[regionIndex] + for item in vardata.Item: + item[varRegionIndex] *= otRound(scalar) - table.VarStore.VarRegionList.Region = newRegions - - if not table.VarStore.VarRegionList.Region: - # Delete table if no more regions left. - del varfont[tableName] - return - - # Start modifying deltas. - if regionInfluenceMap: - regionsToBeRemoved = sorted( - [ - regionIndex - for regionIndex, scalar in regionInfluenceMap.items() - if scalar is None + if regionsToBeRemoved: + # from each deltaset row, delete columns corresponding to the regions to + # be deleted + newItems = [] + varRegionIndex = vardata.VarRegionIndex + for item in vardata.Item: + newItems.append( + [ + delta + for column, delta in enumerate(item) + if varRegionIndex[column] not in regionsToBeRemoved + ] + ) + vardata.Item = newItems + # prune VarRegionIndex from the regions to be deleted + vardata.VarRegionIndex = [ + ri for ri in vardata.VarRegionIndex if ri not in regionsToBeRemoved ] - ) - for vardata in table.VarStore.VarData: - varRegionIndexMapping = {v: k for k, v in enumerate(vardata.VarRegionIndex)} - # Apply scalars for regions to be retained. - for regionIndex, scalar in regionInfluenceMap.items(): - if scalar is not None: - varRegionIndex = varRegionIndexMapping[regionIndex] - for item in vardata.Item: - item[varRegionIndex] = otRound(item[varRegionIndex] * scalar) - if regionsToBeRemoved: - # Delete deltas (in reverse order) for regions to be removed. - for regionIndex in sorted( - regionsToBeRemoved, - key=lambda x: varRegionIndexMapping[x], - reverse=True, - ): - varRegionIndex = varRegionIndexMapping[regionIndex] - for item in vardata.Item: - del item[varRegionIndex] + # remove unused regions from VarRegionList + varStore.prune_regions() - # Adjust VarRegionIndex since we are deleting regions. - newVarRegionIndex = [] - for varRegionIndex in vardata.VarRegionIndex: - if varRegionIndex not in regionsToBeRemoved: - newVarRegionIndex.append( - varRegionIndex - - bisect.bisect_left(regionsToBeRemoved, varRegionIndex) - ) - vardata.VarRegionIndex = newVarRegionIndex + # recalculate counts + varStore.VarRegionList.RegionCount = len(varStore.VarRegionList.Region) + varStore.VarDataCount = len(varStore.VarData) + for data in varStore.VarData: + data.ItemCount = len(data.Item) def instantiateFeatureVariations(varfont, location): From 24569eec9dec92bf5459235d5863bb8a09167046 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 26 Mar 2019 15:31:20 +0000 Subject: [PATCH 033/127] drop VarData if all regions referenced by it are removed --- Lib/fontTools/varLib/instancer.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index b7f32d3e5..47c72eb28 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -209,15 +209,21 @@ def instantiateItemVariationStore(varStore, fvarAxes, location): index = fvarAxisIndices[axis] region.VarRegionAxis[index].PeakCoord = 0 + newVarDatas = [] for vardata in varStore.VarData: - reverseVarRegionIndex = [ - vardata.VarRegionIndex.index(ri) for ri in vardata.VarRegionIndex - ] + # drop VarData subtable if we remove all the regions referenced by it + if regionsToBeRemoved.issuperset(vardata.VarRegionIndex): + continue + regionToColumnMap = { + regionIndex: col for col, regionIndex in enumerate(vardata.VarRegionIndex) + } # Apply scalars for regions to be retained. for regionIndex, scalar in regionScalars.items(): - varRegionIndex = reverseVarRegionIndex[regionIndex] - for item in vardata.Item: - item[varRegionIndex] *= otRound(scalar) + if regionIndex not in regionToColumnMap: + continue + column = regionToColumnMap[regionIndex] + for row in vardata.Item: + row[column] *= otRound(scalar) if regionsToBeRemoved: # from each deltaset row, delete columns corresponding to the regions to @@ -233,20 +239,18 @@ def instantiateItemVariationStore(varStore, fvarAxes, location): ] ) 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 ] + newVarDatas.append(vardata) + varStore.VarData = newVarDatas + varStore.VarDataCount = len(varStore.VarData) # remove unused regions from VarRegionList varStore.prune_regions() - # recalculate counts - varStore.VarRegionList.RegionCount = len(varStore.VarRegionList.Region) - varStore.VarDataCount = len(varStore.VarData) - for data in varStore.VarData: - data.ItemCount = len(data.Item) - def instantiateFeatureVariations(varfont, location): for tableTag in ("GPOS", "GSUB"): From ef14ee9aaca5b4bc2fc6f562d604eadce9193299 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 26 Mar 2019 16:07:46 +0000 Subject: [PATCH 034/127] keep VarData unchanged if none of its referenced regions are being dropped --- Lib/fontTools/varLib/instancer.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 47c72eb28..44104685e 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -211,9 +211,15 @@ def instantiateItemVariationStore(varStore, fvarAxes, location): newVarDatas = [] for vardata in varStore.VarData: - # drop VarData subtable if we remove all the regions referenced by it - if regionsToBeRemoved.issuperset(vardata.VarRegionIndex): + varRegionIndex = vardata.VarRegionIndex + if regionsToBeRemoved.issuperset(varRegionIndex): + # drop VarData subtable if we remove all the regions referenced by it continue + elif regionsToBeRemoved.isdisjoint(varRegionIndex): + # keep VarData unchanged if none of its referenced regions are being dropped + newVarDatas.append(vardata) + continue + regionToColumnMap = { regionIndex: col for col, regionIndex in enumerate(vardata.VarRegionIndex) } @@ -229,7 +235,6 @@ def instantiateItemVariationStore(varStore, fvarAxes, location): # from each deltaset row, delete columns corresponding to the regions to # be deleted newItems = [] - varRegionIndex = vardata.VarRegionIndex for item in vardata.Item: newItems.append( [ From 403782d5f28a47776f75f158e61945a3126061a0 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 26 Mar 2019 16:21:18 +0000 Subject: [PATCH 035/127] fixup previous commit even if none of the referenced regions in this VarData are dropped we may still have to apply the scalars... --- Lib/fontTools/varLib/instancer.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 44104685e..3fc23194f 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -215,10 +215,6 @@ def instantiateItemVariationStore(varStore, fvarAxes, location): if regionsToBeRemoved.issuperset(varRegionIndex): # drop VarData subtable if we remove all the regions referenced by it continue - elif regionsToBeRemoved.isdisjoint(varRegionIndex): - # keep VarData unchanged if none of its referenced regions are being dropped - newVarDatas.append(vardata) - continue regionToColumnMap = { regionIndex: col for col, regionIndex in enumerate(vardata.VarRegionIndex) @@ -231,7 +227,7 @@ def instantiateItemVariationStore(varStore, fvarAxes, location): for row in vardata.Item: row[column] *= otRound(scalar) - if regionsToBeRemoved: + if not regionsToBeRemoved.isdisjoint(varRegionIndex): # from each deltaset row, delete columns corresponding to the regions to # be deleted newItems = [] From 7dd0390579f93867c1c3a68fca9b4df4a6ee7eaa Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 26 Mar 2019 16:30:57 +0000 Subject: [PATCH 036/127] fix rounding deltas after applying scalars to ItemVarStore ok, really time to add some better tests. --- Lib/fontTools/varLib/instancer.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 3fc23194f..e0466d472 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -216,16 +216,13 @@ def instantiateItemVariationStore(varStore, fvarAxes, location): # drop VarData subtable if we remove all the regions referenced by it continue - regionToColumnMap = { - regionIndex: col for col, regionIndex in enumerate(vardata.VarRegionIndex) - } # Apply scalars for regions to be retained. - for regionIndex, scalar in regionScalars.items(): - if regionIndex not in regionToColumnMap: - continue - column = regionToColumnMap[regionIndex] - for row in vardata.Item: - row[column] *= otRound(scalar) + 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) if not regionsToBeRemoved.isdisjoint(varRegionIndex): # from each deltaset row, delete columns corresponding to the regions to From e6033a14da3920da651a3ad9d24046df5247b8b7 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 26 Mar 2019 18:40:03 +0000 Subject: [PATCH 037/127] instancer: drop region if axis scalar is 0 update VarData.VarRegionCount also set StartCoord and EndCoord to 0 (same end result as only setting PeakCoord to 0, but this produces less noise when inspeciting the generated XML dump) --- Lib/fontTools/varLib/instancer.py | 17 ++++++++++++----- Tests/varLib/instancer_test.py | 8 ++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index e0466d472..26c8704f6 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -194,20 +194,26 @@ def instantiateItemVariationStore(varStore, fvarAxes, location): # remove it regionsToBeRemoved.add(regionIndex) else: - # This region will be retained but the deltas have to be adjusted. + # 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) - regionScalars[regionIndex] = pinnedScalar + 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 - for axis in pinnedRegionAxes: + for axisTag in pinnedRegionAxes: # For all pinnedRegionAxes make their influence null by setting # PeakCoord to 0. - index = fvarAxisIndices[axis] - region.VarRegionAxis[index].PeakCoord = 0 + axis = region.VarRegionAxis[fvarAxisIndices[axisTag]] + axis.StartCoord, axis.PeakCoord, axis.EndCoord = (0, 0, 0) newVarDatas = [] for vardata in varStore.VarData: @@ -242,6 +248,7 @@ def instantiateItemVariationStore(varStore, fvarAxes, location): vardata.VarRegionIndex = [ ri for ri in vardata.VarRegionIndex if ri not in regionsToBeRemoved ] + vardata.VarRegionCount = len(vardata.VarRegionIndex) newVarDatas.append(vardata) varStore.VarData = newVarDatas diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index f0868adcc..14204b903 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -151,6 +151,7 @@ class InstantiateMvarTest(object): # initially we have a single VarData with deltas associated with 3 regions: # 1 with only wght, 1 with only wdth, and 1 with both wght and wdth. assert len(mvar.VarStore.VarData) == 1 + assert mvar.VarStore.VarRegionList.RegionCount == 3 assert mvar.VarStore.VarData[0].VarRegionCount == 3 assert all(len(item) == 3 for item in mvar.VarStore.VarData[0].Item) @@ -171,8 +172,11 @@ class InstantiateMvarTest(object): if axis in pinned_axes ) - # check that one region and accompanying deltas has been dropped - assert all(len(item) == 2 for item in mvar.VarStore.VarData[0].Item) + # check that regions and accompanying deltas have been dropped + num_regions_left = len(mvar.VarStore.VarRegionList.Region) + assert num_regions_left < 3 + assert mvar.VarStore.VarRegionList.RegionCount == num_regions_left + assert mvar.VarStore.VarData[0].VarRegionCount == num_regions_left @pytest.mark.parametrize( "location, expected", From 3699f5b08c8db78646cd4faec66482e90f7770cc Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 26 Mar 2019 19:02:51 +0000 Subject: [PATCH 038/127] call VarData.calculateNumShorts after scaling or dropping deltas --- Lib/fontTools/varLib/instancer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 26c8704f6..59bb84b7e 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -249,6 +249,7 @@ def instantiateItemVariationStore(varStore, fvarAxes, location): ri for ri in vardata.VarRegionIndex if ri not in regionsToBeRemoved ] vardata.VarRegionCount = len(vardata.VarRegionIndex) + vardata.calculateNumShorts() newVarDatas.append(vardata) varStore.VarData = newVarDatas From 5f083bdf2e210326099c1d0dd2fa3d46860fcd64 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 28 Mar 2019 17:41:58 +0000 Subject: [PATCH 039/127] 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 040/127] 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 041/127] 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 From a571eee8d6d7a8639a2f102fc1e1bf39aa54c935 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 2 Apr 2019 10:45:36 +0100 Subject: [PATCH 042/127] glyf: setCoordinates must not modify input coord parameter make a copy instead --- Lib/fontTools/ttLib/tables/_g_l_y_f.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/_g_l_y_f.py b/Lib/fontTools/ttLib/tables/_g_l_y_f.py index 03384b0db..68bdeca52 100644 --- a/Lib/fontTools/ttLib/tables/_g_l_y_f.py +++ b/Lib/fontTools/ttLib/tables/_g_l_y_f.py @@ -379,8 +379,7 @@ class table__g_l_y_f(DefaultTable.DefaultTable): topSideY = coord[-2][1] bottomSideY = coord[-1][1] - for _ in range(4): - del coord[-1] + coord = coord[:-4] if glyph.isComposite(): assert len(coord) == len(glyph.components) @@ -391,7 +390,7 @@ class table__g_l_y_f(DefaultTable.DefaultTable): assert len(coord) == 0 else: assert len(coord) == len(glyph.coordinates) - glyph.coordinates = coord + glyph.coordinates = GlyphCoordinates(coord) glyph.recalcBounds(self) From f220d36df1fea21a7f129b469e405248ac25633e Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 1 Apr 2019 11:03:45 +0100 Subject: [PATCH 043/127] 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. --- Lib/fontTools/ttLib/tables/TupleVariation.py | 115 +++++++++++--- Lib/fontTools/varLib/instancer.py | 129 +++++++++------- Tests/ttLib/tables/TupleVariation_test.py | 149 +++++++++++++++++++ Tests/varLib/instancer_test.py | 26 +++- 4 files changed, 345 insertions(+), 74 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/TupleVariation.py b/Lib/fontTools/ttLib/tables/TupleVariation.py index 5eee56e57..1b8e394fd 100644 --- a/Lib/fontTools/ttLib/tables/TupleVariation.py +++ b/Lib/fontTools/ttLib/tables/TupleVariation.py @@ -54,10 +54,7 @@ class TupleVariation(object): If the result is False, the TupleVariation can be omitted from the font without making any visible difference. """ - for c in self.coordinates: - if c is not None: - return True - return False + return any(c is not None for c in self.coordinates) def toXML(self, writer, axisTags): writer.begintag("tuple") @@ -446,14 +443,23 @@ class TupleVariation(object): size += axisCount * 4 return size - def scaleDeltas(self, scalar): - if scalar == 1.0: - return # no change + def checkDeltaType(self): # check if deltas are (x, y) as in gvar, or single values as in cvar firstDelta = next((c for c in self.coordinates if c is not None), None) if firstDelta is None: - return # nothing to scale + return # empty or has no impact if type(firstDelta) is tuple and len(firstDelta) == 2: + return "gvar" + elif type(firstDelta) in (int, float): + return "cvar" + else: + raise TypeError("invalid type of delta: %s" % type(firstDelta)) + + def scaleDeltas(self, scalar): + if scalar == 1.0: + return # no change + deltaType = self.checkDeltaType() + if deltaType == "gvar": if scalar == 0: self.coordinates = [(0, 0)] * len(self.coordinates) else: @@ -461,7 +467,7 @@ class TupleVariation(object): (d[0] * scalar, d[1] * scalar) if d is not None else None for d in self.coordinates ] - elif type(firstDelta) in (int, float): + else: if scalar == 0: self.coordinates = [0] * len(self.coordinates) else: @@ -469,25 +475,96 @@ class TupleVariation(object): d * scalar if d is not None else None for d in self.coordinates ] - else: - raise TypeError("invalid type of delta: %s" % type(firstDelta)) def roundDeltas(self): - # check if deltas are (x, y) as in gvar, or single values as in cvar - firstDelta = next((c for c in self.coordinates if c is not None), None) - if firstDelta is None: - return # nothing to round - if type(firstDelta) is tuple and len(firstDelta) == 2: + deltaType = self.checkDeltaType() + if deltaType == "gvar": self.coordinates = [ (otRound(d[0]), otRound(d[1])) if d is not None else None for d in self.coordinates ] - elif type(firstDelta) in (int, float): + else: self.coordinates = [ otRound(d) if d is not None else None for d in self.coordinates ] - else: - raise TypeError("invalid type of delta: %s" % type(firstDelta)) + + def calcInferredDeltas(self, origCoords, endPts): + from fontTools.varLib.iup import iup_delta + + if self.checkDeltaType() == "cvar": + raise TypeError( + "Only 'gvar' TupleVariation can have inferred deltas" + ) + if None in self.coordinates: + if len(self.coordinates) != len(origCoords): + raise ValueError( + "Expected len(origCoords) == %d; found %d" + % (len(self.coordinates), len(origCoords)) + ) + self.coordinates = iup_delta(self.coordinates, origCoords, endPts) + + def optimize(self, origCoords, endPts, tolerance=0.5, isComposite=False): + from fontTools.varLib.iup import iup_delta_optimize + + if None in self.coordinates: + return # already optimized + + deltaOpt = iup_delta_optimize( + self.coordinates, origCoords, endPts, tolerance=tolerance + ) + if None in deltaOpt: + if isComposite and all(d is None for d in deltaOpt): + # Fix for macOS composites + # https://github.com/fonttools/fonttools/issues/1381 + deltaOpt = [(0, 0)] + [None] * (len(deltaOpt) - 1) + # Use "optimized" version only if smaller... + varOpt = TupleVariation(self.axes, deltaOpt) + + # Shouldn't matter that this is different from fvar...? + axisTags = sorted(self.axes.keys()) + tupleData, auxData, _ = self.compile(axisTags, [], None) + unoptimizedLength = len(tupleData) + len(auxData) + tupleData, auxData, _ = varOpt.compile(axisTags, [], None) + optimizedLength = len(tupleData) + len(auxData) + + if optimizedLength < unoptimizedLength: + self.coordinates = varOpt.coordinates + + def sumDeltas(self, variations, origCoords=None, endPts=None): + # to sum the gvar deltas we need to first interpolate any inferred deltas + if origCoords is not None: + self.calcInferredDeltas(origCoords, endPts) + deltas1 = self.coordinates + axes = self.axes + length = len(deltas1) + deltaRange = range(length) + deltaType = self.checkDeltaType() + for other in variations: + if other.axes != axes: + raise ValueError( + "cannot merge TupleVariations with different axes" + ) + if origCoords is not None: + other.calcInferredDeltas(origCoords, endPts) + deltas2 = other.coordinates + if len(deltas2) != length: + raise ValueError( + "cannot merge TupleVariations with different lengths" + ) + for i, d2 in zip(deltaRange, deltas2): + d1 = deltas1[i] + if d1 is not None and d2 is not None: + if deltaType == "gvar": + deltas1[i] = (d1[0] + d2[0], d1[1] + d2[1]) + else: + deltas1[i] = d1 + d2 + else: + if deltaType == "gvar": + raise ValueError( + "cannot merge gvar deltas with inferred points" + ) + if d1 is None and d2 is not None: + deltas1[i] = d2 def decompileSharedTuples(axisTags, sharedTupleCount, data, offset): diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 3bf46c989..620d86ca3 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -14,10 +14,10 @@ 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.varLib.iup import iup_delta 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 @@ -27,9 +27,8 @@ import re log = logging.getLogger("fontTools.varlib.instancer") -def instantiateTupleVariationStore(variations, location): - newVariations = [] - defaultDeltas = [] +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. @@ -40,49 +39,70 @@ def instantiateTupleVariationStore(variations, location): if scalar == 0.0: # no influence, drop the TupleVariation continue - elif scalar != 1.0: - var.scaleDeltas(scalar) - if not var.axes: - # if no axis is left in the TupleVariation, also drop it; its deltas - # will be folded into the neutral - defaultDeltas.append(var.coordinates) + + 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: - # keep the TupleVariation, and round the scaled deltas to integers - if scalar != 1.0: - var.roundDeltas() + 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 + return defaultDeltas or [] -def setGvarGlyphDeltas(varfont, glyphname, deltasets): +def instantiateGvarGlyph(varfont, glyphname, location, optimize=True): glyf = varfont["glyf"] - coordinates = glyf.getCoordinates(glyphname, varfont) - origCoords = None + coordinates, ctrl = glyf.getCoordinatesAndControls(glyphname, varfont) + endPts = ctrl.endPts - for deltas in deltasets: - hasUntouchedPoints = None in deltas - if hasUntouchedPoints: - if origCoords is None: - origCoords, g = glyf.getCoordinatesAndControls(glyphname, varfont) - deltas = iup_delta(deltas, origCoords, g.endPts) - coordinates += GlyphCoordinates(deltas) - - glyf.setCoordinates(glyphname, coordinates, varfont) - - -def instantiateGvarGlyph(varfont, glyphname, location): gvar = varfont["gvar"] + tupleVarStore = gvar.variations[glyphname] + + defaultDeltas = instantiateTupleVariationStore( + tupleVarStore, location, coordinates, endPts + ) - defaultDeltas = instantiateTupleVariationStore(gvar.variations[glyphname], location) if defaultDeltas: - setGvarGlyphDeltas(varfont, glyphname, 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 gvar.variations[glyphname]: + 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): +def instantiateGvar(varfont, location, optimize=True): log.info("Instantiating glyf/gvar tables") gvar = varfont["gvar"] @@ -101,31 +121,28 @@ def instantiateGvar(varfont, location): ), ) for glyphname in glyphnames: - instantiateGvarGlyph(varfont, glyphname, location) + instantiateGvarGlyph(varfont, glyphname, location, optimize=optimize) if not gvar.variations: del varfont["gvar"] -def setCvarDeltas(cvt, deltasets): - # copy cvt values internally represented as array.array("h") to a list, - # accumulating deltas (that may be float since we scaled them) and only - # do the rounding to integer once at the end to reduce rounding errors - values = list(cvt) - for deltas in deltasets: - for i, delta in enumerate(deltas): - if delta is not None: - values[i] += delta - for i, v in enumerate(values): - cvt[i] = otRound(v) +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"] - cvt = varfont["cvt "] + defaultDeltas = instantiateTupleVariationStore(cvar.variations, location) - setCvarDeltas(cvt, defaultDeltas) + + if defaultDeltas: + setCvarDeltas(varfont["cvt "], defaultDeltas) + if not cvar.variations: del varfont["cvar"] @@ -370,7 +387,7 @@ def sanityCheckVariableTables(varfont): raise ValueError("Can't have gvar without glyf") -def instantiateVariableFont(varfont, axis_limits, inplace=False): +def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): sanityCheckVariableTables(varfont) if not inplace: @@ -384,7 +401,7 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False): raise NotImplementedError("Axes range limits are not supported yet") if "gvar" in varfont: - instantiateGvar(varfont, axis_limits) + instantiateGvar(varfont, axis_limits, optimize=optimize) if "cvar" in varfont: instantiateCvar(varfont, axis_limits) @@ -451,6 +468,12 @@ def parseArgs(args): 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." @@ -473,17 +496,19 @@ def parseArgs(args): 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) + return (infile, outfile, axis_limits, options) def main(args=None): - infile, outfile, axis_limits = parseArgs(args) + 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) + instantiateVariableFont( + varfont, axis_limits, inplace=True, optimize=options.optimize + ) log.info("Saving partial variable font %s", outfile) varfont.save(outfile) diff --git a/Tests/ttLib/tables/TupleVariation_test.py b/Tests/ttLib/tables/TupleVariation_test.py index aab4cba0b..5fa8f2b05 100644 --- a/Tests/ttLib/tables/TupleVariation_test.py +++ b/Tests/ttLib/tables/TupleVariation_test.py @@ -681,6 +681,155 @@ class TupleVariationTest(unittest.TestCase): content = writer.file.getvalue().decode("utf-8") return [line.strip() for line in content.splitlines()][1:] + def test_checkDeltaType(self): + empty = TupleVariation({}, []) + self.assertIsNone(empty.checkDeltaType()) + + empty = TupleVariation({}, [None]) + self.assertIsNone(empty.checkDeltaType()) + + gvarTuple = TupleVariation({}, [None, (0, 0)]) + self.assertEqual(gvarTuple.checkDeltaType(), "gvar") + + cvarTuple = TupleVariation({}, [None, 0]) + self.assertEqual(cvarTuple.checkDeltaType(), "cvar") + + cvarTuple.coordinates[1] *= 1.0 + self.assertEqual(cvarTuple.checkDeltaType(), "cvar") + + with self.assertRaises(TypeError): + TupleVariation({}, [None, "a"]).checkDeltaType() + + def test_scaleDeltas_cvar(self): + var = TupleVariation({}, [100, None]) + + var.scaleDeltas(1.0) + self.assertEqual(var.coordinates, [100, None]) + + var.scaleDeltas(0.5) + self.assertEqual(var.coordinates, [50.0, None]) + + var.scaleDeltas(0.0) + self.assertEqual(var.coordinates, [0, 0]) + + def test_scaleDeltas_gvar(self): + var = TupleVariation({}, [(100, 200), None]) + + var.scaleDeltas(1.0) + self.assertEqual(var.coordinates, [(100, 200), None]) + + var.scaleDeltas(0.5) + self.assertEqual(var.coordinates, [(50.0, 100.0), None]) + + var.scaleDeltas(0.0) + self.assertEqual(var.coordinates, [(0, 0), (0, 0)]) + + def test_roundDeltas_cvar(self): + var = TupleVariation({}, [55.5, None, 99.9]) + var.roundDeltas() + self.assertEqual(var.coordinates, [56, None, 100]) + + def test_roundDeltas_gvar(self): + var = TupleVariation({}, [(55.5, 100.0), None, (99.9, 100.0)]) + var.roundDeltas() + self.assertEqual(var.coordinates, [(56, 100), None, (100, 100)]) + + def test_calcInferredDeltas(self): + var = TupleVariation({}, [(0, 0), None, None, None]) + coords = [(1, 1), (1, 1), (1, 1), (1, 1)] + + var.calcInferredDeltas(coords, []) + + self.assertEqual( + var.coordinates, + [(0, 0), (0, 0), (0, 0), (0, 0)] + ) + + def test_calcInferredDeltas_invalid(self): + # cvar tuples can't have inferred deltas + with self.assertRaises(TypeError): + TupleVariation({}, [0]).calcInferredDeltas([], []) + + # origCoords must have same length as self.coordinates + with self.assertRaises(ValueError): + TupleVariation({}, [(0, 0), None]).calcInferredDeltas([], []) + + # at least 4 phantom points required + with self.assertRaises(AssertionError): + TupleVariation({}, [(0, 0), None]).calcInferredDeltas([(0, 0), (0, 0)], []) + + with self.assertRaises(AssertionError): + TupleVariation({}, [(0, 0)] + [None]*5).calcInferredDeltas( + [(0, 0)]*6, + [1, 0] # endPts not in increasing order + ) + + def test_optimize(self): + var = TupleVariation({"wght": (0.0, 1.0, 1.0)}, [(0, 0)]*5) + + var.optimize([(0, 0)]*5, [0]) + + self.assertEqual(var.coordinates, [None, None, None, None, None]) + + def test_optimize_isComposite(self): + # when a composite glyph's deltas are all (0, 0), we still want + # to write out an entry in gvar, else macOS doesn't apply any + # variations to the composite glyph (even if its individual components + # do vary). + # https://github.com/fonttools/fonttools/issues/1381 + var = TupleVariation({"wght": (0.0, 1.0, 1.0)}, [(0, 0)]*5) + var.optimize([(0, 0)]*5, [0], isComposite=True) + self.assertEqual(var.coordinates, [(0, 0)]*5) + + # it takes more than 128 (0, 0) deltas before the optimized tuple with + # (None) inferred deltas (except for the first) becomes smaller than + # the un-optimized one that has all deltas explicitly set to (0, 0). + var = TupleVariation({"wght": (0.0, 1.0, 1.0)}, [(0, 0)]*129) + var.optimize([(0, 0)]*129, list(range(129-4)), isComposite=True) + self.assertEqual(var.coordinates, [(0, 0)] + [None]*128) + + def test_sumDeltas_gvar(self): + coordinates = [ + (0, 0), (0, 100), (100, 100), (100, 0), + (0, 0), (100, 0), (0, 0), (0, 0), + ] + endPts = [3] + axes = {"wght": (0.0, 1.0, 1.0)} + var1 = TupleVariation( + axes, + [ + (-20, 0), None, None, (20, 0), + None, None, None, None, + ] + ) + var2 = TupleVariation( + axes, + [ + (-10, 0), None, None, (10, 0), + None, (20, 0), None, None, + ] + ) + + var1.sumDeltas([var2], coordinates, endPts) + + self.assertEqual( + var1.coordinates, + [ + (-30, 0), (-30, 0), (30, 0), (30, 0), + (0, 0), (20, 0), (0, 0), (0, 0), + ] + ) + + def test_sumDeltas_cvar(self): + axes = {"wght": (0.0, 1.0, 1.0)} + var1 = TupleVariation(axes, [0, 1, None, None]) + var2 = TupleVariation(axes, [None, 2, None, 3]) + var3 = TupleVariation(axes, [None, None, None, 4]) + + var1.sumDeltas([var2, var3]) + + self.assertEqual(var1.coordinates, [0, 3, None, 7]) + if __name__ == "__main__": import sys diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index a8492e225..37cf0f179 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -27,6 +27,10 @@ def _get_coordinates(varfont, glyphname): class InstantiateGvarTest(object): @pytest.mark.parametrize("glyph_name", ["hyphen"]) + @pytest.mark.parametrize( + "optimize", + [pytest.param(True, id="optimize"), pytest.param(False, id="no-optimize")], + ) @pytest.mark.parametrize( "location, expected", [ @@ -82,8 +86,8 @@ class InstantiateGvarTest(object): ), ], ) - def test_pin_and_drop_axis(self, varfont, glyph_name, location, expected): - instancer.instantiateGvar(varfont, location) + def test_pin_and_drop_axis(self, varfont, glyph_name, location, expected, optimize): + instancer.instantiateGvar(varfont, location, optimize=optimize) assert _get_coordinates(varfont, glyph_name) == expected[glyph_name] @@ -94,6 +98,22 @@ class InstantiateGvarTest(object): for t in tuples ) + def test_full_instance(self, varfont): + instancer.instantiateGvar(varfont, {"wght": 0.0, "wdth": -0.5}) + + assert _get_coordinates(varfont, "hyphen") == [ + (33.5, 229), + (33.5, 308.5), + (264.5, 308.5), + (264.5, 229), + (0, 0), + (298, 0), + (0, 1000), + (0, 0), + ] + + assert "gvar" not in varfont + class InstantiateCvarTest(object): @pytest.mark.parametrize( @@ -110,7 +130,7 @@ class InstantiateCvarTest(object): assert list(varfont["cvt "].values) == expected - # check that the pinned axis has been dropped from gvar + # check that the pinned axis has been dropped from cvar pinned_axes = location.keys() assert not any( axis in t.axes for t in varfont["cvar"].variations for axis in pinned_axes From 82085f5ea87ccc0fa00b2d93f6144dc25a374382 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 4 Apr 2019 14:02:22 +0100 Subject: [PATCH 044/127] instancer: must redo iup_delta_optimize if the default coordinates have changed If we modify the default instance coordinates, then the inferred deltas that are left in gvar are no longer valid, so we need to calculate them using the original default coordinates. They are then re-optimized using the modified default coordinates. Also, the default deltas returned from instantiateTupleVariationStore are now already rounded to integer. --- Lib/fontTools/varLib/instancer.py | 44 ++++++++++++++++++------------- Tests/varLib/instancer_test.py | 16 +++++------ 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 620d86ca3..c34e78dcf 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -50,28 +50,34 @@ def instantiateTupleVariationStore(variations, location, origCoords=None, endPts else: varGroups[axes] = [var] - defaultDeltas = None - newVariations = [] - for axes, varGroup in varGroups.items(): - var = varGroup.pop(0) + # TupleVariations in which all axes have been pinned are dropped from gvar/cvar, + # their deltas summed up, rounded and subsequently added to the default instance + emptyAxes = () + if emptyAxes in varGroups: + defaultVars = varGroups.pop(emptyAxes) + var = defaultVars.pop(0) + var.sumDeltas(defaultVars, origCoords, endPts) + var.roundDeltas() + defaultDeltas = var.coordinates + else: + defaultDeltas = [] - # merge TupleVariations having the same (or none) axes + # merge remaining TupleVariations having the same axes + newVariations = [] + for varGroup in varGroups.values(): + var = varGroup.pop(0) 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) - + elif origCoords is not None and defaultDeltas: + # if the default instance coordinates will be modified, all the inferred + # deltas that still remains need to be calculated using the original + # coordinates (can later be re-optimized using the modified ones) + var.calcInferredDeltas(origCoords, endPts) + var.roundDeltas() + newVariations.append(var) variations[:] = newVariations - return defaultDeltas or [] + + return defaultDeltas def instantiateGvarGlyph(varfont, glyphname, location, optimize=True): @@ -130,7 +136,7 @@ def instantiateGvar(varfont, location, optimize=True): def setCvarDeltas(cvt, deltas): for i, delta in enumerate(deltas): if delta is not None: - cvt[i] += otRound(delta) + cvt[i] += delta def instantiateCvar(varfont, location): diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 37cf0f179..258a3e214 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -54,10 +54,10 @@ class InstantiateGvarTest(object): {"wdth": -0.5}, { "hyphen": [ - (33.5, 229), - (33.5, 308.5), - (264.5, 308.5), - (264.5, 229), + (34, 229), + (34, 309), + (265, 309), + (265, 229), (0, 0), (298, 0), (0, 1000), @@ -102,10 +102,10 @@ class InstantiateGvarTest(object): instancer.instantiateGvar(varfont, {"wght": 0.0, "wdth": -0.5}) assert _get_coordinates(varfont, "hyphen") == [ - (33.5, 229), - (33.5, 308.5), - (264.5, 308.5), - (264.5, 229), + (34, 229), + (34, 309), + (265, 309), + (265, 229), (0, 0), (298, 0), (0, 1000), From dc99925bee76442d29920a579656e2139fb87f93 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 4 Apr 2019 17:21:01 +0100 Subject: [PATCH 045/127] instancer: always calculate inferred deltas upfront to simplify code and instead of sumDeltas method, use in-place add operator. --- Lib/fontTools/ttLib/tables/TupleVariation.py | 53 ++++++++------------ Lib/fontTools/varLib/instancer.py | 46 ++++++----------- Tests/ttLib/tables/TupleVariation_test.py | 27 ++++------ 3 files changed, 48 insertions(+), 78 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/TupleVariation.py b/Lib/fontTools/ttLib/tables/TupleVariation.py index 1b8e394fd..aaeb4ca21 100644 --- a/Lib/fontTools/ttLib/tables/TupleVariation.py +++ b/Lib/fontTools/ttLib/tables/TupleVariation.py @@ -530,41 +530,32 @@ class TupleVariation(object): if optimizedLength < unoptimizedLength: self.coordinates = varOpt.coordinates - def sumDeltas(self, variations, origCoords=None, endPts=None): - # to sum the gvar deltas we need to first interpolate any inferred deltas - if origCoords is not None: - self.calcInferredDeltas(origCoords, endPts) + def __iadd__(self, other): + if not isinstance(other, TupleVariation): + return NotImplemented deltas1 = self.coordinates - axes = self.axes length = len(deltas1) - deltaRange = range(length) deltaType = self.checkDeltaType() - for other in variations: - if other.axes != axes: - raise ValueError( - "cannot merge TupleVariations with different axes" - ) - if origCoords is not None: - other.calcInferredDeltas(origCoords, endPts) - deltas2 = other.coordinates - if len(deltas2) != length: - raise ValueError( - "cannot merge TupleVariations with different lengths" - ) - for i, d2 in zip(deltaRange, deltas2): - d1 = deltas1[i] - if d1 is not None and d2 is not None: - if deltaType == "gvar": - deltas1[i] = (d1[0] + d2[0], d1[1] + d2[1]) - else: - deltas1[i] = d1 + d2 + deltas2 = other.coordinates + if len(deltas2) != length: + raise ValueError( + "cannot sum TupleVariation deltas with different lengths" + ) + for i, d2 in zip(range(length), deltas2): + d1 = deltas1[i] + if d1 is not None and d2 is not None: + if deltaType == "gvar": + deltas1[i] = (d1[0] + d2[0], d1[1] + d2[1]) else: - if deltaType == "gvar": - raise ValueError( - "cannot merge gvar deltas with inferred points" - ) - if d1 is None and d2 is not None: - deltas1[i] = d2 + deltas1[i] = d1 + d2 + else: + if deltaType == "gvar": + raise ValueError( + "cannot sum gvar deltas with inferred points" + ) + if d1 is None and d2 is not None: + deltas1[i] = d2 + return self def decompileSharedTuples(axisTags, sharedTupleCount, data, offset): diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index c34e78dcf..ac12285b5 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -28,7 +28,7 @@ log = logging.getLogger("fontTools.varlib.instancer") def instantiateTupleVariationStore(variations, location, origCoords=None, endPts=None): - varGroups = collections.OrderedDict() + newVariations = 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. @@ -40,44 +40,28 @@ def instantiateTupleVariationStore(variations, location, origCoords=None, endPts # no influence, drop the TupleVariation continue + if origCoords is not None: + var.calcInferredDeltas(origCoords, endPts) + var.scaleDeltas(scalar) - # group TupleVariations by overlapping "tents" (can be empty if all the axes - # were instanced) + # merge TupleVariations with overlapping "tents" axes = tuple(var.axes.items()) - if axes in varGroups: - varGroups[axes].append(var) + if axes in newVariations: + newVariations[axes] += var else: - varGroups[axes] = [var] + newVariations[axes] = var - # TupleVariations in which all axes have been pinned are dropped from gvar/cvar, - # their deltas summed up, rounded and subsequently added to the default instance - emptyAxes = () - if emptyAxes in varGroups: - defaultVars = varGroups.pop(emptyAxes) - var = defaultVars.pop(0) - var.sumDeltas(defaultVars, origCoords, endPts) + for var in newVariations.values(): var.roundDeltas() - defaultDeltas = var.coordinates - else: - defaultDeltas = [] - # merge remaining TupleVariations having the same axes - newVariations = [] - for varGroup in varGroups.values(): - var = varGroup.pop(0) - if varGroup: - var.sumDeltas(varGroup, origCoords, endPts) - elif origCoords is not None and defaultDeltas: - # if the default instance coordinates will be modified, all the inferred - # deltas that still remains need to be calculated using the original - # coordinates (can later be re-optimized using the modified ones) - var.calcInferredDeltas(origCoords, endPts) - var.roundDeltas() - newVariations.append(var) - variations[:] = newVariations + # drop TupleVariation if all axes have been pinned (var.axes.items() is empty); + # its deltas will be added to the default instance's coordinates + defaultVar = newVariations.pop(tuple(), None) - return defaultDeltas + variations[:] = list(newVariations.values()) + + return defaultVar.coordinates if defaultVar is not None else [] def instantiateGvarGlyph(varfont, glyphname, location, optimize=True): diff --git a/Tests/ttLib/tables/TupleVariation_test.py b/Tests/ttLib/tables/TupleVariation_test.py index 5fa8f2b05..6910cbf0a 100644 --- a/Tests/ttLib/tables/TupleVariation_test.py +++ b/Tests/ttLib/tables/TupleVariation_test.py @@ -788,29 +788,23 @@ class TupleVariationTest(unittest.TestCase): var.optimize([(0, 0)]*129, list(range(129-4)), isComposite=True) self.assertEqual(var.coordinates, [(0, 0)] + [None]*128) - def test_sumDeltas_gvar(self): - coordinates = [ - (0, 0), (0, 100), (100, 100), (100, 0), - (0, 0), (100, 0), (0, 0), (0, 0), - ] - endPts = [3] - axes = {"wght": (0.0, 1.0, 1.0)} + def test_sum_deltas_gvar(self): var1 = TupleVariation( - axes, + {}, [ - (-20, 0), None, None, (20, 0), - None, None, None, None, + (-20, 0), (-20, 0), (20, 0), (20, 0), + (0, 0), (0, 0), (0, 0), (0, 0), ] ) var2 = TupleVariation( - axes, + {}, [ - (-10, 0), None, None, (10, 0), - None, (20, 0), None, None, + (-10, 0), (-10, 0), (10, 0), (10, 0), + (0, 0), (20, 0), (0, 0), (0, 0), ] ) - var1.sumDeltas([var2], coordinates, endPts) + var1 += var2 self.assertEqual( var1.coordinates, @@ -820,13 +814,14 @@ class TupleVariationTest(unittest.TestCase): ] ) - def test_sumDeltas_cvar(self): + def test_sum_deltas_cvar(self): axes = {"wght": (0.0, 1.0, 1.0)} var1 = TupleVariation(axes, [0, 1, None, None]) var2 = TupleVariation(axes, [None, 2, None, 3]) var3 = TupleVariation(axes, [None, None, None, 4]) - var1.sumDeltas([var2, var3]) + var1 += var2 + var1 += var3 self.assertEqual(var1.coordinates, [0, 3, None, 7]) From 125bd5186a189d2356f78613bdd1c55e3ab2b470 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 12 Apr 2019 16:43:40 +0100 Subject: [PATCH 046/127] TupleVariation: rename {check,get}DeltaType; refactor __iadd__ --- Lib/fontTools/ttLib/tables/TupleVariation.py | 36 ++++++++------- Lib/fontTools/varLib/instancer.py | 1 + Tests/ttLib/tables/TupleVariation_test.py | 46 +++++++++++++++----- 3 files changed, 57 insertions(+), 26 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/TupleVariation.py b/Lib/fontTools/ttLib/tables/TupleVariation.py index aaeb4ca21..f77cf1780 100644 --- a/Lib/fontTools/ttLib/tables/TupleVariation.py +++ b/Lib/fontTools/ttLib/tables/TupleVariation.py @@ -30,6 +30,7 @@ log = logging.getLogger(__name__) class TupleVariation(object): + def __init__(self, axes, coordinates): self.axes = axes.copy() self.coordinates = coordinates[:] @@ -443,8 +444,10 @@ class TupleVariation(object): size += axisCount * 4 return size - def checkDeltaType(self): - # check if deltas are (x, y) as in gvar, or single values as in cvar + def getDeltaType(self): + """ Check if deltas are (x, y) as in gvar, or single values as in cvar. + Returns a string ("gvar" or "cvar"), or None if empty. + """ firstDelta = next((c for c in self.coordinates if c is not None), None) if firstDelta is None: return # empty or has no impact @@ -458,7 +461,7 @@ class TupleVariation(object): def scaleDeltas(self, scalar): if scalar == 1.0: return # no change - deltaType = self.checkDeltaType() + deltaType = self.getDeltaType() if deltaType == "gvar": if scalar == 0: self.coordinates = [(0, 0)] * len(self.coordinates) @@ -477,7 +480,7 @@ class TupleVariation(object): ] def roundDeltas(self): - deltaType = self.checkDeltaType() + deltaType = self.getDeltaType() if deltaType == "gvar": self.coordinates = [ (otRound(d[0]), otRound(d[1])) if d is not None else None @@ -491,7 +494,7 @@ class TupleVariation(object): def calcInferredDeltas(self, origCoords, endPts): from fontTools.varLib.iup import iup_delta - if self.checkDeltaType() == "cvar": + if self.getDeltaType() == "cvar": raise TypeError( "Only 'gvar' TupleVariation can have inferred deltas" ) @@ -535,26 +538,29 @@ class TupleVariation(object): return NotImplemented deltas1 = self.coordinates length = len(deltas1) - deltaType = self.checkDeltaType() + deltaType = self.getDeltaType() deltas2 = other.coordinates if len(deltas2) != length: raise ValueError( "cannot sum TupleVariation deltas with different lengths" ) - for i, d2 in zip(range(length), deltas2): - d1 = deltas1[i] - if d1 is not None and d2 is not None: - if deltaType == "gvar": + if deltaType == "gvar": + for i, d2 in zip(range(length), deltas2): + d1 = deltas1[i] + try: deltas1[i] = (d1[0] + d2[0], d1[1] + d2[1]) - else: - deltas1[i] = d1 + d2 - else: - if deltaType == "gvar": + except TypeError: raise ValueError( "cannot sum gvar deltas with inferred points" ) - if d1 is None and d2 is not None: + else: + for i, d2 in zip(range(length), deltas2): + d1 = deltas1[i] + if d1 is not None and d2 is not None: + deltas1[i] = d1 + d2 + elif d1 is None and d2 is not None: deltas1[i] = d2 + # elif d2 is None do nothing return self diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index ac12285b5..344b37767 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -40,6 +40,7 @@ def instantiateTupleVariationStore(variations, location, origCoords=None, endPts # no influence, drop the TupleVariation continue + # compute inferred deltas only for gvar ('origCoords' is None for cvar) if origCoords is not None: var.calcInferredDeltas(origCoords, endPts) diff --git a/Tests/ttLib/tables/TupleVariation_test.py b/Tests/ttLib/tables/TupleVariation_test.py index 6910cbf0a..5a29f18f8 100644 --- a/Tests/ttLib/tables/TupleVariation_test.py +++ b/Tests/ttLib/tables/TupleVariation_test.py @@ -69,6 +69,13 @@ SKIA_GVAR_I_DATA = deHexStr( class TupleVariationTest(unittest.TestCase): + def __init__(self, methodName): + unittest.TestCase.__init__(self, methodName) + # Python 3 renamed assertRaisesRegexp to assertRaisesRegex, + # and fires deprecation warnings if a program uses the old name. + if not hasattr(self, "assertRaisesRegex"): + self.assertRaisesRegex = self.assertRaisesRegexp + def test_equal(self): var1 = TupleVariation({"wght":(0.0, 1.0, 1.0)}, [(0,0), (9,8), (7,6)]) var2 = TupleVariation({"wght":(0.0, 1.0, 1.0)}, [(0,0), (9,8), (7,6)]) @@ -681,24 +688,24 @@ class TupleVariationTest(unittest.TestCase): content = writer.file.getvalue().decode("utf-8") return [line.strip() for line in content.splitlines()][1:] - def test_checkDeltaType(self): + def test_getDeltaType(self): empty = TupleVariation({}, []) - self.assertIsNone(empty.checkDeltaType()) + self.assertIsNone(empty.getDeltaType()) empty = TupleVariation({}, [None]) - self.assertIsNone(empty.checkDeltaType()) + self.assertIsNone(empty.getDeltaType()) gvarTuple = TupleVariation({}, [None, (0, 0)]) - self.assertEqual(gvarTuple.checkDeltaType(), "gvar") + self.assertEqual(gvarTuple.getDeltaType(), "gvar") cvarTuple = TupleVariation({}, [None, 0]) - self.assertEqual(cvarTuple.checkDeltaType(), "cvar") + self.assertEqual(cvarTuple.getDeltaType(), "cvar") cvarTuple.coordinates[1] *= 1.0 - self.assertEqual(cvarTuple.checkDeltaType(), "cvar") + self.assertEqual(cvarTuple.getDeltaType(), "cvar") with self.assertRaises(TypeError): - TupleVariation({}, [None, "a"]).checkDeltaType() + TupleVariation({}, [None, "a"]).getDeltaType() def test_scaleDeltas_cvar(self): var = TupleVariation({}, [100, None]) @@ -706,8 +713,9 @@ class TupleVariationTest(unittest.TestCase): var.scaleDeltas(1.0) self.assertEqual(var.coordinates, [100, None]) - var.scaleDeltas(0.5) - self.assertEqual(var.coordinates, [50.0, None]) + var.scaleDeltas(0.333) + self.assertAlmostEqual(var.coordinates[0], 33.3) + self.assertIsNone(var.coordinates[1]) var.scaleDeltas(0.0) self.assertEqual(var.coordinates, [0, 0]) @@ -718,8 +726,10 @@ class TupleVariationTest(unittest.TestCase): var.scaleDeltas(1.0) self.assertEqual(var.coordinates, [(100, 200), None]) - var.scaleDeltas(0.5) - self.assertEqual(var.coordinates, [(50.0, 100.0), None]) + var.scaleDeltas(0.333) + self.assertAlmostEqual(var.coordinates[0][0], 33.3) + self.assertAlmostEqual(var.coordinates[0][1], 66.6) + self.assertIsNone(var.coordinates[1]) var.scaleDeltas(0.0) self.assertEqual(var.coordinates, [(0, 0), (0, 0)]) @@ -814,6 +824,20 @@ class TupleVariationTest(unittest.TestCase): ] ) + def test_sum_deltas_gvar_invalid_length(self): + var1 = TupleVariation({}, [(1, 2)]) + var2 = TupleVariation({}, [(1, 2), (3, 4)]) + + with self.assertRaisesRegex(ValueError, "deltas with different lengths"): + var1 += var2 + + def test_sum_deltas_gvar_with_inferred_points(self): + var1 = TupleVariation({}, [(1, 2), None]) + var2 = TupleVariation({}, [(2, 3), None]) + + with self.assertRaisesRegex(ValueError, "deltas with inferred points"): + var1 += var2 + def test_sum_deltas_cvar(self): axes = {"wght": (0.0, 1.0, 1.0)} var1 = TupleVariation(axes, [0, 1, None, None]) From d9b7b79bca70237e48d1c1b0435edc7ad802f645 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 15 Apr 2019 18:39:49 +0100 Subject: [PATCH 047/127] instancer_test: test gvar full instancing with optimize=True --- Tests/varLib/instancer_test.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 258a3e214..a41ecdaa5 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -19,6 +19,11 @@ def varfont(): return f +@pytest.fixture(params=[True, False], ids=["optimize", "no-optimize"]) +def optimize(request): + return request.param + + def _get_coordinates(varfont, glyphname): # converts GlyphCoordinates to a list of (x, y) tuples, so that pytest's # assert will give us a nicer diff @@ -27,10 +32,6 @@ def _get_coordinates(varfont, glyphname): class InstantiateGvarTest(object): @pytest.mark.parametrize("glyph_name", ["hyphen"]) - @pytest.mark.parametrize( - "optimize", - [pytest.param(True, id="optimize"), pytest.param(False, id="no-optimize")], - ) @pytest.mark.parametrize( "location, expected", [ @@ -98,8 +99,10 @@ class InstantiateGvarTest(object): for t in tuples ) - def test_full_instance(self, varfont): - instancer.instantiateGvar(varfont, {"wght": 0.0, "wdth": -0.5}) + def test_full_instance(self, varfont, optimize): + instancer.instantiateGvar( + varfont, {"wght": 0.0, "wdth": -0.5}, optimize=optimize + ) assert _get_coordinates(varfont, "hyphen") == [ (34, 229), From 4a7ab3fee258773be2a295990b90225c8294f3ca Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 15 Apr 2019 18:45:46 +0100 Subject: [PATCH 048/127] instancer: use VarStore.optimize() and remap MVAR records' VarIdx in test font, add additional VarData subtable in MVAR.VarStore and check it gets merged after optimizing. --- Lib/fontTools/varLib/instancer.py | 28 ++++++--- Tests/varLib/data/PartialInstancerTest-VF.ttx | 11 ++++ Tests/varLib/instancer_test.py | 62 ++++++++++++------- 3 files changed, 68 insertions(+), 33 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 344b37767..ad4edd263 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -163,12 +163,17 @@ def setMvarDeltas(varfont, deltaArray): def instantiateMvar(varfont, location): log.info("Instantiating MVAR table") - varStore = varfont["MVAR"].table.VarStore + mvar = varfont["MVAR"].table fvarAxes = varfont["fvar"].axes - defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, location) + defaultDeltas, varIndexMapping = instantiateItemVariationStore( + mvar.VarStore, fvarAxes, location + ) setMvarDeltas(varfont, defaultDeltas) - if not varStore.VarRegionList.Region: + if varIndexMapping: + for rec in mvar.ValueRecord: + rec.VarIdx = varIndexMapping[rec.VarIdx] + else: # Delete table if no more regions left. del varfont["MVAR"] @@ -222,10 +227,12 @@ def _getVarDataDeltasForRegions(varData, regionIndices, rounded=False): 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 + # empty VarData subtable if we remove all the regions referenced by it + varData.Item = [[] for _ in range(varData.ItemCount)] + varData.VarRegionIndex = [] + varData.VarRegionCount = varData.NumShorts = 0 continue # only retain delta-set columns that correspond to the given regions @@ -237,10 +244,7 @@ def _subsetVarStoreRegions(varStore, regionIndices): # 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() @@ -281,7 +285,13 @@ def instantiateItemVariationStore(varStore, fvarAxes, location): } _subsetVarStoreRegions(varStore, newRegionIndices) - return defaultDeltaArray + if varStore.VarRegionList.Region: + # optimize VarStore, and get a map from old to new VarIdx after optimization + varIndexMapping = varStore.optimize() + else: + varIndexMapping = None # VarStore is empty + + return defaultDeltaArray, varIndexMapping def instantiateFeatureVariations(varfont, location): diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/data/PartialInstancerTest-VF.ttx index 8e728fc0d..800c7255d 100644 --- a/Tests/varLib/data/PartialInstancerTest-VF.ttx +++ b/Tests/varLib/data/PartialInstancerTest-VF.ttx @@ -644,6 +644,13 @@ + + + + + + + @@ -657,6 +664,10 @@ + + + + diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index a41ecdaa5..649cc1af9 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -152,33 +152,48 @@ class InstantiateMvarTest(object): "location, expected", [ pytest.param( - {"wght": 1.0}, {"strs": 100, "undo": -200, "unds": 150}, id="wght=1.0" + {"wght": 1.0}, + {"strs": 100, "undo": -200, "unds": 150, "xhgt": 530}, + id="wght=1.0", ), pytest.param( - {"wght": 0.5}, {"strs": 75, "undo": -150, "unds": 100}, id="wght=0.5" + {"wght": 0.5}, + {"strs": 75, "undo": -150, "unds": 100, "xhgt": 515}, + id="wght=0.5", ), pytest.param( - {"wght": 0.0}, {"strs": 50, "undo": -100, "unds": 50}, id="wght=0.0" + {"wght": 0.0}, + {"strs": 50, "undo": -100, "unds": 50, "xhgt": 500}, + id="wght=0.0", ), pytest.param( - {"wdth": -1.0}, {"strs": 20, "undo": -100, "unds": 50}, id="wdth=-1.0" + {"wdth": -1.0}, + {"strs": 20, "undo": -100, "unds": 50, "xhgt": 500}, + id="wdth=-1.0", ), pytest.param( - {"wdth": -0.5}, {"strs": 35, "undo": -100, "unds": 50}, id="wdth=-0.5" + {"wdth": -0.5}, + {"strs": 35, "undo": -100, "unds": 50, "xhgt": 500}, + id="wdth=-0.5", ), pytest.param( - {"wdth": 0.0}, {"strs": 50, "undo": -100, "unds": 50}, id="wdth=0.0" + {"wdth": 0.0}, + {"strs": 50, "undo": -100, "unds": 50, "xhgt": 500}, + id="wdth=0.0", ), ], ) def test_pin_and_drop_axis(self, varfont, location, expected): mvar = varfont["MVAR"].table - # initially we have a single VarData with deltas associated with 3 regions: - # 1 with only wght, 1 with only wdth, and 1 with both wght and wdth. - assert len(mvar.VarStore.VarData) == 1 + # initially we have two VarData: the first contains deltas associated with 3 + # regions: 1 with only wght, 1 with only wdth, and 1 with both wght and wdth + assert len(mvar.VarStore.VarData) == 2 assert mvar.VarStore.VarRegionList.RegionCount == 3 assert mvar.VarStore.VarData[0].VarRegionCount == 3 assert all(len(item) == 3 for item in mvar.VarStore.VarData[0].Item) + # The second VarData has deltas associated only with 1 region (wght only). + assert mvar.VarStore.VarData[1].VarRegionCount == 1 + assert all(len(item) == 1 for item in mvar.VarStore.VarData[1].Item) instancer.instantiateMvar(varfont, location) @@ -202,6 +217,8 @@ class InstantiateMvarTest(object): assert num_regions_left < 3 assert mvar.VarStore.VarRegionList.RegionCount == num_regions_left assert mvar.VarStore.VarData[0].VarRegionCount == num_regions_left + # VarData subtables have been merged + assert len(mvar.VarStore.VarData) == 1 @pytest.mark.parametrize( "location, expected", @@ -328,11 +345,15 @@ class InstantiateItemVariationStoreTest(object): 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.VarDataCount == len(varStore.VarData) == 2 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 + assert varStore.VarData[1].VarRegionCount == 0 + assert varStore.VarData[1].VarRegionIndex == [] + assert varStore.VarData[1].Item == [[], []] + assert varStore.VarData[1].NumShorts == 0 @pytest.fixture def fvarAxes(self): @@ -372,42 +393,35 @@ class InstantiateItemVariationStoreTest(object): ) @pytest.mark.parametrize( - "location, expected_deltas, num_regions, num_vardatas", + "location, expected_deltas, num_regions", [ - ({"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}, [[[0, 0, 0], [0, 0, 0]], [[], []]], 1), + ({"wght": 0.25}, [[[0, 50, 0], [0, 50, 0]], [[], []]], 2), + ({"wdth": 0}, [[[], []], [[0], [0]]], 3), + ({"wdth": -0.75}, [[[], []], [[75], [75]]], 6), ( {"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 + self, varStore, fvarAxes, location, expected_deltas, num_regions ): - defaultDeltas = instancer.instantiateItemVariationStore( + 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 From 8aa57fef812b07af1ac72a7377157d702dd7ce79 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 16 Apr 2019 18:14:05 +0100 Subject: [PATCH 049/127] instancer: convert item to tuple varstore to reuse same partial istancing code --- Lib/fontTools/varLib/instancer.py | 204 ++++++++++---------- Lib/fontTools/varLib/varStore.py | 7 +- Tests/varLib/instancer_test.py | 302 +++++++++++++++++------------- 3 files changed, 282 insertions(+), 231 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index ad4edd263..714d99636 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -12,10 +12,12 @@ 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.misc.fixedTools import floatToFixedToFloat from fontTools.varLib.models import supportScalar, normalizeValue, piecewiseLinearMap from fontTools.ttLib import TTFont +from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates +from fontTools.varLib import builder from fontTools.varLib.mvar import MVAR_ENTRIES import collections from copy import deepcopy @@ -150,13 +152,12 @@ def setMvarDeltas(varfont, deltaArray): tableTag, itemName = MVAR_ENTRIES[mvarTag] varDataIndex = rec.VarIdx >> 16 itemIndex = rec.VarIdx & 0xFFFF - deltaRow = deltaArray[varDataIndex][itemIndex] - delta = sum(deltaRow) + delta = deltaArray[varDataIndex][itemIndex] if delta != 0: setattr( varfont[tableTag], itemName, - getattr(varfont[tableTag], itemName) + otRound(delta), + getattr(varfont[tableTag], itemName) + delta, ) @@ -178,116 +179,111 @@ def instantiateMvar(varfont, location): 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 +class _TupleVarStoreAdapter(object): + def __init__(self, regions, axisOrder, tupleVarData, itemCounts): + self.regions = regions + self.axisOrder = axisOrder + self.tupleVarData = tupleVarData + self.itemCounts = itemCounts - -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 - for varData in varStore.VarData: - if regionIndices.isdisjoint(varData.VarRegionIndex): - # empty VarData subtable if we remove all the regions referenced by it - varData.Item = [[] for _ in range(varData.ItemCount)] - varData.VarRegionIndex = [] - varData.VarRegionCount = varData.NumShorts = 0 - 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 + @classmethod + def fromItemVarStore(cls, itemVarStore, fvarAxes): + axisOrder = [axis.axisTag for axis in fvarAxes] + regions = [ + region.get_support(fvarAxes) for region in itemVarStore.VarRegionList.Region ] - varData.VarRegionCount = len(varData.VarRegionIndex) + tupleVarData = [] + itemCounts = [] + for varData in itemVarStore.VarData: + variations = [] + varDataRegions = (regions[i] for i in varData.VarRegionIndex) + for axes, coordinates in zip(varDataRegions, zip(*varData.Item)): + variations.append(TupleVariation(axes, list(coordinates))) + tupleVarData.append(variations) + itemCounts.append(varData.ItemCount) + return cls(regions, axisOrder, tupleVarData, itemCounts) - # recalculate NumShorts, reordering columns as necessary - varData.optimize() + def dropAxes(self, axes): + prunedRegions = ( + frozenset( + (axisTag, support) + for axisTag, support in region.items() + if axisTag not in axes + ) + for region in self.regions + ) + # dedup regions while keeping original order + uniqueRegions = collections.OrderedDict.fromkeys(prunedRegions) + self.regions = [dict(items) for items in uniqueRegions if items] + # TODO(anthrotype) uncomment this once we support subsetting fvar axes + # self.axisOrder = [ + # axisTag for axisTag in self.axisOrder if axisTag not in axes + # ] - # remove unused regions from VarRegionList - varStore.prune_regions() + def instantiate(self, location): + defaultDeltaArray = [] + for variations, itemCount in zip(self.tupleVarData, self.itemCounts): + defaultDeltas = instantiateTupleVariationStore(variations, location) + if not defaultDeltas: + defaultDeltas = [0] * itemCount + defaultDeltaArray.append(defaultDeltas) + + # remove pinned axes from all the regions + self.dropAxes(location.keys()) + + return defaultDeltaArray + + def asItemVarStore(self): + regionOrder = [frozenset(axes.items()) for axes in self.regions] + varDatas = [] + for variations, itemCount in zip(self.tupleVarData, self.itemCounts): + if variations: + assert len(variations[0].coordinates) == itemCount + varRegionIndices = [ + regionOrder.index(frozenset(var.axes.items())) for var in variations + ] + varDataItems = list(zip(*(var.coordinates for var in variations))) + varDatas.append( + builder.buildVarData(varRegionIndices, varDataItems, optimize=False) + ) + else: + varDatas.append( + builder.buildVarData([], [[] for _ in range(itemCount)]) + ) + regionList = builder.buildVarRegionList(self.regions, self.axisOrder) + itemVarStore = builder.buildVarStore(regionList, varDatas) + return itemVarStore -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) +def instantiateItemVariationStore(itemVarStore, fvarAxes, location): + """ Compute deltas at partial location, and update varStore in-place. - # 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 - ] + Remove regions in which all axes were instanced, and scale the deltas of + the remaining regions where only some of the axes were instanced. - # 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) + Args: + varStore: An otTables.VarStore object (Item Variation Store) + fvarAxes: list of fvar's Axis objects + location: Dict[str, float] mapping axis tags to normalized axis coordinates. + May not specify coordinates for all the fvar axes. - if varStore.VarRegionList.Region: + Returns: + defaultDeltaArray: the deltas to be added to the default instance (list of list + of integers, indexed by outer/inner VarIdx) + varIndexMapping: a mapping from old to new VarIdx after optimization (None if + varStore was fully instanced thus left empty). + """ + tupleVarStore = _TupleVarStoreAdapter.fromItemVarStore(itemVarStore, fvarAxes) + defaultDeltaArray = tupleVarStore.instantiate(location) + newItemVarStore = tupleVarStore.asItemVarStore() + + itemVarStore.VarRegionList = newItemVarStore.VarRegionList + assert itemVarStore.VarDataCount == newItemVarStore.VarDataCount + itemVarStore.VarData = newItemVarStore.VarData + + if itemVarStore.VarRegionList.Region: # optimize VarStore, and get a map from old to new VarIdx after optimization - varIndexMapping = varStore.optimize() + varIndexMapping = itemVarStore.optimize() else: varIndexMapping = None # VarStore is empty diff --git a/Lib/fontTools/varLib/varStore.py b/Lib/fontTools/varLib/varStore.py index 66d0c95a6..dbccfe6bc 100644 --- a/Lib/fontTools/varLib/varStore.py +++ b/Lib/fontTools/varLib/varStore.py @@ -133,8 +133,11 @@ def VarData_addItem(self, deltas): ot.VarData.addItem = VarData_addItem def VarRegion_get_support(self, fvar_axes): - return {fvar_axes[i].axisTag: (reg.StartCoord,reg.PeakCoord,reg.EndCoord) - for i,reg in enumerate(self.VarRegionAxis)} + return { + fvar_axes[i].axisTag: (reg.StartCoord,reg.PeakCoord,reg.EndCoord) + for i, reg in enumerate(self.VarRegionAxis) + if reg.PeakCoord != 0 + } ot.VarRegion.get_support = VarRegion_get_support diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 649cc1af9..c633da3c2 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -2,6 +2,7 @@ from __future__ import print_function, division, absolute_import from fontTools.misc.py23 import * from fontTools import ttLib from fontTools.ttLib.tables import _f_v_a_r +from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools.varLib import instancer from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib import builder @@ -24,6 +25,21 @@ def optimize(request): return request.param +@pytest.fixture +def fvarAxes(): + 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] + + def _get_coordinates(varfont, glyphname): # converts GlyphCoordinates to a list of (x, y) tuples, so that pytest's # assert will give us a nicer diff @@ -256,118 +272,17 @@ class InstantiateMvarTest(object): class InstantiateItemVariationStoreTest(object): - def test_getVarRegionAxes(self): + def test_VarRegion_get_support(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 len(region.VarRegionAxis) == 3 + assert region.VarRegionAxis[2].PeakCoord == 0 - assert { - axisTag: (axis.StartCoord, axis.PeakCoord, axis.EndCoord) - for axisTag, axis in result.items() - } == regionAxes + fvarAxes = [SimpleNamespace(axisTag=axisTag) for axisTag in axisOrder] - @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], - ] - - 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) == 2 - 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 - assert varStore.VarData[1].VarRegionCount == 0 - assert varStore.VarData[1].VarRegionIndex == [] - assert varStore.VarData[1].Item == [[], []] - assert varStore.VarData[1].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] + assert region.get_support(fvarAxes) == regionAxes @pytest.fixture def varStore(self): @@ -395,25 +310,13 @@ class InstantiateItemVariationStoreTest(object): @pytest.mark.parametrize( "location, expected_deltas, num_regions", [ - ({"wght": 0}, [[[0, 0, 0], [0, 0, 0]], [[], []]], 1), - ({"wght": 0.25}, [[[0, 50, 0], [0, 50, 0]], [[], []]], 2), - ({"wdth": 0}, [[[], []], [[0], [0]]], 3), - ({"wdth": -0.75}, [[[], []], [[75], [75]]], 6), - ( - {"wght": 0, "wdth": 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, - ), - ( - {"wght": 0, "wdth": -0.75}, - [[[0, 0, 0], [0, 0, 0]], [[75, 0, 0, 0], [75, 0, 0, 0]]], - 0, - ), + ({"wght": 0}, [[0, 0], [0, 0]], 1), + ({"wght": 0.25}, [[50, 50], [0, 0]], 1), + ({"wdth": 0}, [[0, 0], [0, 0]], 3), + ({"wdth": -0.75}, [[0, 0], [75, 75]], 3), + ({"wght": 0, "wdth": 0}, [[0, 0], [0, 0]], 0), + ({"wght": 0.25, "wdth": 0}, [[50, 50], [0, 0]], 0), + ({"wght": 0, "wdth": -0.75}, [[0, 0], [75, 75]], 0), ], ) def test_instantiate_default_deltas( @@ -425,3 +328,152 @@ class InstantiateItemVariationStoreTest(object): assert defaultDeltas == expected_deltas assert varStore.VarRegionList.RegionCount == num_regions + + +class TupleVarStoreAdapterTest(object): + def test_instantiate(self): + regions = [ + {"wght": (-1.0, -1.0, 0)}, + {"wght": (0.0, 1.0, 1.0)}, + {"wdth": (-1.0, -1.0, 0)}, + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, + {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, + ] + axisOrder = ["wght", "wdth"] + tupleVarData = [ + [ + TupleVariation({"wght": (-1.0, -1.0, 0)}, [10, 70]), + TupleVariation({"wght": (0.0, 1.0, 1.0)}, [30, 90]), + TupleVariation( + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-40, -100] + ), + TupleVariation( + {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-60, -120] + ), + ], + [ + TupleVariation({"wdth": (-1.0, -1.0, 0)}, [5, 45]), + TupleVariation( + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-15, -55] + ), + TupleVariation( + {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-35, -75] + ), + ], + ] + adapter = instancer._TupleVarStoreAdapter( + regions, axisOrder, tupleVarData, itemCounts=[2, 2] + ) + + defaultDeltaArray = adapter.instantiate({"wght": 0.5}) + + assert defaultDeltaArray == [[15, 45], [0, 0]] + assert adapter.regions == [{"wdth": (-1.0, -1.0, 0)}] + assert adapter.tupleVarData == [ + [TupleVariation({"wdth": (-1.0, -1.0, 0)}, [-30, -60])], + [TupleVariation({"wdth": (-1.0, -1.0, 0)}, [-12, 8])], + ] + + def test_dropAxes(self): + regions = [ + {"wght": (-1.0, -1.0, 0)}, + {"wght": (0.0, 1.0, 1.0)}, + {"wdth": (-1.0, -1.0, 0)}, + {"opsz": (0.0, 1.0, 1.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)}, + ] + axisOrder = ["wght", "wdth", "opsz"] + adapter = instancer._TupleVarStoreAdapter(regions, axisOrder, [], itemCounts=[]) + + adapter.dropAxes({"wdth"}) + + assert adapter.regions == [ + {"wght": (-1.0, -1.0, 0)}, + {"wght": (0.0, 1.0, 1.0)}, + {"opsz": (0.0, 1.0, 1.0)}, + {"wght": (0.0, 0.5, 1.0)}, + {"wght": (0.5, 1.0, 1.0)}, + ] + + adapter.dropAxes({"wght", "opsz"}) + + assert adapter.regions == [] + + def test_roundtrip(self, fvarAxes): + regions = [ + {"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)}, + ] + axisOrder = [axis.axisTag for axis in fvarAxes] + + itemVarStore = builder.buildVarStore( + builder.buildVarRegionList(regions, axisOrder), + [ + builder.buildVarData( + [0, 1, 2, 4, 5, 6], + [[10, -20, 30, -40, 50, -60], [70, -80, 90, -100, 110, -120]], + ), + builder.buildVarData( + [3, 4, 5, 6], [[5, -15, 25, -35], [45, -55, 65, -75]] + ), + ], + ) + + adapter = instancer._TupleVarStoreAdapter.fromItemVarStore( + itemVarStore, fvarAxes + ) + + assert adapter.tupleVarData == [ + [ + TupleVariation({"wght": (-1.0, -1.0, 0)}, [10, 70]), + TupleVariation({"wght": (0, 0.5, 1.0)}, [-20, -80]), + TupleVariation({"wght": (0.5, 1.0, 1.0)}, [30, 90]), + TupleVariation( + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-40, -100] + ), + TupleVariation( + {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, [50, 110] + ), + TupleVariation( + {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-60, -120] + ), + ], + [ + TupleVariation({"wdth": (-1.0, -1.0, 0)}, [5, 45]), + TupleVariation( + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-15, -55] + ), + TupleVariation( + {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, [25, 65] + ), + TupleVariation( + {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-35, -75] + ), + ], + ] + assert adapter.itemCounts == [data.ItemCount for data in itemVarStore.VarData] + assert adapter.regions == regions + assert adapter.axisOrder == axisOrder + + itemVarStore2 = adapter.asItemVarStore() + + assert [ + reg.get_support(fvarAxes) + for reg in itemVarStore2.VarRegionList.Region + ] == regions + + assert itemVarStore2.VarDataCount == 2 + assert itemVarStore2.VarData[0].VarRegionIndex == [0, 1, 2, 4, 5, 6] + assert itemVarStore2.VarData[0].Item == [ + [10, -20, 30, -40, 50, -60], + [70, -80, 90, -100, 110, -120], + ] + assert itemVarStore2.VarData[1].VarRegionIndex == [3, 4, 5, 6] + assert itemVarStore2.VarData[1].Item == [[5, -15, 25, -35], [45, -55, 65, -75]] From 4db603be96006a0c42e6403ebc25a38c4960b5dc Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 17 Apr 2019 19:18:55 +0100 Subject: [PATCH 050/127] varLib.merger: have MutatorMerger use pre-computed deltas and optionally keep VarIdx tables --- Lib/fontTools/varLib/merger.py | 31 ++++++++++++++----------------- Lib/fontTools/varLib/mutator.py | 2 +- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/Lib/fontTools/varLib/merger.py b/Lib/fontTools/varLib/merger.py index 688790c5e..faa84c573 100644 --- a/Lib/fontTools/varLib/merger.py +++ b/Lib/fontTools/varLib/merger.py @@ -833,17 +833,10 @@ class MutatorMerger(AligningMerger): the operation can benefit from many operations that the aligning merger does.""" - def __init__(self, font, location): + def __init__(self, font, instancer, deleteVariations=True): Merger.__init__(self, font) - self.location = location - - store = None - if 'GDEF' in font: - gdef = font['GDEF'].table - if gdef.Version >= 0x00010003: - store = gdef.VarStore - - self.instancer = VarStoreInstancer(store, font['fvar'].axes, location) + self.instancer = instancer + self.deleteVariations = deleteVariations @MutatorMerger.merger(ot.CaretValue) def merge(merger, self, lst): @@ -856,14 +849,16 @@ def merge(merger, self, lst): instancer = merger.instancer dev = self.DeviceTable - del self.DeviceTable + if merger.deleteVariations: + del self.DeviceTable if dev: assert dev.DeltaFormat == 0x8000 varidx = (dev.StartSize << 16) + dev.EndSize delta = otRound(instancer[varidx]) - self.Coordinate += delta + self.Coordinate += delta - self.Format = 1 + if merger.deleteVariations: + self.Format = 1 @MutatorMerger.merger(ot.Anchor) def merge(merger, self, lst): @@ -880,7 +875,8 @@ def merge(merger, self, lst): if not hasattr(self, tableName): continue dev = getattr(self, tableName) - delattr(self, tableName) + if merger.deleteVariations: + delattr(self, tableName) if dev is None: continue @@ -891,7 +887,8 @@ def merge(merger, self, lst): attr = v+'Coordinate' setattr(self, attr, getattr(self, attr) + delta) - self.Format = 1 + if merger.deleteVariations: + self.Format = 1 @MutatorMerger.merger(otBase.ValueRecord) def merge(merger, self, lst): @@ -900,7 +897,6 @@ def merge(merger, self, lst): self.__dict__ = lst[0].__dict__.copy() instancer = merger.instancer - # TODO Handle differing valueformats for name, tableName in [('XAdvance','XAdvDevice'), ('YAdvance','YAdvDevice'), ('XPlacement','XPlaDevice'), @@ -909,7 +905,8 @@ def merge(merger, self, lst): if not hasattr(self, tableName): continue dev = getattr(self, tableName) - delattr(self, tableName) + if merger.deleteVariations: + delattr(self, tableName) if dev is None: continue diff --git a/Lib/fontTools/varLib/mutator.py b/Lib/fontTools/varLib/mutator.py index 1524cff64..c2defb234 100644 --- a/Lib/fontTools/varLib/mutator.py +++ b/Lib/fontTools/varLib/mutator.py @@ -284,7 +284,7 @@ def instantiateVariableFont(varfont, location, inplace=False, overlap=True): gdef = varfont['GDEF'].table instancer = VarStoreInstancer(gdef.VarStore, fvar.axes, loc) - merger = MutatorMerger(varfont, loc) + merger = MutatorMerger(varfont, instancer) merger.mergeTables(varfont, [varfont], ['GDEF', 'GPOS']) # Downgrade GDEF. From f7427389497047eee119aabdbefdda50fe89d016 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 17 Apr 2019 19:20:26 +0100 Subject: [PATCH 051/127] instancer: partially instantiate GDEF and GPOS --- Lib/fontTools/varLib/instancer.py | 75 ++++++++++++++++++++++++++++--- Tests/varLib/instancer_test.py | 10 ++++- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 714d99636..1279cae85 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -19,6 +19,7 @@ from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates from fontTools.varLib import builder from fontTools.varLib.mvar import MVAR_ENTRIES +from fontTools.varLib.merger import MutatorMerger import collections from copy import deepcopy import logging @@ -140,7 +141,7 @@ def instantiateCvar(varfont, location): del varfont["cvar"] -def setMvarDeltas(varfont, deltaArray): +def setMvarDeltas(varfont, deltas): log.info("Setting MVAR deltas") mvar = varfont["MVAR"].table @@ -150,9 +151,7 @@ def setMvarDeltas(varfont, deltaArray): if mvarTag not in MVAR_ENTRIES: continue tableTag, itemName = MVAR_ENTRIES[mvarTag] - varDataIndex = rec.VarIdx >> 16 - itemIndex = rec.VarIdx & 0xFFFF - delta = deltaArray[varDataIndex][itemIndex] + delta = deltas[rec.VarIdx] if delta != 0: setattr( varfont[tableTag], @@ -268,8 +267,8 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, location): May not specify coordinates for all the fvar axes. Returns: - defaultDeltaArray: the deltas to be added to the default instance (list of list - of integers, indexed by outer/inner VarIdx) + defaultDeltas: to be added to the default instance, of type dict of ints keyed + by VariationIndex compound values: i.e. (outer << 16) + inner. varIndexMapping: a mapping from old to new VarIdx after optimization (None if varStore was fully instanced thus left empty). """ @@ -287,7 +286,67 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, location): else: varIndexMapping = None # VarStore is empty - return defaultDeltaArray, varIndexMapping + defaultDeltas = { + ((major << 16) + minor): delta + for major, deltas in enumerate(defaultDeltaArray) + for minor, delta in enumerate(deltas) + } + return defaultDeltas, varIndexMapping + + +def instantiateOTL(varfont, location): + # TODO(anthrotype) Support partial instancing of JSTF and BASE tables + + if "GDEF" not in varfont: + return + + if "GPOS" in varfont: + msg = "Instantiating GDEF and GPOS tables" + else: + msg = "Instantiating GDEF table" + log.info(msg) + + gdef = varfont["GDEF"].table + fvarAxes = varfont["fvar"].axes + + defaultDeltas, varIndexMapping = instantiateItemVariationStore( + gdef.VarStore, fvarAxes, location + ) + + # When VF are built, big lookups may overflow and be broken into multiple + # subtables. MutatorMerger (which inherits from AligningMerger) reattaches + # them upon instancing, in case they can now fit a single subtable (if not, + # they will be split again upon compilation). + # This 'merger' also works as a 'visitor' that traverses the OTL tables and + # calls specific methods when instances of a given type are found. + # Specifically, it adds default deltas to GPOS Anchors/ValueRecords and GDEF + # LigatureCarets, and optionally deletes all VariationIndex tables if the + # VarStore is fully instanced. + merger = MutatorMerger( + varfont, defaultDeltas, deleteVariations=(varIndexMapping is None) + ) + merger.mergeTables(varfont, [varfont], ["GDEF", "GPOS"]) + + if varIndexMapping: + gdef.remap_device_varidxes(varIndexMapping) + if "GPOS" in varfont: + varfont["GPOS"].table.remap_device_varidxes(varIndexMapping) + else: + # Downgrade GDEF. + del gdef.VarStore + gdef.Version = 0x00010002 + if gdef.MarkGlyphSetsDef is None: + del gdef.MarkGlyphSetsDef + gdef.Version = 0x00010000 + + if not ( + gdef.LigCaretList + or gdef.MarkAttachClassDef + or gdef.GlyphClassDef + or gdef.AttachList + or (gdef.Version >= 0x00010002 and gdef.MarkGlyphSetsDef) + ): + del varfont["GDEF"] def instantiateFeatureVariations(varfont, location): @@ -406,6 +465,8 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): if "MVAR" in varfont: instantiateMvar(varfont, axis_limits) + instantiateOTL(varfont, axis_limits) + instantiateFeatureVariations(varfont, axis_limits) # TODO: actually process HVAR instead of dropping it diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index c633da3c2..be3d0c1f2 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -326,7 +326,15 @@ class InstantiateItemVariationStoreTest(object): varStore, fvarAxes, location ) - assert defaultDeltas == expected_deltas + defaultDeltaArray = [] + for varidx, delta in sorted(defaultDeltas.items()): + major, minor = varidx >> 16, varidx & 0xFFFF + if major == len(defaultDeltaArray): + defaultDeltaArray.append([]) + assert len(defaultDeltaArray[major]) == minor + defaultDeltaArray[major].append(delta) + + assert defaultDeltaArray == expected_deltas assert varStore.VarRegionList.RegionCount == num_regions From 9ddbabb38aa223476c99e4760fc06c518e521f3f Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 18 Apr 2019 10:50:32 +0100 Subject: [PATCH 052/127] instancer: remove too verbose logging message --- Lib/fontTools/varLib/instancer.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 1279cae85..9e42f9d28 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -142,8 +142,6 @@ def instantiateCvar(varfont, location): def setMvarDeltas(varfont, deltas): - log.info("Setting MVAR deltas") - mvar = varfont["MVAR"].table records = mvar.ValueRecord for rec in records: From dc14a50029d4f83639a292ce6a8a9027d4ce1632 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 18 Apr 2019 13:11:33 +0100 Subject: [PATCH 053/127] minor: autoformat --- Tests/varLib/instancer_test.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index be3d0c1f2..a4c8f0288 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -473,8 +473,7 @@ class TupleVarStoreAdapterTest(object): itemVarStore2 = adapter.asItemVarStore() assert [ - reg.get_support(fvarAxes) - for reg in itemVarStore2.VarRegionList.Region + reg.get_support(fvarAxes) for reg in itemVarStore2.VarRegionList.Region ] == regions assert itemVarStore2.VarDataCount == 2 From 9aa5407f31fb6d8abe7207c7d5b510e4f06fdc89 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 18 Apr 2019 13:17:37 +0100 Subject: [PATCH 054/127] instancer_test: add tests for instatiating GDEF ligature carets --- Tests/varLib/instancer_test.py | 125 +++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index a4c8f0288..fa275716c 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1,11 +1,17 @@ from __future__ import print_function, division, absolute_import from fontTools.misc.py23 import * from fontTools import ttLib +from fontTools import designspaceLib +from fontTools.feaLib.builder import addOpenTypeFeaturesFromString from fontTools.ttLib.tables import _f_v_a_r from fontTools.ttLib.tables.TupleVariation import TupleVariation +from fontTools import varLib from fontTools.varLib import instancer from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib import builder +from fontTools.varLib import models +import collections +from copy import deepcopy import os import pytest @@ -484,3 +490,122 @@ class TupleVarStoreAdapterTest(object): ] assert itemVarStore2.VarData[1].VarRegionIndex == [3, 4, 5, 6] assert itemVarStore2.VarData[1].Item == [[5, -15, 25, -35], [45, -55, 65, -75]] + + +def makeTTFont(glyphOrder, features): + font = ttLib.TTFont() + font.setGlyphOrder(glyphOrder) + addOpenTypeFeaturesFromString(font, features) + font["name"] = ttLib.newTable("name") + return font + + +def _makeDSAxesDict(axes): + dsAxes = collections.OrderedDict() + for axisTag, axisValues in axes: + axis = designspaceLib.AxisDescriptor() + axis.name = axis.tag = axis.labelNames["en"] = axisTag + axis.minimum, axis.default, axis.maximum = axisValues + dsAxes[axis.tag] = axis + return dsAxes + + +def makeVariableFont(masters, baseIndex, axes, masterLocations): + vf = deepcopy(masters[baseIndex]) + dsAxes = _makeDSAxesDict(axes) + fvar = varLib._add_fvar(vf, dsAxes, instances=()) + axisTags = [axis.axisTag for axis in fvar.axes] + normalizedLocs = [models.normalizeLocation(m, dict(axes)) for m in masterLocations] + model = models.VariationModel(normalizedLocs, axisOrder=axisTags) + varLib._merge_OTL(vf, model, masters, axisTags) + return vf + + +@pytest.fixture +def varfontGDEF(): + glyphOrder = [".notdef", "f", "i", "f_i"] + masters = [] + masterLocations = [] + weight = v = 100 + for _ in range(3): + width = 50 + for _ in range(3): + master = makeTTFont( + glyphOrder, + features=( + "feature liga { sub f i by f_i;} liga;" + "table GDEF { LigatureCaretByPos f_i %d; } GDEF;" % v + ), + ) + masters.append(master) + masterLocations.append({"wght": weight, "wdth": width}) + width += 50 + v += 10 + weight += 300 + v += 30 + axes = [("wght", (100, 400, 700)), ("wdth", (50, 100, 150))] + baseIndex = 4 # index of base master (wght=400, wdth=100) + vf = makeVariableFont(masters, baseIndex, axes, masterLocations) + return vf + + +class InstantiateOTLTest(object): + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": -1.0}, 110), # -60 + ({"wght": 0}, 170), + ({"wght": 0.5}, 200), # +30 + ({"wght": 1.0}, 230), # +60 + ({"wdth": -1.0}, 160), # -10 + ({"wdth": -0.3}, 167), # -3 + ({"wdth": 0}, 170), + ({"wdth": 1.0}, 180), # +10 + ], + ) + def test_pin_and_drop_axis_GDEF(self, varfontGDEF, location, expected): + vf = varfontGDEF + assert "GDEF" in vf + + instancer.instantiateOTL(vf, location) + + assert "GDEF" in vf + gdef = vf["GDEF"].table + assert gdef.Version == 0x00010003 + assert gdef.VarStore + assert gdef.LigCaretList + caretValue = gdef.LigCaretList.LigGlyph[0].CaretValue[0] + assert caretValue.Format == 3 + assert hasattr(caretValue, "DeviceTable") + assert caretValue.DeviceTable.DeltaFormat == 0x8000 + assert caretValue.Coordinate == expected + + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": -1.0, "wdth": -1.0}, 100), # -60 - 10 + ({"wght": -1.0, "wdth": 0.0}, 110), # -60 + ({"wght": -1.0, "wdth": 1.0}, 120), # -60 + 10 + ({"wght": 0.0, "wdth": -1.0}, 160), # -10 + ({"wght": 0.0, "wdth": 0.0}, 170), + ({"wght": 0.0, "wdth": 1.0}, 180), # +10 + ({"wght": 1.0, "wdth": -1.0}, 220), # +60 - 10 + ({"wght": 1.0, "wdth": 0.0}, 230), # +60 + ({"wght": 1.0, "wdth": 1.0}, 240), # +60 + 10 + ], + ) + def test_full_instance_GDEF(self, varfontGDEF, location, expected): + vf = varfontGDEF + assert "GDEF" in vf + + instancer.instantiateOTL(vf, location) + + assert "GDEF" in vf + gdef = vf["GDEF"].table + assert gdef.Version == 0x00010000 + assert not hasattr(gdef, "VarStore") + assert gdef.LigCaretList + caretValue = gdef.LigCaretList.LigGlyph[0].CaretValue[0] + assert caretValue.Format == 1 + assert not hasattr(caretValue, "DeviceTable") + assert caretValue.Coordinate == expected From 7209862e89b4589243de071582c42e5dc90b1948 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 18 Apr 2019 17:29:56 +0100 Subject: [PATCH 055/127] varLib: initialize all fields to None in new empty GDEF code elsewhere assumes that all optional fields in OT tables are initialized to None (that is the case when decompiling from a file). This patch makes sure that the new GDEF table build by varLib when creating a new VF is properly initialised. Ideally we wouldn't have to do that manually, but the constructor would take care of that. But otData-generated classes are special... --- Lib/fontTools/varLib/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 334e2623d..4379e666a 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -597,9 +597,15 @@ def _merge_OTL(font, model, master_fonts, axisTags): GDEF = font['GDEF'].table assert GDEF.Version <= 0x00010002 except KeyError: - font['GDEF']= newTable('GDEF') + font['GDEF'] = newTable('GDEF') GDEFTable = font["GDEF"] = newTable('GDEF') GDEF = GDEFTable.table = ot.GDEF() + GDEF.GlyphClassDef = None + GDEF.AttachList = None + GDEF.LigCaretList = None + GDEF.MarkAttachClassDef = None + GDEF.MarkGlyphSetsDef = None + GDEF.Version = 0x00010003 GDEF.VarStore = store From 544f6aae43e2d1dcfd8ad5dcf429fc607d365b8c Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 18 Apr 2019 17:37:08 +0100 Subject: [PATCH 056/127] instancer_test: add test for instancing GPOS with kern and mark features --- Tests/varLib/instancer_test.py | 242 +++++++++++++++++++++++++++++---- 1 file changed, 219 insertions(+), 23 deletions(-) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index fa275716c..07600015c 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -521,32 +521,79 @@ def makeVariableFont(masters, baseIndex, axes, masterLocations): return vf +def makeParametrizedVF(glyphOrder, features, values, increments): + # Create a test VF with given glyphs and parametrized OTL features. + # The VF is built from 9 masters (3 x 3 along wght and wdth), with + # locations hard-coded and base master at wght=400 and wdth=100. + # 'values' is a list of initial values that are interpolated in the + # 'features' string, and incremented for each subsequent master by the + # given 'increments' (list of 2-tuple) along the two axes. + assert values and len(values) == len(increments) + assert all(len(i) == 2 for i in increments) + masterLocations = [ + {"wght": 100, "wdth": 50}, + {"wght": 100, "wdth": 100}, + {"wght": 100, "wdth": 150}, + {"wght": 400, "wdth": 50}, + {"wght": 400, "wdth": 100}, # base master + {"wght": 400, "wdth": 150}, + {"wght": 700, "wdth": 50}, + {"wght": 700, "wdth": 100}, + {"wght": 700, "wdth": 150}, + ] + n = len(values) + values = list(values) + masters = [] + for _ in range(3): + for _ in range(3): + master = makeTTFont(glyphOrder, features=features % tuple(values)) + masters.append(master) + for i in range(n): + values[i] += increments[i][1] + for i in range(n): + values[i] += increments[i][0] + baseIndex = 4 + axes = [("wght", (100, 400, 700)), ("wdth", (50, 100, 150))] + vf = makeVariableFont(masters, baseIndex, axes, masterLocations) + return vf + + @pytest.fixture def varfontGDEF(): glyphOrder = [".notdef", "f", "i", "f_i"] - masters = [] - masterLocations = [] - weight = v = 100 - for _ in range(3): - width = 50 - for _ in range(3): - master = makeTTFont( - glyphOrder, - features=( - "feature liga { sub f i by f_i;} liga;" - "table GDEF { LigatureCaretByPos f_i %d; } GDEF;" % v - ), - ) - masters.append(master) - masterLocations.append({"wght": weight, "wdth": width}) - width += 50 - v += 10 - weight += 300 - v += 30 - axes = [("wght", (100, 400, 700)), ("wdth", (50, 100, 150))] - baseIndex = 4 # index of base master (wght=400, wdth=100) - vf = makeVariableFont(masters, baseIndex, axes, masterLocations) - return vf + features = ( + "feature liga { sub f i by f_i;} liga;" + "table GDEF { LigatureCaretByPos f_i %d; } GDEF;" + ) + values = [100] + increments = [(+30, +10)] + return makeParametrizedVF(glyphOrder, features, values, increments) + + +@pytest.fixture +def varfontGPOS(): + glyphOrder = [".notdef", "V", "A"] + features = "feature kern { pos V A %d; } kern;" + values = [-80] + increments = [(-10, -5)] + return makeParametrizedVF(glyphOrder, features, values, increments) + + +@pytest.fixture +def varfontGPOS2(): + glyphOrder = [".notdef", "V", "A", "acutecomb"] + features = ( + "markClass [acutecomb] @TOP_MARKS;" + "feature mark {" + " pos base A mark @TOP_MARKS;" + "} mark;" + "feature kern {" + " pos V A %d;" + "} kern;" + ) + values = [200, -80] + increments = [(+30, +10), (-10, -5)] + return makeParametrizedVF(glyphOrder, features, values, increments) class InstantiateOTLTest(object): @@ -609,3 +656,152 @@ class InstantiateOTLTest(object): assert caretValue.Format == 1 assert not hasattr(caretValue, "DeviceTable") assert caretValue.Coordinate == expected + + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": -1.0}, -85), # +25 + ({"wght": 0}, -110), + ({"wght": 1.0}, -135), # -25 + ({"wdth": -1.0}, -105), # +5 + ({"wdth": 0}, -110), + ({"wdth": 1.0}, -115), # -5 + ], + ) + def test_pin_and_drop_axis_GPOS_kern(self, varfontGPOS, location, expected): + vf = varfontGPOS + assert "GDEF" in vf + assert "GPOS" in vf + + instancer.instantiateOTL(vf, location) + + gdef = vf["GDEF"].table + gpos = vf["GPOS"].table + assert gdef.Version == 0x00010003 + assert gdef.VarStore + + assert gpos.LookupList.Lookup[0].LookupType == 2 # PairPos + pairPos = gpos.LookupList.Lookup[0].SubTable[0] + valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 + assert valueRec1.XAdvDevice + assert valueRec1.XAdvDevice.DeltaFormat == 0x8000 + assert valueRec1.XAdvance == expected + + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": -1.0, "wdth": -1.0}, -80), # +25 + 5 + ({"wght": -1.0, "wdth": 0.0}, -85), # +25 + ({"wght": -1.0, "wdth": 1.0}, -90), # +25 - 5 + ({"wght": 0.0, "wdth": -1.0}, -105), # +5 + ({"wght": 0.0, "wdth": 0.0}, -110), + ({"wght": 0.0, "wdth": 1.0}, -115), # -5 + ({"wght": 1.0, "wdth": -1.0}, -130), # -25 + 5 + ({"wght": 1.0, "wdth": 0.0}, -135), # -25 + ({"wght": 1.0, "wdth": 1.0}, -140), # -25 - 5 + ], + ) + def test_full_instance_GPOS_kern(self, varfontGPOS, location, expected): + vf = varfontGPOS + assert "GDEF" in vf + assert "GPOS" in vf + + instancer.instantiateOTL(vf, location) + + assert "GDEF" not in vf + gpos = vf["GPOS"].table + + assert gpos.LookupList.Lookup[0].LookupType == 2 # PairPos + pairPos = gpos.LookupList.Lookup[0].SubTable[0] + valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 + assert not hasattr(valueRec1, "XAdvDevice") + assert valueRec1.XAdvance == expected + + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": -1.0}, (210, -85)), # -60, +25 + ({"wght": 0}, (270, -110)), + ({"wght": 0.5}, (300, -122)), # +30, -12 + ({"wght": 1.0}, (330, -135)), # +60, -25 + ({"wdth": -1.0}, (260, -105)), # -10, +5 + ({"wdth": -0.3}, (267, -108)), # -3, +2 + ({"wdth": 0}, (270, -110)), + ({"wdth": 1.0}, (280, -115)), # +10, -5 + ], + ) + def test_pin_and_drop_axis_GPOS_mark_and_kern( + self, varfontGPOS2, location, expected + ): + vf = varfontGPOS2 + assert "GDEF" in vf + assert "GPOS" in vf + + instancer.instantiateOTL(vf, location) + + v1, v2 = expected + gdef = vf["GDEF"].table + gpos = vf["GPOS"].table + assert gdef.Version == 0x00010003 + assert gdef.VarStore + assert gdef.GlyphClassDef + + assert gpos.LookupList.Lookup[0].LookupType == 4 # MarkBasePos + markBasePos = gpos.LookupList.Lookup[0].SubTable[0] + baseAnchor = markBasePos.BaseArray.BaseRecord[0].BaseAnchor[0] + assert baseAnchor.Format == 3 + assert baseAnchor.XDeviceTable + assert baseAnchor.XDeviceTable.DeltaFormat == 0x8000 + assert not baseAnchor.YDeviceTable + assert baseAnchor.XCoordinate == v1 + assert baseAnchor.YCoordinate == 450 + + assert gpos.LookupList.Lookup[1].LookupType == 2 # PairPos + pairPos = gpos.LookupList.Lookup[1].SubTable[0] + valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 + assert valueRec1.XAdvDevice + assert valueRec1.XAdvDevice.DeltaFormat == 0x8000 + assert valueRec1.XAdvance == v2 + + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": -1.0, "wdth": -1.0}, (200, -80)), # -60 - 10, +25 + 5 + ({"wght": -1.0, "wdth": 0.0}, (210, -85)), # -60, +25 + ({"wght": -1.0, "wdth": 1.0}, (220, -90)), # -60 + 10, +25 - 5 + ({"wght": 0.0, "wdth": -1.0}, (260, -105)), # -10, +5 + ({"wght": 0.0, "wdth": 0.0}, (270, -110)), + ({"wght": 0.0, "wdth": 1.0}, (280, -115)), # +10, -5 + ({"wght": 1.0, "wdth": -1.0}, (320, -130)), # +60 - 10, -25 + 5 + ({"wght": 1.0, "wdth": 0.0}, (330, -135)), # +60, -25 + ({"wght": 1.0, "wdth": 1.0}, (340, -140)), # +60 + 10, -25 - 5 + ], + ) + def test_full_instance_GPOS_mark_and_kern(self, varfontGPOS2, location, expected): + vf = varfontGPOS2 + assert "GDEF" in vf + assert "GPOS" in vf + + instancer.instantiateOTL(vf, location) + + v1, v2 = expected + gdef = vf["GDEF"].table + gpos = vf["GPOS"].table + assert gdef.Version == 0x00010000 + assert not hasattr(gdef, "VarStore") + assert gdef.GlyphClassDef + + assert gpos.LookupList.Lookup[0].LookupType == 4 # MarkBasePos + markBasePos = gpos.LookupList.Lookup[0].SubTable[0] + baseAnchor = markBasePos.BaseArray.BaseRecord[0].BaseAnchor[0] + assert baseAnchor.Format == 1 + assert not hasattr(baseAnchor, "XDeviceTable") + assert not hasattr(baseAnchor, "YDeviceTable") + assert baseAnchor.XCoordinate == v1 + assert baseAnchor.YCoordinate == 450 + + assert gpos.LookupList.Lookup[1].LookupType == 2 # PairPos + pairPos = gpos.LookupList.Lookup[1].SubTable[0] + valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 + assert not hasattr(valueRec1, "XAdvDevice") + assert valueRec1.XAdvance == v2 From aacbc7153d3627f8733c847d0f692e5fba6fc1e7 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sat, 20 Apr 2019 11:05:44 +0100 Subject: [PATCH 057/127] instancer: capitalise 'MVAR' in method names --- Lib/fontTools/varLib/instancer.py | 4 ++-- Tests/varLib/instancer_test.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 9e42f9d28..214fcc535 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -158,7 +158,7 @@ def setMvarDeltas(varfont, deltas): ) -def instantiateMvar(varfont, location): +def instantiateMVAR(varfont, location): log.info("Instantiating MVAR table") mvar = varfont["MVAR"].table @@ -461,7 +461,7 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): instantiateCvar(varfont, axis_limits) if "MVAR" in varfont: - instantiateMvar(varfont, axis_limits) + instantiateMVAR(varfont, axis_limits) instantiateOTL(varfont, axis_limits) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 07600015c..cf5e28456 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -169,7 +169,7 @@ class InstantiateCvarTest(object): assert "cvar" not in varfont -class InstantiateMvarTest(object): +class InstantiateMVARTest(object): @pytest.mark.parametrize( "location, expected", [ @@ -217,7 +217,7 @@ class InstantiateMvarTest(object): assert mvar.VarStore.VarData[1].VarRegionCount == 1 assert all(len(item) == 1 for item in mvar.VarStore.VarData[1].Item) - instancer.instantiateMvar(varfont, location) + instancer.instantiateMVAR(varfont, location) for mvar_tag, expected_value in expected.items(): table_tag, item_name = MVAR_ENTRIES[mvar_tag] @@ -268,7 +268,7 @@ class InstantiateMvarTest(object): ], ) def test_full_instance(self, varfont, location, expected): - instancer.instantiateMvar(varfont, location) + instancer.instantiateMVAR(varfont, location) for mvar_tag, expected_value in expected.items(): table_tag, item_name = MVAR_ENTRIES[mvar_tag] From 349417d57ea2702eda552ff8cf19ecace00f584c Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sat, 20 Apr 2019 11:55:31 +0100 Subject: [PATCH 058/127] varLib: rename {H,V}VAR_FIELDS constants we shall reuse them from varLib.instancer too --- Lib/fontTools/varLib/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 229b31e5f..110dbc5eb 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -367,20 +367,20 @@ def _merge_TTHinting(font, masterModel, master_ttfs, tolerance=0.5): var = TupleVariation(support, delta) cvar.variations.append(var) -MetricsFields = namedtuple('MetricsFields', +_MetricsFields = namedtuple('_MetricsFields', ['tableTag', 'metricsTag', 'sb1', 'sb2', 'advMapping', 'vOrigMapping']) -hvarFields = MetricsFields(tableTag='HVAR', metricsTag='hmtx', sb1='LsbMap', +HVAR_FIELDS = _MetricsFields(tableTag='HVAR', metricsTag='hmtx', sb1='LsbMap', sb2='RsbMap', advMapping='AdvWidthMap', vOrigMapping=None) -vvarFields = MetricsFields(tableTag='VVAR', metricsTag='vmtx', sb1='TsbMap', +VVAR_FIELDS = _MetricsFields(tableTag='VVAR', metricsTag='vmtx', sb1='TsbMap', sb2='BsbMap', advMapping='AdvHeightMap', vOrigMapping='VOrgMap') def _add_HVAR(font, masterModel, master_ttfs, axisTags): - _add_VHVAR(font, masterModel, master_ttfs, axisTags, hvarFields) + _add_VHVAR(font, masterModel, master_ttfs, axisTags, HVAR_FIELDS) def _add_VVAR(font, masterModel, master_ttfs, axisTags): - _add_VHVAR(font, masterModel, master_ttfs, axisTags, vvarFields) + _add_VHVAR(font, masterModel, master_ttfs, axisTags, VVAR_FIELDS) def _add_VHVAR(font, masterModel, master_ttfs, axisTags, tableFields): From 1b5393acddb387fab9158d8460296cc918d0beb8 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sat, 20 Apr 2019 12:25:25 +0100 Subject: [PATCH 059/127] instancer: call optimize() after instantiateItemVariationStore, not inside for HVAR/VVAR without indirect mappings, we can skip calling VarStore.optimize() and keep a direct mapping from GID to VarIdx --- Lib/fontTools/varLib/instancer.py | 33 +++++++++++++------------------ Tests/varLib/instancer_test.py | 2 +- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 214fcc535..dffa95710 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -163,16 +163,15 @@ def instantiateMVAR(varfont, location): mvar = varfont["MVAR"].table fvarAxes = varfont["fvar"].axes - defaultDeltas, varIndexMapping = instantiateItemVariationStore( - mvar.VarStore, fvarAxes, location - ) + varStore = mvar.VarStore + defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, location) setMvarDeltas(varfont, defaultDeltas) - if varIndexMapping: + if varStore.VarRegionList.Region: + varIndexMapping = varStore.optimize() for rec in mvar.ValueRecord: rec.VarIdx = varIndexMapping[rec.VarIdx] else: - # Delete table if no more regions left. del varfont["MVAR"] @@ -258,6 +257,10 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, location): Remove regions in which all axes were instanced, and scale the deltas of the remaining regions where only some of the axes were instanced. + The number of VarData subtables, and the number of items within each, are + not modified, in order to keep the existing VariationIndex valid. + One may call VarStore.optimize() method after this to further optimize those. + Args: varStore: An otTables.VarStore object (Item Variation Store) fvarAxes: list of fvar's Axis objects @@ -267,8 +270,6 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, location): Returns: defaultDeltas: to be added to the default instance, of type dict of ints keyed by VariationIndex compound values: i.e. (outer << 16) + inner. - varIndexMapping: a mapping from old to new VarIdx after optimization (None if - varStore was fully instanced thus left empty). """ tupleVarStore = _TupleVarStoreAdapter.fromItemVarStore(itemVarStore, fvarAxes) defaultDeltaArray = tupleVarStore.instantiate(location) @@ -278,18 +279,12 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, location): assert itemVarStore.VarDataCount == newItemVarStore.VarDataCount itemVarStore.VarData = newItemVarStore.VarData - if itemVarStore.VarRegionList.Region: - # optimize VarStore, and get a map from old to new VarIdx after optimization - varIndexMapping = itemVarStore.optimize() - else: - varIndexMapping = None # VarStore is empty - defaultDeltas = { ((major << 16) + minor): delta for major, deltas in enumerate(defaultDeltaArray) for minor, delta in enumerate(deltas) } - return defaultDeltas, varIndexMapping + return defaultDeltas def instantiateOTL(varfont, location): @@ -305,11 +300,10 @@ def instantiateOTL(varfont, location): log.info(msg) gdef = varfont["GDEF"].table + varStore = gdef.VarStore fvarAxes = varfont["fvar"].axes - defaultDeltas, varIndexMapping = instantiateItemVariationStore( - gdef.VarStore, fvarAxes, location - ) + defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, location) # When VF are built, big lookups may overflow and be broken into multiple # subtables. MutatorMerger (which inherits from AligningMerger) reattaches @@ -321,11 +315,12 @@ def instantiateOTL(varfont, location): # LigatureCarets, and optionally deletes all VariationIndex tables if the # VarStore is fully instanced. merger = MutatorMerger( - varfont, defaultDeltas, deleteVariations=(varIndexMapping is None) + varfont, defaultDeltas, deleteVariations=(not varStore.VarRegionList.Region) ) merger.mergeTables(varfont, [varfont], ["GDEF", "GPOS"]) - if varIndexMapping: + if varStore.VarRegionList.Region: + varIndexMapping = varStore.optimize() gdef.remap_device_varidxes(varIndexMapping) if "GPOS" in varfont: varfont["GPOS"].table.remap_device_varidxes(varIndexMapping) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index cf5e28456..6bfa1c8c6 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -328,7 +328,7 @@ class InstantiateItemVariationStoreTest(object): def test_instantiate_default_deltas( self, varStore, fvarAxes, location, expected_deltas, num_regions ): - defaultDeltas, _ = instancer.instantiateItemVariationStore( + defaultDeltas = instancer.instantiateItemVariationStore( varStore, fvarAxes, location ) From bdef36501fceca604f549f9513d05d8a2613a9fc Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sat, 20 Apr 2019 12:31:43 +0100 Subject: [PATCH 060/127] instancer: raise NotImplementedError with CFF2 table for now --- Lib/fontTools/varLib/instancer.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index dffa95710..7d1bf402d 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -434,6 +434,11 @@ def sanityCheckVariableTables(varfont): if "gvar" in varfont: if "glyf" not in varfont: raise ValueError("Can't have gvar without glyf") + # TODO(anthrotype) Remove once we do support partial instancing CFF2 + if "CFF2" in varfont: + raise NotImplementedError( + "Instancing CFF2 variable fonts is not supported yet" + ) def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): From 2b50b94ed7bc511bf68318d617638c6aaf360bf3 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sat, 20 Apr 2019 12:42:31 +0100 Subject: [PATCH 061/127] instancer: support partial instancing HVAR We don't actually apply deltas to hmtx since these have already been applied from the gvar deltas when we call glyf.setCoordinates method using the glyf phantom points. We simply call instantiateItemVariationStore on HVAR.VarStore to remove regions and scale remaining deltas, but ignore the return value. We only run VarStore.optimize() if the HVAR originally has an AdvWidthMap, if it does not then it uses a direct implicit GID->VariationIndex mapping for advance widths deltas, and we keep the VariationIndex unchanged by not optimizing VarStore. If all axes in fvar are being instanced, then we simply delete HVAR (just like varLib.mutator currently does). VVAR is not supported yet because we do not set the 3rd and 4th phantom points from gvar to the vmtx table yet (this should be done in glyf.setCoordinates). Also, supporting CFF2 would need more work, in that HVAR there is required and we need to apply the deltas to hmtx/vmtx in here. --- Lib/fontTools/varLib/instancer.py | 63 ++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 7d1bf402d..633c4341b 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -17,6 +17,7 @@ from fontTools.varLib.models import supportScalar, normalizeValue, piecewiseLine from fontTools.ttLib import TTFont from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates +from fontTools import varLib from fontTools.varLib import builder from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.merger import MutatorMerger @@ -175,6 +176,54 @@ def instantiateMVAR(varfont, location): del varfont["MVAR"] +def _remapVarIdxMap(table, attrName, varIndexMapping, glyphOrder): + oldMapping = getattr(table, attrName).mapping + newMapping = [varIndexMapping[oldMapping[glyphName]] for glyphName in glyphOrder] + setattr(table, attrName, builder.buildVarIdxMap(newMapping, glyphOrder)) + + +# TODO(anthrotype) Add support for HVAR/VVAR in CFF2 +def _instantiateVHVAR(varfont, location, tableFields): + tableTag = tableFields.tableTag + fvarAxes = varfont["fvar"].axes + # Deltas from gvar table have already been applied to the hmtx/vmtx. For full + # instances (i.e. all axes pinned), we can simply drop HVAR/VVAR and return + if set(location).issuperset(axis.axisTag for axis in fvarAxes): + log.info("Dropping %s table", tableTag) + del varfont[tableTag] + return + + log.info("Instantiating %s table", tableTag) + vhvar = varfont[tableTag].table + varStore = vhvar.VarStore + # since deltas were already applied, the return value here is ignored + instantiateItemVariationStore(varStore, fvarAxes, location) + + if varStore.VarRegionList.Region: + if getattr(vhvar, tableFields.advMapping): + varIndexMapping = varStore.optimize() + glyphOrder = varfont.getGlyphOrder() + _remapVarIdxMap(vhvar, tableFields.advMapping, varIndexMapping, glyphOrder) + if getattr(vhvar, tableFields.sb1): + _remapVarIdxMap(vhvar, tableFields.sb1, varIndexMapping, glyphOrder) + if getattr(vhvar, tableFields.sb2): + _remapVarIdxMap(vhvar, tableFields.sb2, varIndexMapping, glyphOrder) + if tableFields.vOrigMapping and getattr(vhvar, tableFields.vOrigMapping): + _remapVarIdxMap( + vhvar, tableFields.vOrigMapping, varIndexMapping, glyphOrder + ) + else: + del varfont[tableTag] + + +def instantiateHVAR(varfont, location): + return _instantiateVHVAR(varfont, location, varLib.HVAR_FIELDS) + + +def instantiateVVAR(varfont, location): + return _instantiateVHVAR(varfont, location, varLib.VVAR_FIELDS) + + class _TupleVarStoreAdapter(object): def __init__(self, regions, axisOrder, tupleVarData, itemCounts): self.regions = regions @@ -436,9 +485,7 @@ def sanityCheckVariableTables(varfont): raise ValueError("Can't have gvar without glyf") # TODO(anthrotype) Remove once we do support partial instancing CFF2 if "CFF2" in varfont: - raise NotImplementedError( - "Instancing CFF2 variable fonts is not supported yet" - ) + raise NotImplementedError("Instancing CFF2 variable fonts is not supported yet") def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): @@ -463,13 +510,17 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): if "MVAR" in varfont: instantiateMVAR(varfont, axis_limits) + if "HVAR" in varfont: + instantiateHVAR(varfont, axis_limits) + + # TODO(anthrotype) Uncomment this once we apply gvar deltas to vmtx + # if "VVAR" in varfont: + # instantiateVVAR(varfont, axis_limits) + instantiateOTL(varfont, axis_limits) instantiateFeatureVariations(varfont, axis_limits) - # TODO: actually process HVAR instead of dropping it - del varfont["HVAR"] - return varfont From 91089b7a1b0dd15a8f99b4762a72a906dcd8c56b Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sat, 20 Apr 2019 13:05:42 +0100 Subject: [PATCH 062/127] glyf: support setting vmtx advance/tsb from phantom points --- Lib/fontTools/ttLib/tables/_g_l_y_f.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/_g_l_y_f.py b/Lib/fontTools/ttLib/tables/_g_l_y_f.py index 68bdeca52..154d4b799 100644 --- a/Lib/fontTools/ttLib/tables/_g_l_y_f.py +++ b/Lib/fontTools/ttLib/tables/_g_l_y_f.py @@ -362,9 +362,9 @@ class table__g_l_y_f(DefaultTable.DefaultTable): "coord" is an array of GlyphCoordinates which must include the four "phantom points". - Only the horizontal advance and sidebearings in "hmtx" table are updated - from the first two phantom points. The last two phantom points for - vertical typesetting are currently ignored. + Both the horizontal/vertical advances and left/top sidebearings in "hmtx" + and "vmtx" tables (if any) are updated from four phantom points and + the glyph's bounding boxes. """ # TODO: Create new glyph if not already present assert glyphName in self.glyphs @@ -400,9 +400,15 @@ class table__g_l_y_f(DefaultTable.DefaultTable): # https://github.com/fonttools/fonttools/pull/1198 horizontalAdvanceWidth = 0 leftSideBearing = otRound(glyph.xMin - leftSideX) - # TODO Handle vertical metrics? ttFont["hmtx"].metrics[glyphName] = horizontalAdvanceWidth, leftSideBearing + if "vmtx" in ttFont: + verticalAdvanceWidth = otRound(topSideY - bottomSideY) + if verticalAdvanceWidth < 0: # unlikely but do the same as horizontal + verticalAdvanceWidth = 0 + topSideBearing = otRound(topSideY - glyph.yMax) + ttFont["vmtx"].metrics[glyphName] = verticalAdvanceWidth, topSideBearing + _GlyphControls = namedtuple( "_GlyphControls", "numberOfContours endPts flags components" From 1e6f8bc39bde3ba16536354a1e9158ec52755ab0 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sat, 20 Apr 2019 13:06:13 +0100 Subject: [PATCH 063/127] instancer: support partial instancing VVAR table as well for TrueType VF only yet --- Lib/fontTools/varLib/instancer.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 633c4341b..12c98a91f 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -83,8 +83,8 @@ def instantiateGvarGlyph(varfont, glyphname, location, optimize=True): 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) + # this will also set the hmtx/vmtx advance widths and sidebearings from + # the four phantom points and glyph bounding boxes glyf.setCoordinates(glyphname, coordinates, varfont) if not tupleVarStore: @@ -513,9 +513,8 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): if "HVAR" in varfont: instantiateHVAR(varfont, axis_limits) - # TODO(anthrotype) Uncomment this once we apply gvar deltas to vmtx - # if "VVAR" in varfont: - # instantiateVVAR(varfont, axis_limits) + if "VVAR" in varfont: + instantiateVVAR(varfont, axis_limits) instantiateOTL(varfont, axis_limits) From 7b5202cd79c955d6873cb308201b4ea8035fe67e Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sat, 20 Apr 2019 15:16:02 +0100 Subject: [PATCH 064/127] glyf: only recalcBounds once in setCoordinates glyph.recalcBounds is called unconditionally a few lines below within the same setCoordinates method, just after setting the new glyph's coordinates. We don't need to call recalcBounds twice. Only empty glyphs with numberOfContours == 0 may not have xMin set. recalcBounds ensure it's set to 0 for those. --- Lib/fontTools/ttLib/tables/_g_l_y_f.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/_g_l_y_f.py b/Lib/fontTools/ttLib/tables/_g_l_y_f.py index 154d4b799..845d84902 100644 --- a/Lib/fontTools/ttLib/tables/_g_l_y_f.py +++ b/Lib/fontTools/ttLib/tables/_g_l_y_f.py @@ -372,8 +372,6 @@ class table__g_l_y_f(DefaultTable.DefaultTable): # Handle phantom points for (left, right, top, bottom) positions. assert len(coord) >= 4 - if not hasattr(glyph, 'xMin'): - glyph.recalcBounds(self) leftSideX = coord[-4][0] rightSideX = coord[-3][0] topSideY = coord[-2][1] From 002de44c13e6d5afe25a4fc7122882a6e60a91f5 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sat, 20 Apr 2019 15:41:39 +0100 Subject: [PATCH 065/127] instancer_test: add vmtx to PartialInstancer-VF.ttx used in gvar unit tests in instancer_test.py --- Tests/varLib/data/PartialInstancerTest-VF.ttx | 16 +++++++++++----- Tests/varLib/instancer_test.py | 8 ++++---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/data/PartialInstancerTest-VF.ttx index 800c7255d..30d00b96b 100644 --- a/Tests/varLib/data/PartialInstancerTest-VF.ttx +++ b/Tests/varLib/data/PartialInstancerTest-VF.ttx @@ -12,12 +12,12 @@ - + - + @@ -589,7 +589,7 @@ - + @@ -632,7 +632,7 @@ - + @@ -644,7 +644,7 @@ - + @@ -1016,4 +1016,10 @@ + + + + + + diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 6bfa1c8c6..aa5c1177a 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -67,7 +67,7 @@ class InstantiateGvarTest(object): (247, 229), (0, 0), (274, 0), - (0, 1000), + (0, 536), (0, 0), ] }, @@ -83,7 +83,7 @@ class InstantiateGvarTest(object): (265, 229), (0, 0), (298, 0), - (0, 1000), + (0, 536), (0, 0), ] }, @@ -101,7 +101,7 @@ class InstantiateGvarTest(object): (282, 229), (0, 0), (322, 0), - (0, 1000), + (0, 536), (0, 0), ] }, @@ -133,7 +133,7 @@ class InstantiateGvarTest(object): (265, 229), (0, 0), (298, 0), - (0, 1000), + (0, 536), (0, 0), ] From c5ec06d82fc80576aa4f57f6b29e26e44437308c Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sat, 20 Apr 2019 19:02:47 +0100 Subject: [PATCH 066/127] instancer: remove unused regions from VarRegionList if the original VarStore had any regions in VarRegionList that wasn't even referenced in any VarData VarRegionIndex, this makes sure we remove those as well from VarRegionList (and remap the VarRegionIndex accordingly) --- Lib/fontTools/varLib/instancer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 12c98a91f..a470bf018 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -297,6 +297,8 @@ class _TupleVarStoreAdapter(object): ) regionList = builder.buildVarRegionList(self.regions, self.axisOrder) itemVarStore = builder.buildVarStore(regionList, varDatas) + # remove unused regions from VarRegionList + itemVarStore.prune_regions() return itemVarStore From 2a1e6a1fd56d2c175d8ca594a6b8449760e0c855 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sat, 20 Apr 2019 19:24:30 +0100 Subject: [PATCH 067/127] instancer_test: test instancing HVAR table Aldo added AdvWidthMap to PartialInstancer-VF.ttx test font --- Tests/varLib/data/PartialInstancerTest-VF.ttx | 18 +++--- Tests/varLib/instancer_test.py | 56 +++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/data/PartialInstancerTest-VF.ttx index 30d00b96b..712e2c94a 100644 --- a/Tests/varLib/data/PartialInstancerTest-VF.ttx +++ b/Tests/varLib/data/PartialInstancerTest-VF.ttx @@ -1,5 +1,5 @@ - + @@ -12,12 +12,12 @@ - + - + @@ -570,7 +570,7 @@ - + @@ -578,11 +578,15 @@ - - - + + + + + + + diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index aa5c1177a..99e4b6db7 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -277,6 +277,62 @@ class InstantiateMVARTest(object): assert "MVAR" not in varfont +class InstantiateHVARTest(object): + # the 'expectedDeltas' below refer to the VarData item deltas for the "hyphen" + # glyph in the PartialInstancerTest-VF.ttx test font, that are left after + # partial instancing + @pytest.mark.parametrize( + "location, expectedRegions, expectedDeltas", + [ + ({"wght": -1.0}, [{"wdth": (-1.0, -1.0, 0)}], [-59]), + ({"wght": 0}, [{"wdth": (-1.0, -1.0, 0)}], [-48]), + ({"wght": 1.0}, [{"wdth": (-1.0, -1.0, 0)}], [7]), + ( + {"wdth": -1.0}, + [ + {"wght": (-1.0, -1.0, 0.0)}, + {"wght": (0.0, 0.61, 1.0)}, + {"wght": (0.61, 1.0, 1.0)}, + ], + [-11, 31, 51], + ), + ({"wdth": 0}, [{"wght": (0.61, 1.0, 1.0)}], [-4]), + ], + ) + def test_partial_instance( + self, varfont, fvarAxes, location, expectedRegions, expectedDeltas + ): + instancer.instantiateHVAR(varfont, location) + + assert "HVAR" in varfont + hvar = varfont["HVAR"].table + varStore = hvar.VarStore + + regions = varStore.VarRegionList.Region + assert [reg.get_support(fvarAxes) for reg in regions] == expectedRegions + + assert len(varStore.VarData) == 1 + assert varStore.VarData[0].ItemCount == 2 + + assert hvar.AdvWidthMap is not None + advWithMap = hvar.AdvWidthMap.mapping + + assert advWithMap[".notdef"] == advWithMap["space"] + varIdx = advWithMap[".notdef"] + # these glyphs have no metrics variations in the test font + assert varStore.VarData[varIdx >> 16].Item[varIdx & 0xFFFF] == ( + [0] * varStore.VarData[0].VarRegionCount + ) + + varIdx = advWithMap["hyphen"] + assert varStore.VarData[varIdx >> 16].Item[varIdx & 0xFFFF] == expectedDeltas + + def test_full_instance(self, varfont): + instancer.instantiateHVAR(varfont, {"wght": 0, "wdth": 0}) + + assert "HVAR" not in varfont + + class InstantiateItemVariationStoreTest(object): def test_VarRegion_get_support(self): axisOrder = ["wght", "wdth", "opsz"] From 76fb26306db6f8fa1b15a15a0048e65dd75826d0 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 1 May 2019 11:49:01 +0100 Subject: [PATCH 068/127] instancer: add comments in instantiateHVAR as per nyshadhr9's review in https://github.com/fonttools/fonttools/pull/1583 --- Lib/fontTools/varLib/instancer.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index a470bf018..4df669f86 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -200,15 +200,18 @@ def _instantiateVHVAR(varfont, location, tableFields): instantiateItemVariationStore(varStore, fvarAxes, location) if varStore.VarRegionList.Region: + # Only re-optimize VarStore if the HVAR/VVAR already uses indirect AdvWidthMap + # or AdvHeightMap. If a direct, implicit glyphID->VariationIndex mapping is + # used for advances, skip re-optimizing and maintain original VariationIndex. if getattr(vhvar, tableFields.advMapping): varIndexMapping = varStore.optimize() glyphOrder = varfont.getGlyphOrder() _remapVarIdxMap(vhvar, tableFields.advMapping, varIndexMapping, glyphOrder) - if getattr(vhvar, tableFields.sb1): + if getattr(vhvar, tableFields.sb1): # left or top sidebearings _remapVarIdxMap(vhvar, tableFields.sb1, varIndexMapping, glyphOrder) - if getattr(vhvar, tableFields.sb2): + if getattr(vhvar, tableFields.sb2): # right or bottom sidebearings _remapVarIdxMap(vhvar, tableFields.sb2, varIndexMapping, glyphOrder) - if tableFields.vOrigMapping and getattr(vhvar, tableFields.vOrigMapping): + if tableTag == "VVAR" and getattr(vhvar, tableFields.vOrigMapping): _remapVarIdxMap( vhvar, tableFields.vOrigMapping, varIndexMapping, glyphOrder ) From d478ef050fc36ec24937718d63c6401b8c534b1c Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 29 Apr 2019 18:04:21 +0200 Subject: [PATCH 069/127] instancer: partially instantiate avar and fvar for avar, we drop segments of the axes being pinned. for fvar, we drop the pinned axes and all the named instances whose coordinates are different from the pinned location. --- Lib/fontTools/varLib/instancer.py | 75 ++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 4df669f86..f5b6c2dd2 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -263,10 +263,7 @@ class _TupleVarStoreAdapter(object): # dedup regions while keeping original order uniqueRegions = collections.OrderedDict.fromkeys(prunedRegions) self.regions = [dict(items) for items in uniqueRegions if items] - # TODO(anthrotype) uncomment this once we support subsetting fvar axes - # self.axisOrder = [ - # axisTag for axisTag in self.axisOrder if axisTag not in axes - # ] + self.axisOrder = [axisTag for axisTag in self.axisOrder if axisTag not in axes] def instantiate(self, location): defaultDeltaArray = [] @@ -448,6 +445,47 @@ def _instantiateFeatureVariations(table, fvarAxes, location): del table.FeatureVariations +def instantiateAvar(varfont, location): + segments = varfont["avar"].segments + + # drop table if we instantiate all the axes + if set(location).issuperset(segments): + log.info("Dropping avar table") + del varfont["avar"] + return + + log.info("Instantiating avar table") + for axis in location: + if axis in segments: + del segments[axis] + + +def instantiateFvar(varfont, location): + # 'location' dict must contain user-space (non-normalized) coordinates + + fvar = varfont["fvar"] + + # drop table if we instantiate all the axes + if set(location).issuperset(axis.axisTag for axis in fvar.axes): + log.info("Dropping fvar table") + del varfont["fvar"] + return + + log.info("Instantiating fvar table") + + fvar.axes = [axis for axis in fvar.axes if axis.axisTag not in location] + + # only keep NamedInstances whose coordinates == pinned axis location + instances = [] + for instance in fvar.instances: + if any(instance.coordinates[axis] != value for axis, value in location.items()): + continue + for axis in location: + del instance.coordinates[axis] + instances.append(instance) + fvar.instances = instances + + def normalize(value, triple, avar_mapping): value = normalizeValue(value, triple) if avar_mapping: @@ -471,15 +509,17 @@ def normalizeAxisLimits(varfont, axis_limits): avar_segments = {} if "avar" in varfont: avar_segments = varfont["avar"].segments + normalized_limits = {} 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( + normalized_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) + normalized_limits[axis_tag] = normalize(value, triple, avar_mapping) + return normalized_limits def sanityCheckVariableTables(varfont): @@ -498,32 +538,37 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): if not inplace: varfont = deepcopy(varfont) - normalizeAxisLimits(varfont, axis_limits) + normalized_limits = normalizeAxisLimits(varfont, axis_limits) - log.info("Normalized limits: %s", axis_limits) + log.info("Normalized limits: %s", normalized_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) + instantiateGvar(varfont, normalized_limits, optimize=optimize) if "cvar" in varfont: - instantiateCvar(varfont, axis_limits) + instantiateCvar(varfont, normalized_limits) if "MVAR" in varfont: - instantiateMVAR(varfont, axis_limits) + instantiateMVAR(varfont, normalized_limits) if "HVAR" in varfont: - instantiateHVAR(varfont, axis_limits) + instantiateHVAR(varfont, normalized_limits) if "VVAR" in varfont: - instantiateVVAR(varfont, axis_limits) + instantiateVVAR(varfont, normalized_limits) - instantiateOTL(varfont, axis_limits) + instantiateOTL(varfont, normalized_limits) - instantiateFeatureVariations(varfont, axis_limits) + instantiateFeatureVariations(varfont, normalized_limits) + + if "avar" in varfont: + instantiateAvar(varfont, normalized_limits) + + instantiateFvar(varfont, axis_limits) return varfont From cbf1a854ee282bb505b10d30a5882af522fd3775 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 1 May 2019 14:17:45 +0100 Subject: [PATCH 070/127] instancer_test: fix MVAR/HVAR tests now that pinned VarRegionAxis are dropped --- Tests/varLib/instancer_test.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 99e4b6db7..2b7bb7f90 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -223,17 +223,6 @@ class InstantiateMVARTest(object): table_tag, item_name = MVAR_ENTRIES[mvar_tag] assert getattr(varfont[table_tag], item_name) == expected_value - # check that the pinned axis does not influence any of the remaining regions - # in MVAR VarStore - pinned_axes = location.keys() - fvar = varfont["fvar"] - assert all( - peak == 0 - for region in mvar.VarStore.VarRegionList.Region - for axis, (start, peak, end) in region.get_support(fvar.axes).items() - if axis in pinned_axes - ) - # check that regions and accompanying deltas have been dropped num_regions_left = len(mvar.VarStore.VarRegionList.Region) assert num_regions_left < 3 @@ -299,9 +288,7 @@ class InstantiateHVARTest(object): ({"wdth": 0}, [{"wght": (0.61, 1.0, 1.0)}], [-4]), ], ) - def test_partial_instance( - self, varfont, fvarAxes, location, expectedRegions, expectedDeltas - ): + def test_partial_instance(self, varfont, location, expectedRegions, expectedDeltas): instancer.instantiateHVAR(varfont, location) assert "HVAR" in varfont @@ -309,6 +296,7 @@ class InstantiateHVARTest(object): varStore = hvar.VarStore regions = varStore.VarRegionList.Region + fvarAxes = [a for a in varfont["fvar"].axes if a.axisTag not in location] assert [reg.get_support(fvarAxes) for reg in regions] == expectedRegions assert len(varStore.VarData) == 1 From c8d82e809de03f2b8fe49493c99a3f6bb731f77b Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 1 May 2019 14:22:19 +0100 Subject: [PATCH 071/127] instancer_test: add test for instantiateAvar --- Tests/varLib/instancer_test.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 2b7bb7f90..013e2c5e7 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -849,3 +849,16 @@ class InstantiateOTLTest(object): valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 assert not hasattr(valueRec1, "XAdvDevice") assert valueRec1.XAdvance == v2 + + +class InstantiateAvarTest(object): + @pytest.mark.parametrize("location", [{"wght": 0.0}, {"wdth": 0.0}]) + def test_pin_and_drop_axis(self, varfont, location): + instancer.instantiateAvar(varfont, location) + + assert set(varfont["avar"].segments).isdisjoint(location) + + def test_full_instance(self, varfont): + instancer.instantiateAvar(varfont, {"wght": 0.0, "wdth": 0.0}) + + assert "avar" not in varfont From 8eed2a2ec0d13d6f8967c5d58067d5c06908b7bd Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 1 May 2019 15:54:58 +0100 Subject: [PATCH 072/127] instancer_test: add test for instantiateFvar --- Tests/varLib/instancer_test.py | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 013e2c5e7..d53830562 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -862,3 +862,53 @@ class InstantiateAvarTest(object): instancer.instantiateAvar(varfont, {"wght": 0.0, "wdth": 0.0}) assert "avar" not in varfont + + +class InstantiateFvarTest(object): + @pytest.mark.parametrize( + "location, instancesLeft", + [ + ( + {"wght": 400.0}, + ["Regular", "SemiCondensed", "Condensed", "ExtraCondensed"], + ), + ( + {"wght": 100.0}, + ["Thin", "SemiCondensed Thin", "Condensed Thin", "ExtraCondensed Thin"], + ), + ( + {"wdth": 100.0}, + [ + "Thin", + "ExtraLight", + "Light", + "Regular", + "Medium", + "SemiBold", + "Bold", + "ExtraBold", + "Black", + ], + ), + # no named instance at pinned location + ({"wdth": 90.0}, []), + ], + ) + def test_pin_and_drop_axis(self, varfont, location, instancesLeft): + instancer.instantiateFvar(varfont, location) + + fvar = varfont["fvar"] + assert {a.axisTag for a in fvar.axes}.isdisjoint(location) + + for instance in fvar.instances: + assert set(instance.coordinates).isdisjoint(location) + + name = varfont["name"] + assert [ + name.getDebugName(instance.subfamilyNameID) for instance in fvar.instances + ] == instancesLeft + + def test_full_instance(self, varfont): + instancer.instantiateFvar(varfont, {"wght": 0.0, "wdth": 0.0}) + + assert "fvar" not in varfont From b8a33d0c7501bb61bbbe93085ea9a85d40bfce90 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 1 May 2019 18:30:49 +0100 Subject: [PATCH 073/127] instancer: drop STAT when varfont fully instanced varLib.mutator does the same. Ideally we would keep STAT if has any extra (inter-family) DesignAxis or it font was only partially instanced. We can improve on this later as needed. --- Lib/fontTools/varLib/instancer.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index f5b6c2dd2..91ddd006b 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -570,6 +570,15 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): instantiateFvar(varfont, axis_limits) + if "fvar" not in varfont and "STAT" in varfont: + # Drop the entire STAT table when the varfont is fully instanced (or keep it + # as is if only partially instanced). + # TODO(anthrotype) Only drop DesignAxis and corresponding AxisValue records + # for the pinned axes that were removed from fvar. STAT design axes may be a + # superset of fvar axes (e.g. can include axes for an entire family). + log.info("Dropping STAT table") + del varfont["STAT"] + return varfont From aa6c9a111055b35a0fc413f90de286a029fcbd3d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 1 May 2019 19:25:32 +0100 Subject: [PATCH 074/127] instancer: drop STAT DesignAxes and AxisValues for pinned axes --- Lib/fontTools/varLib/instancer.py | 54 +++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 91ddd006b..3a32228a3 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -486,6 +486,48 @@ def instantiateFvar(varfont, location): fvar.instances = instances +def instantiateSTAT(varfont, location): + pinnedAxes = set(location.keys()) + + stat = varfont["STAT"].table + designAxes = stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else () + + pinnedAxisIndices = { + i for i, axis in enumerate(designAxes) if axis.AxisTag in pinnedAxes + } + + if len(pinnedAxisIndices) == stat.DesignAxisCount: + log.info("Dropping STAT table") + del varfont["STAT"] + return + + log.info("Instantiating STAT table") + + if stat.AxisValueArray and stat.AxisValueArray.AxisValue: + newAxisValueTables = [] + for axisValueTable in stat.AxisValueArray.AxisValue: + if axisValueTable.Format in (1, 2, 3): + if axisValueTable.AxisIndex in pinnedAxisIndices: + continue + newAxisValueTables.append(axisValueTable) + elif axisValueTable.Format == 4: + if any( + rec.AxisIndex in pinnedAxisIndices + for rec in axisValueTable.AxisValueRecord + ): + continue + newAxisValueTables.append(axisValueTable) + else: + raise NotImplementedError(axisValueTable.Format) + stat.AxisValueArray.AxisValue = newAxisValueTables + stat.AxisValueCount = len(stat.AxisValueArray.AxisValue) + + stat.DesignAxisRecord.Axis[:] = [ + axis for axis in designAxes if axis.AxisTag not in pinnedAxes + ] + stat.DesignAxisCount = len(stat.DesignAxisRecord.Axis) + + def normalize(value, triple, avar_mapping): value = normalizeValue(value, triple) if avar_mapping: @@ -568,16 +610,10 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): if "avar" in varfont: instantiateAvar(varfont, normalized_limits) - instantiateFvar(varfont, axis_limits) + if "STAT" in varfont: + instantiateSTAT(varfont, axis_limits) - if "fvar" not in varfont and "STAT" in varfont: - # Drop the entire STAT table when the varfont is fully instanced (or keep it - # as is if only partially instanced). - # TODO(anthrotype) Only drop DesignAxis and corresponding AxisValue records - # for the pinned axes that were removed from fvar. STAT design axes may be a - # superset of fvar axes (e.g. can include axes for an entire family). - log.info("Dropping STAT table") - del varfont["STAT"] + instantiateFvar(varfont, axis_limits) return varfont From 3bfff09c8c49d19e27d727b105d486b563a06773 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 3 May 2019 13:29:43 +0100 Subject: [PATCH 075/127] instancer: remap STAT AxisValue.AxisIndex if STAT table contains no DesignAxisRecord, then keep it empty and skip. --- Lib/fontTools/varLib/instancer.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 3a32228a3..9cc68f224 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -490,25 +490,38 @@ def instantiateSTAT(varfont, location): pinnedAxes = set(location.keys()) stat = varfont["STAT"].table - designAxes = stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else () + if not stat.DesignAxisRecord: + return # skip empty STAT table + designAxes = stat.DesignAxisRecord.Axis pinnedAxisIndices = { i for i, axis in enumerate(designAxes) if axis.AxisTag in pinnedAxes } - if len(pinnedAxisIndices) == stat.DesignAxisCount: + if len(pinnedAxisIndices) == len(designAxes): log.info("Dropping STAT table") del varfont["STAT"] return log.info("Instantiating STAT table") + # only keep DesignAxis that were not instanced, a build a mapping from old + # to new axis indices + newDesignAxes = [] + axisIndexMap = {} + for i, axis in enumerate(designAxes): + if i not in pinnedAxisIndices: + axisIndexMap[i] = len(newDesignAxes) + newDesignAxes.append(axis) + if stat.AxisValueArray and stat.AxisValueArray.AxisValue: + # drop all AxisValue tables that reference any of the pinned axes newAxisValueTables = [] for axisValueTable in stat.AxisValueArray.AxisValue: if axisValueTable.Format in (1, 2, 3): if axisValueTable.AxisIndex in pinnedAxisIndices: continue + axisValueTable.AxisIndex = axisIndexMap[axisValueTable.AxisIndex] newAxisValueTables.append(axisValueTable) elif axisValueTable.Format == 4: if any( @@ -516,15 +529,15 @@ def instantiateSTAT(varfont, location): for rec in axisValueTable.AxisValueRecord ): continue + for rec in axisValueTable.AxisValueRecord: + rec.AxisIndex = axisIndexMap[rec.AxisIndex] newAxisValueTables.append(axisValueTable) else: raise NotImplementedError(axisValueTable.Format) stat.AxisValueArray.AxisValue = newAxisValueTables stat.AxisValueCount = len(stat.AxisValueArray.AxisValue) - stat.DesignAxisRecord.Axis[:] = [ - axis for axis in designAxes if axis.AxisTag not in pinnedAxes - ] + stat.DesignAxisRecord.Axis[:] = newDesignAxes stat.DesignAxisCount = len(stat.DesignAxisRecord.Axis) From 89ce41be55325a159ce4abc12c02091794acb617 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 3 May 2019 13:32:06 +0100 Subject: [PATCH 076/127] instancer_test: add test for instantiateSTAT added a dummy STAT table to PartialInstancer-VF.ttx font that has all 4 AxisValue formats. It doesn't have contain AxisValue for each fvar NamedInstance like the spec recommends, but it's ok for the sake of this test --- Tests/varLib/data/PartialInstancerTest-VF.ttx | 68 +++++++++++++++++-- Tests/varLib/instancer_test.py | 54 +++++++++++++++ 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/data/PartialInstancerTest-VF.ttx index 712e2c94a..24de57a09 100644 --- a/Tests/varLib/data/PartialInstancerTest-VF.ttx +++ b/Tests/varLib/data/PartialInstancerTest-VF.ttx @@ -1,5 +1,5 @@ - + @@ -12,12 +12,12 @@ - + - + @@ -300,6 +300,12 @@ ExtraCondensed Black + + Italic + + + Upright + Copyright 2015 Google Inc. All Rights Reserved. @@ -450,6 +456,12 @@ ExtraCondensed Black + + Italic + + + Upright + @@ -675,9 +687,9 @@ - + - + @@ -689,8 +701,52 @@ + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index d53830562..a91eb2572 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -4,6 +4,7 @@ from fontTools import ttLib from fontTools import designspaceLib from fontTools.feaLib.builder import addOpenTypeFeaturesFromString from fontTools.ttLib.tables import _f_v_a_r +from fontTools.ttLib.tables import otTables from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools import varLib from fontTools.varLib import instancer @@ -912,3 +913,56 @@ class InstantiateFvarTest(object): instancer.instantiateFvar(varfont, {"wght": 0.0, "wdth": 0.0}) assert "fvar" not in varfont + + +class InstantiateSTATTest(object): + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": 400}, ["Condensed", "Upright"]), + ({"wdth": 100}, ["Thin", "Regular", "Black", "Upright"]), + ], + ) + def test_pin_and_drop_axis(self, varfont, location, expected): + instancer.instantiateSTAT(varfont, location) + + stat = varfont["STAT"].table + designAxes = {a.AxisTag for a in stat.DesignAxisRecord.Axis} + + assert designAxes == {"wght", "wdth", "ital"}.difference(location) + + name = varfont["name"] + valueNames = [] + for axisValueTable in stat.AxisValueArray.AxisValue: + valueName = name.getDebugName(axisValueTable.ValueNameID) + valueNames.append(valueName) + + assert valueNames == expected + + def test_skip_empty_table(self, varfont): + stat = otTables.STAT() + stat.Version = 0x00010001 + stat.populateDefaults() + assert not stat.DesignAxisRecord + assert not stat.AxisValueArray + varfont["STAT"].table = stat + + instancer.instantiateSTAT(varfont, {"wght": 100}) + + assert not varfont["STAT"].table.DesignAxisRecord + + def test_drop_table(self, varfont): + stat = otTables.STAT() + stat.Version = 0x00010001 + stat.populateDefaults() + stat.DesignAxisRecord = otTables.AxisRecordArray() + axis = otTables.AxisRecord() + axis.AxisTag = "wght" + axis.AxisNameID = 0 + axis.AxisOrdering = 0 + stat.DesignAxisRecord.Axis = [axis] + varfont["STAT"].table = stat + + instancer.instantiateSTAT(varfont, {"wght": 100}) + + assert "STAT" not in varfont From 1041cf90ef5d64b1759a8b73a7768865a999d27f Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 3 May 2019 18:59:29 +0100 Subject: [PATCH 077/127] _g_l_y_f: don't return component flags in getCoordinatesAndControls varLib._GetCoordinates (which this method is copied from) did not return such data either. The problem with also including component flags in the returned controls tuple is that different masters may happen to have different component flags (e.g. if one master has USE_MY_METRICS, another doesn't). --- Lib/fontTools/ttLib/tables/_g_l_y_f.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/_g_l_y_f.py b/Lib/fontTools/ttLib/tables/_g_l_y_f.py index 845d84902..362cfd19e 100644 --- a/Lib/fontTools/ttLib/tables/_g_l_y_f.py +++ b/Lib/fontTools/ttLib/tables/_g_l_y_f.py @@ -296,8 +296,8 @@ class table__g_l_y_f(DefaultTable.DefaultTable): - endPts: list of indices of end points for each contour in simple glyphs, or component indices in composite glyphs (used for IUP optimization). - - flags: array of contour point flags for simple glyphs, or component - flags for composite glyphs. + - flags: array of contour point flags for simple glyphs (None for + composite glyphs). - components: list of base glyph names (str) for each component in composite glyphs (None for simple glyphs). @@ -316,7 +316,7 @@ class table__g_l_y_f(DefaultTable.DefaultTable): controls = _GlyphControls( numberOfContours=glyph.numberOfContours, endPts=list(range(len(glyph.components))), - flags=[c.flags for c in glyph.components], + flags=None, components=[c.glyphName for c in glyph.components], ) else: From 0010a3cd9aa25f84a3a6250dafb119743d32aa40 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 7 May 2019 14:05:16 +0100 Subject: [PATCH 078/127] instancer: return default deltas as floats from instantiateTupleVariationStore Do not round them to integer, but let the caller do the rounding immediately before adding them to the default instance (or just before compiling the binary table as with glyf). This ensures that the glyphs' left sidebearings are calculated in the same way as they were by varLib.mutator. If we round deltas too early, then we may get off-by-one differences. See the glyf table setCoordinates method where left sidebearings are computed. --- Lib/fontTools/varLib/instancer.py | 15 +++++++-------- Tests/varLib/instancer_test.py | 16 ++++++++-------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 9cc68f224..201dce64a 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -12,7 +12,7 @@ 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 +from fontTools.misc.fixedTools import floatToFixedToFloat, otRound from fontTools.varLib.models import supportScalar, normalizeValue, piecewiseLinearMap from fontTools.ttLib import TTFont from fontTools.ttLib.tables.TupleVariation import TupleVariation @@ -57,13 +57,12 @@ def instantiateTupleVariationStore(variations, location, origCoords=None, endPts else: newVariations[axes] = var - for var in newVariations.values(): - var.roundDeltas() - # drop TupleVariation if all axes have been pinned (var.axes.items() is empty); # its deltas will be added to the default instance's coordinates defaultVar = newVariations.pop(tuple(), None) + for var in newVariations.values(): + var.roundDeltas() variations[:] = list(newVariations.values()) return defaultVar.coordinates if defaultVar is not None else [] @@ -125,7 +124,7 @@ def instantiateGvar(varfont, location, optimize=True): def setCvarDeltas(cvt, deltas): for i, delta in enumerate(deltas): if delta is not None: - cvt[i] += delta + cvt[i] += otRound(delta) def instantiateCvar(varfont, location): @@ -155,7 +154,7 @@ def setMvarDeltas(varfont, deltas): setattr( varfont[tableTag], itemName, - getattr(varfont[tableTag], itemName) + delta, + getattr(varfont[tableTag], itemName) + otRound(delta), ) @@ -319,8 +318,8 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, location): May not specify coordinates for all the fvar axes. Returns: - defaultDeltas: to be added to the default instance, of type dict of ints keyed - by VariationIndex compound values: i.e. (outer << 16) + inner. + defaultDeltas: to be added to the default instance, of type dict of floats + keyed by VariationIndex compound values: i.e. (outer << 16) + inner. """ tupleVarStore = _TupleVarStoreAdapter.fromItemVarStore(itemVarStore, fvarAxes) defaultDeltaArray = tupleVarStore.instantiate(location) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index a91eb2572..fbabf4a44 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -78,10 +78,10 @@ class InstantiateGvarTest(object): {"wdth": -0.5}, { "hyphen": [ - (34, 229), - (34, 309), - (265, 309), - (265, 229), + (33.5, 229), + (33.5, 308.5), + (264.5, 308.5), + (264.5, 229), (0, 0), (298, 0), (0, 536), @@ -128,10 +128,10 @@ class InstantiateGvarTest(object): ) assert _get_coordinates(varfont, "hyphen") == [ - (34, 229), - (34, 309), - (265, 309), - (265, 229), + (33.5, 229), + (33.5, 308.5), + (264.5, 308.5), + (264.5, 229), (0, 0), (298, 0), (0, 536), From 5a530880c06bf98f1140714637ada28f9a57f259 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 7 May 2019 17:55:50 +0100 Subject: [PATCH 079/127] instancer: prune unused name records after instancing --- Lib/fontTools/varLib/instancer.py | 52 +++++++++++++++++-- Tests/varLib/data/PartialInstancerTest-VF.ttx | 22 ++++++-- Tests/varLib/instancer_test.py | 24 +++++++++ 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 201dce64a..43113f2f2 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -21,6 +21,7 @@ from fontTools import varLib from fontTools.varLib import builder from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.merger import MutatorMerger +from contextlib import contextmanager import collections from copy import deepcopy import logging @@ -540,6 +541,50 @@ def instantiateSTAT(varfont, location): stat.DesignAxisCount = len(stat.DesignAxisRecord.Axis) +def getVariationNameIDs(varfont): + used = [] + if "fvar" in varfont: + fvar = varfont["fvar"] + for axis in fvar.axes: + used.append(axis.axisNameID) + for instance in fvar.instances: + used.append(instance.subfamilyNameID) + if instance.postscriptNameID != 0xFFFF: + used.append(instance.postscriptNameID) + if "STAT" in varfont: + stat = varfont["STAT"].table + for axis in stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else (): + used.append(axis.AxisNameID) + for value in stat.AxisValueArray.AxisValue if stat.AxisValueArray else (): + used.append(value.ValueNameID) + # nameIDs <= 255 are reserved by OT spec so we don't touch them + return {nameID for nameID in used if nameID > 255} + + +@contextmanager +def pruningUnusedNames(varfont): + origNameIDs = getVariationNameIDs(varfont) + + yield + + log.info("Pruning name table") + exclude = origNameIDs - getVariationNameIDs(varfont) + varfont["name"].names[:] = [ + record for record in varfont["name"].names if record.nameID not in exclude + ] + if "ltag" in varfont: + # Drop the whole 'ltag' table if all the language-dependent Unicode name + # records that reference it have been dropped. + # TODO: Only prune unused ltag tags, renumerating langIDs accordingly. + # Note ltag can also be used by feat or morx tables, so check those too. + if not any( + record + for record in varfont["name"].names + if record.platformID == 0 and record.langID != 0xFFFF + ): + del varfont["ltag"] + + def normalize(value, triple, avar_mapping): value = normalizeValue(value, triple) if avar_mapping: @@ -622,10 +667,11 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): if "avar" in varfont: instantiateAvar(varfont, normalized_limits) - if "STAT" in varfont: - instantiateSTAT(varfont, axis_limits) + with pruningUnusedNames(varfont): + if "STAT" in varfont: + instantiateSTAT(varfont, axis_limits) - instantiateFvar(varfont, axis_limits) + instantiateFvar(varfont, axis_limits) return varfont diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/data/PartialInstancerTest-VF.ttx index 24de57a09..faefba56a 100644 --- a/Tests/varLib/data/PartialInstancerTest-VF.ttx +++ b/Tests/varLib/data/PartialInstancerTest-VF.ttx @@ -12,12 +12,12 @@ - + - + @@ -186,6 +186,9 @@ + + Bräiti + Weight @@ -306,6 +309,9 @@ Upright + + TestVariableFont-XCdBd + Copyright 2015 Google Inc. All Rights Reserved. @@ -462,6 +468,9 @@ Upright + + TestVariableFont-XCdBd + @@ -1008,7 +1017,8 @@ - + + @@ -1076,6 +1086,12 @@ + + + + + + diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index fbabf4a44..4c9a44d28 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -966,3 +966,27 @@ class InstantiateSTATTest(object): instancer.instantiateSTAT(varfont, {"wght": 100}) assert "STAT" not in varfont + + +def test_pruningUnusedNames(varfont): + varNameIDs = instancer.getVariationNameIDs(varfont) + + assert varNameIDs == set(range(256, 296 + 1)) + + fvar = varfont["fvar"] + stat = varfont["STAT"].table + + with instancer.pruningUnusedNames(varfont): + del fvar.axes[0] # Weight (nameID=256) + del fvar.instances[0] # Thin (nameID=258) + del stat.DesignAxisRecord.Axis[0] # Weight (nameID=256) + del stat.AxisValueArray.AxisValue[0] # Thin (nameID=258) + + assert not any(n for n in varfont["name"].names if n.nameID in {256, 258}) + + with instancer.pruningUnusedNames(varfont): + del varfont["fvar"] + del varfont["STAT"] + + assert not any(n for n in varfont["name"].names if n.nameID in varNameIDs) + assert "ltag" not in varfont From 5871a754de2e8bc205196ba9b8c8ad1ec5e705d7 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 8 May 2019 16:24:17 +0100 Subject: [PATCH 080/127] instancer: set mac overlap glyf flags when fully instancing like varLib.mutator does --- Lib/fontTools/varLib/instancer.py | 25 ++++++++++++++++++++++--- Tests/varLib/instancer_test.py | 26 +++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 43113f2f2..580c6cc66 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -16,7 +16,7 @@ from fontTools.misc.fixedTools import floatToFixedToFloat, otRound from fontTools.varLib.models import supportScalar, normalizeValue, piecewiseLinearMap from fontTools.ttLib import TTFont from fontTools.ttLib.tables.TupleVariation import TupleVariation -from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates +from fontTools.ttLib.tables import _g_l_y_f from fontTools import varLib from fontTools.varLib import builder from fontTools.varLib.mvar import MVAR_ENTRIES @@ -82,7 +82,7 @@ def instantiateGvarGlyph(varfont, glyphname, location, optimize=True): ) if defaultDeltas: - coordinates += GlyphCoordinates(defaultDeltas) + coordinates += _g_l_y_f.GlyphCoordinates(defaultDeltas) # this will also set the hmtx/vmtx advance widths and sidebearings from # the four phantom points and glyph bounding boxes glyf.setCoordinates(glyphname, coordinates, varfont) @@ -585,6 +585,19 @@ def pruningUnusedNames(varfont): del varfont["ltag"] +def setMacOverlapFlags(glyfTable): + flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND + flagOverlapSimple = _g_l_y_f.flagOverlapSimple + for glyphName in glyfTable.keys(): + glyph = glyfTable[glyphName] + # Set OVERLAP_COMPOUND bit for compound glyphs + if glyph.isComposite(): + glyph.components[0].flags |= flagOverlapCompound + # Set OVERLAP_SIMPLE bit for simple glyphs + elif glyph.numberOfContours > 0: + glyph.flags[0] |= flagOverlapSimple + + def normalize(value, triple, avar_mapping): value = normalizeValue(value, triple) if avar_mapping: @@ -632,7 +645,9 @@ def sanityCheckVariableTables(varfont): raise NotImplementedError("Instancing CFF2 variable fonts is not supported yet") -def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): +def instantiateVariableFont( + varfont, axis_limits, inplace=False, optimize=True, overlap=True +): sanityCheckVariableTables(varfont) if not inplace: @@ -673,6 +688,10 @@ def instantiateVariableFont(varfont, axis_limits, inplace=False, optimize=True): instantiateFvar(varfont, axis_limits) + if "fvar" not in varfont: + if "glyf" in varfont and overlap: + setMacOverlapFlags(varfont["glyf"]) + return varfont diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 4c9a44d28..308348bcd 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -3,7 +3,7 @@ from fontTools.misc.py23 import * from fontTools import ttLib from fontTools import designspaceLib from fontTools.feaLib.builder import addOpenTypeFeaturesFromString -from fontTools.ttLib.tables import _f_v_a_r +from fontTools.ttLib.tables import _f_v_a_r, _g_l_y_f from fontTools.ttLib.tables import otTables from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools import varLib @@ -990,3 +990,27 @@ def test_pruningUnusedNames(varfont): assert not any(n for n in varfont["name"].names if n.nameID in varNameIDs) assert "ltag" not in varfont + + +def test_setMacOverlapFlags(): + flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND + flagOverlapSimple = _g_l_y_f.flagOverlapSimple + + glyf = ttLib.newTable("glyf") + glyf.glyphOrder = ["a", "b", "c"] + a = _g_l_y_f.Glyph() + a.numberOfContours = 1 + a.flags = [0] + b = _g_l_y_f.Glyph() + b.numberOfContours = -1 + comp = _g_l_y_f.GlyphComponent() + comp.flags = 0 + b.components = [comp] + c = _g_l_y_f.Glyph() + c.numberOfContours = 0 + glyf.glyphs = {"a": a, "b": b, "c": c} + + instancer.setMacOverlapFlags(glyf) + + assert a.flags[0] & flagOverlapSimple != 0 + assert b.components[0].flags & flagOverlapCompound != 0 From 178840dcf90e208943efbeb24022b71af1eec7ad Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 8 May 2019 16:29:38 +0100 Subject: [PATCH 081/127] instancer: add --no-overlap to CLI options --- Lib/fontTools/varLib/instancer.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 580c6cc66..0991f762c 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -752,6 +752,13 @@ def parseArgs(args): action="store_false", help="do not perform IUP optimization on the remaining gvar TupleVariations", ) + parser.add_argument( + "--no-overlap", + dest="overlap", + action="store_false", + help="Don't set OVERLAP_SIMPLE/OVERLAP_COMPOUND glyf flags (only applicable " + "when generating a full instance)", + ) logging_group = parser.add_mutually_exclusive_group(required=False) logging_group.add_argument( "-v", "--verbose", action="store_true", help="Run more verbosely." @@ -785,7 +792,11 @@ def main(args=None): varfont = TTFont(infile) instantiateVariableFont( - varfont, axis_limits, inplace=True, optimize=options.optimize + varfont, + axis_limits, + inplace=True, + optimize=options.optimize, + overlap=options.overlap, ) log.info("Saving partial variable font %s", outfile) From 2d99beb0daab7d817a0c331640b2856799e13864 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 8 May 2019 16:33:18 +0100 Subject: [PATCH 082/127] instancer: distinguish full/partial instance in log and outfile and call argparse parser.error() instead of letting ValueError propagate when parsing CLI options in parseArgs --- Lib/fontTools/varLib/instancer.py | 44 ++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 0991f762c..8b1cb6972 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -717,7 +717,7 @@ def parseArgs(args): """Parse argv. Returns: - 3-tuple (infile, outfile, axis_limits) + 3-tuple (infile, axis_limits, options) 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. @@ -750,7 +750,7 @@ def parseArgs(args): "--no-optimize", dest="optimize", action="store_false", - help="do not perform IUP optimization on the remaining gvar TupleVariations", + help="Don't perform IUP optimization on the remaining gvar TupleVariations", ) parser.add_argument( "--no-overlap", @@ -769,28 +769,37 @@ def parseArgs(args): options = parser.parse_args(args) infile = options.input - outfile = ( - os.path.splitext(infile)[0] + "-instance.ttf" - if not options.output - else options.output - ) + if not os.path.isfile(infile): + parser.error("No such file '{}'".format(infile)) + configLogger( level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO") ) - axis_limits = parseLimits(options.locargs) + try: + axis_limits = parseLimits(options.locargs) + except ValueError as e: + parser.error(e) + if len(axis_limits) != len(options.locargs): - raise ValueError("Specified multiple limits for the same axis") - return (infile, outfile, axis_limits, options) + parser.error("Specified multiple limits for the same axis") + + return (infile, axis_limits, options) def main(args=None): - infile, outfile, axis_limits, options = parseArgs(args) + infile, axis_limits, options = parseArgs(args) log.info("Restricting axes: %s", axis_limits) log.info("Loading variable font") varfont = TTFont(infile) + isFullInstance = { + axisTag + for axisTag, limit in axis_limits.items() + if not isinstance(limit, tuple) + }.issuperset(axis.axisTag for axis in varfont["fvar"].axes) + instantiateVariableFont( varfont, axis_limits, @@ -799,7 +808,18 @@ def main(args=None): overlap=options.overlap, ) - log.info("Saving partial variable font %s", outfile) + outfile = ( + os.path.splitext(infile)[0] + + "-{}.ttf".format("instance" if isFullInstance else "partial") + if not options.output + else options.output + ) + + log.info( + "Saving %s font %s", + "instance" if isFullInstance else "partial variable", + outfile, + ) varfont.save(outfile) From d9ad9d8ef55a70b869cc3b54cc60990523e2f9db Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 8 May 2019 18:46:43 +0100 Subject: [PATCH 083/127] instancer: set OS/2 weight/width and post.italicAngle --- Lib/fontTools/varLib/instancer.py | 41 +++++++++++++++++ Tests/varLib/instancer_test.py | 73 +++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 8b1cb6972..4fb8e4804 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -25,6 +25,7 @@ from contextlib import contextmanager import collections from copy import deepcopy import logging +from itertools import islice import os import re @@ -598,6 +599,37 @@ def setMacOverlapFlags(glyfTable): glyph.flags[0] |= flagOverlapSimple +def setDefaultWeightWidthSlant(ttFont, location): + if "wght" in location and "OS/2" in ttFont: + weightClass = otRound(max(1, min(location["wght"], 1000))) + log.info("Setting OS/2.usWidthClass = %s", weightClass) + ttFont["OS/2"].usWeightClass = weightClass + + if "wdth" in location: + # map 'wdth' axis (1..200) to OS/2.usWidthClass (1..9), rounding to closest + steps = [50.0, 62.5, 75.0, 87.5, 100.0, 112.5, 125.0, 150.0, 200.0] + n = len(steps) + os2WidthClasses = { + (prev + curr) / 2: widthClass + for widthClass, (prev, curr) in enumerate( + zip(islice(steps, 0, n - 1), islice(steps, 1, n)), start=1 + ) + } + wdth = location["wdth"] + for percent, widthClass in sorted(os2WidthClasses.items()): + if wdth < percent: + break + else: + widthClass = 9 + log.info("Setting OS/2.usWidthClass = %s", widthClass) + ttFont["OS/2"].usWidthClass = widthClass + + if "slnt" in location and "post" in ttFont: + italicAngle = max(-90, min(location["slnt"], 90)) + log.info("Setting post.italicAngle = %s", italicAngle) + ttFont["post"].italicAngle = italicAngle + + def normalize(value, triple, avar_mapping): value = normalizeValue(value, triple) if avar_mapping: @@ -692,6 +724,15 @@ def instantiateVariableFont( if "glyf" in varfont and overlap: setMacOverlapFlags(varfont["glyf"]) + setDefaultWeightWidthSlant( + varfont, + location={ + axisTag: limit + for axisTag, limit in axis_limits.items() + if not isinstance(limit, tuple) + }, + ) + return varfont diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 308348bcd..93385bad1 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1014,3 +1014,76 @@ def test_setMacOverlapFlags(): assert a.flags[0] & flagOverlapSimple != 0 assert b.components[0].flags & flagOverlapCompound != 0 + + +@pytest.fixture +def ttFont(): + f = ttLib.TTFont() + f["OS/2"] = ttLib.newTable("OS/2") + f["post"] = ttLib.newTable("post") + return f + + +class SetDefaultWeightWidthSlantTest(object): + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": 0}, 1), + ({"wght": 1}, 1), + ({"wght": 100}, 100), + ({"wght": 1000}, 1000), + ({"wght": 1001}, 1000), + ], + ) + def test_wght(self, ttFont, location, expected): + instancer.setDefaultWeightWidthSlant(ttFont, location) + + assert ttFont["OS/2"].usWeightClass == expected + + @pytest.mark.parametrize( + "location, expected", + [ + ({"wdth": 0}, 1), + ({"wdth": 56}, 1), + ({"wdth": 57}, 2), + ({"wdth": 62.5}, 2), + ({"wdth": 75}, 3), + ({"wdth": 87.5}, 4), + ({"wdth": 100}, 5), + ({"wdth": 112.5}, 6), + ({"wdth": 125}, 7), + ({"wdth": 150}, 8), + ({"wdth": 200}, 9), + ({"wdth": 201}, 9), + ({"wdth": 1000}, 9), + ], + ) + def test_wdth(self, ttFont, location, expected): + instancer.setDefaultWeightWidthSlant(ttFont, location) + + assert ttFont["OS/2"].usWidthClass == expected + + @pytest.mark.parametrize( + "location, expected", + [ + ({"slnt": -91}, -90), + ({"slnt": -90}, -90), + ({"slnt": 0}, 0), + ({"slnt": 11.5}, 11.5), + ({"slnt": 90}, 90), + ({"slnt": 91}, 90), + ], + ) + def test_slnt(self, ttFont, location, expected): + instancer.setDefaultWeightWidthSlant(ttFont, location) + + assert ttFont["post"].italicAngle == expected + + def test_all(self, ttFont): + instancer.setDefaultWeightWidthSlant( + ttFont, {"wght": 500, "wdth": 150, "slnt": -12.0} + ) + + assert ttFont["OS/2"].usWeightClass == 500 + assert ttFont["OS/2"].usWidthClass == 8 + assert ttFont["post"].italicAngle == -12.0 From b5da46425c32d57d4b99a49e0c1c18380c5c98b4 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 9 May 2019 16:39:26 +0100 Subject: [PATCH 084/127] instancer: rename --no-overlap to --no-overlap-flag as suggested by Laurence https://github.com/fonttools/fonttools/pull/1603#commitcomment-33462372 --- Lib/fontTools/varLib/instancer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 4fb8e4804..b0f482789 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -794,7 +794,7 @@ def parseArgs(args): help="Don't perform IUP optimization on the remaining gvar TupleVariations", ) parser.add_argument( - "--no-overlap", + "--no-overlap-flag", dest="overlap", action="store_false", help="Don't set OVERLAP_SIMPLE/OVERLAP_COMPOUND glyf flags (only applicable " From 28fb29e7581b016ed223b7f01f7a7da12f73a4bd Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 20 May 2019 17:31:46 -0400 Subject: [PATCH 085/127] instancer: make 'None' mean to drop an axis, or pinning at default This allows to drop an axis (aka L1 instancing) without knowing the axis' actual default value from fvar table. One can simply call `instantiateVariableFont` function with a `None` value for a given axis (i.e. axis_limits={'wght': None}); the `None` value is replaced by the axis default value as per fvar table. The same can be done from the console script as well. The special string literal 'None' is parsed as the Python `None` object. E.g.: $ fonttools varLib.instancer MyFont-VF.ttf wght=None --- Lib/fontTools/varLib/instancer.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index b0f482789..9139a2a25 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -677,6 +677,17 @@ def sanityCheckVariableTables(varfont): raise NotImplementedError("Instancing CFF2 variable fonts is not supported yet") +def populateAxisDefaults(varfont, axis_limits): + if any(value is None for value in axis_limits.values()): + fvar = varfont["fvar"] + defaultValues = {a.axisTag: a.defaultValue for a in fvar.axes} + return { + axisTag: defaultValues[axisTag] if value is None else value + for axisTag, value in axis_limits.items() + } + return axis_limits + + def instantiateVariableFont( varfont, axis_limits, inplace=False, optimize=True, overlap=True ): @@ -684,6 +695,9 @@ def instantiateVariableFont( if not inplace: varfont = deepcopy(varfont) + + axis_limits = populateAxisDefaults(varfont, axis_limits) + normalized_limits = normalizeAxisLimits(varfont, axis_limits) log.info("Normalized limits: %s", normalized_limits) @@ -739,14 +753,19 @@ def instantiateVariableFont( def parseLimits(limits): result = {} for limit_string in limits: - match = re.match(r"^(\w{1,4})=([^:]+)(?:[:](.+))?$", limit_string) + match = re.match( + r"^(\w{1,4})=(?:(None)|(?:([^:]+)(?:[:](.+))?))$", limit_string + ) if not match: raise ValueError("invalid location format: %r" % limit_string) tag = match.group(1).ljust(4) - lbound = float(match.group(2)) + if match.group(2): # matches literal 'None', i.e. drop axis + lbound = None + else: + lbound = float(match.group(3)) ubound = lbound - if match.group(3): - ubound = float(match.group(3)) + if match.group(4): + ubound = float(match.group(4)) if lbound != ubound: result[tag] = (lbound, ubound) else: From 3bc983e78dbe1f3ba0374f073b2b0a8e20822879 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 21 May 2019 12:17:27 -0400 Subject: [PATCH 086/127] instancer: skip instantiating GPOS/GDEF if GDEF version < 0x00010003 in which case GDEF doesn't have a VarStore attatched, thus GPOS can't have variations --- Lib/fontTools/varLib/instancer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 9139a2a25..07772f7c3 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -342,7 +342,7 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, location): def instantiateOTL(varfont, location): # TODO(anthrotype) Support partial instancing of JSTF and BASE tables - if "GDEF" not in varfont: + if "GDEF" not in varfont or varfont["GDEF"].table.Version < 0x00010003: return if "GPOS" in varfont: From 0e9c5d9fe8d2a72f3460fb5bbeb429922b46343c Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 21 May 2019 14:06:44 -0400 Subject: [PATCH 087/127] instancer: rename 'None' to 'drop' https://github.com/fonttools/fonttools/pull/1617#issuecomment-494455729 --- Lib/fontTools/varLib/instancer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 07772f7c3..08624d3bd 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -754,12 +754,12 @@ def parseLimits(limits): result = {} for limit_string in limits: match = re.match( - r"^(\w{1,4})=(?:(None)|(?:([^:]+)(?:[:](.+))?))$", limit_string + r"^(\w{1,4})=(?:(drop)|(?:([^:]+)(?:[:](.+))?))$", limit_string ) if not match: raise ValueError("invalid location format: %r" % limit_string) tag = match.group(1).ljust(4) - if match.group(2): # matches literal 'None', i.e. drop axis + if match.group(2): # 'drop' lbound = None else: lbound = float(match.group(3)) From 058165dc5c6b33bdaf278761bd68cbfa9079478b Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 21 May 2019 14:40:26 -0400 Subject: [PATCH 088/127] instancer: mention special None and 'drop' in docstring and --help --- Lib/fontTools/varLib/instancer.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 08624d3bd..2b46738cd 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -778,9 +778,10 @@ def parseArgs(args): Returns: 3-tuple (infile, axis_limits, options) - 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. + axis_limits is either a Dict[str, Optional[float]], for pinning variation axes + to specific coordinates along those axes (with `None` as a placeholder for an + axis' default value); or a Dict[str, Tuple(float, float)], 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 @@ -796,8 +797,9 @@ def parseArgs(args): 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", + "the tag of a variation axis, followed by '=' and one of number, " + "number:number or the literal string 'drop'. " + "E.g.: wdth=100 or wght=75.0:125.0 or wght=drop", ) parser.add_argument( "-o", From 1fdee0454ae3f5fc0574728c0b48051cc78c03b0 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 21 May 2019 18:42:13 -0400 Subject: [PATCH 089/127] instancer: also in GDEF 1.3 VarStore is optional and can be None --- Lib/fontTools/varLib/instancer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 2b46738cd..11aa17395 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -342,7 +342,11 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, location): def instantiateOTL(varfont, location): # TODO(anthrotype) Support partial instancing of JSTF and BASE tables - if "GDEF" not in varfont or varfont["GDEF"].table.Version < 0x00010003: + if ( + "GDEF" not in varfont + or varfont["GDEF"].table.Version < 0x00010003 + or not varfont["GDEF"].table.VarStore + ): return if "GPOS" in varfont: From 17254fe37a2bbed1204fb3f710062331022e7837 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 21 May 2019 18:02:28 -0400 Subject: [PATCH 090/127] varLib.merger: Class2Record.Value{1,2} may not be initialised to None When importing from TTX, these attribute are not there. --- Lib/fontTools/varLib/merger.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/merger.py b/Lib/fontTools/varLib/merger.py index 62c42ca30..e19ffc77f 100644 --- a/Lib/fontTools/varLib/merger.py +++ b/Lib/fontTools/varLib/merger.py @@ -289,7 +289,8 @@ def merge(merger, self, lst): if vpair is None: v1, v2 = None, None else: - v1, v2 = vpair.Value1, vpair.Value2 + v1 = getattr(vpair, "Value1", None) + v2 = getattr(vpair, "Value2", None) v.Value1 = otBase.ValueRecord(merger.valueFormat1, src=v1) if merger.valueFormat1 else None v.Value2 = otBase.ValueRecord(merger.valueFormat2, src=v2) if merger.valueFormat2 else None values[j] = v @@ -515,19 +516,19 @@ def merge(merger, self, lst): if self.Format == 1: for pairSet in self.PairSet: for pairValueRecord in pairSet.PairValueRecord: - pv1 = pairValueRecord.Value1 + pv1 = getattr(pairValueRecord, "Value1", None) if pv1 is not None: vf1 |= pv1.getFormat() - pv2 = pairValueRecord.Value2 + pv2 = getattr(pairValueRecord, "Value2", None) if pv2 is not None: vf2 |= pv2.getFormat() elif self.Format == 2: for class1Record in self.Class1Record: for class2Record in class1Record.Class2Record: - pv1 = class2Record.Value1 + pv1 = getattr(class2Record, "Value1", None) if pv1 is not None: vf1 |= pv1.getFormat() - pv2 = class2Record.Value2 + pv2 = getattr(class2Record, "Value2", None) if pv2 is not None: vf2 |= pv2.getFormat() self.ValueFormat1 = vf1 From 6efc66e0fc6daab40aea4830e07c51cf11604168 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 21 May 2019 18:09:33 -0400 Subject: [PATCH 091/127] instancer_test: test instantiateVariableFont main function Added new test VF font (a subset of NotoSans-VF only containing glyphs 'A', 'Agrave' and 'T'); the VF was instanced with varLib.mutator, producing a series of full instances, which are included as ttx files as well. The tests run the partial instancer twice, once only instancing wght, then again for wdth, and assert that the generated instance is identical to those. --- .../varLib/data/PartialInstancerTest2-VF.ttx | 1803 +++++++++++++++++ ...tialInstancerTest2-VF-instance-100,100.ttx | 484 +++++ ...ialInstancerTest2-VF-instance-100,62.5.ttx | 484 +++++ ...tialInstancerTest2-VF-instance-400,100.ttx | 484 +++++ ...ialInstancerTest2-VF-instance-400,62.5.ttx | 484 +++++ ...tialInstancerTest2-VF-instance-900,100.ttx | 484 +++++ ...ialInstancerTest2-VF-instance-900,62.5.ttx | 484 +++++ Tests/varLib/instancer_test.py | 59 + 8 files changed, 4766 insertions(+) create mode 100644 Tests/varLib/data/PartialInstancerTest2-VF.ttx create mode 100644 Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx create mode 100644 Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx create mode 100644 Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx create mode 100644 Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx create mode 100644 Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx create mode 100644 Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx diff --git a/Tests/varLib/data/PartialInstancerTest2-VF.ttx b/Tests/varLib/data/PartialInstancerTest2-VF.ttx new file mode 100644 index 000000000..0f19bde35 --- /dev/null +++ b/Tests/varLib/data/PartialInstancerTest2-VF.ttx @@ -0,0 +1,1803 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Weight + + + Width + + + Thin + + + ExtraLight + + + Light + + + Regular + + + Medium + + + SemiBold + + + Bold + + + ExtraBold + + + Black + + + SemiCondensed Thin + + + SemiCondensed ExtraLight + + + SemiCondensed Light + + + SemiCondensed + + + SemiCondensed Medium + + + SemiCondensed SemiBold + + + SemiCondensed Bold + + + SemiCondensed ExtraBold + + + SemiCondensed Black + + + Condensed Thin + + + Condensed ExtraLight + + + Condensed Light + + + Condensed + + + Condensed Medium + + + Condensed SemiBold + + + Condensed Bold + + + Condensed ExtraBold + + + Condensed Black + + + ExtraCondensed Thin + + + ExtraCondensed ExtraLight + + + ExtraCondensed Light + + + ExtraCondensed + + + ExtraCondensed Medium + + + ExtraCondensed SemiBold + + + ExtraCondensed Bold + + + ExtraCondensed ExtraBold + + + ExtraCondensed Black + + + Copyright 2015 Google Inc. All Rights Reserved. + + + Noto Sans + + + Regular + + + 2.001;GOOG;NotoSans-Regular + + + Noto Sans Regular + + + Version 2.001 + + + NotoSans-Regular + + + Noto is a trademark of Google Inc. + + + Monotype Imaging Inc. + + + Monotype Design Team + + + Designed by Monotype design team. + + + http://www.google.com/get/noto/ + + + http://www.monotype.com/studio + + + This Font Software is licensed under the SIL Open Font License, Version 1.1. This Font Software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the SIL Open Font License for the specific language, permissions and limitations governing your use of this Font Software. + + + http://scripts.sil.org/OFL + + + Weight + + + Width + + + Thin + + + ExtraLight + + + Light + + + Regular + + + Medium + + + SemiBold + + + Bold + + + ExtraBold + + + Black + + + SemiCondensed Thin + + + SemiCondensed ExtraLight + + + SemiCondensed Light + + + SemiCondensed + + + SemiCondensed Medium + + + SemiCondensed SemiBold + + + SemiCondensed Bold + + + SemiCondensed ExtraBold + + + SemiCondensed Black + + + Condensed Thin + + + Condensed ExtraLight + + + Condensed Light + + + Condensed + + + Condensed Medium + + + Condensed SemiBold + + + Condensed Bold + + + Condensed ExtraBold + + + Condensed Black + + + ExtraCondensed Thin + + + ExtraCondensed ExtraLight + + + ExtraCondensed Light + + + ExtraCondensed + + + ExtraCondensed Medium + + + ExtraCondensed SemiBold + + + ExtraCondensed Bold + + + ExtraCondensed ExtraBold + + + ExtraCondensed Black + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wght + 0x0 + 100.0 + 400.0 + 900.0 + 256 + + + + + wdth + 0x0 + 62.5 + 100.0 + 100.0 + 257 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx new file mode 100644 index 000000000..c64049fed --- /dev/null +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx @@ -0,0 +1,484 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Copyright 2015 Google Inc. All Rights Reserved. + + + Noto Sans + + + Regular + + + 2.001;GOOG;NotoSans-Regular + + + Noto Sans Regular + + + Version 2.001 + + + NotoSans-Regular + + + Noto is a trademark of Google Inc. + + + Monotype Imaging Inc. + + + Monotype Design Team + + + Designed by Monotype design team. + + + http://www.google.com/get/noto/ + + + http://www.monotype.com/studio + + + This Font Software is licensed under the SIL Open Font License, Version 1.1. This Font Software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the SIL Open Font License for the specific language, permissions and limitations governing your use of this Font Software. + + + http://scripts.sil.org/OFL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx new file mode 100644 index 000000000..87d0c65c7 --- /dev/null +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx @@ -0,0 +1,484 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Copyright 2015 Google Inc. All Rights Reserved. + + + Noto Sans + + + Regular + + + 2.001;GOOG;NotoSans-Regular + + + Noto Sans Regular + + + Version 2.001 + + + NotoSans-Regular + + + Noto is a trademark of Google Inc. + + + Monotype Imaging Inc. + + + Monotype Design Team + + + Designed by Monotype design team. + + + http://www.google.com/get/noto/ + + + http://www.monotype.com/studio + + + This Font Software is licensed under the SIL Open Font License, Version 1.1. This Font Software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the SIL Open Font License for the specific language, permissions and limitations governing your use of this Font Software. + + + http://scripts.sil.org/OFL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx new file mode 100644 index 000000000..fc64365eb --- /dev/null +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx @@ -0,0 +1,484 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Copyright 2015 Google Inc. All Rights Reserved. + + + Noto Sans + + + Regular + + + 2.001;GOOG;NotoSans-Regular + + + Noto Sans Regular + + + Version 2.001 + + + NotoSans-Regular + + + Noto is a trademark of Google Inc. + + + Monotype Imaging Inc. + + + Monotype Design Team + + + Designed by Monotype design team. + + + http://www.google.com/get/noto/ + + + http://www.monotype.com/studio + + + This Font Software is licensed under the SIL Open Font License, Version 1.1. This Font Software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the SIL Open Font License for the specific language, permissions and limitations governing your use of this Font Software. + + + http://scripts.sil.org/OFL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx new file mode 100644 index 000000000..9b40106fe --- /dev/null +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx @@ -0,0 +1,484 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Copyright 2015 Google Inc. All Rights Reserved. + + + Noto Sans + + + Regular + + + 2.001;GOOG;NotoSans-Regular + + + Noto Sans Regular + + + Version 2.001 + + + NotoSans-Regular + + + Noto is a trademark of Google Inc. + + + Monotype Imaging Inc. + + + Monotype Design Team + + + Designed by Monotype design team. + + + http://www.google.com/get/noto/ + + + http://www.monotype.com/studio + + + This Font Software is licensed under the SIL Open Font License, Version 1.1. This Font Software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the SIL Open Font License for the specific language, permissions and limitations governing your use of this Font Software. + + + http://scripts.sil.org/OFL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx new file mode 100644 index 000000000..8f8517960 --- /dev/null +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx @@ -0,0 +1,484 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Copyright 2015 Google Inc. All Rights Reserved. + + + Noto Sans + + + Regular + + + 2.001;GOOG;NotoSans-Regular + + + Noto Sans Regular + + + Version 2.001 + + + NotoSans-Regular + + + Noto is a trademark of Google Inc. + + + Monotype Imaging Inc. + + + Monotype Design Team + + + Designed by Monotype design team. + + + http://www.google.com/get/noto/ + + + http://www.monotype.com/studio + + + This Font Software is licensed under the SIL Open Font License, Version 1.1. This Font Software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the SIL Open Font License for the specific language, permissions and limitations governing your use of this Font Software. + + + http://scripts.sil.org/OFL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx new file mode 100644 index 000000000..bc8c7e9a6 --- /dev/null +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx @@ -0,0 +1,484 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Copyright 2015 Google Inc. All Rights Reserved. + + + Noto Sans + + + Regular + + + 2.001;GOOG;NotoSans-Regular + + + Noto Sans Regular + + + Version 2.001 + + + NotoSans-Regular + + + Noto is a trademark of Google Inc. + + + Monotype Imaging Inc. + + + Monotype Design Team + + + Designed by Monotype design team. + + + http://www.google.com/get/noto/ + + + http://www.monotype.com/studio + + + This Font Software is licensed under the SIL Open Font License, Version 1.1. This Font Software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the SIL Open Font License for the specific language, permissions and limitations governing your use of this Font Software. + + + http://scripts.sil.org/OFL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 93385bad1..0e4b14b45 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -14,6 +14,7 @@ from fontTools.varLib import models import collections from copy import deepcopy import os +import re import pytest @@ -1087,3 +1088,61 @@ class SetDefaultWeightWidthSlantTest(object): assert ttFont["OS/2"].usWeightClass == 500 assert ttFont["OS/2"].usWidthClass == 8 assert ttFont["post"].italicAngle == -12.0 + + +def _strip_ttLibVersion(string): + return re.sub(' ttLibVersion=".*"', "", string) + + +@pytest.fixture +def varfont2(): + f = ttLib.TTFont(recalcTimestamp=False) + f.importXML(os.path.join(TESTDATA, "PartialInstancerTest2-VF.ttx")) + return f + + +def _dump_ttx(ttFont): + # compile to temporary bytes stream, reload and dump to XML + tmp = BytesIO() + ttFont.save(tmp) + tmp.seek(0) + ttFont2 = ttLib.TTFont(tmp, recalcBBoxes=False, recalcTimestamp=False) + s = StringIO() + ttFont2.saveXML(s) + return _strip_ttLibVersion(s.getvalue()) + + +def _get_expected_instance_ttx(wght, wdth): + with open( + os.path.join( + TESTDATA, + "test_results", + "PartialInstancerTest2-VF-instance-{0},{1}.ttx".format(wght, wdth), + ), + "r", + encoding="utf-8", + ) as fp: + return _strip_ttLibVersion(fp.read()) + + +class InstantiateVariableFontTest(object): + @pytest.mark.parametrize( + "wght, wdth", + [(100, 100), (400, 100), (900, 100), (100, 62.5), (400, 62.5), (900, 62.5)], + ) + def test_multiple_instancing(self, varfont2, wght, wdth): + partial = instancer.instantiateVariableFont(varfont2, {"wght": wght}) + instance = instancer.instantiateVariableFont(partial, {"wdth": wdth}) + + expected = _get_expected_instance_ttx(wght, wdth) + + assert _dump_ttx(instance) == expected + + def test_default_instance(self, varfont2): + instance = instancer.instantiateVariableFont( + varfont2, {"wght": None, "wdth": None} + ) + + expected = _get_expected_instance_ttx(400, 100) + + assert _dump_ttx(instance) == expected From 7867c582da2d7522f0a5ec39a58496adb3528904 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 29 May 2019 18:20:11 +0100 Subject: [PATCH 092/127] instancer: remap axis indices in instantiateFeatureVariations ConditionTable.AxisIndex needs to change when dropping axes, to refer to the same axis in the modified fvar.axes array. There was also another bug when a condition was not met, and the `applies` flag (initialised to `True`) was not set to `False`, thus substutions were incorrectly applied. --- Lib/fontTools/varLib/instancer.py | 41 +++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 11aa17395..06acf0b3d 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -411,25 +411,39 @@ def instantiateFeatureVariations(varfont, location): def _instantiateFeatureVariations(table, fvarAxes, location): - newRecords = [] 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 - for record in table.FeatureVariations.FeatureVariationRecord: + newRecords = [] + + for i, record in enumerate(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 + for j, condition in enumerate(record.ConditionSet.ConditionTable): + if condition.Format == 1: + axisIdx = condition.AxisIndex + axisTag = fvarAxes[axisIdx].axisTag + if 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 = applies = False + break + else: + # axis not pinned, keep condition with remapped axis index + applies = False + condition.AxisIndex = axisIndexMap[axisTag] + newConditions.append(condition) else: + log.warning( + "Condition table {0} of FeatureVariationRecord {1} has " + "unsupported format ({2}); ignored".format(j, i, condition.Format) + ) applies = False newConditions.append(condition) @@ -446,6 +460,7 @@ def _instantiateFeatureVariations(table, fvarAxes, location): if newRecords: table.FeatureVariations.FeatureVariationRecord = newRecords + table.FeatureVariations.FeatureVariationCount = len(newRecords) else: del table.FeatureVariations From 60754aab8e692ce975f81e5ed1b7f9f55b082940 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 29 May 2019 18:22:30 +0100 Subject: [PATCH 093/127] instancer: prune unreferenced lookups in instantiateFeatureVariations the 'prune_lookups' method is dynamically set on the table_G_S_U_B class only after importing the fontTools.subset module --- Lib/fontTools/varLib/instancer.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 06acf0b3d..9ada2fced 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -18,6 +18,9 @@ from fontTools.ttLib import TTFont from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools.ttLib.tables import _g_l_y_f 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.mvar import MVAR_ENTRIES from fontTools.varLib.merger import MutatorMerger @@ -408,6 +411,8 @@ def instantiateFeatureVariations(varfont, location): _instantiateFeatureVariations( varfont[tableTag].table, varfont["fvar"].axes, location ) + # remove unreferenced lookups + varfont[tableTag].prune_lookups() def _instantiateFeatureVariations(table, fvarAxes, location): From 11c662ee4dc4899e73299b337c62402a51e9d96d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 29 May 2019 18:28:03 +0100 Subject: [PATCH 094/127] instancer: only keep unique FeatureVariationRecords After partial instancing, multiple FeatureVariationRecords may end up with the same set of conditions (e.g. if one references two axes, one of which is dropped, and a subsequent one also references the same axis that was kept in the preceding record's condition set, and the min/max values are the same for both records). Therefore, we make sure only the first unique record with a given configuration of conditions is kept. Any additional records with identical conditions will never match the current context so they can be dropped. --- Lib/fontTools/varLib/instancer.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 9ada2fced..98895ca5b 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -415,12 +415,35 @@ def instantiateFeatureVariations(varfont, location): 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 not seen + return False + 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): 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 + uniqueRecords = set() newRecords = [] for i, record in enumerate(table.FeatureVariations.FeatureVariationRecord): @@ -454,7 +477,8 @@ def _instantiateFeatureVariations(table, fvarAxes, location): if retainRecord and newConditions: record.ConditionSet.ConditionTable = newConditions - newRecords.append(record) + if _featureVariationRecordIsUnique(record, uniqueRecords): + newRecords.append(record) if applies and not featureVariationApplied: assert record.FeatureTableSubstitution.Version == 0x00010000 From 5a3c3334fe7b38572dd432f9d3d346b432f34d62 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 29 May 2019 19:22:02 +0100 Subject: [PATCH 095/127] instancer: fix typo in logger name --- Lib/fontTools/varLib/instancer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 98895ca5b..6ba4d59d2 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -33,7 +33,7 @@ import os import re -log = logging.getLogger("fontTools.varlib.instancer") +log = logging.getLogger("fontTools.varLib.instancer") def instantiateTupleVariationStore(variations, location, origCoords=None, endPts=None): From 3560267a0bafebeb6b401686e1f6217a393a838e Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 29 May 2019 19:24:02 +0100 Subject: [PATCH 096/127] instancer_test: add tests for instantiateFeatureVariations using the same test file FeatureVars.ttx used by varLib_test.py --- Tests/varLib/instancer_test.py | 150 +++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 0e4b14b45..9fead7ab4 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -11,6 +11,7 @@ from fontTools.varLib import instancer from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib import builder from fontTools.varLib import models +from fontTools.misc.loggingTools import CapturingLogHandler import collections from copy import deepcopy import os @@ -1146,3 +1147,152 @@ class InstantiateVariableFontTest(object): expected = _get_expected_instance_ttx(400, 100) assert _dump_ttx(instance) == expected + + +@pytest.fixture +def varfont3(): + # The test file "Tests/varLib/data/test_results/FeatureVars.ttx" contains + # a GSUB.FeatureVariations table built from conditional rules specified in + # "Tests/varLib/data/FeatureVars.designspace" file, which are equivalent to: + # + # conditionalSubstitutions = [ + # ([{"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"}), + # ] + ttx = os.path.join(TESTDATA, "test_results", "FeatureVars.ttx") + font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) + font.importXML(ttx) + return font + + +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 + + +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, varfont3, location, appliedSubs, expectedRecords): + font = varfont3 + + 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, varfont3, location, appliedSubs): + font = varfont3 + + 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, varfont3): + gsub = varfont3["GSUB"].table + featureVariations = gsub.FeatureVariations + cd = featureVariations.FeatureVariationRecord[0].ConditionSet.ConditionTable[0] + assert cd.Format == 1 + cd.Format = 2 + + with CapturingLogHandler("fontTools.varLib.instancer", "WARNING") as captor: + instancer.instantiateFeatureVariations(varfont3, {"wght": 0}) + + captor.assertRegex( + r"Condition table 0 of FeatureVariationRecord 0 " + r"has unsupported format \(2\); ignored" + ) From 823f0fc0214a8a2592a7bffb22b3d8939dbe4bb7 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 29 May 2019 19:33:36 +0100 Subject: [PATCH 097/127] instancer: fix invalid operand '-'; dict.keys() returns list in py27 --- Lib/fontTools/varLib/instancer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 6ba4d59d2..2f92157b0 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -688,7 +688,7 @@ def normalize(value, triple, avar_mapping): def normalizeAxisLimits(varfont, axis_limits): 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: raise ValueError("Cannot limit: {} not present in fvar".format(bad_limits)) From 36f2775d6c5b5426e8641e161b9e191651fe1c5b Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 30 May 2019 11:22:01 +0100 Subject: [PATCH 098/127] instancer: always keep FeatureVariationRecords with unknown condition format there was a logic issue in the function that checks whether a FeatureVariationRecord has a unique set of condition (was returning False instead of True for unsupported condition). It's safer to always keep such records with unknown condition formats as new formats may be added in the future. A warning is already issued in these cases. --- Lib/fontTools/varLib/instancer.py | 5 +++-- Tests/varLib/instancer_test.py | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 2f92157b0..c3d342433 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -419,8 +419,8 @@ def _featureVariationRecordIsUnique(rec, seen): conditionSet = [] for cond in rec.ConditionSet.ConditionTable: if cond.Format != 1: - # can't tell whether this is duplicate, assume not seen - return False + # can't tell whether this is duplicate, assume is unique + return True conditionSet.append( (cond.AxisIndex, cond.FilterRangeMinValue, cond.FilterRangeMaxValue) ) @@ -474,6 +474,7 @@ def _instantiateFeatureVariations(table, fvarAxes, location): ) applies = False newConditions.append(condition) + break if retainRecord and newConditions: record.ConditionSet.ConditionTable = newConditions diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 9fead7ab4..2c418697a 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1285,9 +1285,8 @@ class InstantiateFeatureVariationsTest(object): def test_unsupported_condition_format(self, varfont3): gsub = varfont3["GSUB"].table featureVariations = gsub.FeatureVariations - cd = featureVariations.FeatureVariationRecord[0].ConditionSet.ConditionTable[0] - assert cd.Format == 1 - cd.Format = 2 + rec1 = featureVariations.FeatureVariationRecord[0] + rec1.ConditionSet.ConditionTable[0].Format = 2 with CapturingLogHandler("fontTools.varLib.instancer", "WARNING") as captor: instancer.instantiateFeatureVariations(varfont3, {"wght": 0}) @@ -1296,3 +1295,6 @@ class InstantiateFeatureVariationsTest(object): r"Condition table 0 of FeatureVariationRecord 0 " r"has unsupported format \(2\); ignored" ) + + # check that record with unsupported condition format is kept + assert featureVariations.FeatureVariationRecord[0] is rec1 From afc194db19a2b71869ddcad87af2a0b0fa109712 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 30 May 2019 12:29:23 +0100 Subject: [PATCH 099/127] featureVars: populate counts on OT tables this is done automatically upon compiling; however it's good to do it here as well, in case one wants to pass the updated font directly to other modules like 'subset' which requires these fields to be present -- without having to first compile and decompile. --- Lib/fontTools/varLib/featureVars.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index 4caf30a22..d5793a560 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -283,6 +283,7 @@ def addFeatureVariationsRaw(font, conditionalSubstitutions): rvrnFeature = buildFeatureRecord('rvrn', []) gsub.FeatureList.FeatureRecord.append(rvrnFeature) + gsub.FeatureList.FeatureCount = len(gsub.FeatureList.FeatureRecord) sortFeatureList(gsub) rvrnFeatureIndex = gsub.FeatureList.FeatureRecord.index(rvrnFeature) @@ -346,6 +347,7 @@ def buildGSUB(): srec.Script.DefaultLangSys = langrec.LangSys gsub.ScriptList.ScriptRecord.append(srec) + gsub.ScriptList.ScriptCount = 1 gsub.FeatureVariations = None return fontTable @@ -380,6 +382,7 @@ def buildSubstitutionLookups(gsub, allSubstitutions): lookup = buildLookup([buildSingleSubstSubtable(substMap)]) gsub.LookupList.Lookup.append(lookup) assert gsub.LookupList.Lookup[lookupMap[subst]] is lookup + gsub.LookupList.LookupCount = len(gsub.LookupList.Lookup) return lookupMap @@ -397,6 +400,7 @@ def buildFeatureRecord(featureTag, lookupListIndices): fr.FeatureTag = featureTag fr.Feature = ot.Feature() fr.Feature.LookupListIndex = lookupListIndices + fr.Feature.populateDefaults() return fr From 874947c00b8431083cf013d593e0cbd8df7e1d16 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 30 May 2019 12:35:44 +0100 Subject: [PATCH 100/127] instancer_test: make new test font with FeatureVariations instead of reading off existing FeatureVars.ttx test file. This gives us more flexibility to add more tests, and keeps the input values closer to the expected results --- Tests/varLib/instancer_test.py | 81 ++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 24 deletions(-) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 2c418697a..7b945778a 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -10,6 +10,7 @@ from fontTools import varLib from fontTools.varLib import instancer from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib import builder +from fontTools.varLib import featureVars from fontTools.varLib import models from fontTools.misc.loggingTools import CapturingLogHandler import collections @@ -1149,23 +1150,6 @@ class InstantiateVariableFontTest(object): assert _dump_ttx(instance) == expected -@pytest.fixture -def varfont3(): - # The test file "Tests/varLib/data/test_results/FeatureVars.ttx" contains - # a GSUB.FeatureVariations table built from conditional rules specified in - # "Tests/varLib/data/FeatureVars.designspace" file, which are equivalent to: - # - # conditionalSubstitutions = [ - # ([{"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"}), - # ] - ttx = os.path.join(TESTDATA, "test_results", "FeatureVars.ttx") - font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) - font.importXML(ttx) - return font - - def _conditionSetAsDict(conditionSet, axisOrder): result = {} for cond in conditionSet.ConditionTable: @@ -1184,6 +1168,29 @@ def _getSubstitutions(gsub, lookupIndices): 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", @@ -1227,8 +1234,17 @@ class InstantiateFeatureVariationsTest(object): ), ], ) - def test_partial_instance(self, varfont3, location, appliedSubs, expectedRecords): - font = varfont3 + 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) @@ -1268,8 +1284,17 @@ class InstantiateFeatureVariationsTest(object): ({"wght": -1.0, "cntr": 0.3}, None), ], ) - def test_full_instance(self, varfont3, location, appliedSubs): - font = varfont3 + 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) @@ -1282,14 +1307,22 @@ class InstantiateFeatureVariationsTest(object): else: assert not gsub.FeatureList.FeatureRecord - def test_unsupported_condition_format(self, varfont3): - gsub = varfont3["GSUB"].table + def test_unsupported_condition_format(self): + font = makeFeatureVarsFont( + [ + ( + [{"wdth": (-1.0, -0.5), "wght": (0.5, 1.0)}], + {"dollar": "dollar.nostroke"}, + ) + ] + ) + gsub = font["GSUB"].table featureVariations = gsub.FeatureVariations rec1 = featureVariations.FeatureVariationRecord[0] rec1.ConditionSet.ConditionTable[0].Format = 2 with CapturingLogHandler("fontTools.varLib.instancer", "WARNING") as captor: - instancer.instantiateFeatureVariations(varfont3, {"wght": 0}) + instancer.instantiateFeatureVariations(font, {"wght": 0}) captor.assertRegex( r"Condition table 0 of FeatureVariationRecord 0 " From b878b867c0558b3907fe74fc8d4aa16b1adb0c59 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 30 May 2019 13:05:07 +0100 Subject: [PATCH 101/127] instancer: don't break on unsupported condition formats continue checking subsequent condition tables in case the other may be format=1 and may reference a pinned axis; in which case, these conditions need to be dropped from the condition set, or the whole record needs to be dropped if the instance coordinate is outside the condition range. Condition tables within a condition set are associated with a AND boolean operator, so if any one doesn't match, the whole set doesn't apply. Even if we don't recognize one condition format, if we do ascertain that another condition table does not match the current partial instance location, we can drop the FeatureVariation record since it doesn't apply. --- Lib/fontTools/varLib/instancer.py | 1 - Tests/varLib/instancer_test.py | 12 ++++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index c3d342433..bcedab03d 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -474,7 +474,6 @@ def _instantiateFeatureVariations(table, fvarAxes, location): ) applies = False newConditions.append(condition) - break if retainRecord and newConditions: record.ConditionSet.ConditionTable = newConditions diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 7b945778a..a29dfbede 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1316,18 +1316,22 @@ class InstantiateFeatureVariationsTest(object): ) ] ) - gsub = font["GSUB"].table - featureVariations = gsub.FeatureVariations + 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, {"wght": 0}) + 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 is kept + # 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 From b528ff67f055a24114265d33bc812ac703ca91dc Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 30 May 2019 13:35:56 +0100 Subject: [PATCH 102/127] instancer_test: add unit tests for parseLimits --- Tests/varLib/instancer_test.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index a29dfbede..0924f0abe 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1335,3 +1335,24 @@ class InstantiateFeatureVariationsTest(object): 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) From 68bbc74a7875b9c2968b23cec4c48c4db198af75 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 30 May 2019 13:56:58 +0100 Subject: [PATCH 103/127] instancer_test: add tests for main() function --- Tests/varLib/instancer_test.py | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 0924f0abe..b95b12de9 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1356,3 +1356,42 @@ def test_parseLimits(limits, expected): def test_parseLimits_invalid(limits): with pytest.raises(ValueError, match="invalid location format"): instancer.parseLimits(limits) + + +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 From b0ede1a3c6ac3264fa3f733f5092cacf587e65cb Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 30 May 2019 14:10:26 +0100 Subject: [PATCH 104/127] instancer_test: add tests for normalizeAxisLimits function coverage is now 97%, good enough --- Tests/varLib/instancer_test.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index b95b12de9..e15773cef 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1358,6 +1358,35 @@ def test_parseLimits_invalid(limits): 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) From de2d5382ba87c4d805b0d10db814c896ccceb733 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 31 May 2019 11:52:39 +0100 Subject: [PATCH 105/127] instancer: add more info to module-level docstring --- Lib/fontTools/varLib/instancer.py | 89 +++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index bcedab03d..6e41c815a 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -3,12 +3,67 @@ 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: +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. +See `fonttools varLib.instancer --help` for more info on the CLI options. + +The module's entry point is the `instantiateVariableFont` function, which takes +a TTFont object and a dict specifying a location along either some or all the axes, +and returns a new TTFont representing respectively a partial or a full instance. + +E.g. here's how to pin the wght axis at a given location in a wght+wdth variable +font, keeping only the deltas associated with the wdth axis: + +| >>> from fontTools import ttLib +| >>> from fontTools.varLib import instancer +| >>> varfont = ttLib.TTFont("path/to/MyVariableFont.ttf") +| >>> [a.axisTag for a in partial["fvar"].axes] # the varfont's current axes +| ['wght', 'wdth'] +| >>> partial = instancer.instantiateVariableFont(varfont, {"wght": 300}) +| >>> [a.axisTag for a in partial["fvar"].axes] # axes left after pinning 'wght' +| ['wdth'] + +If the input location specifies all the axes, the resulting instance is no longer +'variable' (same as using fontools varLib.mutator): + +| >>> instance = instancer.instantiateVariableFont( +| ... varfont, {"wght": 700, "wdth": 67.5} +| ... ) +| >>> "fvar" not in instance +| True + +If one just want to drop an axis at the default location, without knowing in +advance what the default value for that axis is, one can pass a `None` value: + +| >>> instance = instancer.instantiateVariableFont(varfont, {"wght": None}) +| >>> len(varfont["fvar"].axes) +| 1 + +From the console script, this is equivalent to passing `wght=drop` as input. + +Note that, unlike varLib.mutator, when an axis is not mentioned in the input +location, the varLib.instancer will keep the axis and the corresponding deltas, +whereas mutator implicitly drops the axis at its default coordinate. + +The module currently supports only the first two "levels" of partial instancing, +with the rest planned to be implemented in the future, namely: +L1) dropping one or more axes while leaving the default tables unmodified; +L2) dropping one or more axes while pinning them at non-default locations; +L3) restricting the range of variation of one or more axes, by setting either + a new minimum or maximum, potentially -- though not necessarily -- dropping + entire regions of variations that fall completely outside this new range. +L4) moving the default location of an axis. + +Currently only TrueType-flavored variable fonts (i.e. containing 'glyf' table) +are supported, but support for CFF2 variable fonts will be added soon. + +The discussion and implementation of these features are tracked at +https://github.com/fonttools/fonttools/issues/1537 + +NOTE: The module is experimental and both the API and the CLI *may* change. """ from __future__ import print_function, division, absolute_import from fontTools.misc.py23 import * @@ -739,6 +794,34 @@ def populateAxisDefaults(varfont, axis_limits): def instantiateVariableFont( varfont, axis_limits, inplace=False, optimize=True, overlap=True ): + """ Instantiate variable font, either fully or partially. + + Depending on whether the `axisLimits` dictionary references all or some of the + input varfont's axes, the output font will either be a full instance (static + font) or a variable font with possibly less variation data. + + Args: + varfont: a TTFont instance, which must contain at least an 'fvar' table. + Note that variable fonts with 'CFF2' table are not supported yet. + axisLimits: a dict keyed by axis tags (str) containing the coordinates (float) + along one or more axes where the desired instance will be located. + If the value is `None`, the default coordinate as per 'fvar' table for + that axis is used. + The limit values can also be (min, max) tuples for restricting an + axis's variation range, but this is not implemented yet. + inplace (bool): whether to modify input TTFont object in-place instead of + returning a distinct object. + optimize (bool): if False, do not perform IUP-delta optimization on the + remaining 'gvar' table's deltas. Possibly faster, and might work around + rendering issues in some buggy environments, at the cost of a slightly + larger file size. + overlap (bool): variable fonts usually contain overlapping contours, and some + font rendering engines on Apple platforms require that the `OVERLAP_SIMPLE` + and `OVERLAP_COMPOUND` flags in the 'glyf' table be set to force rendering + using a non-zero fill rule. Thus we always set these flags on all glyphs + to maximise cross-compatibility of the generated instance. You can disable + this by setting `overalap` to False. + """ sanityCheckVariableTables(varfont) if not inplace: From 06ad903ef162f9f558f2e93d6a863749581c1cfd Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 31 May 2019 12:00:08 +0100 Subject: [PATCH 106/127] instancer: rename locals from snake_case to camelCase for consistency the rest of the instancer module uses camelCase, no point in having both styles within the same module --- Lib/fontTools/varLib/instancer.py | 104 +++++++++++++++--------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 6e41c815a..c54ce0266 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -733,40 +733,40 @@ def setDefaultWeightWidthSlant(ttFont, location): ttFont["post"].italicAngle = italicAngle -def normalize(value, triple, avar_mapping): +def normalize(value, triple, avarMapping): value = normalizeValue(value, triple) - if avar_mapping: - value = piecewiseLinearMap(value, avar_mapping) + if avarMapping: + value = piecewiseLinearMap(value, avarMapping) # Quantize to F2Dot14, to avoid surprise interpolations. return floatToFixedToFloat(value, 14) -def normalizeAxisLimits(varfont, axis_limits): +def normalizeAxisLimits(varfont, axisLimits): fvar = varfont["fvar"] - bad_limits = set(axis_limits.keys()).difference(a.axisTag for a in fvar.axes) - if bad_limits: - raise ValueError("Cannot limit: {} not present in fvar".format(bad_limits)) + badLimits = set(axisLimits.keys()).difference(a.axisTag for a in fvar.axes) + if badLimits: + raise ValueError("Cannot limit: {} not present in fvar".format(badLimits)) axes = { a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in fvar.axes - if a.axisTag in axis_limits + if a.axisTag in axisLimits } - avar_segments = {} + avarSegments = {} if "avar" in varfont: - avar_segments = varfont["avar"].segments - normalized_limits = {} + avarSegments = varfont["avar"].segments + normalizedLimits = {} for axis_tag, triple in axes.items(): - avar_mapping = avar_segments.get(axis_tag, None) - value = axis_limits[axis_tag] + avarMapping = avarSegments.get(axis_tag, None) + value = axisLimits[axis_tag] if isinstance(value, tuple): - normalized_limits[axis_tag] = tuple( - normalize(v, triple, avar_mapping) for v in axis_limits[axis_tag] + normalizedLimits[axis_tag] = tuple( + normalize(v, triple, avarMapping) for v in axisLimits[axis_tag] ) else: - normalized_limits[axis_tag] = normalize(value, triple, avar_mapping) - return normalized_limits + normalizedLimits[axis_tag] = normalize(value, triple, avarMapping) + return normalizedLimits def sanityCheckVariableTables(varfont): @@ -780,19 +780,19 @@ def sanityCheckVariableTables(varfont): raise NotImplementedError("Instancing CFF2 variable fonts is not supported yet") -def populateAxisDefaults(varfont, axis_limits): - if any(value is None for value in axis_limits.values()): +def populateAxisDefaults(varfont, axisLimits): + if any(value is None for value in axisLimits.values()): fvar = varfont["fvar"] defaultValues = {a.axisTag: a.defaultValue for a in fvar.axes} return { axisTag: defaultValues[axisTag] if value is None else value - for axisTag, value in axis_limits.items() + for axisTag, value in axisLimits.items() } - return axis_limits + return axisLimits def instantiateVariableFont( - varfont, axis_limits, inplace=False, optimize=True, overlap=True + varfont, axisLimits, inplace=False, optimize=True, overlap=True ): """ Instantiate variable font, either fully or partially. @@ -827,43 +827,43 @@ def instantiateVariableFont( if not inplace: varfont = deepcopy(varfont) - axis_limits = populateAxisDefaults(varfont, axis_limits) + axisLimits = populateAxisDefaults(varfont, axisLimits) - normalized_limits = normalizeAxisLimits(varfont, axis_limits) + normalizedLimits = normalizeAxisLimits(varfont, axisLimits) - log.info("Normalized limits: %s", normalized_limits) + log.info("Normalized limits: %s", normalizedLimits) # TODO Remove this check once ranges are supported - if any(isinstance(v, tuple) for v in axis_limits.values()): + if any(isinstance(v, tuple) for v in axisLimits.values()): raise NotImplementedError("Axes range limits are not supported yet") if "gvar" in varfont: - instantiateGvar(varfont, normalized_limits, optimize=optimize) + instantiateGvar(varfont, normalizedLimits, optimize=optimize) if "cvar" in varfont: - instantiateCvar(varfont, normalized_limits) + instantiateCvar(varfont, normalizedLimits) if "MVAR" in varfont: - instantiateMVAR(varfont, normalized_limits) + instantiateMVAR(varfont, normalizedLimits) if "HVAR" in varfont: - instantiateHVAR(varfont, normalized_limits) + instantiateHVAR(varfont, normalizedLimits) if "VVAR" in varfont: - instantiateVVAR(varfont, normalized_limits) + instantiateVVAR(varfont, normalizedLimits) - instantiateOTL(varfont, normalized_limits) + instantiateOTL(varfont, normalizedLimits) - instantiateFeatureVariations(varfont, normalized_limits) + instantiateFeatureVariations(varfont, normalizedLimits) if "avar" in varfont: - instantiateAvar(varfont, normalized_limits) + instantiateAvar(varfont, normalizedLimits) with pruningUnusedNames(varfont): if "STAT" in varfont: - instantiateSTAT(varfont, axis_limits) + instantiateSTAT(varfont, axisLimits) - instantiateFvar(varfont, axis_limits) + instantiateFvar(varfont, axisLimits) if "fvar" not in varfont: if "glyf" in varfont and overlap: @@ -873,7 +873,7 @@ def instantiateVariableFont( varfont, location={ axisTag: limit - for axisTag, limit in axis_limits.items() + for axisTag, limit in axisLimits.items() if not isinstance(limit, tuple) }, ) @@ -883,12 +883,12 @@ def instantiateVariableFont( def parseLimits(limits): result = {} - for limit_string in limits: + for limitString in limits: match = re.match( - r"^(\w{1,4})=(?:(drop)|(?:([^:]+)(?:[:](.+))?))$", limit_string + r"^(\w{1,4})=(?:(drop)|(?:([^:]+)(?:[:](.+))?))$", limitString ) if not match: - raise ValueError("invalid location format: %r" % limit_string) + raise ValueError("invalid location format: %r" % limitString) tag = match.group(1).ljust(4) if match.group(2): # 'drop' lbound = None @@ -908,8 +908,8 @@ def parseArgs(args): """Parse argv. Returns: - 3-tuple (infile, axis_limits, options) - axis_limits is either a Dict[str, Optional[float]], for pinning variation axes + 3-tuple (infile, axisLimits, options) + axisLimits is either a Dict[str, Optional[float]], for pinning variation axes to specific coordinates along those axes (with `None` as a placeholder for an axis' default value); or a Dict[str, Tuple(float, float)], meaning limit this axis to min/max range. @@ -952,11 +952,11 @@ def parseArgs(args): help="Don't set OVERLAP_SIMPLE/OVERLAP_COMPOUND glyf flags (only applicable " "when generating a full instance)", ) - logging_group = parser.add_mutually_exclusive_group(required=False) - logging_group.add_argument( + loggingGroup = parser.add_mutually_exclusive_group(required=False) + loggingGroup.add_argument( "-v", "--verbose", action="store_true", help="Run more verbosely." ) - logging_group.add_argument( + loggingGroup.add_argument( "-q", "--quiet", action="store_true", help="Turn verbosity off." ) options = parser.parse_args(args) @@ -970,32 +970,32 @@ def parseArgs(args): ) try: - axis_limits = parseLimits(options.locargs) + axisLimits = parseLimits(options.locargs) except ValueError as e: parser.error(e) - if len(axis_limits) != len(options.locargs): + if len(axisLimits) != len(options.locargs): parser.error("Specified multiple limits for the same axis") - return (infile, axis_limits, options) + return (infile, axisLimits, options) def main(args=None): - infile, axis_limits, options = parseArgs(args) - log.info("Restricting axes: %s", axis_limits) + infile, axisLimits, options = parseArgs(args) + log.info("Restricting axes: %s", axisLimits) log.info("Loading variable font") varfont = TTFont(infile) isFullInstance = { axisTag - for axisTag, limit in axis_limits.items() + for axisTag, limit in axisLimits.items() if not isinstance(limit, tuple) }.issuperset(axis.axisTag for axis in varfont["fvar"].axes) instantiateVariableFont( varfont, - axis_limits, + axisLimits, inplace=True, optimize=options.optimize, overlap=options.overlap, From 1febf7f5a24e83fb189ba67b0b507c3285240337 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 31 May 2019 12:37:31 +0100 Subject: [PATCH 107/127] minor formatting --- Lib/fontTools/varLib/instancer.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index c54ce0266..e59c9ca1c 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -73,6 +73,7 @@ from fontTools.ttLib import TTFont from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools.ttLib.tables import _g_l_y_f 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 @@ -884,9 +885,7 @@ def instantiateVariableFont( def parseLimits(limits): result = {} for limitString in limits: - match = re.match( - r"^(\w{1,4})=(?:(drop)|(?:([^:]+)(?:[:](.+))?))$", limitString - ) + match = re.match(r"^(\w{1,4})=(?:(drop)|(?:([^:]+)(?:[:](.+))?))$", limitString) if not match: raise ValueError("invalid location format: %r" % limitString) tag = match.group(1).ljust(4) @@ -988,9 +987,7 @@ def main(args=None): varfont = TTFont(infile) isFullInstance = { - axisTag - for axisTag, limit in axisLimits.items() - if not isinstance(limit, tuple) + axisTag for axisTag, limit in axisLimits.items() if not isinstance(limit, tuple) }.issuperset(axis.axisTag for axis in varfont["fvar"].axes) instantiateVariableFont( From 3d5f5c0a363524fa0d1d6f51fbde49788a07341a Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 31 May 2019 19:57:30 +0100 Subject: [PATCH 108/127] Tests/conftest.py: disable fontTools.configLogger globally during tests otherwise it causes sides effects since logging state is global and should only be done when __name__ == '__main__'. We can capture logging messages via the caplog pytest fixture --- Tests/conftest.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 Tests/conftest.py diff --git a/Tests/conftest.py b/Tests/conftest.py new file mode 100644 index 000000000..11e783122 --- /dev/null +++ b/Tests/conftest.py @@ -0,0 +1,28 @@ +import fontTools +import pytest + + +@pytest.fixture(autouse=True, scope="session") +def disableConfigLogger(): + """Session-scoped fixture to make fontTools.configLogger function no-op. + + Logging in python maintain a global state. When in the tests we call a main() + function from modules subset or ttx, a call to configLogger is made that modifies + this global state (to configures a handler for the fontTools logger). + To prevent that, we monkey-patch the `configLogger` attribute of the `fontTools` + module (the one used in the scripts main() functions) so that it does nothing, + to avoid any side effects. + + NOTE: `fontTools.configLogger` is only an alias for the configLogger function in + `fontTools.misc.loggingTools` module; the original function is not modified. + """ + + def noop(*args, **kwargs): + return + + originalConfigLogger = fontTools.configLogger + fontTools.configLogger = noop + try: + yield + finally: + fontTools.configLogger = originalConfigLogger From 499d97464df1c825e396aa227761ad6e595b3534 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 31 May 2019 18:25:17 +0100 Subject: [PATCH 109/127] instancer_test: use caplog fixture --- Tests/varLib/instancer_test.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index e15773cef..c57039117 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -12,9 +12,9 @@ from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib import builder from fontTools.varLib import featureVars from fontTools.varLib import models -from fontTools.misc.loggingTools import CapturingLogHandler import collections from copy import deepcopy +import logging import os import re import pytest @@ -1307,7 +1307,7 @@ class InstantiateFeatureVariationsTest(object): else: assert not gsub.FeatureList.FeatureRecord - def test_unsupported_condition_format(self): + def test_unsupported_condition_format(self, caplog): font = makeFeatureVarsFont( [ ( @@ -1321,13 +1321,13 @@ class InstantiateFeatureVariationsTest(object): assert len(rec1.ConditionSet.ConditionTable) == 2 rec1.ConditionSet.ConditionTable[0].Format = 2 - with CapturingLogHandler("fontTools.varLib.instancer", "WARNING") as captor: + with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"): instancer.instantiateFeatureVariations(font, {"wdth": 0}) - captor.assertRegex( - r"Condition table 0 of FeatureVariationRecord 0 " - r"has unsupported format \(2\); ignored" - ) + assert ( + "Condition table 0 of FeatureVariationRecord 0 " + "has unsupported format (2); ignored" + ) in caplog.text # check that record with unsupported condition format (but whose other # conditions do not reference pinned axes) is kept as is From 8c3bfe547575c8c416a90b66cf0aa5e5b4378d7c Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 31 May 2019 19:55:43 +0100 Subject: [PATCH 110/127] ttx: use caplog fixture instead of capsys this capture logging messages whereas capsys captures stdout/stderr --- Tests/ttx/ttx_test.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/Tests/ttx/ttx_test.py b/Tests/ttx/ttx_test.py index eb5816ba8..97307fb7a 100644 --- a/Tests/ttx/ttx_test.py +++ b/Tests/ttx/ttx_test.py @@ -953,7 +953,7 @@ def test_main_getopterror_missing_directory(): ttx.main(args) -def test_main_keyboard_interrupt(tmpdir, monkeypatch, capsys): +def test_main_keyboard_interrupt(tmpdir, monkeypatch, caplog): with pytest.raises(SystemExit): inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx") outpath = tmpdir.join("TestTTF.ttf") @@ -963,8 +963,7 @@ def test_main_keyboard_interrupt(tmpdir, monkeypatch, capsys): ) ttx.main(args) - out, err = capsys.readouterr() - assert "(Cancelled.)" in err + assert "(Cancelled.)" in caplog.text @pytest.mark.skipif( @@ -982,7 +981,7 @@ def test_main_system_exit(tmpdir, monkeypatch): ttx.main(args) -def test_main_ttlib_error(tmpdir, monkeypatch, capsys): +def test_main_ttlib_error(tmpdir, monkeypatch, caplog): with pytest.raises(SystemExit): inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx") outpath = tmpdir.join("TestTTF.ttf") @@ -994,15 +993,14 @@ def test_main_ttlib_error(tmpdir, monkeypatch, capsys): ) ttx.main(args) - out, err = capsys.readouterr() - assert "Test error" in err + assert "Test error" in caplog.text @pytest.mark.skipif( sys.platform == "win32", reason="waitForKeyPress function causes test to hang on Windows platform", ) -def test_main_base_exception(tmpdir, monkeypatch, capsys): +def test_main_base_exception(tmpdir, monkeypatch, caplog): with pytest.raises(SystemExit): inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx") outpath = tmpdir.join("TestTTF.ttf") @@ -1014,8 +1012,7 @@ def test_main_base_exception(tmpdir, monkeypatch, capsys): ) ttx.main(args) - out, err = capsys.readouterr() - assert "Unhandled exception has occurred" in err + assert "Unhandled exception has occurred" in caplog.text # --------------------------- From 11b73034d7a55326da5bd164a1b9a73452130c9a Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 31 May 2019 20:03:52 +0100 Subject: [PATCH 111/127] instancer_test: read expected test file with unix newlines '\n' or windows isn't happy.. --- Tests/varLib/instancer_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index c57039117..4c89bc0b7 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1123,6 +1123,7 @@ def _get_expected_instance_ttx(wght, wdth): ), "r", encoding="utf-8", + newline="\n", ) as fp: return _strip_ttLibVersion(fp.read()) From 0abf6a82958150e6a1aa9aec0501cd6048c552ae Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 31 May 2019 20:08:50 +0100 Subject: [PATCH 112/127] instancer_test: always dump test ttx files with '\n' --- Tests/varLib/instancer_test.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 4c89bc0b7..f97e2a9ad 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1110,7 +1110,7 @@ def _dump_ttx(ttFont): tmp.seek(0) ttFont2 = ttLib.TTFont(tmp, recalcBBoxes=False, recalcTimestamp=False) s = StringIO() - ttFont2.saveXML(s) + ttFont2.saveXML(s, newlinestr="\n") return _strip_ttLibVersion(s.getvalue()) @@ -1123,7 +1123,6 @@ def _get_expected_instance_ttx(wght, wdth): ), "r", encoding="utf-8", - newline="\n", ) as fp: return _strip_ttLibVersion(fp.read()) From ff473515a238fba1f539562782757bd26c47a944 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 4 Jun 2019 16:16:31 +0100 Subject: [PATCH 113/127] g_l_y_f: use '==' instead of 'is' for comparing equality with int literals --- Lib/fontTools/ttLib/tables/_g_l_y_f.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/ttLib/tables/_g_l_y_f.py b/Lib/fontTools/ttLib/tables/_g_l_y_f.py index 362cfd19e..bba13ffd1 100644 --- a/Lib/fontTools/ttLib/tables/_g_l_y_f.py +++ b/Lib/fontTools/ttLib/tables/_g_l_y_f.py @@ -384,7 +384,7 @@ class table__g_l_y_f(DefaultTable.DefaultTable): for p,comp in zip(coord, glyph.components): if hasattr(comp, 'x'): comp.x,comp.y = p - elif glyph.numberOfContours is 0: + elif glyph.numberOfContours == 0: assert len(coord) == 0 else: assert len(coord) == len(glyph.coordinates) From 1722f99182b496ccbec22ede8df8813a59c6f847 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 6 Jun 2019 13:30:21 +0100 Subject: [PATCH 114/127] PartialInstancer-VF.ttx: add composite glyph and vhea table the glyph 'minus' references 'hyphen' as component, but doesn't have any deltas in gvar. vhea table is required when vmtx is present. --- Tests/varLib/data/PartialInstancerTest-VF.ttx | 52 +++++++++++++++---- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/data/PartialInstancerTest-VF.ttx index faefba56a..92540e03e 100644 --- a/Tests/varLib/data/PartialInstancerTest-VF.ttx +++ b/Tests/varLib/data/PartialInstancerTest-VF.ttx @@ -1,23 +1,24 @@ - + + - + - + - + @@ -46,17 +47,17 @@ - + - + - - + + @@ -64,8 +65,8 @@ - - + + @@ -106,7 +107,7 @@ - + @@ -124,6 +125,7 @@ + @@ -132,10 +134,12 @@ + + @@ -181,6 +185,10 @@ + + + + @@ -606,6 +614,7 @@ + @@ -1092,9 +1101,30 @@ + + + + + + + + + + + + + + + + + + + + + From 9d895be4d5ae31f58e8e7d93debcca4909889d1d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 6 Jun 2019 13:33:57 +0100 Subject: [PATCH 115/127] instancer_test: add test for composite glyph without variations We assert that the composite glyph's sidebearings and bbox are updated when its parent glyph has changed. The tests will fail, but a fix will follow shortly. --- Tests/varLib/instancer_test.py | 53 ++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index f97e2a9ad..dcc98669c 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -143,6 +143,59 @@ class InstantiateGvarTest(object): assert "gvar" not in varfont + def test_composite_glyph_not_in_gvar(self, varfont): + """ The 'minus' glyph is a composite glyph, which references 'hyphen' as a + component, but has no tuple variations in gvar table, so the component offset + and the phantom points do not change; however the sidebearings and bounding box + do change as a result of the parent glyph 'hyphen' changing. + """ + hmtx = varfont["hmtx"] + vmtx = varfont["vmtx"] + + hyphenCoords = _get_coordinates(varfont, "hyphen") + assert hyphenCoords == [ + (40, 229), + (40, 307), + (282, 307), + (282, 229), + (0, 0), + (322, 0), + (0, 536), + (0, 0), + ] + assert hmtx["hyphen"] == (322, 40) + assert vmtx["hyphen"] == (536, 229) + + minusCoords = _get_coordinates(varfont, "minus") + assert minusCoords == [(0, 0), (0, 0), (422, 0), (0, 536), (0, 0)] + assert hmtx["minus"] == (422, 40) + assert vmtx["minus"] == (536, 229) + + location = {"wght": -1.0, "wdth": -1.0} + + instancer.instantiateGvar(varfont, location) + + # check 'hyphen' coordinates changed + assert _get_coordinates(varfont, "hyphen") == [ + (26, 259), + (26, 286), + (237, 286), + (237, 259), + (0, 0), + (263, 0), + (0, 536), + (0, 0), + ] + # check 'minus' coordinates (i.e. component offset and phantom points) + # did _not_ change + assert _get_coordinates(varfont, "minus") == minusCoords + + assert hmtx["hyphen"] == (263, 26) + assert vmtx["hyphen"] == (536, 250) + + assert hmtx["minus"] == (422, 26) # 'minus' left sidebearing changed + assert vmtx["minus"] == (536, 250) # 'minus' top sidebearing too + class InstantiateCvarTest(object): @pytest.mark.parametrize( From 97405ddb354b13034427b7237f1d9a0e52f458a8 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 6 Jun 2019 13:38:11 +0100 Subject: [PATCH 116/127] instancer: also update sidebearings+bbox of glyphs with no variations Previously we were calling glyf.setCoordinates method only when a glyph had some variation deltas to be applied to the default glyf coordinates. However, some composite glyph may contain no variation delta but their base glyphs may change, thus we still need to update the sidebearings and bounding box of the composite glyphs. --- Lib/fontTools/varLib/instancer.py | 36 ++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index e59c9ca1c..0beb4b4bc 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -135,20 +135,32 @@ def instantiateGvarGlyph(varfont, glyphname, location, optimize=True): endPts = ctrl.endPts gvar = varfont["gvar"] - tupleVarStore = gvar.variations[glyphname] + # when exporting to TTX, a glyph with no variations is omitted; thus when loading + # a TTFont from TTX, a glyph that's present in glyf table may be missing from gvar. + tupleVarStore = gvar.variations.get(glyphname) - defaultDeltas = instantiateTupleVariationStore( - tupleVarStore, location, coordinates, endPts - ) + if tupleVarStore: + defaultDeltas = instantiateTupleVariationStore( + tupleVarStore, location, coordinates, endPts + ) - if defaultDeltas: - coordinates += _g_l_y_f.GlyphCoordinates(defaultDeltas) - # this will also set the hmtx/vmtx advance widths and sidebearings from - # the four phantom points and glyph bounding boxes - glyf.setCoordinates(glyphname, coordinates, varfont) + if defaultDeltas: + coordinates += _g_l_y_f.GlyphCoordinates(defaultDeltas) + + # setCoordinates also sets the hmtx/vmtx advance widths and sidebearings from + # the four phantom points and glyph bounding boxes. + # We call it unconditionally even if a glyph has no variations or no deltas are + # applied at this location, in case the glyph's xMin and in turn its sidebearing + # have changed. E.g. a composite glyph has no deltas for the component's (x, y) + # offset nor for the 4 phantom points (e.g. it's monospaced). Thus its entry in + # gvar table is empty; however, the composite's base glyph may have deltas + # applied, hence the composite's bbox and left/top sidebearings may need updating + # in the instanced font. + glyf.setCoordinates(glyphname, coordinates, varfont) if not tupleVarStore: - del gvar.variations[glyphname] + if glyphname in gvar.variations: + del gvar.variations[glyphname] return if optimize: @@ -162,12 +174,12 @@ def instantiateGvar(varfont, location, optimize=True): gvar = varfont["gvar"] glyf = varfont["glyf"] - # Get list of glyph names in gvar sorted by component depth. + # Get list of glyph names 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(), + glyf.glyphOrder, key=lambda name: ( glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth if glyf[name].isComposite() From 18efee2c7bb9ba4afb66912240d183fc0b6b4214 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 14 Jun 2019 10:59:41 +0100 Subject: [PATCH 117/127] TupleVariation: rename get{DeltaType,CoordWidth}; do not special-case scalar=0 we still need to branch between the case where coordinates are wrapped in (x, y) tuples or naked floats. --- Lib/fontTools/ttLib/tables/TupleVariation.py | 51 +++++++++----------- Tests/ttLib/tables/TupleVariation_test.py | 18 +++---- 2 files changed, 31 insertions(+), 38 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/TupleVariation.py b/Lib/fontTools/ttLib/tables/TupleVariation.py index f77cf1780..1f1612d75 100644 --- a/Lib/fontTools/ttLib/tables/TupleVariation.py +++ b/Lib/fontTools/ttLib/tables/TupleVariation.py @@ -444,49 +444,43 @@ class TupleVariation(object): size += axisCount * 4 return size - def getDeltaType(self): - """ Check if deltas are (x, y) as in gvar, or single values as in cvar. - Returns a string ("gvar" or "cvar"), or None if empty. + def getCoordWidth(self): + """ Return 2 if coordinates are (x, y) as in gvar, 1 if single values + as in cvar, or 0 if empty. """ firstDelta = next((c for c in self.coordinates if c is not None), None) if firstDelta is None: - return # empty or has no impact + return 0 # empty or has no impact if type(firstDelta) is tuple and len(firstDelta) == 2: - return "gvar" + return 2 elif type(firstDelta) in (int, float): - return "cvar" + return 1 else: raise TypeError("invalid type of delta: %s" % type(firstDelta)) def scaleDeltas(self, scalar): if scalar == 1.0: return # no change - deltaType = self.getDeltaType() - if deltaType == "gvar": - if scalar == 0: - self.coordinates = [(0, 0)] * len(self.coordinates) - else: - self.coordinates = [ - (d[0] * scalar, d[1] * scalar) if d is not None else None - for d in self.coordinates - ] - else: - if scalar == 0: - self.coordinates = [0] * len(self.coordinates) - else: - self.coordinates = [ - d * scalar if d is not None else None - for d in self.coordinates - ] + coordWidth = self.getCoordWidth() + if coordWidth == 2: + self.coordinates = [ + (d[0] * scalar, d[1] * scalar) if d is not None else None + for d in self.coordinates + ] + elif coordWidth == 1: + self.coordinates = [ + d * scalar if d is not None else None + for d in self.coordinates + ] def roundDeltas(self): - deltaType = self.getDeltaType() - if deltaType == "gvar": + coordWidth = self.getCoordWidth() + if coordWidth == 2: self.coordinates = [ (otRound(d[0]), otRound(d[1])) if d is not None else None for d in self.coordinates ] - else: + elif coordWidth == 1: self.coordinates = [ otRound(d) if d is not None else None for d in self.coordinates ] @@ -494,7 +488,7 @@ class TupleVariation(object): def calcInferredDeltas(self, origCoords, endPts): from fontTools.varLib.iup import iup_delta - if self.getDeltaType() == "cvar": + if self.getCoordWidth() == 1: raise TypeError( "Only 'gvar' TupleVariation can have inferred deltas" ) @@ -538,13 +532,12 @@ class TupleVariation(object): return NotImplemented deltas1 = self.coordinates length = len(deltas1) - deltaType = self.getDeltaType() deltas2 = other.coordinates if len(deltas2) != length: raise ValueError( "cannot sum TupleVariation deltas with different lengths" ) - if deltaType == "gvar": + if self.getCoordWidth() == 2: for i, d2 in zip(range(length), deltas2): d1 = deltas1[i] try: diff --git a/Tests/ttLib/tables/TupleVariation_test.py b/Tests/ttLib/tables/TupleVariation_test.py index 5a29f18f8..d78810a9c 100644 --- a/Tests/ttLib/tables/TupleVariation_test.py +++ b/Tests/ttLib/tables/TupleVariation_test.py @@ -688,24 +688,24 @@ class TupleVariationTest(unittest.TestCase): content = writer.file.getvalue().decode("utf-8") return [line.strip() for line in content.splitlines()][1:] - def test_getDeltaType(self): + def test_getCoordWidth(self): empty = TupleVariation({}, []) - self.assertIsNone(empty.getDeltaType()) + self.assertEqual(empty.getCoordWidth(), 0) empty = TupleVariation({}, [None]) - self.assertIsNone(empty.getDeltaType()) + self.assertEqual(empty.getCoordWidth(), 0) gvarTuple = TupleVariation({}, [None, (0, 0)]) - self.assertEqual(gvarTuple.getDeltaType(), "gvar") + self.assertEqual(gvarTuple.getCoordWidth(), 2) cvarTuple = TupleVariation({}, [None, 0]) - self.assertEqual(cvarTuple.getDeltaType(), "cvar") + self.assertEqual(cvarTuple.getCoordWidth(), 1) cvarTuple.coordinates[1] *= 1.0 - self.assertEqual(cvarTuple.getDeltaType(), "cvar") + self.assertEqual(cvarTuple.getCoordWidth(), 1) with self.assertRaises(TypeError): - TupleVariation({}, [None, "a"]).getDeltaType() + TupleVariation({}, [None, "a"]).getCoordWidth() def test_scaleDeltas_cvar(self): var = TupleVariation({}, [100, None]) @@ -718,7 +718,7 @@ class TupleVariationTest(unittest.TestCase): self.assertIsNone(var.coordinates[1]) var.scaleDeltas(0.0) - self.assertEqual(var.coordinates, [0, 0]) + self.assertEqual(var.coordinates, [0, None]) def test_scaleDeltas_gvar(self): var = TupleVariation({}, [(100, 200), None]) @@ -732,7 +732,7 @@ class TupleVariationTest(unittest.TestCase): self.assertIsNone(var.coordinates[1]) var.scaleDeltas(0.0) - self.assertEqual(var.coordinates, [(0, 0), (0, 0)]) + self.assertEqual(var.coordinates, [(0, 0), None]) def test_roundDeltas_cvar(self): var = TupleVariation({}, [55.5, None, 99.9]) From 751c181f2d74893d2821206c29673fe10a507d55 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 14 Jun 2019 11:09:48 +0100 Subject: [PATCH 118/127] instancer: remove comment about module being 'experimental' --- Lib/fontTools/varLib/instancer.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 0beb4b4bc..6a142ada4 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -62,8 +62,6 @@ are supported, but support for CFF2 variable fonts will be added soon. The discussion and implementation of these features are tracked at https://github.com/fonttools/fonttools/issues/1537 - -NOTE: The module is experimental and both the API and the CLI *may* change. """ from __future__ import print_function, division, absolute_import from fontTools.misc.py23 import * From 65b0609be120f6b247fa8f5f6d7e191c5780210d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 14 Jun 2019 11:17:37 +0100 Subject: [PATCH 119/127] remove redundant table__g_l_y_f.getCoordinates method just use getCoordinatesAndControls --- Lib/fontTools/ttLib/tables/_g_l_y_f.py | 21 --------------------- Lib/fontTools/varLib/mutator.py | 2 +- Tests/varLib/instancer_test.py | 2 +- 3 files changed, 2 insertions(+), 23 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/_g_l_y_f.py b/Lib/fontTools/ttLib/tables/_g_l_y_f.py index bba13ffd1..03501fd2e 100644 --- a/Lib/fontTools/ttLib/tables/_g_l_y_f.py +++ b/Lib/fontTools/ttLib/tables/_g_l_y_f.py @@ -335,27 +335,6 @@ class table__g_l_y_f(DefaultTable.DefaultTable): coords.extend(phantomPoints) return coords, controls - def getCoordinates(self, glyphName, ttFont, defaultVerticalOrigin=None): - """Same as `getCoordinatesAndControls` but only returns coordinates array, - or None if the glyph is missing. - """ - if glyphName not in self.glyphs: - return None - glyph = self[glyphName] - if glyph.isComposite(): - coords = GlyphCoordinates( - [(getattr(c, 'x', 0), getattr(c, 'y', 0)) for c in glyph.components] - ) - else: - coords, _, _ = glyph.getCoordinates(self) - coords = coords.copy() - # Add phantom points for (left, right, top, bottom) positions. - phantomPoints = self.getPhantomPoints( - glyphName, ttFont, defaultVerticalOrigin=defaultVerticalOrigin - ) - coords.extend(phantomPoints) - return coords - def setCoordinates(self, glyphName, coord, ttFont): """Set coordinates and metrics for the given glyph. diff --git a/Lib/fontTools/varLib/mutator.py b/Lib/fontTools/varLib/mutator.py index 9f25d2afc..aba327167 100644 --- a/Lib/fontTools/varLib/mutator.py +++ b/Lib/fontTools/varLib/mutator.py @@ -194,7 +194,7 @@ def instantiateVariableFont(varfont, location, inplace=False, overlap=True): name)) for glyphname in glyphnames: variations = gvar.variations[glyphname] - coordinates = glyf.getCoordinates(glyphname, varfont) + coordinates, _ = glyf.getCoordinatesAndControls(glyphname, varfont) origCoords, endPts = None, None for var in variations: scalar = supportScalar(loc, var.axes) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index dcc98669c..5cb604e40 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -53,7 +53,7 @@ def fvarAxes(): def _get_coordinates(varfont, glyphname): # converts GlyphCoordinates to a list of (x, y) tuples, so that pytest's # assert will give us a nicer diff - return list(varfont["glyf"].getCoordinates(glyphname, varfont)) + return list(varfont["glyf"].getCoordinatesAndControls(glyphname, varfont)[0]) class InstantiateGvarTest(object): From 1345ae8693a95d3bf7c4c7f43ce0fd97fca7f23f Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 14 Jun 2019 11:24:33 +0100 Subject: [PATCH 120/127] instancer: don't define self in terms of mutator, just say what it is --- Lib/fontTools/varLib/instancer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 6a142ada4..67b85f406 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1,8 +1,9 @@ """ 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. +The module exports an `instantiateVariableFont` function and CLI that allow to +create full instances (i.e. static fonts) from variable fonts, as well as "partial" +variable fonts that only contain a subset of the original 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: @@ -44,6 +45,7 @@ advance what the default value for that axis is, one can pass a `None` value: From the console script, this is equivalent to passing `wght=drop` as input. +This module is similar to fontTools.varLib.mutator, which it's intended to supersede. Note that, unlike varLib.mutator, when an axis is not mentioned in the input location, the varLib.instancer will keep the axis and the corresponding deltas, whereas mutator implicitly drops the axis at its default coordinate. From 29daa994f0806caad4d3ff4cbcaaeb9cbc425911 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 14 Jun 2019 12:51:31 +0100 Subject: [PATCH 121/127] instancer: add docstring for 'instantiateTupleVariationStore' --- Lib/fontTools/varLib/instancer.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 67b85f406..1c7e32050 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -93,6 +93,30 @@ log = logging.getLogger("fontTools.varLib.instancer") def instantiateTupleVariationStore(variations, location, origCoords=None, endPts=None): + """Instantiate TupleVariation list at the given location. + + The 'variations' list of TupleVariation objects is modified in-place. + The input location can describe either a full instance (all the axes are assigned an + explicit coordinate) or partial (some of the axes are omitted). + Tuples that do not participate are kept as they are. Those that have 0 influence + at the given location are removed from the variation store. + Those that are fully instantiated (i.e. all their axes are being pinned) are also + removed from the variation store, their scaled deltas accummulated and returned, so + that they can be added by the caller to the default instance's coordinates. + Tuples that are only partially instantiated (i.e. not all the axes that they + participate in are being pinned) are kept in the store, and their deltas multiplied + by the scalar support of the axes to be pinned at the desired location. + + Args: + variations: List[TupleVariation] from either 'gvar' or 'cvar'. + location: Dict[str, float]: axes coordinates for the full or partial instance. + origCoords: GlyphCoordinates: default instance's coordinates for computing 'gvar' + inferred points (cf. table__g_l_y_f.getCoordinatesAndControls). + endPts: List[int]: indices of contour end points, for inferring 'gvar' deltas. + + Returns: + List[float]: the overall delta adjustment after applicable deltas were summed. + """ newVariations = collections.OrderedDict() for var in variations: # Compute the scalar support of the axes to be pinned at the desired location, From 952fe9b059aefdb86b7990736a7a51793fdb97a3 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 14 Jun 2019 14:34:47 +0100 Subject: [PATCH 122/127] instancer: refactor _instantiateFeatureVariations to avoid too deep nesting https://github.com/fonttools/fonttools/pull/1628#discussion_r292600019 --- Lib/fontTools/varLib/instancer.py | 73 ++++++++++++++++++------------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 1c7e32050..27606fa89 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -529,6 +529,45 @@ def _featureVariationRecordIsUnique(rec, seen): return True +def _instantiateFeatureVariationRecord( + record, recIdx, location, fvarAxes, axisIndexMap +): + shouldKeep = False + applies = True + newConditions = [] + for i, condition in enumerate(record.ConditionSet.ConditionTable): + if condition.Format == 1: + axisIdx = condition.AxisIndex + axisTag = fvarAxes[axisIdx].axisTag + if axisTag in location: + minValue = condition.FilterRangeMinValue + maxValue = condition.FilterRangeMaxValue + v = location[axisTag] + if not (minValue <= v <= maxValue): + # condition not met so remove entire record + applies = False + newConditions = None + break + else: + # axis not pinned, keep condition with remapped axis index + applies = False + condition.AxisIndex = axisIndexMap[axisTag] + newConditions.append(condition) + else: + log.warning( + "Condition table {0} of FeatureVariationRecord {1} has " + "unsupported format ({2}); ignored".format(i, recIdx, condition.Format) + ) + applies = False + newConditions.append(condition) + + if newConditions: + record.ConditionSet.ConditionTable = newConditions + shouldKeep = True + + return applies, shouldKeep + + def _instantiateFeatureVariations(table, fvarAxes, location): pinnedAxes = set(location.keys()) axisOrder = [axis.axisTag for axis in fvarAxes if axis.axisTag not in pinnedAxes] @@ -539,36 +578,10 @@ def _instantiateFeatureVariations(table, fvarAxes, location): newRecords = [] for i, record in enumerate(table.FeatureVariations.FeatureVariationRecord): - retainRecord = True - applies = True - newConditions = [] - for j, condition in enumerate(record.ConditionSet.ConditionTable): - if condition.Format == 1: - axisIdx = condition.AxisIndex - axisTag = fvarAxes[axisIdx].axisTag - if 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 = applies = False - break - else: - # axis not pinned, keep condition with remapped axis index - applies = False - condition.AxisIndex = axisIndexMap[axisTag] - newConditions.append(condition) - else: - log.warning( - "Condition table {0} of FeatureVariationRecord {1} has " - "unsupported format ({2}); ignored".format(j, i, condition.Format) - ) - applies = False - newConditions.append(condition) - - if retainRecord and newConditions: - record.ConditionSet.ConditionTable = newConditions + applies, shouldKeep = _instantiateFeatureVariationRecord( + record, i, location, fvarAxes, axisIndexMap + ) + if shouldKeep: if _featureVariationRecordIsUnique(record, uniqueRecords): newRecords.append(record) From 720b266e892ece2d67e6385ab45c6abe1d836775 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 14 Jun 2019 15:41:33 +0100 Subject: [PATCH 123/127] instancer: minor: skip add cvar delta if None or 0 --- Lib/fontTools/varLib/instancer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 27606fa89..f62f99c57 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -220,7 +220,7 @@ def instantiateGvar(varfont, location, optimize=True): def setCvarDeltas(cvt, deltas): for i, delta in enumerate(deltas): - if delta is not None: + if delta: cvt[i] += otRound(delta) From a45af5d8db2bf0beed3450e821e35d4606fbba4d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 14 Jun 2019 16:05:56 +0100 Subject: [PATCH 124/127] minor: typos in comments [skip ci] --- Lib/fontTools/varLib/instancer.py | 2 +- Tests/conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index f62f99c57..570160e15 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -659,7 +659,7 @@ def instantiateSTAT(varfont, location): log.info("Instantiating STAT table") - # only keep DesignAxis that were not instanced, a build a mapping from old + # only keep DesignAxis that were not instanced, and build a mapping from old # to new axis indices newDesignAxes = [] axisIndexMap = {} diff --git a/Tests/conftest.py b/Tests/conftest.py index 11e783122..492861420 100644 --- a/Tests/conftest.py +++ b/Tests/conftest.py @@ -6,7 +6,7 @@ import pytest def disableConfigLogger(): """Session-scoped fixture to make fontTools.configLogger function no-op. - Logging in python maintain a global state. When in the tests we call a main() + Logging in python maintains a global state. When in the tests we call a main() function from modules subset or ttx, a call to configLogger is made that modifies this global state (to configures a handler for the fontTools logger). To prevent that, we monkey-patch the `configLogger` attribute of the `fontTools` From d21aa298248ebc43a9d7aab2652bdb4620573e61 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 17 Jun 2019 15:18:54 +0100 Subject: [PATCH 125/127] instancer: fixes addressing Evan's comments - changed getCoordWidth method to use all if-stmt, ordered by ret value (0, 1, 2). - added more info to TypeError message in getCoordWidth method. - in round/scaleDeltas, chained if statements in one line to avoid writing the loop twice. --- Lib/fontTools/ttLib/tables/TupleVariation.py | 45 ++++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/TupleVariation.py b/Lib/fontTools/ttLib/tables/TupleVariation.py index 1f1612d75..8d4e34a05 100644 --- a/Lib/fontTools/ttLib/tables/TupleVariation.py +++ b/Lib/fontTools/ttLib/tables/TupleVariation.py @@ -451,39 +451,38 @@ class TupleVariation(object): firstDelta = next((c for c in self.coordinates if c is not None), None) if firstDelta is None: return 0 # empty or has no impact + if type(firstDelta) in (int, float): + return 1 if type(firstDelta) is tuple and len(firstDelta) == 2: return 2 - elif type(firstDelta) in (int, float): - return 1 - else: - raise TypeError("invalid type of delta: %s" % type(firstDelta)) + raise TypeError( + "invalid type of delta; expected (int or float) number, or " + "Tuple[number, number]: %r" % firstDelta + ) def scaleDeltas(self, scalar): if scalar == 1.0: return # no change coordWidth = self.getCoordWidth() - if coordWidth == 2: - self.coordinates = [ - (d[0] * scalar, d[1] * scalar) if d is not None else None - for d in self.coordinates - ] - elif coordWidth == 1: - self.coordinates = [ - d * scalar if d is not None else None - for d in self.coordinates - ] + self.coordinates = [ + None + if d is None + else d * scalar + if coordWidth == 1 + else (d[0] * scalar, d[1] * scalar) + for d in self.coordinates + ] def roundDeltas(self): coordWidth = self.getCoordWidth() - if coordWidth == 2: - self.coordinates = [ - (otRound(d[0]), otRound(d[1])) if d is not None else None - for d in self.coordinates - ] - elif coordWidth == 1: - self.coordinates = [ - otRound(d) if d is not None else None for d in self.coordinates - ] + self.coordinates = [ + None + if d is None + else otRound(d) + if coordWidth == 1 + else (otRound(d[0]), otRound(d[1])) + for d in self.coordinates + ] def calcInferredDeltas(self, origCoords, endPts): from fontTools.varLib.iup import iup_delta From e6b8897f1861fffd7948b06d8b64410bbb6f9c89 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 20 Jun 2019 15:09:17 +0100 Subject: [PATCH 126/127] instancer: reuse varLib.set_default_weight_width_slant function --- Lib/fontTools/varLib/instancer.py | 33 +------------- Tests/varLib/instancer_test.py | 73 ------------------------------- 2 files changed, 1 insertion(+), 105 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 570160e15..52d62c998 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -752,37 +752,6 @@ def setMacOverlapFlags(glyfTable): glyph.flags[0] |= flagOverlapSimple -def setDefaultWeightWidthSlant(ttFont, location): - if "wght" in location and "OS/2" in ttFont: - weightClass = otRound(max(1, min(location["wght"], 1000))) - log.info("Setting OS/2.usWidthClass = %s", weightClass) - ttFont["OS/2"].usWeightClass = weightClass - - if "wdth" in location: - # map 'wdth' axis (1..200) to OS/2.usWidthClass (1..9), rounding to closest - steps = [50.0, 62.5, 75.0, 87.5, 100.0, 112.5, 125.0, 150.0, 200.0] - n = len(steps) - os2WidthClasses = { - (prev + curr) / 2: widthClass - for widthClass, (prev, curr) in enumerate( - zip(islice(steps, 0, n - 1), islice(steps, 1, n)), start=1 - ) - } - wdth = location["wdth"] - for percent, widthClass in sorted(os2WidthClasses.items()): - if wdth < percent: - break - else: - widthClass = 9 - log.info("Setting OS/2.usWidthClass = %s", widthClass) - ttFont["OS/2"].usWidthClass = widthClass - - if "slnt" in location and "post" in ttFont: - italicAngle = max(-90, min(location["slnt"], 90)) - log.info("Setting post.italicAngle = %s", italicAngle) - ttFont["post"].italicAngle = italicAngle - - def normalize(value, triple, avarMapping): value = normalizeValue(value, triple) if avarMapping: @@ -919,7 +888,7 @@ def instantiateVariableFont( if "glyf" in varfont and overlap: setMacOverlapFlags(varfont["glyf"]) - setDefaultWeightWidthSlant( + varLib.set_default_weight_width_slant( varfont, location={ axisTag: limit diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 5cb604e40..7c587b595 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1072,79 +1072,6 @@ def test_setMacOverlapFlags(): assert b.components[0].flags & flagOverlapCompound != 0 -@pytest.fixture -def ttFont(): - f = ttLib.TTFont() - f["OS/2"] = ttLib.newTable("OS/2") - f["post"] = ttLib.newTable("post") - return f - - -class SetDefaultWeightWidthSlantTest(object): - @pytest.mark.parametrize( - "location, expected", - [ - ({"wght": 0}, 1), - ({"wght": 1}, 1), - ({"wght": 100}, 100), - ({"wght": 1000}, 1000), - ({"wght": 1001}, 1000), - ], - ) - def test_wght(self, ttFont, location, expected): - instancer.setDefaultWeightWidthSlant(ttFont, location) - - assert ttFont["OS/2"].usWeightClass == expected - - @pytest.mark.parametrize( - "location, expected", - [ - ({"wdth": 0}, 1), - ({"wdth": 56}, 1), - ({"wdth": 57}, 2), - ({"wdth": 62.5}, 2), - ({"wdth": 75}, 3), - ({"wdth": 87.5}, 4), - ({"wdth": 100}, 5), - ({"wdth": 112.5}, 6), - ({"wdth": 125}, 7), - ({"wdth": 150}, 8), - ({"wdth": 200}, 9), - ({"wdth": 201}, 9), - ({"wdth": 1000}, 9), - ], - ) - def test_wdth(self, ttFont, location, expected): - instancer.setDefaultWeightWidthSlant(ttFont, location) - - assert ttFont["OS/2"].usWidthClass == expected - - @pytest.mark.parametrize( - "location, expected", - [ - ({"slnt": -91}, -90), - ({"slnt": -90}, -90), - ({"slnt": 0}, 0), - ({"slnt": 11.5}, 11.5), - ({"slnt": 90}, 90), - ({"slnt": 91}, 90), - ], - ) - def test_slnt(self, ttFont, location, expected): - instancer.setDefaultWeightWidthSlant(ttFont, location) - - assert ttFont["post"].italicAngle == expected - - def test_all(self, ttFont): - instancer.setDefaultWeightWidthSlant( - ttFont, {"wght": 500, "wdth": 150, "slnt": -12.0} - ) - - assert ttFont["OS/2"].usWeightClass == 500 - assert ttFont["OS/2"].usWidthClass == 8 - assert ttFont["post"].italicAngle == -12.0 - - def _strip_ttLibVersion(string): return re.sub(' ttLibVersion=".*"', "", string) From 7ffd6a3d0f04e5d10999ac179b0e1ecc1474a6bc Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 31 Jul 2019 16:31:29 +0100 Subject: [PATCH 127/127] instancer: minor changes following Evan's review --- Lib/fontTools/ttLib/tables/TupleVariation.py | 8 ++++++ Lib/fontTools/ttLib/tables/_g_l_y_f.py | 28 +++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/TupleVariation.py b/Lib/fontTools/ttLib/tables/TupleVariation.py index 8d4e34a05..fb2131b2b 100644 --- a/Lib/fontTools/ttLib/tables/TupleVariation.py +++ b/Lib/fontTools/ttLib/tables/TupleVariation.py @@ -536,6 +536,14 @@ class TupleVariation(object): raise ValueError( "cannot sum TupleVariation deltas with different lengths" ) + # 'None' values have different meanings in gvar vs cvar TupleVariations: + # within the gvar, when deltas are not provided explicitly for some points, + # they need to be inferred; whereas for the 'cvar' table, if deltas are not + # provided for some CVT values, then no adjustments are made (i.e. None == 0). + # Thus, we cannot sum deltas for gvar TupleVariations if they contain + # inferred inferred deltas (the latter need to be computed first using + # 'calcInferredDeltas' method), but we can treat 'None' values in cvar + # deltas as if they are zeros. if self.getCoordWidth() == 2: for i, d2 in zip(range(length), deltas2): d1 = deltas1[i] diff --git a/Lib/fontTools/ttLib/tables/_g_l_y_f.py b/Lib/fontTools/ttLib/tables/_g_l_y_f.py index 03501fd2e..7d2c16e6d 100644 --- a/Lib/fontTools/ttLib/tables/_g_l_y_f.py +++ b/Lib/fontTools/ttLib/tables/_g_l_y_f.py @@ -251,8 +251,9 @@ class table__g_l_y_f(DefaultTable.DefaultTable): If the ttFont doesn't contain a "vmtx" table, the hhea.ascent is used as the vertical origin, and the head.unitsPerEm as the vertical advance. - The "defaultVerticalOrigin" (Optional[int]) is used when the ttFont contains - neither a "vmtx" nor an "hhea" table. + The "defaultVerticalOrigin" (Optional[int]) is needed when the ttFont contains + neither a "vmtx" nor an "hhea" table, as may happen with 'sparse' masters. + The value should be the hhea.ascent of the default master. https://docs.microsoft.com/en-us/typography/opentype/spec/tt_instructing_glyphs#phantoms """ @@ -270,13 +271,20 @@ class table__g_l_y_f(DefaultTable.DefaultTable): # without vmtx, use ascent as vertical origin and UPEM as vertical advance # like HarfBuzz does verticalAdvanceWidth = ttFont["head"].unitsPerEm - try: + if "hhea" in ttFont: topSideY = ttFont["hhea"].ascent - except KeyError: + else: # sparse masters may not contain an hhea table; use the ascent # of the default master as the vertical origin - assert defaultVerticalOrigin is not None - topSideY = defaultVerticalOrigin + if defaultVerticalOrigin is not None: + topSideY = defaultVerticalOrigin + else: + log.warning( + "font is missing both 'vmtx' and 'hhea' tables, " + "and no 'defaultVerticalOrigin' was provided; " + "the vertical phantom points may be incorrect." + ) + topSideY = verticalAdvanceWidth bottomSideY = topSideY - verticalAdvanceWidth return [ (leftSideX, 0), @@ -338,8 +346,8 @@ class table__g_l_y_f(DefaultTable.DefaultTable): def setCoordinates(self, glyphName, coord, ttFont): """Set coordinates and metrics for the given glyph. - "coord" is an array of GlyphCoordinates which must include the four - "phantom points". + "coord" is an array of GlyphCoordinates which must include the "phantom + points" as the last four coordinates. Both the horizontal/vertical advances and left/top sidebearings in "hmtx" and "vmtx" tables (if any) are updated from four phantom points and @@ -360,9 +368,9 @@ class table__g_l_y_f(DefaultTable.DefaultTable): if glyph.isComposite(): assert len(coord) == len(glyph.components) - for p,comp in zip(coord, glyph.components): + for p, comp in zip(coord, glyph.components): if hasattr(comp, 'x'): - comp.x,comp.y = p + comp.x, comp.y = p elif glyph.numberOfContours == 0: assert len(coord) == 0 else: