aboutsummaryrefslogtreecommitdiff
path: root/Lib/fontTools/varLib
diff options
context:
space:
mode:
Diffstat (limited to 'Lib/fontTools/varLib')
-rw-r--r--Lib/fontTools/varLib/__init__.py34
-rw-r--r--Lib/fontTools/varLib/cff.py33
-rw-r--r--Lib/fontTools/varLib/errors.py157
-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__.py5
-rw-r--r--Lib/fontTools/varLib/instancer/names.py379
-rw-r--r--Lib/fontTools/varLib/merger.py158
-rw-r--r--Lib/fontTools/varLib/models.py92
-rw-r--r--Lib/fontTools/varLib/mutator.py13
-rw-r--r--Lib/fontTools/varLib/plot.py6
-rw-r--r--Lib/fontTools/varLib/varStore.py23
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)