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