diff options
Diffstat (limited to 'Lib/fontTools/varLib')
-rw-r--r-- | Lib/fontTools/varLib/__init__.py | 34 | ||||
-rw-r--r-- | Lib/fontTools/varLib/cff.py | 33 | ||||
-rw-r--r-- | Lib/fontTools/varLib/errors.py | 157 | ||||
-rw-r--r-- | Lib/fontTools/varLib/instancer/__init__.py (renamed from Lib/fontTools/varLib/instancer.py) | 87 | ||||
-rw-r--r-- | Lib/fontTools/varLib/instancer/__main__.py | 5 | ||||
-rw-r--r-- | Lib/fontTools/varLib/instancer/names.py | 379 | ||||
-rw-r--r-- | Lib/fontTools/varLib/merger.py | 158 | ||||
-rw-r--r-- | Lib/fontTools/varLib/models.py | 92 | ||||
-rw-r--r-- | Lib/fontTools/varLib/mutator.py | 13 | ||||
-rw-r--r-- | Lib/fontTools/varLib/plot.py | 6 | ||||
-rw-r--r-- | Lib/fontTools/varLib/varStore.py | 23 |
11 files changed, 733 insertions, 254 deletions
diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 605fda2a..36ff0d97 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -18,9 +18,9 @@ Then you can make a variable-font this way: API *will* change in near future. """ -from fontTools.misc.py23 import * -from fontTools.misc.fixedTools import otRound -from fontTools.misc.arrayTools import Vector +from fontTools.misc.py23 import Tag, tostr +from fontTools.misc.roundTools import noRound, otRound +from fontTools.misc.vector import Vector from fontTools.ttLib import TTFont, newTable from fontTools.ttLib.tables._f_v_a_r import Axis, NamedInstance from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates @@ -34,6 +34,7 @@ from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.iup import iup_delta_optimize from fontTools.varLib.featureVars import addFeatureVariations from fontTools.designspaceLib import DesignSpaceDocument +from functools import partial from collections import OrderedDict, namedtuple import os.path import logging @@ -90,7 +91,7 @@ def _add_fvar(font, axes, instances): "stylename element with an 'xml:lang=\"en\"' attribute)." ) localisedStyleName = dict(instance.localisedStyleName) - localisedStyleName["en"] = tounicode(instance.styleName) + localisedStyleName["en"] = tostr(instance.styleName) else: localisedStyleName = instance.localisedStyleName @@ -99,7 +100,7 @@ def _add_fvar(font, axes, instances): inst = NamedInstance() inst.subfamilyNameID = nameTable.addMultilingualName(localisedStyleName) if psname is not None: - psname = tounicode(psname) + psname = tostr(psname) inst.postscriptNameID = nameTable.addName(psname) inst.coordinates = {axes[k].tag:axes[k].map_backward(v) for k,v in coordinates.items()} #inst.coordinates = {axes[k].tag:v for k,v in coordinates.items()} @@ -253,7 +254,7 @@ def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): # Update gvar gvar.variations[glyph] = [] - deltas = model.getDeltas(allCoords) + deltas = model.getDeltas(allCoords, round=partial(GlyphCoordinates.__round__, round=round)) supports = model.supports assert len(deltas) == len(supports) @@ -262,7 +263,7 @@ def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): 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: + if all(v == 0 for v in delta.array) and not isComposite: continue var = TupleVariation(support, delta) if optimize: @@ -304,7 +305,7 @@ def _remove_TTHinting(font): font["glyf"].removeHinting() # TODO: Modify gasp table to deactivate gridfitting for all ranges? -def _merge_TTHinting(font, masterModel, master_ttfs, tolerance=0.5): +def _merge_TTHinting(font, masterModel, master_ttfs): log.info("Merging TT hinting") assert "cvar" not in font @@ -363,10 +364,9 @@ def _merge_TTHinting(font, masterModel, master_ttfs, tolerance=0.5): return variations = [] - deltas, supports = masterModel.getDeltasAndSupports(all_cvs) + deltas, supports = masterModel.getDeltasAndSupports(all_cvs, round=round) # builtin round calls into Vector.__round__, which uses builtin round as we like for i,(delta,support) in enumerate(zip(deltas[1:], supports[1:])): - delta = [otRound(d) for d in delta] - if all(abs(v) <= tolerance for v in delta): + if all(v == 0 for v in delta): continue var = TupleVariation(support, delta) variations.append(var) @@ -441,7 +441,7 @@ def _get_advance_metrics(font, masterModel, master_ttfs, vOrigDeltasAndSupports = {} for glyph in glyphOrder: vhAdvances = [metrics[glyph][0] if glyph in metrics else None for metrics in advMetricses] - vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vhAdvances) + vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vhAdvances, round=round) singleModel = models.allEqual(id(v[1]) for v in vhAdvanceDeltasAndSupports.values()) @@ -453,7 +453,7 @@ def _get_advance_metrics(font, masterModel, master_ttfs, # glyphs which have a non-default vOrig. vOrigs = [metrics[glyph] if glyph in metrics else defaultVOrig for metrics, defaultVOrig in vOrigMetricses] - vOrigDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vOrigs) + vOrigDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vOrigs, round=round) directStore = None if singleModel: @@ -463,7 +463,7 @@ def _get_advance_metrics(font, masterModel, master_ttfs, varTupleIndexes = list(range(len(supports))) varData = builder.buildVarData(varTupleIndexes, [], optimize=False) for glyphName in glyphOrder: - varData.addItem(vhAdvanceDeltasAndSupports[glyphName][0]) + varData.addItem(vhAdvanceDeltasAndSupports[glyphName][0], round=noRound) varData.optimize() directStore = builder.buildVarStore(varTupleList, [varData]) @@ -473,14 +473,14 @@ def _get_advance_metrics(font, masterModel, master_ttfs, for glyphName in glyphOrder: deltas, supports = vhAdvanceDeltasAndSupports[glyphName] storeBuilder.setSupports(supports) - advMapping[glyphName] = storeBuilder.storeDeltas(deltas) + advMapping[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound) if vOrigMetricses: vOrigMap = {} for glyphName in glyphOrder: deltas, supports = vOrigDeltasAndSupports[glyphName] storeBuilder.setSupports(supports) - vOrigMap[glyphName] = storeBuilder.storeDeltas(deltas) + vOrigMap[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound) indirectStore = storeBuilder.finish() mapping2 = indirectStore.optimize() @@ -751,7 +751,7 @@ def load_designspace(designspace): if not axis.tag: raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.") if not axis.labelNames: - axis.labelNames["en"] = tounicode(axis_name) + axis.labelNames["en"] = tostr(axis_name) axes[axis_name] = axis log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()])) diff --git a/Lib/fontTools/varLib/cff.py b/Lib/fontTools/varLib/cff.py index 0a6ba220..4eed8b33 100644 --- a/Lib/fontTools/varLib/cff.py +++ b/Lib/fontTools/varLib/cff.py @@ -17,10 +17,14 @@ from fontTools.cffLib.specializer import ( from fontTools.ttLib import newTable from fontTools import varLib from fontTools.varLib.models import allEqual +from fontTools.misc.roundTools import roundFunc from fontTools.misc.psCharStrings import T2CharString, T2OutlineExtractor -from fontTools.pens.t2CharStringPen import T2CharStringPen, t2c_round +from fontTools.pens.t2CharStringPen import T2CharStringPen +from functools import partial -from .errors import VarLibCFFDictMergeError, VarLibCFFPointTypeMergeError, VarLibMergeError +from .errors import ( + VarLibCFFDictMergeError, VarLibCFFPointTypeMergeError, + VarLibCFFHintTypeMergeError,VarLibMergeError) # Backwards compatibility @@ -422,16 +426,6 @@ def merge_charstrings(glyphOrder, num_masters, top_dicts, masterModel): return cvData -def makeRoundNumberFunc(tolerance): - if tolerance < 0: - raise ValueError("Rounding tolerance must be positive") - - def roundNumber(val): - return t2c_round(val, tolerance) - - return roundNumber - - class CFFToCFF2OutlineExtractor(T2OutlineExtractor): """ This class is used to remove the initial width from the CFF charstring without trying to add the width to self.nominalWidthX, @@ -518,7 +512,7 @@ class CFF2CharStringMergePen(T2CharStringPen): self.prev_move_idx = 0 self.seen_moveto = False self.glyphName = glyphName - self.roundNumber = makeRoundNumberFunc(roundTolerance) + self.round = roundFunc(roundTolerance, round=round) def add_point(self, point_type, pt_coords): if self.m_index == 0: @@ -539,7 +533,7 @@ class CFF2CharStringMergePen(T2CharStringPen): else: cmd = self._commands[self.pt_index] if cmd[0] != hint_type: - raise VarLibCFFPointTypeMergeError(hint_type, self.pt_index, len(cmd[1]), + raise VarLibCFFHintTypeMergeError(hint_type, self.pt_index, len(cmd[1]), cmd[0], self.glyphName) cmd[1].append(args) self.pt_index += 1 @@ -548,14 +542,14 @@ class CFF2CharStringMergePen(T2CharStringPen): # For hintmask, fonttools.cffLib.specializer.py expects # each of these to be represented by two sequential commands: # first holding only the operator name, with an empty arg list, - # second with an empty string as the op name, and the mask arg list. + # second with an empty string as the op name, and the mask arg list. if self.m_index == 0: self._commands.append([hint_type, []]) self._commands.append(["", [abs_args]]) else: cmd = self._commands[self.pt_index] if cmd[0] != hint_type: - raise VarLibCFFPointTypeMergeError(hint_type, self.pt_index, len(cmd[1]), + raise VarLibCFFHintTypeMergeError(hint_type, self.pt_index, len(cmd[1]), cmd[0], self.glyphName) self.pt_index += 1 cmd = self._commands[self.pt_index] @@ -594,7 +588,7 @@ class CFF2CharStringMergePen(T2CharStringPen): def getCommands(self): return self._commands - def reorder_blend_args(self, commands, get_delta_func, round_func): + def reorder_blend_args(self, commands, get_delta_func): """ We first re-order the master coordinate values. For a moveto to lineto, the args are now arranged as: @@ -637,8 +631,6 @@ class CFF2CharStringMergePen(T2CharStringPen): else: # convert to deltas deltas = get_delta_func(coord)[1:] - if round_func: - deltas = [round_func(delta) for delta in deltas] coord = [coord[0]] + deltas new_coords.append(coord) cmd[1] = new_coords @@ -649,8 +641,7 @@ class CFF2CharStringMergePen(T2CharStringPen): self, private=None, globalSubrs=None, var_model=None, optimize=True): commands = self._commands - commands = self.reorder_blend_args(commands, var_model.getDeltas, - self.roundNumber) + commands = self.reorder_blend_args(commands, partial (var_model.getDeltas, round=self.round)) if optimize: commands = specializeCommands( commands, generalizeFirst=False, diff --git a/Lib/fontTools/varLib/errors.py b/Lib/fontTools/varLib/errors.py index b73f1886..5840070f 100644 --- a/Lib/fontTools/varLib/errors.py +++ b/Lib/fontTools/varLib/errors.py @@ -1,3 +1,6 @@ +import textwrap + + class VarLibError(Exception): """Base exception for the varLib module.""" @@ -9,8 +12,144 @@ class VarLibValidationError(VarLibError): class VarLibMergeError(VarLibError): """Raised when input data cannot be merged into a variable font.""" + def __init__(self, merger, **kwargs): + self.merger = merger + if not kwargs: + kwargs = {} + if "stack" in kwargs: + self.stack = kwargs["stack"] + del kwargs["stack"] + else: + self.stack = [] + self.cause = kwargs + + @property + def reason(self): + return self.__doc__ + + def _master_name(self, ix): + ttf = self.merger.ttfs[ix] + if ( + "name" in ttf + and ttf["name"].getDebugName(1) + and ttf["name"].getDebugName(2) + ): + return ttf["name"].getDebugName(1) + " " + ttf["name"].getDebugName(2) + elif hasattr(ttf.reader, "file") and hasattr(ttf.reader.file, "name"): + return ttf.reader.file.name + else: + return "master number %i" % ix + + @property + def offender(self): + if "expected" in self.cause and "got" in self.cause: + index = [x == self.cause["expected"] for x in self.cause["got"]].index( + False + ) + return index, self._master_name(index) + return None, None + + @property + def details(self): + if "expected" in self.cause and "got" in self.cause: + offender_index, offender = self.offender + got = self.cause["got"][offender_index] + return f"Expected to see {self.stack[0]}=={self.cause['expected']}, instead saw {got}\n" + return "" + + def __str__(self): + offender_index, offender = self.offender + location = "" + if offender: + location = f"\n\nThe problem is likely to be in {offender}:\n" + context = "".join(reversed(self.stack)) + basic = textwrap.fill( + f"Couldn't merge the fonts, because {self.reason}. " + f"This happened while performing the following operation: {context}", + width=78, + ) + return "\n\n" + basic + location + self.details + + +class ShouldBeConstant(VarLibMergeError): + """some values were different, but should have been the same""" + + @property + def details(self): + if self.stack[0] != ".FeatureCount": + return super().details + offender_index, offender = self.offender + bad_ttf = self.merger.ttfs[offender_index] + good_ttf = self.merger.ttfs[offender_index - 1] + + good_features = [ + x.FeatureTag + for x in good_ttf[self.stack[-1]].table.FeatureList.FeatureRecord + ] + bad_features = [ + x.FeatureTag + for x in bad_ttf[self.stack[-1]].table.FeatureList.FeatureRecord + ] + return ( + "\nIncompatible features between masters.\n" + f"Expected: {', '.join(good_features)}.\n" + f"Got: {', '.join(bad_features)}.\n" + ) + + +class FoundANone(VarLibMergeError): + """one of the values in a list was empty when it shouldn't have been""" + + @property + def offender(self): + cause = self.argv[0] + index = [x is None for x in cause["got"]].index(True) + return index, self._master_name(index) + + @property + def details(self): + cause, stack = self.args[0], self.args[1:] + return f"{stack[0]}=={cause['got']}\n" + + +class MismatchedTypes(VarLibMergeError): + """data had inconsistent types""" + + +class LengthsDiffer(VarLibMergeError): + """a list of objects had inconsistent lengths""" -class VarLibCFFDictMergeError(VarLibMergeError): + +class KeysDiffer(VarLibMergeError): + """a list of objects had different keys""" + + +class InconsistentGlyphOrder(VarLibMergeError): + """the glyph order was inconsistent between masters""" + + +class InconsistentExtensions(VarLibMergeError): + """the masters use extension lookups in inconsistent ways""" + + +class UnsupportedFormat(VarLibMergeError): + """an OpenType subtable (%s) had a format I didn't expect""" + + @property + def reason(self): + cause, stack = self.args[0], self.args[1:] + return self.__doc__ % cause["subtable"] + + +class UnsupportedFormat(UnsupportedFormat): + """an OpenType subtable (%s) had inconsistent formats between masters""" + + +class VarLibCFFMergeError(VarLibError): + pass + + +class VarLibCFFDictMergeError(VarLibCFFMergeError): """Raised when a CFF PrivateDict cannot be merged.""" def __init__(self, key, value, values): @@ -23,8 +162,8 @@ class VarLibCFFDictMergeError(VarLibMergeError): self.args = (error_msg,) -class VarLibCFFPointTypeMergeError(VarLibMergeError): - """Raised when a CFF glyph cannot be merged.""" +class VarLibCFFPointTypeMergeError(VarLibCFFMergeError): + """Raised when a CFF glyph cannot be merged because of point type differences.""" def __init__(self, point_type, pt_index, m_index, default_type, glyph_name): error_msg = ( @@ -35,5 +174,17 @@ class VarLibCFFPointTypeMergeError(VarLibMergeError): self.args = (error_msg,) +class VarLibCFFHintTypeMergeError(VarLibCFFMergeError): + """Raised when a CFF glyph cannot be merged because of hint type differences.""" + + def __init__(self, hint_type, cmd_index, m_index, default_type, glyph_name): + error_msg = ( + f"Glyph '{glyph_name}': '{hint_type}' at index {cmd_index} in " + f"master index {m_index} differs from the default font hint type " + f"'{default_type}'" + ) + self.args = (error_msg,) + + class VariationModelError(VarLibError): """Raised when a variation model is faulty.""" diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer/__init__.py index fba17842..9bd30f19 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -84,6 +84,7 @@ from fontTools import subset # noqa: F401 from fontTools.varLib import builder from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.merger import MutatorMerger +from fontTools.varLib.instancer import names from contextlib import contextmanager import collections from copy import deepcopy @@ -1008,6 +1009,13 @@ def instantiateSTAT(varfont, axisLimits): ): return # STAT table empty, nothing to do + log.info("Instantiating STAT table") + newAxisValueTables = axisValuesFromAxisLimits(stat, axisLimits) + stat.AxisValueArray.AxisValue = newAxisValueTables + stat.AxisValueCount = len(stat.AxisValueArray.AxisValue) + + +def axisValuesFromAxisLimits(stat, axisLimits): location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange) def isAxisValueOutsideLimits(axisTag, axisValue): @@ -1019,8 +1027,6 @@ def instantiateSTAT(varfont, axisLimits): return True return False - log.info("Instantiating STAT table") - # only keep AxisValues whose axis is not pinned nor restricted, or is pinned at the # exact (nominal) value, or is restricted but the value is within the new range designAxes = stat.DesignAxisRecord.Axis @@ -1048,55 +1054,9 @@ def instantiateSTAT(varfont, axisLimits): if dropAxisValueTable: continue else: - log.warn("Unknown AxisValue table format (%s); ignored", axisValueFormat) + log.warning("Unknown AxisValue table format (%s); ignored", axisValueFormat) newAxisValueTables.append(axisValueTable) - - stat.AxisValueArray.AxisValue = newAxisValueTables - stat.AxisValueCount = len(stat.AxisValueArray.AxisValue) - - -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"] + return newAxisValueTables def setMacOverlapFlags(glyfTable): @@ -1187,6 +1147,7 @@ def instantiateVariableFont( inplace=False, optimize=True, overlap=OverlapMode.KEEP_AND_SET_FLAGS, + updateFontNames=False, ): """Instantiate variable font, either fully or partially. @@ -1219,6 +1180,11 @@ def instantiateVariableFont( contours and components, you can pass OverlapMode.REMOVE. Note that this requires the skia-pathops package (available to pip install). The overlap parameter only has effect when generating full static instances. + updateFontNames (bool): if True, update the instantiated font's name table using + the Axis Value Tables from the STAT table. The name table will be updated so + it conforms to the R/I/B/BI model. If the STAT table is missing or + an Axis Value table is missing for a given axis coordinate, a ValueError will + be raised. """ # 'overlap' used to be bool and is now enum; for backward compat keep accepting bool overlap = OverlapMode(int(overlap)) @@ -1234,6 +1200,10 @@ def instantiateVariableFont( if not inplace: varfont = deepcopy(varfont) + if updateFontNames: + log.info("Updating name table") + names.updateNameTable(varfont, axisLimits) + if "gvar" in varfont: instantiateGvar(varfont, normalizedLimits, optimize=optimize) @@ -1256,7 +1226,7 @@ def instantiateVariableFont( if "avar" in varfont: instantiateAvar(varfont, axisLimits) - with pruningUnusedNames(varfont): + with names.pruningUnusedNames(varfont): if "STAT" in varfont: instantiateSTAT(varfont, axisLimits) @@ -1345,7 +1315,7 @@ def parseArgs(args): "locargs", metavar="AXIS=LOC", nargs="*", - help="List of space separated locations. A location consist in " + help="List of space separated locations. A location consists of " "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", @@ -1377,6 +1347,12 @@ def parseArgs(args): help="Merge overlapping contours and components (only applicable " "when generating a full instance). Requires skia-pathops", ) + parser.add_argument( + "--update-name-table", + action="store_true", + help="Update the instantiated font's `name` table. Input font must have " + "a STAT table with Axis Value Tables", + ) loggingGroup = parser.add_mutually_exclusive_group(required=False) loggingGroup.add_argument( "-v", "--verbose", action="store_true", help="Run more verbosely." @@ -1428,6 +1404,7 @@ def main(args=None): inplace=True, optimize=options.optimize, overlap=options.overlap, + updateFontNames=options.update_name_table, ) outfile = ( @@ -1443,9 +1420,3 @@ def main(args=None): outfile, ) varfont.save(outfile) - - -if __name__ == "__main__": - import sys - - sys.exit(main()) diff --git a/Lib/fontTools/varLib/instancer/__main__.py b/Lib/fontTools/varLib/instancer/__main__.py new file mode 100644 index 00000000..64ffff2b --- /dev/null +++ b/Lib/fontTools/varLib/instancer/__main__.py @@ -0,0 +1,5 @@ +import sys +from fontTools.varLib.instancer import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Lib/fontTools/varLib/instancer/names.py b/Lib/fontTools/varLib/instancer/names.py new file mode 100644 index 00000000..cfe12a94 --- /dev/null +++ b/Lib/fontTools/varLib/instancer/names.py @@ -0,0 +1,379 @@ +"""Helpers for instantiating name table records.""" + +from contextlib import contextmanager +from copy import deepcopy +from enum import IntEnum +import re + + +class NameID(IntEnum): + FAMILY_NAME = 1 + SUBFAMILY_NAME = 2 + UNIQUE_FONT_IDENTIFIER = 3 + FULL_FONT_NAME = 4 + VERSION_STRING = 5 + POSTSCRIPT_NAME = 6 + TYPOGRAPHIC_FAMILY_NAME = 16 + TYPOGRAPHIC_SUBFAMILY_NAME = 17 + VARIATIONS_POSTSCRIPT_NAME_PREFIX = 25 + + +ELIDABLE_AXIS_VALUE_NAME = 2 + + +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): + from . import log + + 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 updateNameTable(varfont, axisLimits): + """Update instatiated variable font's name table using STAT AxisValues. + + Raises ValueError if the STAT table is missing or an Axis Value table is + missing for requested axis locations. + + First, collect all STAT AxisValues that match the new default axis locations + (excluding "elided" ones); concatenate the strings in design axis order, + while giving priority to "synthetic" values (Format 4), to form the + typographic subfamily name associated with the new default instance. + Finally, update all related records in the name table, making sure that + legacy family/sub-family names conform to the the R/I/B/BI (Regular, Italic, + Bold, Bold Italic) naming model. + + Example: Updating a partial variable font: + | >>> ttFont = TTFont("OpenSans[wdth,wght].ttf") + | >>> updateNameTable(ttFont, {"wght": AxisRange(400, 900), "wdth": 75}) + + The name table records will be updated in the following manner: + NameID 1 familyName: "Open Sans" --> "Open Sans Condensed" + NameID 2 subFamilyName: "Regular" --> "Regular" + NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \ + "3.000;GOOG;OpenSans-Condensed" + NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Condensed" + NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Condensed" + NameID 16 Typographic Family name: None --> "Open Sans" + NameID 17 Typographic Subfamily name: None --> "Condensed" + + References: + https://docs.microsoft.com/en-us/typography/opentype/spec/stat + https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids + """ + from . import AxisRange, axisValuesFromAxisLimits + + if "STAT" not in varfont: + raise ValueError("Cannot update name table since there is no STAT table.") + stat = varfont["STAT"].table + if not stat.AxisValueArray: + raise ValueError("Cannot update name table since there are no STAT Axis Values") + fvar = varfont["fvar"] + + # The updated name table will reflect the new 'zero origin' of the font. + # If we're instantiating a partial font, we will populate the unpinned + # axes with their default axis values. + fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} + defaultAxisCoords = deepcopy(axisLimits) + for axisTag, val in fvarDefaults.items(): + if axisTag not in defaultAxisCoords or isinstance( + defaultAxisCoords[axisTag], AxisRange + ): + defaultAxisCoords[axisTag] = val + + axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords) + checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords) + + # ignore "elidable" axis values, should be omitted in application font menus. + axisValueTables = [ + v for v in axisValueTables if not v.Flags & ELIDABLE_AXIS_VALUE_NAME + ] + axisValueTables = _sortAxisValues(axisValueTables) + _updateNameRecords(varfont, axisValueTables) + + +def checkAxisValuesExist(stat, axisValues, axisCoords): + seen = set() + designAxes = stat.DesignAxisRecord.Axis + for axisValueTable in axisValues: + axisValueFormat = axisValueTable.Format + if axisValueTable.Format in (1, 2, 3): + axisTag = designAxes[axisValueTable.AxisIndex].AxisTag + if axisValueFormat == 2: + axisValue = axisValueTable.NominalValue + else: + axisValue = axisValueTable.Value + if axisTag in axisCoords and axisValue == axisCoords[axisTag]: + seen.add(axisTag) + elif axisValueTable.Format == 4: + for rec in axisValueTable.AxisValueRecord: + axisTag = designAxes[rec.AxisIndex].AxisTag + if axisTag in axisCoords and rec.Value == axisCoords[axisTag]: + seen.add(axisTag) + + missingAxes = set(axisCoords) - seen + if missingAxes: + missing = ", ".join(f"'{i}={axisCoords[i]}'" for i in missingAxes) + raise ValueError(f"Cannot find Axis Values [{missing}]") + + +def _sortAxisValues(axisValues): + # Sort by axis index, remove duplicates and ensure that format 4 AxisValues + # are dominant. + # The MS Spec states: "if a format 1, format 2 or format 3 table has a + # (nominal) value used in a format 4 table that also has values for + # other axes, the format 4 table, being the more specific match, is used", + # https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4 + results = [] + seenAxes = set() + # Sort format 4 axes so the tables with the most AxisValueRecords are first + format4 = sorted( + [v for v in axisValues if v.Format == 4], + key=lambda v: len(v.AxisValueRecord), + reverse=True, + ) + + for val in format4: + axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord) + minIndex = min(axisIndexes) + if not seenAxes & axisIndexes: + seenAxes |= axisIndexes + results.append((minIndex, val)) + + for val in axisValues: + if val in format4: + continue + axisIndex = val.AxisIndex + if axisIndex not in seenAxes: + seenAxes.add(axisIndex) + results.append((axisIndex, val)) + + return [axisValue for _, axisValue in sorted(results)] + + +def _updateNameRecords(varfont, axisValues): + # Update nametable based on the axisValues using the R/I/B/BI model. + nametable = varfont["name"] + stat = varfont["STAT"].table + + axisValueNameIDs = [a.ValueNameID for a in axisValues] + ribbiNameIDs = [n for n in axisValueNameIDs if _isRibbi(nametable, n)] + nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs] + elidedNameID = stat.ElidedFallbackNameID + elidedNameIsRibbi = _isRibbi(nametable, elidedNameID) + + getName = nametable.getName + platforms = set((r.platformID, r.platEncID, r.langID) for r in nametable.names) + for platform in platforms: + if not all(getName(i, *platform) for i in (1, 2, elidedNameID)): + # Since no family name and subfamily name records were found, + # we cannot update this set of name Records. + continue + + subFamilyName = " ".join( + getName(n, *platform).toUnicode() for n in ribbiNameIDs + ) + if nonRibbiNameIDs: + typoSubFamilyName = " ".join( + getName(n, *platform).toUnicode() for n in axisValueNameIDs + ) + else: + typoSubFamilyName = None + + # If neither subFamilyName and typographic SubFamilyName exist, + # we will use the STAT's elidedFallbackName + if not typoSubFamilyName and not subFamilyName: + if elidedNameIsRibbi: + subFamilyName = getName(elidedNameID, *platform).toUnicode() + else: + typoSubFamilyName = getName(elidedNameID, *platform).toUnicode() + + familyNameSuffix = " ".join( + getName(n, *platform).toUnicode() for n in nonRibbiNameIDs + ) + + _updateNameTableStyleRecords( + varfont, + familyNameSuffix, + subFamilyName, + typoSubFamilyName, + *platform, + ) + + +def _isRibbi(nametable, nameID): + englishRecord = nametable.getName(nameID, 3, 1, 0x409) + return ( + True + if englishRecord is not None + and englishRecord.toUnicode() in ("Regular", "Italic", "Bold", "Bold Italic") + else False + ) + + +def _updateNameTableStyleRecords( + varfont, + familyNameSuffix, + subFamilyName, + typoSubFamilyName, + platformID=3, + platEncID=1, + langID=0x409, +): + # TODO (Marc F) It may be nice to make this part a standalone + # font renamer in the future. + nametable = varfont["name"] + platform = (platformID, platEncID, langID) + + currentFamilyName = nametable.getName( + NameID.TYPOGRAPHIC_FAMILY_NAME, *platform + ) or nametable.getName(NameID.FAMILY_NAME, *platform) + + currentStyleName = nametable.getName( + NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platform + ) or nametable.getName(NameID.SUBFAMILY_NAME, *platform) + + if not all([currentFamilyName, currentStyleName]): + raise ValueError(f"Missing required NameIDs 1 and 2 for platform {platform}") + + currentFamilyName = currentFamilyName.toUnicode() + currentStyleName = currentStyleName.toUnicode() + + nameIDs = { + NameID.FAMILY_NAME: currentFamilyName, + NameID.SUBFAMILY_NAME: subFamilyName or "Regular", + } + if typoSubFamilyName: + nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip() + nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName + nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = typoSubFamilyName + else: + # Remove previous Typographic Family and SubFamily names since they're + # no longer required + for nameID in ( + NameID.TYPOGRAPHIC_FAMILY_NAME, + NameID.TYPOGRAPHIC_SUBFAMILY_NAME, + ): + nametable.removeNames(nameID=nameID) + + newFamilyName = ( + nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs[NameID.FAMILY_NAME] + ) + newStyleName = ( + nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or nameIDs[NameID.SUBFAMILY_NAME] + ) + + nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}" + nameIDs[NameID.POSTSCRIPT_NAME] = _updatePSNameRecord( + varfont, newFamilyName, newStyleName, platform + ) + + uniqueID = _updateUniqueIdNameRecord(varfont, nameIDs, platform) + if uniqueID: + nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = uniqueID + + for nameID, string in nameIDs.items(): + assert string, nameID + nametable.setName(string, nameID, *platform) + + if "fvar" not in varfont: + nametable.removeNames(NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX) + + +def _updatePSNameRecord(varfont, familyName, styleName, platform): + # Implementation based on Adobe Technical Note #5902 : + # https://wwwimages2.adobe.com/content/dam/acom/en/devnet/font/pdfs/5902.AdobePSNameGeneration.pdf + nametable = varfont["name"] + + family_prefix = nametable.getName( + NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX, *platform + ) + if family_prefix: + family_prefix = family_prefix.toUnicode() + else: + family_prefix = familyName + + psName = f"{family_prefix}-{styleName}" + # Remove any characters other than uppercase Latin letters, lowercase + # Latin letters, digits and hyphens. + psName = re.sub(r"[^A-Za-z0-9-]", r"", psName) + + if len(psName) > 127: + # Abbreviating the stylename so it fits within 127 characters whilst + # conforming to every vendor's specification is too complex. Instead + # we simply truncate the psname and add the required "..." + return f"{psName[:124]}..." + return psName + + +def _updateUniqueIdNameRecord(varfont, nameIDs, platform): + nametable = varfont["name"] + currentRecord = nametable.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platform) + if not currentRecord: + return None + + # Check if full name and postscript name are a substring of currentRecord + for nameID in (NameID.FULL_FONT_NAME, NameID.POSTSCRIPT_NAME): + nameRecord = nametable.getName(nameID, *platform) + if not nameRecord: + continue + if nameRecord.toUnicode() in currentRecord.toUnicode(): + return currentRecord.toUnicode().replace( + nameRecord.toUnicode(), nameIDs[nameRecord.nameID] + ) + + # Create a new string since we couldn't find any substrings. + fontVersion = _fontVersion(varfont, platform) + achVendID = varfont["OS/2"].achVendID + # Remove non-ASCII characers and trailing spaces + vendor = re.sub(r"[^\x00-\x7F]", "", achVendID).strip() + psName = nameIDs[NameID.POSTSCRIPT_NAME] + return f"{fontVersion};{vendor};{psName}" + + +def _fontVersion(font, platform=(3, 1, 0x409)): + nameRecord = font["name"].getName(NameID.VERSION_STRING, *platform) + if nameRecord is None: + return f'{font["head"].fontRevision:.3f}' + # "Version 1.101; ttfautohint (v1.8.1.43-b0c9)" --> "1.101" + # Also works fine with inputs "Version 1.101" or "1.101" etc + versionNumber = nameRecord.toUnicode().split(";")[0] + return versionNumber.lstrip("Version ").strip() diff --git a/Lib/fontTools/varLib/merger.py b/Lib/fontTools/varLib/merger.py index 071942b8..c9d14381 100644 --- a/Lib/fontTools/varLib/merger.py +++ b/Lib/fontTools/varLib/merger.py @@ -3,8 +3,8 @@ Merge OpenType Layout tables (GDEF / GPOS / GSUB). """ import copy from operator import ior -from fontTools.misc.fixedTools import otRound from fontTools.misc import classifyTools +from fontTools.misc.roundTools import otRound from fontTools.ttLib.tables import otTables as ot from fontTools.ttLib.tables import otBase as otBase from fontTools.ttLib.tables.DefaultTable import DefaultTable @@ -14,8 +14,18 @@ from fontTools.varLib.varStore import VarStoreInstancer from functools import reduce from fontTools.otlLib.builder import buildSinglePos -from .errors import VarLibMergeError - +from .errors import ( + ShouldBeConstant, + FoundANone, + MismatchedTypes, + LengthsDiffer, + KeysDiffer, + InconsistentGlyphOrder, + InconsistentExtensions, + UnsupportedFormat, + UnsupportedFormat, + VarLibMergeError, +) class Merger(object): @@ -69,7 +79,9 @@ class Merger(object): item.ensureDecompiled() keys = sorted(vars(out).keys()) if not all(keys == sorted(vars(v).keys()) for v in lst): - raise VarLibMergeError((keys, [sorted(vars(v).keys()) for v in lst])) + raise KeysDiffer(self, expected=keys, + got=[sorted(vars(v).keys()) for v in lst] + ) mergers = self.mergersFor(out) defaultMerger = mergers.get('*', self.__class__.mergeThings) try: @@ -79,44 +91,47 @@ class Merger(object): values = [getattr(table, key) for table in lst] mergerFunc = mergers.get(key, defaultMerger) mergerFunc(self, value, values) - except Exception as e: - e.args = e.args + ('.'+key,) + except VarLibMergeError as e: + e.stack.append('.'+key) raise def mergeLists(self, out, lst): if not allEqualTo(out, lst, len): - raise VarLibMergeError((len(out), [len(v) for v in lst])) + raise LengthsDiffer(self, expected=len(out), got=[len(x) for x in lst]) for i,(value,values) in enumerate(zip(out, zip(*lst))): try: self.mergeThings(value, values) - except Exception as e: - e.args = e.args + ('[%d]' % i,) + except VarLibMergeError as e: + e.stack.append('[%d]' % i) raise def mergeThings(self, out, lst): - try: - if not allEqualTo(out, lst, type): - raise VarLibMergeError((out, lst)) - mergerFunc = self.mergersFor(out).get(None, None) - if mergerFunc is not None: - mergerFunc(self, out, lst) - elif hasattr(out, '__dict__'): - self.mergeObjects(out, lst) - elif isinstance(out, list): - self.mergeLists(out, lst) - else: - if not allEqualTo(out, lst): - raise VarLibMergeError((out, lst)) - except Exception as e: - e.args = e.args + (type(out).__name__,) - raise + if not allEqualTo(out, lst, type): + raise MismatchedTypes(self, + expected=type(out).__name__, + got=[type(x).__name__ for x in lst] + ) + mergerFunc = self.mergersFor(out).get(None, None) + if mergerFunc is not None: + mergerFunc(self, out, lst) + elif hasattr(out, '__dict__'): + self.mergeObjects(out, lst) + elif isinstance(out, list): + self.mergeLists(out, lst) + else: + if not allEqualTo(out, lst): + raise ShouldBeConstant(self, expected=out, got=lst) def mergeTables(self, font, master_ttfs, tableTags): - for tag in tableTags: if tag not in font: continue - self.mergeThings(font[tag], [m[tag] if tag in m else None - for m in master_ttfs]) + try: + self.ttfs = [m for m in master_ttfs if tag in m] + self.mergeThings(font[tag], [m[tag] if tag in m else None + for m in master_ttfs]) + except VarLibMergeError as e: + e.stack.append(tag) + raise # # Aligning merger @@ -128,7 +143,7 @@ class AligningMerger(Merger): def merge(merger, self, lst): if self is None: if not allNone(lst): - raise VarLibMergeError(lst) + raise NotANone(self, expected=None, got=lst) return lst = [l.classDefs for l in lst] @@ -141,7 +156,7 @@ def merge(merger, self, lst): for k in allKeys: allValues = nonNone(l.get(k) for l in lst) if not allEqual(allValues): - raise VarLibMergeError(allValues) + raise ShouldBeConstant(self, expected=allValues[0], got=lst, stack="."+k) if not allValues: self[k] = None else: @@ -178,7 +193,7 @@ def _merge_GlyphOrders(font, lst, values_lst=None, default=None): order = sorted(combined, key=sortKey) # Make sure all input glyphsets were in proper order if not all(sorted(vs, key=sortKey) == vs for vs in lst): - raise VarLibMergeError("Glyph order inconsistent across masters.") + raise InconsistentGlyphOrder(self) del combined paddedValues = None @@ -205,10 +220,7 @@ def _Lookup_SinglePos_get_effective_value(subtables, glyph): elif self.Format == 2: return self.Value[self.Coverage.glyphs.index(glyph)] else: - raise VarLibMergeError( - "Cannot retrieve effective value for SinglePos lookup, unsupported " - f"format {self.Format}." - ) + raise UnsupportedFormat(self, subtable="single positioning lookup") return None def _Lookup_PairPos_get_effective_value_pair(subtables, firstGlyph, secondGlyph): @@ -230,17 +242,14 @@ def _Lookup_PairPos_get_effective_value_pair(subtables, firstGlyph, secondGlyph) klass2 = self.ClassDef2.classDefs.get(secondGlyph, 0) return self.Class1Record[klass1].Class2Record[klass2] else: - raise VarLibMergeError( - "Cannot retrieve effective value pair for PairPos lookup, unsupported " - f"format {self.Format}." - ) + raise UnsupportedFormat(self, subtable="pair positioning lookup") return None @AligningMerger.merger(ot.SinglePos) def merge(merger, self, lst): self.ValueFormat = valueFormat = reduce(int.__or__, [l.ValueFormat for l in lst], 0) if not (len(lst) == 1 or (valueFormat & ~0xF == 0)): - raise VarLibMergeError(f"SinglePos format {valueFormat} is unsupported.") + raise UnsupportedFormat(self, subtable="single positioning lookup") # If all have same coverage table and all are format 1, coverageGlyphs = self.Coverage.glyphs @@ -400,28 +409,12 @@ def _ClassDef_merge_classify(lst, allGlyphses=None): return self, classes -# It's stupid that we need to do this here. Just need to, to match test -# expecatation results, since ttx prints out format of ClassDef (and Coverage) -# even though it should not. -def _ClassDef_calculate_Format(self, font): - fmt = 2 - ranges = self._getClassRanges(font) - if ranges: - startGlyph = ranges[0][1] - endGlyph = ranges[-1][3] - glyphCount = endGlyph - startGlyph + 1 - if len(ranges) * 3 >= glyphCount + 1: - # Format 1 is more compact - fmt = 1 - self.Format = fmt - def _PairPosFormat2_align_matrices(self, lst, font, transparent=False): matrices = [l.Class1Record for l in lst] # Align first classes self.ClassDef1, classes = _ClassDef_merge_classify([l.ClassDef1 for l in lst], [l.Coverage.glyphs for l in lst]) - _ClassDef_calculate_Format(self.ClassDef1, font) self.Class1Count = len(classes) new_matrices = [] for l,matrix in zip(lst, matrices): @@ -460,7 +453,6 @@ def _PairPosFormat2_align_matrices(self, lst, font, transparent=False): # Align second classes self.ClassDef2, classes = _ClassDef_merge_classify([l.ClassDef2 for l in lst]) - _ClassDef_calculate_Format(self.ClassDef2, font) self.Class2Count = len(classes) new_matrices = [] for l,matrix in zip(lst, matrices): @@ -526,9 +518,7 @@ def merge(merger, self, lst): elif self.Format == 2: _PairPosFormat2_merge(self, lst, merger) else: - raise VarLibMergeError( - f"Cannot merge PairPos lookup, unsupported format {self.Format}." - ) + raise UnsupportedFormat(self, subtable="pair positioning lookup") del merger.valueFormat1, merger.valueFormat2 @@ -594,8 +584,7 @@ def _MarkBasePosFormat1_merge(self, lst, merger, Mark='Mark', Base='Base'): # input masters. if not allEqual(allClasses): - raise VarLibMergeError(allClasses) - if not allClasses: + raise allClasses(self, allClasses) rec = None else: rec = ot.MarkRecord() @@ -644,36 +633,32 @@ def _MarkBasePosFormat1_merge(self, lst, merger, Mark='Mark', Base='Base'): @AligningMerger.merger(ot.MarkBasePos) def merge(merger, self, lst): if not allEqualTo(self.Format, (l.Format for l in lst)): - raise VarLibMergeError( - f"MarkBasePos formats inconsistent across masters, " - f"expected {self.Format} but got {[l.Format for l in lst]}." + raise InconsistentFormats(self, + subtable="mark-to-base positioning lookup", + expected=self.Format, + got=[l.Format for l in lst] ) if self.Format == 1: _MarkBasePosFormat1_merge(self, lst, merger) else: - raise VarLibMergeError( - f"Cannot merge MarkBasePos lookup, unsupported format {self.Format}." - ) + raise UnsupportedFormat(self, subtable="mark-to-base positioning lookup") @AligningMerger.merger(ot.MarkMarkPos) def merge(merger, self, lst): if not allEqualTo(self.Format, (l.Format for l in lst)): - raise VarLibMergeError( - f"MarkMarkPos formats inconsistent across masters, " - f"expected {self.Format} but got {[l.Format for l in lst]}." + raise InconsistentFormats(self, + subtable="mark-to-mark positioning lookup", + expected=self.Format, + got=[l.Format for l in lst] ) if self.Format == 1: _MarkBasePosFormat1_merge(self, lst, merger, 'Mark1', 'Mark2') else: - raise VarLibMergeError( - f"Cannot merge MarkMarkPos lookup, unsupported format {self.Format}." - ) - + raise UnsupportedFormat(self, subtable="mark-to-mark positioning lookup") def _PairSet_flatten(lst, font): self = ot.PairSet() self.Coverage = ot.Coverage() - self.Coverage.Format = 1 # Align them glyphs, padded = _merge_GlyphOrders(font, @@ -699,7 +684,6 @@ def _Lookup_PairPosFormat1_subtables_flatten(lst, font): self = ot.PairPos() self.Format = 1 self.Coverage = ot.Coverage() - self.Coverage.Format = 1 self.ValueFormat1 = reduce(int.__or__, [l.ValueFormat1 for l in lst], 0) self.ValueFormat2 = reduce(int.__or__, [l.ValueFormat2 for l in lst], 0) @@ -720,7 +704,6 @@ def _Lookup_PairPosFormat2_subtables_flatten(lst, font): self = ot.PairPos() self.Format = 2 self.Coverage = ot.Coverage() - self.Coverage.Format = 1 self.ValueFormat1 = reduce(int.__or__, [l.ValueFormat1 for l in lst], 0) self.ValueFormat2 = reduce(int.__or__, [l.ValueFormat2 for l in lst], 0) @@ -797,15 +780,12 @@ def merge(merger, self, lst): continue if sts[0].__class__.__name__.startswith('Extension'): if not allEqual([st.__class__ for st in sts]): - raise VarLibMergeError( - "Use of extensions inconsistent between masters: " - f"{[st.__class__.__name__ for st in sts]}." + raise InconsistentExtensions(self, + expected="Extension", + got=[st.__class__.__name__ for st in sts] ) if not allEqual([st.ExtensionLookupType for st in sts]): - raise VarLibMergeError( - "Extension lookup type differs between masters: " - f"{[st.ExtensionLookupType for st in sts]}." - ) + raise InconsistentExtensions(self) l.LookupType = sts[0].ExtensionLookupType new_sts = [st.ExtSubTable for st in sts] del sts[:] @@ -1034,7 +1014,7 @@ class VariationMerger(AligningMerger): if None in lst: if allNone(lst): if out is not None: - raise VarLibMergeError((out, lst)) + raise FoundANone(self, got=lst) return masterModel = self.model model, lst = masterModel.getSubModel(lst) @@ -1055,7 +1035,7 @@ def buildVarDevTable(store_builder, master_values): @VariationMerger.merger(ot.BaseCoord) def merge(merger, self, lst): if self.Format != 1: - raise VarLibMergeError(f"BaseCoord format {self.Format} unsupported.") + raise UnsupportedFormat(self, subtable="a baseline coordinate") self.Coordinate, DeviceTable = buildVarDevTable(merger.store_builder, [a.Coordinate for a in lst]) if DeviceTable: self.Format = 3 @@ -1064,7 +1044,7 @@ def merge(merger, self, lst): @VariationMerger.merger(ot.CaretValue) def merge(merger, self, lst): if self.Format != 1: - raise VarLibMergeError(f"CaretValue format {self.Format} unsupported.") + raise UnsupportedFormat(self, subtable="a caret") self.Coordinate, DeviceTable = buildVarDevTable(merger.store_builder, [a.Coordinate for a in lst]) if DeviceTable: self.Format = 3 @@ -1073,7 +1053,7 @@ def merge(merger, self, lst): @VariationMerger.merger(ot.Anchor) def merge(merger, self, lst): if self.Format != 1: - raise VarLibMergeError(f"Anchor format {self.Format} unsupported.") + raise UnsupportedFormat(self, subtable="an anchor") self.XCoordinate, XDeviceTable = buildVarDevTable(merger.store_builder, [a.XCoordinate for a in lst]) self.YCoordinate, YDeviceTable = buildVarDevTable(merger.store_builder, [a.YCoordinate for a in lst]) if XDeviceTable or YDeviceTable: diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index 9cc40b1c..9296deda 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -5,6 +5,7 @@ __all__ = ['nonNone', 'allNone', 'allEqual', 'allEqualTo', 'subList', 'supportScalar', 'VariationModel'] +from fontTools.misc.roundTools import noRound from .errors import VariationModelError @@ -281,34 +282,18 @@ class VariationModel(object): def _computeMasterSupports(self, axisPoints): supports = [] - deltaWeights = [] - locations = self.locations - # Compute min/max across each axis, use it as total range. - # TODO Take this as input from outside? - minV = {} - maxV = {} - for l in locations: - for k,v in l.items(): - minV[k] = min(v, minV.get(k, v)) - maxV[k] = max(v, maxV.get(k, v)) - for i,loc in enumerate(locations): - box = {} - for axis,locV in loc.items(): - if locV > 0: - box[axis] = (0, locV, maxV[axis]) - else: - box[axis] = (minV[axis], locV, 0) - - locAxes = set(loc.keys()) + regions = self._locationsToRegions() + for i,region in enumerate(regions): + locAxes = set(region.keys()) # Walk over previous masters now - for j,m in enumerate(locations[:i]): + for j,prev_region in enumerate(regions[:i]): # Master with extra axes do not participte - if not set(m.keys()).issubset(locAxes): + if not set(prev_region.keys()).issubset(locAxes): continue # If it's NOT in the current box, it does not participate relevant = True - for axis, (lower,peak,upper) in box.items(): - if axis not in m or not (m[axis] == peak or lower < m[axis] < upper): + for axis, (lower,peak,upper) in region.items(): + if axis not in prev_region or not (prev_region[axis][1] == peak or lower < prev_region[axis][1] < upper): relevant = False break if not relevant: @@ -323,10 +308,10 @@ class VariationModel(object): bestAxes = {} bestRatio = -1 - for axis in m.keys(): - val = m[axis] - assert axis in box - lower,locV,upper = box[axis] + for axis in prev_region.keys(): + val = prev_region[axis][1] + assert axis in region + lower,locV,upper = region[axis] newLower, newUpper = lower, upper if val < locV: newLower = val @@ -344,21 +329,46 @@ class VariationModel(object): bestAxes[axis] = (newLower, locV, newUpper) for axis,triple in bestAxes.items (): - box[axis] = triple - supports.append(box) + region[axis] = triple + supports.append(region) + self.supports = supports + self._computeDeltaWeights() + + def _locationsToRegions(self): + locations = self.locations + # Compute min/max across each axis, use it as total range. + # TODO Take this as input from outside? + minV = {} + maxV = {} + for l in locations: + for k,v in l.items(): + minV[k] = min(v, minV.get(k, v)) + maxV[k] = max(v, maxV.get(k, v)) + regions = [] + for i,loc in enumerate(locations): + region = {} + for axis,locV in loc.items(): + if locV > 0: + region[axis] = (0, locV, maxV[axis]) + else: + region[axis] = (minV[axis], locV, 0) + regions.append(region) + return regions + + def _computeDeltaWeights(self): + deltaWeights = [] + for i,loc in enumerate(self.locations): deltaWeight = {} # Walk over previous masters now, populate deltaWeight - for j,m in enumerate(locations[:i]): - scalar = supportScalar(loc, supports[j]) + for j,m in enumerate(self.locations[:i]): + scalar = supportScalar(loc, self.supports[j]) if scalar: deltaWeight[j] = scalar deltaWeights.append(deltaWeight) - - self.supports = supports self.deltaWeights = deltaWeights - def getDeltas(self, masterValues): + def getDeltas(self, masterValues, *, round=noRound): assert len(masterValues) == len(self.deltaWeights) mapping = self.reverseMapping out = [] @@ -366,12 +376,12 @@ class VariationModel(object): delta = masterValues[mapping[i]] for j,weight in weights.items(): delta -= out[j] * weight - out.append(delta) + out.append(round(delta)) return out - def getDeltasAndSupports(self, items): + def getDeltasAndSupports(self, items, *, round=noRound): model, items = self.getSubModel(items) - return model.getDeltas(items), model.supports + return model.getDeltas(items, round=round), model.supports def getScalars(self, loc): return [supportScalar(loc, support) for support in self.supports] @@ -393,12 +403,12 @@ class VariationModel(object): scalars = self.getScalars(loc) return self.interpolateFromDeltasAndScalars(deltas, scalars) - def interpolateFromMasters(self, loc, masterValues): - deltas = self.getDeltas(masterValues) + def interpolateFromMasters(self, loc, masterValues, *, round=noRound): + deltas = self.getDeltas(masterValues, round=round) return self.interpolateFromDeltas(loc, deltas) - def interpolateFromMastersAndScalars(self, masterValues, scalars): - deltas = self.getDeltas(masterValues) + def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound): + deltas = self.getDeltas(masterValues, round=round) return self.interpolateFromDeltasAndScalars(deltas, scalars) diff --git a/Lib/fontTools/varLib/mutator.py b/Lib/fontTools/varLib/mutator.py index ad76420a..02ce4422 100644 --- a/Lib/fontTools/varLib/mutator.py +++ b/Lib/fontTools/varLib/mutator.py @@ -3,7 +3,8 @@ Instantiate a variation font. Run, eg: $ fonttools varLib.mutator ./NotoSansArabic-VF.ttf wght=140 wdth=85 """ -from fontTools.misc.fixedTools import floatToFixedToFloat, otRound, floatToFixed +from fontTools.misc.fixedTools import floatToFixedToFloat, floatToFixed +from fontTools.misc.roundTools import otRound from fontTools.pens.boundsPen import BoundsPen from fontTools.ttLib import TTFont, newTable from fontTools.ttLib.tables import ttProgram @@ -345,14 +346,8 @@ def instantiateVariableFont(varfont, location, inplace=False, overlap=True): # Change maxp attributes as IDEF is added if 'maxp' in varfont: maxp = varfont['maxp'] - if hasattr(maxp, "maxInstructionDefs"): - maxp.maxInstructionDefs += 1 - else: - setattr(maxp, "maxInstructionDefs", 1) - if hasattr(maxp, "maxStackElements"): - maxp.maxStackElements = max(len(loc), maxp.maxStackElements) - else: - setattr(maxp, "maxInstructionDefs", len(loc)) + setattr(maxp, "maxInstructionDefs", 1 + getattr(maxp, "maxInstructionDefs", 0)) + setattr(maxp, "maxStackElements", max(len(loc), getattr(maxp, "maxStackElements", 0))) if 'name' in varfont: log.info("Pruning name table") diff --git a/Lib/fontTools/varLib/plot.py b/Lib/fontTools/varLib/plot.py index b6561dc6..811559fa 100644 --- a/Lib/fontTools/varLib/plot.py +++ b/Lib/fontTools/varLib/plot.py @@ -2,8 +2,8 @@ from fontTools.varLib.models import VariationModel, supportScalar from fontTools.designspaceLib import DesignSpaceDocument -from mpl_toolkits.mplot3d import axes3d from matplotlib import pyplot +from mpl_toolkits.mplot3d import axes3d from itertools import cycle import math import logging @@ -68,10 +68,10 @@ def plotLocations(locations, fig, names=None, **kwargs): def _plotLocations2D(model, axis, fig, cols, rows, names, **kwargs): + subplot = fig.add_subplot(111) for i, (support, color, name) in enumerate( zip(model.supports, cycle(pyplot.cm.Set1.colors), cycle(names)) ): - subplot = fig.add_subplot(rows, cols, i + 1) if name is not None: subplot.set_title(name) subplot.set_xlabel(axis) @@ -91,10 +91,10 @@ def _plotLocations2D(model, axis, fig, cols, rows, names, **kwargs): def _plotLocations3D(model, axes, fig, rows, cols, names, **kwargs): ax1, ax2 = axes + axis3D = fig.add_subplot(111, projection='3d') for i, (support, color, name) in enumerate( zip(model.supports, cycle(pyplot.cm.Set1.colors), cycle(names)) ): - axis3D = fig.add_subplot(rows, cols, i + 1, projection='3d') if name is not None: axis3D.set_title(name) axis3D.set_xlabel(ax1) diff --git a/Lib/fontTools/varLib/varStore.py b/Lib/fontTools/varLib/varStore.py index b28d2a65..8a382df0 100644 --- a/Lib/fontTools/varLib/varStore.py +++ b/Lib/fontTools/varLib/varStore.py @@ -1,4 +1,4 @@ -from fontTools.misc.fixedTools import otRound +from fontTools.misc.roundTools import noRound, otRound from fontTools.ttLib.tables import otTables as ot from fontTools.varLib.models import supportScalar from fontTools.varLib.builder import (buildVarRegionList, buildVarStore, @@ -83,15 +83,12 @@ class OnlineVarStoreBuilder(object): def storeMasters(self, master_values): - deltas = self._model.getDeltas(master_values) - base = otRound(deltas.pop(0)) - return base, self.storeDeltas(deltas) - - def storeDeltas(self, deltas): - # Pity that this exists here, since VarData_addItem - # does the same. But to look into our cache, it's - # good to adjust deltas here as well... - deltas = [otRound(d) for d in deltas] + deltas = self._model.getDeltas(master_values, round=round) + base = deltas.pop(0) + return base, self.storeDeltas(deltas, round=noRound) + + def storeDeltas(self, deltas, *, round=round): + deltas = [round(d) for d in deltas] if len(deltas) == len(self._supports) + 1: deltas = tuple(deltas[1:]) else: @@ -109,14 +106,14 @@ class OnlineVarStoreBuilder(object): # Full array. Start new one. self._add_VarData() return self.storeDeltas(deltas) - self._data.addItem(deltas) + self._data.addItem(deltas, round=noRound) varIdx = (self._outer << 16) + inner self._cache[deltas] = varIdx return varIdx -def VarData_addItem(self, deltas): - deltas = [otRound(d) for d in deltas] +def VarData_addItem(self, deltas, *, round=round): + deltas = [round(d) for d in deltas] countUs = self.VarRegionCount countThem = len(deltas) |