diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py
index d4b940512..f3fe92a83 100644
--- a/Lib/fontTools/fontBuilder.py
+++ b/Lib/fontTools/fontBuilder.py
@@ -803,6 +803,15 @@ class FontBuilder(object):
nameTable=self.font.get("name")
)
+ def setupStat(self, axes, locations=None, elidedFallbackName=2):
+ """Build a new 'STAT' table.
+
+ See `fontTools.otlLib.builder.buildStatTable` for details about
+ the arguments.
+ """
+ from .otlLib.builder import buildStatTable
+ buildStatTable(self.font, axes, locations, elidedFallbackName)
+
def buildCmapSubTable(cmapping, format, platformID, platEncID):
subTable = cmap_classes[format](format)
diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py
index dd0aabe75..4d9d2bc02 100644
--- a/Lib/fontTools/otlLib/builder.py
+++ b/Lib/fontTools/otlLib/builder.py
@@ -1,4 +1,5 @@
from collections import namedtuple
+from fontTools.misc.fixedTools import fixedToFloat
from fontTools import ttLib
from fontTools.ttLib.tables import otTables as ot
from fontTools.ttLib.tables.otBase import ValueRecord, valueRecordFormatDict
@@ -657,3 +658,193 @@ class ClassDefBuilder(object):
classDef = ot.ClassDef()
classDef.classDefs = glyphClasses
return classDef
+
+
+AXIS_VALUE_NEGATIVE_INFINITY = fixedToFloat(-0x80000000, 16)
+AXIS_VALUE_POSITIVE_INFINITY = fixedToFloat(0x7FFFFFFF, 16)
+
+
+def buildStatTable(ttFont, axes, locations=None, elidedFallbackName=2):
+ """Add a 'STAT' table to 'ttFont'.
+
+ 'axes' is a list of dictionaries describing axes and their
+ values.
+
+ Example:
+
+ axes = [
+ dict(
+ tag="wght",
+ name="Weight",
+ ordering=0, # optional
+ values=[
+ dict(value=100, name='Thin'),
+ dict(value=300, name='Light'),
+ dict(value=400, name='Regular', flags=0x2),
+ dict(value=900, name='Black'),
+ ],
+ )
+ ]
+
+ Each axis dict must have 'tag' and 'name' items. 'tag' maps
+ to the 'AxisTag' field. 'name' can be a name ID (int), a string,
+ or a dictionary containing multilingual names (see the
+ addMultilingualName() name table method), and will translate to
+ the AxisNameID field.
+
+ An axis dict may contain an 'ordering' item that maps to the
+ AxisOrdering field. If omitted, the order of the axes list is
+ used to calculate AxisOrdering fields.
+
+ The axis dict may contain a 'values' item, which is a list of
+ dictionaries describing AxisValue records belonging to this axis.
+
+ Each value dict must have a 'name' item, which can be a name ID
+ (int), a string, or a dictionary containing multilingual names,
+ like the axis name. It translates to the ValueNameID field.
+
+ Optionally the value dict can contain a 'flags' item. It maps to
+ the AxisValue Flags field, and will be 0 when omitted.
+
+ The format of the AxisValue is determined by the remaining contents
+ of the value dictionary:
+
+ If the value dict contains a 'value' item, an AxisValue record
+ Format 1 is created. If in addition to the 'value' item it contains
+ a 'linkedValue' item, an AxisValue record Format 3 is built.
+
+ If the value dict contains a 'nominalValue' item, an AxisValue
+ record Format 2 is built. Optionally it may contain 'rangeMinValue'
+ and 'rangeMaxValue' items. These map to -Infinity and +Infinity
+ respectively if omitted.
+
+ You cannot specify Format 4 AxisValue tables this way, as they are
+ not tied to a single axis, and specify a name for a location that
+ is defined by multiple axes values. Instead, you need to supply the
+ 'locations' argument.
+
+ The optional 'locations' argument specifies AxisValue Format 4
+ tables. It should be a list of dicts, where each dict has a 'name'
+ item, which works just like the value dicts above, an optional
+ 'flags' item (defaulting to 0x0), and a 'location' dict. A
+ location dict key is an axis tag, and the associated value is the
+ location on the specified axis. They map to the AxisIndex and Value
+ fields of the AxisValueRecord.
+
+ Example:
+
+ locations = [
+ dict(name='Regular ABCD', location=dict(wght=300, ABCD=100)),
+ dict(name='Bold ABCD XYZ', location=dict(wght=600, ABCD=200)),
+ ]
+
+ The optional 'elidedFallbackName' argument can be a name ID (int),
+ a string, or a dictionary containing multilingual names. It
+ translates to the ElidedFallbackNameID field.
+
+ The 'ttFont' argument must be a TTFont instance that already has a
+ 'name' table. If a 'STAT' table already exists, it will be
+ overwritten by the newly created one.
+ """
+ ttFont["STAT"] = ttLib.newTable("STAT")
+ statTable = ttFont["STAT"].table = ot.STAT()
+ nameTable = ttFont["name"]
+ statTable.ElidedFallbackNameID = _addName(nameTable, elidedFallbackName)
+
+ # 'locations' contains data for AxisValue Format 4
+ axisRecords, axisValues = _buildAxisRecords(axes, nameTable)
+ if not locations:
+ statTable.Version = 0x00010001
+ else:
+ # We'll be adding Format 4 AxisValue records, which
+ # requires a higher table version
+ statTable.Version = 0x00010002
+ multiAxisValues = _buildAxisValuesFormat4(locations, axes, nameTable)
+ axisValues = multiAxisValues + axisValues
+
+ # Store AxisRecords
+ axisRecordArray = ot.AxisRecordArray()
+ axisRecordArray.Axis = axisRecords
+ # XXX these should not be hard-coded but computed automatically
+ statTable.DesignAxisRecordSize = 8
+ statTable.DesignAxisRecord = axisRecordArray
+ statTable.DesignAxisCount = len(axisRecords)
+
+ if axisValues:
+ # Store AxisValueRecords
+ axisValueArray = ot.AxisValueArray()
+ axisValueArray.AxisValue = axisValues
+ statTable.AxisValueArray = axisValueArray
+ statTable.AxisValueCount = len(axisValues)
+
+
+def _buildAxisRecords(axes, nameTable):
+ axisRecords = []
+ axisValues = []
+ for axisRecordIndex, axisDict in enumerate(axes):
+ axis = ot.AxisRecord()
+ axis.AxisTag = axisDict["tag"]
+ axis.AxisNameID = _addName(nameTable, axisDict["name"])
+ axis.AxisOrdering = axisDict.get("ordering", axisRecordIndex)
+ axisRecords.append(axis)
+
+ for axisVal in axisDict.get("values", ()):
+ axisValRec = ot.AxisValue()
+ axisValRec.AxisIndex = axisRecordIndex
+ axisValRec.Flags = axisVal.get("flags", 0)
+ axisValRec.ValueNameID = _addName(nameTable, axisVal['name'])
+
+ if "value" in axisVal:
+ axisValRec.Value = axisVal["value"]
+ if "linkedValue" in axisVal:
+ axisValRec.Format = 3
+ axisValRec.LinkedValue = axisVal["linkedValue"]
+ else:
+ axisValRec.Format = 1
+ elif "nominalValue" in axisVal:
+ axisValRec.Format = 2
+ axisValRec.NominalValue = axisVal["nominalValue"]
+ axisValRec.RangeMinValue = axisVal.get("rangeMinValue", AXIS_VALUE_NEGATIVE_INFINITY)
+ axisValRec.RangeMaxValue = axisVal.get("rangeMaxValue", AXIS_VALUE_POSITIVE_INFINITY)
+ else:
+ raise ValueError("Can't determine format for AxisValue")
+
+ axisValues.append(axisValRec)
+ return axisRecords, axisValues
+
+
+def _buildAxisValuesFormat4(locations, axes, nameTable):
+ axisTagToIndex = {}
+ for axisRecordIndex, axisDict in enumerate(axes):
+ axisTagToIndex[axisDict["tag"]] = axisRecordIndex
+
+ axisValues = []
+ for axisLocationDict in locations:
+ axisValRec = ot.AxisValue()
+ axisValRec.Format = 4
+ axisValRec.ValueNameID = _addName(nameTable, axisLocationDict['name'])
+ axisValRec.Flags = axisLocationDict.get("flags", 0)
+ axisValueRecords = []
+ for tag, value in axisLocationDict["location"].items():
+ avr = ot.AxisValueRecord()
+ avr.AxisIndex = axisTagToIndex[tag]
+ avr.Value = value
+ axisValueRecords.append(avr)
+ axisValueRecords.sort(key=lambda avr: avr.AxisIndex)
+ axisValRec.AxisCount = len(axisValueRecords)
+ axisValRec.AxisValueRecord = axisValueRecords
+ axisValues.append(axisValRec)
+ return axisValues
+
+
+def _addName(nameTable, value):
+ if isinstance(value, int):
+ # Already a nameID
+ return value
+ if isinstance(value, str):
+ names = dict(en=value)
+ elif isinstance(value, dict):
+ names = value
+ else:
+ raise TypeError("value must be int, str or dict")
+ return nameTable.addMultilingualName(names)
diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py
index 862decacd..f8ae30e29 100644
--- a/Lib/fontTools/varLib/__init__.py
+++ b/Lib/fontTools/varLib/__init__.py
@@ -202,30 +202,10 @@ def _add_stat(font, axes):
if "STAT" in font:
return
+ from ..otlLib.builder import buildStatTable
fvarTable = font['fvar']
-
- STAT = font["STAT"] = newTable('STAT')
- stat = STAT.table = ot.STAT()
- stat.Version = 0x00010001
-
- axisRecords = []
- for i, a in enumerate(fvarTable.axes):
- axis = ot.AxisRecord()
- axis.AxisTag = Tag(a.axisTag)
- axis.AxisNameID = a.axisNameID
- axis.AxisOrdering = i
- axisRecords.append(axis)
-
- axisRecordArray = ot.AxisRecordArray()
- axisRecordArray.Axis = axisRecords
- # XXX these should not be hard-coded but computed automatically
- stat.DesignAxisRecordSize = 8
- stat.DesignAxisCount = len(axisRecords)
- stat.DesignAxisRecord = axisRecordArray
-
- # for the elided fallback name, we default to the base style name.
- # TODO make this user-configurable via designspace document
- stat.ElidedFallbackNameID = 2
+ axes = [dict(tag=a.axisTag, name=a.axisNameID) for a in fvarTable.axes]
+ buildStatTable(font, axes)
def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True):
diff --git a/Tests/fontBuilder/data/test_var.ttf.ttx b/Tests/fontBuilder/data/test_var.ttf.ttx
index bc1aae250..ed8fd3075 100644
--- a/Tests/fontBuilder/data/test_var.ttf.ttx
+++ b/Tests/fontBuilder/data/test_var.ttf.ttx
@@ -204,6 +204,9 @@
Right Up
+
+ Neutral
+
HalloTestFont
@@ -237,6 +240,9 @@
Right Up
+
+ Neutral
+
HalloTestFont
@@ -363,6 +369,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/fontBuilder/fontBuilder_test.py b/Tests/fontBuilder/fontBuilder_test.py
index bc7837c30..6368cb876 100644
--- a/Tests/fontBuilder/fontBuilder_test.py
+++ b/Tests/fontBuilder/fontBuilder_test.py
@@ -226,6 +226,13 @@ def test_build_var(tmpdir):
featureTag="rclt",
)
+ statAxes = []
+ for tag, minVal, defaultVal, maxVal, name in axes:
+ values = [dict(name="Neutral", value=defaultVal, flags=0x2),
+ dict(name=name, value=maxVal)]
+ statAxes.append(dict(tag=tag, name=name, values=values))
+ fb.setupStat(statAxes)
+
fb.setupOS2()
fb.setupPost()
fb.setupDummyDSIG()
diff --git a/Tests/otlLib/builder_test.py b/Tests/otlLib/builder_test.py
index 3675395fe..727d685f3 100644
--- a/Tests/otlLib/builder_test.py
+++ b/Tests/otlLib/builder_test.py
@@ -1,5 +1,9 @@
+import io
+import struct
+from fontTools.misc.fixedTools import floatToFixed
from fontTools.misc.testTools import getXML
from fontTools.otlLib import builder
+from fontTools import ttLib
from fontTools.ttLib.tables import otTables
import pytest
@@ -1106,6 +1110,291 @@ class ClassDefBuilderTest(object):
assert not b.canAdd({"f"})
+buildStatTable_test_data = [
+ ([
+ dict(
+ tag="wght",
+ name="Weight",
+ values=[
+ dict(value=100, name='Thin'),
+ dict(value=400, name='Regular', flags=0x2),
+ dict(value=900, name='Black')])], None, "Regular", [
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ']),
+ ([
+ dict(
+ tag="wght",
+ name=dict(en="Weight", nl="Gewicht"),
+ values=[
+ dict(value=100, name=dict(en='Thin', nl='Dun')),
+ dict(value=400, name='Regular', flags=0x2),
+ dict(value=900, name='Black'),
+ ]),
+ dict(
+ tag="wdth",
+ name="Width",
+ values=[
+ dict(value=50, name='Condensed'),
+ dict(value=100, name='Regular', flags=0x2),
+ dict(value=200, name='Extended')])], None, 2, [
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ']),
+ ([
+ dict(
+ tag="wght",
+ name="Weight",
+ values=[
+ dict(value=400, name='Regular', flags=0x2),
+ dict(value=600, linkedValue=650, name='Bold')])], None, 18, [
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ']),
+ ([
+ dict(
+ tag="opsz",
+ name="Optical Size",
+ values=[
+ dict(nominalValue=6, rangeMaxValue=10, name='Small'),
+ dict(rangeMinValue=10, nominalValue=14, rangeMaxValue=24, name='Text', flags=0x2),
+ dict(rangeMinValue=24, nominalValue=600, name='Display')])], None, 2, [
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ']),
+ ([
+ dict(
+ tag="wght",
+ name="Weight",
+ ordering=1,
+ values=[]),
+ dict(
+ tag="ABCD",
+ name="ABCDTest",
+ ordering=0,
+ values=[
+ dict(value=100, name="Regular", flags=0x2)])],
+ [dict(location=dict(wght=300, ABCD=100), name='Regular ABCD')], 18, [
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ']),
+]
+
+
+@pytest.mark.parametrize("axes, axisValues, elidedFallbackName, expected_ttx", buildStatTable_test_data)
+def test_buildStatTable(axes, axisValues, elidedFallbackName, expected_ttx):
+ font = ttLib.TTFont()
+ font["name"] = ttLib.newTable("name")
+ font["name"].names = []
+ builder.buildStatTable(font, axes, axisValues, elidedFallbackName)
+ f = io.StringIO()
+ font.saveXML(f, tables=["STAT"])
+ ttx = f.getvalue().splitlines()
+ ttx = ttx[3:-2] # strip XML header and element
+ assert expected_ttx == ttx
+ # Compile and round-trip
+ f = io.BytesIO()
+ font.save(f)
+ font = ttLib.TTFont(f)
+ f = io.StringIO()
+ font.saveXML(f, tables=["STAT"])
+ ttx = f.getvalue().splitlines()
+ ttx = ttx[3:-2] # strip XML header and element
+ assert expected_ttx == ttx
+
+
+def test_stat_infinities():
+ negInf = floatToFixed(builder.AXIS_VALUE_NEGATIVE_INFINITY, 16)
+ assert struct.pack(">l", negInf) == b"\x80\x00\x00\x00"
+ posInf = floatToFixed(builder.AXIS_VALUE_POSITIVE_INFINITY, 16)
+ assert struct.pack(">l", posInf) == b"\x7f\xff\xff\xff"
+
+
if __name__ == "__main__":
import sys