From 75f314b149e0ea5eb0f8a68a7c502bcf0e27c834 Mon Sep 17 00:00:00 2001 From: Martin Hosken Date: Tue, 1 Dec 2020 23:38:12 +0700 Subject: [PATCH 001/167] Fix class2defs self overlapping glyphsets --- Lib/fontTools/otlLib/builder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index 1ba63c35c..3f5ae4e2f 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -2558,11 +2558,13 @@ class ClassDefBuilder(object): if isinstance(glyphs, (set, frozenset)): glyphs = sorted(glyphs) glyphs = tuple(glyphs) + tempglyphs = set() if glyphs in self.classes_: return True for glyph in glyphs: - if glyph in self.glyphs_: + if glyph in self.glyphs_ or glyph in tempglyphs: return False + tempglyphs.add(glyph) return True def add(self, glyphs): From ee8779032643d11409f12990f90f3c7d92a8ab95 Mon Sep 17 00:00:00 2001 From: Martin Hosken Date: Tue, 1 Dec 2020 23:55:43 +0700 Subject: [PATCH 002/167] Refix by removing duplicates from the same glyphset --- Lib/fontTools/otlLib/builder.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index 3f5ae4e2f..7e1444518 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -314,9 +314,10 @@ class ChainContextualRuleset: classdefbuilder = ClassDefBuilder(useClass0=False) for position in context: for glyphset in position: - if not classdefbuilder.canAdd(glyphset): + glyphs = set(glyphset) + if not classdefbuilder.canAdd(glyphs): return None - classdefbuilder.add(glyphset) + classdefbuilder.add(glyphs) return classdefbuilder @@ -2558,13 +2559,11 @@ class ClassDefBuilder(object): if isinstance(glyphs, (set, frozenset)): glyphs = sorted(glyphs) glyphs = tuple(glyphs) - tempglyphs = set() if glyphs in self.classes_: return True for glyph in glyphs: - if glyph in self.glyphs_ or glyph in tempglyphs: + if glyph in self.glyphs_: return False - tempglyphs.add(glyph) return True def add(self, glyphs): From d5353a4085d573f2a18cc31f67393b6581ed1865 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Tue, 1 Dec 2020 19:36:56 -0600 Subject: [PATCH 003/167] Switch from brotlipy to brotlicffi for PyPy support --- Lib/fontTools/ttLib/woff2.py | 5 ++++- Tests/ttLib/woff2_test.py | 5 ++++- Tests/ttx/ttx_test.py | 5 ++++- setup.py | 4 ++-- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/ttLib/woff2.py b/Lib/fontTools/ttLib/woff2.py index 67b1d1c10..75b55527c 100644 --- a/Lib/fontTools/ttLib/woff2.py +++ b/Lib/fontTools/ttLib/woff2.py @@ -19,7 +19,10 @@ log = logging.getLogger("fontTools.ttLib.woff2") haveBrotli = False try: - import brotli + try: + import brotlicffi as brotli + except ImportError: + import brotli haveBrotli = True except ImportError: pass diff --git a/Tests/ttLib/woff2_test.py b/Tests/ttLib/woff2_test.py index 2651e8085..661fd4485 100644 --- a/Tests/ttLib/woff2_test.py +++ b/Tests/ttLib/woff2_test.py @@ -21,7 +21,10 @@ import pytest haveBrotli = False try: - import brotli + try: + import brotlicffi as brotli + except ImportError: + import brotli haveBrotli = True except ImportError: pass diff --git a/Tests/ttx/ttx_test.py b/Tests/ttx/ttx_test.py index 753cc9ce2..3d4c3f92b 100644 --- a/Tests/ttx/ttx_test.py +++ b/Tests/ttx/ttx_test.py @@ -18,7 +18,10 @@ try: except ImportError: zopfli = None try: - import brotli + try: + import brotlicffi as brotli + except ImportError: + import brotli except ImportError: brotli = None diff --git a/setup.py b/setup.py index 2cd2ccb0c..8a04da167 100755 --- a/setup.py +++ b/setup.py @@ -82,8 +82,8 @@ extras_require = { # for fontTools.sfnt and fontTools.woff2: to compress/uncompress # WOFF 1.0 and WOFF 2.0 webfonts. "woff": [ - "brotli >= 1.0.1; platform_python_implementation != 'PyPy'", - "brotlipy >= 0.7.0; platform_python_implementation == 'PyPy'", + "brotli >= 1.0.1; platform_python_implementation == 'CPython'", + "brotlicffi >= 0.8.0; platform_python_implementation != 'CPython'", "zopfli >= 0.1.4", ], # for fontTools.unicode and fontTools.unicodedata: to use the latest version From d888526659dccf9c4f49b90584e49d23d5126e41 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Wed, 2 Dec 2020 21:21:19 -0800 Subject: [PATCH 004/167] Hook up paint skew and rotate --- Lib/fontTools/colorLib/builder.py | 32 +++++++++++++++++++++ Lib/fontTools/ttLib/tables/otData.py | 17 +++++++++++ Lib/fontTools/ttLib/tables/otTables.py | 4 ++- Tests/colorLib/builder_test.py | 40 +++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 5e7d8c6eb..d5084f456 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -542,6 +542,38 @@ class LayerV1ListBuilder: ot_paint.Paint = self.buildPaint(paint) return ot_paint + def buildPaintRotate( + self, + paint: _PaintInput, + angle: _ScalarInput, + centerX: _ScalarInput, + centerY: _ScalarInput, + ) -> ot.Paint: + ot_paint = ot.Paint() + ot_paint.Format = int(ot.Paint.Format.PaintRotate) + ot_paint.Paint = self.buildPaint(paint) + ot_paint.angle = _to_variable_f16dot16_float(angle) + ot_paint.centerX = _to_variable_f16dot16_float(centerX) + ot_paint.centerY = _to_variable_f16dot16_float(centerY) + return ot_paint + + def buildPaintSkew( + self, + paint: _PaintInput, + xSkewAngle: _ScalarInput, + ySkewAngle: _ScalarInput, + centerX: _ScalarInput, + centerY: _ScalarInput, + ) -> ot.Paint: + ot_paint = ot.Paint() + ot_paint.Format = int(ot.Paint.Format.PaintSkew) + ot_paint.Paint = self.buildPaint(paint) + ot_paint.xSkewAngle = _to_variable_f16dot16_float(xSkewAngle) + ot_paint.ySkewAngle = _to_variable_f16dot16_float(ySkewAngle) + ot_paint.centerX = _to_variable_f16dot16_float(centerX) + ot_paint.centerY = _to_variable_f16dot16_float(centerY) + return ot_paint + def buildPaintComposite( self, mode: _CompositeInput, diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py index f260a542a..776cf75ba 100755 --- a/Lib/fontTools/ttLib/tables/otData.py +++ b/Lib/fontTools/ttLib/tables/otData.py @@ -1664,6 +1664,23 @@ otData = [ ]), ('PaintFormat8', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 8'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintRotate table) to Paint subtable.'), + ('VarFixed', 'angle', None, None, ''), + ('VarFixed', 'centerX', None, None, ''), + ('VarFixed', 'centerY', None, None, ''), + ]), + + ('PaintFormat9', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 9'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintRotate table) to Paint subtable.'), + ('VarFixed', 'xSkewAngle', None, None, ''), + ('VarFixed', 'ySkewAngle', None, None, ''), + ('VarFixed', 'centerX', None, None, ''), + ('VarFixed', 'centerY', None, None, ''), + ]), + + ('PaintFormat10', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 8'), ('LOffset24To(Paint)', 'SourcePaint', None, None, 'Offset (from beginning of PaintComposite table) to source Paint subtable.'), ('CompositeMode', 'CompositeMode', None, None, 'A CompositeMode enumeration value.'), diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py index 7a04d5aa1..c4208a55f 100644 --- a/Lib/fontTools/ttLib/tables/otTables.py +++ b/Lib/fontTools/ttLib/tables/otTables.py @@ -1334,7 +1334,9 @@ class Paint(getFormatSwitchingBaseTableClass("uint8")): PaintGlyph = 5 PaintColrGlyph = 6 PaintTransform = 7 - PaintComposite = 8 + PaintRotate = 8 + PaintSkew = 9 + PaintComposite = 10 def getFormatName(self): try: diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index 86b5f9e92..152e16e08 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -544,7 +544,7 @@ def test_buildPaintComposite(): composite = layerBuilder.buildPaintComposite( mode=ot.CompositeMode.SRC_OVER, source={ - "format": 8, + "format": 10, "mode": "src_over", "source": {"format": 5, "glyph": "c", "paint": 2}, "backdrop": {"format": 5, "glyph": "b", "paint": 1}, @@ -574,6 +574,44 @@ def test_buildPaintComposite(): assert composite.BackdropPaint.Paint.Color.PaletteIndex == 0 +def test_buildPaintRotate(): + layerBuilder = LayerV1ListBuilder() + paint = layerBuilder.buildPaintRotate( + paint=layerBuilder.buildPaintGlyph( + "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) + ), + angle=15, + centerX=127, + centerY=129, + ) + + assert paint.Format == ot.Paint.Format.PaintRotate + assert paint.Paint.Format == ot.Paint.Format.PaintGlyph + assert paint.angle.value == 15 + assert paint.centerX.value == 127 + assert paint.centerY.value == 129 + + +def test_buildPaintRotate(): + layerBuilder = LayerV1ListBuilder() + paint = layerBuilder.buildPaintSkew( + paint=layerBuilder.buildPaintGlyph( + "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) + ), + xSkewAngle=15, + ySkewAngle=42, + centerX=127, + centerY=129, + ) + + assert paint.Format == ot.Paint.Format.PaintSkew + assert paint.Paint.Format == ot.Paint.Format.PaintGlyph + assert paint.xSkewAngle.value == 15 + assert paint.ySkewAngle.value == 42 + assert paint.centerX.value == 127 + assert paint.centerY.value == 129 + + def test_buildColrV1(): colorGlyphs = { "a": [("b", 0), ("c", 1)], From 40f95ba4eaf2c8e7239904202e01e61cbe80da46 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Wed, 2 Dec 2020 22:58:17 -0800 Subject: [PATCH 005/167] Update C_O_L_R_test.py; try to make the binary data test more palatable --- Tests/ttLib/tables/C_O_L_R_test.py | 396 +++++++++++++++++------------ 1 file changed, 239 insertions(+), 157 deletions(-) diff --git a/Tests/ttLib/tables/C_O_L_R_test.py b/Tests/ttLib/tables/C_O_L_R_test.py index 0a1d9df9d..76e9e61a1 100644 --- a/Tests/ttLib/tables/C_O_L_R_test.py +++ b/Tests/ttLib/tables/C_O_L_R_test.py @@ -2,26 +2,32 @@ from fontTools import ttLib from fontTools.misc.testTools import getXML, parseXML from fontTools.ttLib.tables.C_O_L_R_ import table_C_O_L_R_ +import binascii import pytest -COLR_V0_DATA = ( - b"\x00\x00" # Version (0) - b"\x00\x01" # BaseGlyphRecordCount (1) - b"\x00\x00\x00\x0e" # Offset to BaseGlyphRecordArray from beginning of table (14) - b"\x00\x00\x00\x14" # Offset to LayerRecordArray from beginning of table (20) - b"\x00\x03" # LayerRecordCount (3) - b"\x00\x06" # BaseGlyphRecord[0].BaseGlyph (6) - b"\x00\x00" # BaseGlyphRecord[0].FirstLayerIndex (0) - b"\x00\x03" # BaseGlyphRecord[0].NumLayers (3) - b"\x00\x07" # LayerRecord[0].LayerGlyph (7) - b"\x00\x00" # LayerRecord[0].PaletteIndex (0) - b"\x00\x08" # LayerRecord[1].LayerGlyph (8) - b"\x00\x01" # LayerRecord[1].PaletteIndex (1) - b"\x00\t" # LayerRecord[2].LayerGlyph (9) - b"\x00\x02" # LayerRecord[3].PaletteIndex (2) +COLR_V0_SAMPLE = ( + (b"\x00\x00", "Version (0)"), + (b"\x00\x01", "BaseGlyphRecordCount (1)"), + ( + b"\x00\x00\x00\x0e", + "Offset to BaseGlyphRecordArray from beginning of table (14)", + ), + (b"\x00\x00\x00\x14", "Offset to LayerRecordArray from beginning of table (20)"), + (b"\x00\x03", "LayerRecordCount (3)"), + (b"\x00\x06", "BaseGlyphRecord[0].BaseGlyph (6)"), + (b"\x00\x00", "BaseGlyphRecord[0].FirstLayerIndex (0)"), + (b"\x00\x03", "BaseGlyphRecord[0].NumLayers (3)"), + (b"\x00\x07", "LayerRecord[0].LayerGlyph (7)"), + (b"\x00\x00", "LayerRecord[0].PaletteIndex (0)"), + (b"\x00\x08", "LayerRecord[1].LayerGlyph (8)"), + (b"\x00\x01", "LayerRecord[1].PaletteIndex (1)"), + (b"\x00\t", "LayerRecord[2].LayerGlyph (9)"), + (b"\x00\x02", "LayerRecord[3].PaletteIndex (2)"), ) +COLR_V0_DATA = b"".join(t[0] for t in COLR_V0_SAMPLE) + COLR_V0_XML = [ '', @@ -37,6 +43,21 @@ def dump(table, ttFont=None): print("\n".join(getXML(table.toXML, ttFont))) +def diff_binary_fragments(font_bytes, expected_fragments): + pos = 0 + prev_desc = "" + for expected_bytes, description in expected_fragments: + actual_bytes = font_bytes[pos : pos + len(expected_bytes)] + assert ( + actual_bytes == expected_bytes + ), f'{description} (previous "{prev_desc}", bytes: {str(font_bytes[pos:pos+16])}' + pos += len(expected_bytes) + prev_desc = description + assert pos == len( + font_bytes + ), f"Leftover font bytes, used {pos} of {len(font_bytes)}" + + @pytest.fixture def font(): font = ttLib.TTFont() @@ -48,7 +69,7 @@ class COLR_V0_Test(object): def test_decompile_and_compile(self, font): colr = table_C_O_L_R_() colr.decompile(COLR_V0_DATA, font) - assert colr.compile(font) == COLR_V0_DATA + diff_binary_fragments(colr.compile(font), COLR_V0_SAMPLE) def test_decompile_and_dump_xml(self, font): colr = table_C_O_L_R_() @@ -62,145 +83,177 @@ class COLR_V0_Test(object): for name, attrs, content in parseXML(COLR_V0_XML): colr.fromXML(name, attrs, content, font) - assert colr.compile(font) == COLR_V0_DATA + diff_binary_fragments(colr.compile(font), COLR_V0_SAMPLE) + + def test_round_trip_xml(self, font): + colr = table_C_O_L_R_() + for name, attrs, content in parseXML(COLR_V0_XML): + colr.fromXML(name, attrs, content, font) + compiled = colr.compile(font) + + colr = table_C_O_L_R_() + colr.decompile(compiled, font) + assert getXML(colr.toXML, font) == COLR_V0_XML -COLR_V1_DATA = ( - b"\x00\x01" # Version (1) - b"\x00\x01" # BaseGlyphRecordCount (1) - b"\x00\x00\x00\x1a" # Offset to BaseGlyphRecordArray from beginning of table (26) - b"\x00\x00\x00 " # Offset to LayerRecordArray from beginning of table (32) - b"\x00\x03" # LayerRecordCount (3) - b"\x00\x00\x00," # Offset to BaseGlyphV1List from beginning of table (44) - b"\x00\x00\x00\x81" # Offset to LayerV1List from beginning of table (129) - b"\x00\x00\x00\x00" # Offset to VarStore (NULL) - b"\x00\x06" # BaseGlyphRecord[0].BaseGlyph (6) - b"\x00\x00" # BaseGlyphRecord[0].FirstLayerIndex (0) - b"\x00\x03" # BaseGlyphRecord[0].NumLayers (3) - b"\x00\x07" # LayerRecord[0].LayerGlyph (7) - b"\x00\x00" # LayerRecord[0].PaletteIndex (0) - b"\x00\x08" # LayerRecord[1].LayerGlyph (8) - b"\x00\x01" # LayerRecord[1].PaletteIndex (1) - b"\x00\t" # LayerRecord[2].LayerGlyph (9) - b"\x00\x02" # LayerRecord[2].PaletteIndex (2) - b"\x00\x00\x00\x02" # BaseGlyphV1List.BaseGlyphCount (2) - b"\x00\n" # BaseGlyphV1List.BaseGlyphV1Record[0].BaseGlyph (10) - b"\x00\x00\x00\x10" # Offset to Paint table from beginning of BaseGlyphV1List (16) - b"\x00\x0e" # BaseGlyphV1List.BaseGlyphV1Record[1].BaseGlyph (14) - b"\x00\x00\x00\x16" # Offset to Paint table from beginning of BaseGlyphV1List (22) - b"\x01" # BaseGlyphV1Record[0].Paint.Format (1) - b"\x03" # BaseGlyphV1Record[0].Paint.NumLayers (3) - b"\x00\x00\x00\x00" # BaseGlyphV1Record[0].Paint.FirstLayerIndex (0) - b"\x08" # BaseGlyphV1Record[1].Paint.Format (8) - b"\x00\x00<" # Offset to SourcePaint from beginning of PaintComposite (60) - b"\x03" # BaseGlyphV1Record[1].Paint.CompositeMode [SRC_OVER] (3) - b"\x00\x00\x08" # Offset to BackdropPaint from beginning of PaintComposite (8) - b"\x07" # BaseGlyphV1Record[1].Paint.BackdropPaint.Format (7) - b"\x00\x004" # Offset to Paint from beginning of PaintTransform (52) - b"\x00\x01\x00\x00" # Affine2x3.xx.value (1.0) - b"\x00\x00\x00\x00" - b"\x00\x00\x00\x00" # Affine2x3.xy.value (0.0) - b"\x00\x00\x00\x00" - b"\x00\x00\x00\x00" # Affine2x3.yx.value (0.0) - b"\x00\x00\x00\x00" - b"\x00\x01\x00\x00" # Affine2x3.yy.value (1.0) - b"\x00\x00\x00\x00" - b"\x01,\x00\x00" # Affine2x3.dx.value (300.0) - b"\x00\x00\x00\x00" - b"\x00\x00\x00\x00" # Affine2x3.dy.value (0.0) - b"\x00\x00\x00\x00" - b"\x06" # BaseGlyphV1Record[1].Paint.SourcePaint.Format (6) - b"\x00\n" # BaseGlyphV1Record[1].Paint.SourcePaint.Glyph (10) - b"\x00\x00\x00\x03" # LayerV1List.LayerCount (3) - b"\x00\x00\x00\x10" # Offset to Paint table from beginning of LayerV1List (16) - b"\x00\x00\x00\x1f" # Offset to Paint table from beginning of LayerV1List (31) - b"\x00\x00\x00z" # Offset to Paint table from beginning of LayerV1List (122) - b"\x05" # LayerV1List.Paint[0].Format (5) - b"\x00\x00\x06" # Offset to Paint subtable from beginning of PaintGlyph (6) - b"\x00\x0b" # LayerV1List.Paint[0].Glyph (11) - b"\x02" # LayerV1List.Paint[0].Paint.Format (2) - b"\x00\x02" # Paint.Color.PaletteIndex (2) - b" \x00" # Paint.Color.Alpha.value (0.5) - b"\x00\x00\x00\x00" # Paint.Color.Alpha.varIdx (0) - b"\x05" # LayerV1List.Paint[1].Format (5) - b"\x00\x00\x06" # Offset to Paint subtable from beginning of PaintGlyph (6) - b"\x00\x0c" # LayerV1List.Paint[1].Glyph (12) - b"\x03" # LayerV1List.Paint[1].Paint.Format (3) - b"\x00\x00(" # Offset to ColorLine from beginning of PaintLinearGradient (40) - b"\x00\x01" # Paint.x0.value (1) - b"\x00\x00\x00\x00" # Paint.x0.varIdx (0) - b"\x00\x02" # Paint.y0.value (2) - b"\x00\x00\x00\x00" # Paint.y0.varIdx (0) - b"\xff\xfd" # Paint.x1.value (-3) - b"\x00\x00\x00\x00" # Paint.x1.varIdx (0) - b"\xff\xfc" # Paint.y1.value (-4) - b"\x00\x00\x00\x00" # Paint.y1.varIdx (0) - b"\x00\x05" # Paint.x2.value (5) - b"\x00\x00\x00\x00" # Paint.x2.varIdx (0) - b"\x00\x06" # Paint.y2.value (6) - b"\x00\x00\x00\x00" # Paint.y2.varIdx (0) - b"\x01" # ColorLine.Extend (1 or "repeat") - b"\x00\x03" # ColorLine.StopCount (3) - b"\x00\x00" # ColorLine.ColorStop[0].StopOffset.value (0.0) - b"\x00\x00\x00\x00" # ColorLine.ColorStop[0].StopOffset.varIdx (0) - b"\x00\x03" # ColorLine.ColorStop[0].Color.PaletteIndex (3) - b"@\x00" # ColorLine.ColorStop[0].Color.Alpha.value (1.0) - b"\x00\x00\x00\x00" # ColorLine.ColorStop[0].Color.Alpha.varIdx (0) - b" \x00" # ColorLine.ColorStop[1].StopOffset.value (0.5) - b"\x00\x00\x00\x00" # ColorLine.ColorStop[1].StopOffset.varIdx (0) - b"\x00\x04" # ColorLine.ColorStop[1].Color.PaletteIndex (4) - b"@\x00" # ColorLine.ColorStop[1].Color.Alpha.value (1.0) - b"\x00\x00\x00\x00" # ColorLine.ColorStop[1].Color.Alpha.varIdx (0) - b"@\x00" # ColorLine.ColorStop[2].StopOffset.value (1.0) - b"\x00\x00\x00\x00" # ColorLine.ColorStop[2].StopOffset.varIdx (0) - b"\x00\x05" # ColorLine.ColorStop[2].Color.PaletteIndex (5) - b"@\x00" # ColorLine.ColorStop[2].Color.Alpha.value (1.0) - b"\x00\x00\x00\x00" # ColorLine.ColorStop[2].Color.Alpha.varIdx (0) - b"\x05" # LayerV1List.Paint[2].Format (5) - b"\x00\x00\x06" # Offset to Paint subtable from beginning of PaintGlyph (6) - b"\x00\r" # LayerV1List.Paint[2].Glyph (13) - b"\x07" # LayerV1List.Paint[2].Paint.Format (5) - b"\x00\x004" # Offset to Paint subtable from beginning of PaintTransform (52) - b"\xff\xf3\x00\x00" # Affine2x3.xx.value (-13) - b"\x00\x00\x00\x00" - b"\x00\x0e\x00\x00" # Affine2x3.xy.value (14) - b"\x00\x00\x00\x00" - b"\x00\x0f\x00\x00" # Affine2x3.yx.value (15) - b"\x00\x00\x00\x00" - b"\xff\xef\x00\x00" # Affine2x3.yy.value (-17) - b"\x00\x00\x00\x00" - b"\x00\x12\x00\x00" # Affine2x3.yy.value (18) - b"\x00\x00\x00\x00" - b"\x00\x13\x00\x00" # Affine2x3.yy.value (19) - b"\x00\x00\x00\x00" - b"\x04" # LayerV1List.Paint[2].Paint.Paint.Format (4) - b"\x00\x00(" # Offset to ColorLine from beginning of PaintRadialGradient (40) - b"\x00\x07" # Paint.x0.value (7) - b"\x00\x00\x00\x00" - b"\x00\x08" # Paint.y0.value (8) - b"\x00\x00\x00\x00" - b"\x00\t" # Paint.r0.value (9) - b"\x00\x00\x00\x00" - b"\x00\n" # Paint.x1.value (10) - b"\x00\x00\x00\x00" - b"\x00\x0b" # Paint.y1.value (11) - b"\x00\x00\x00\x00" - b"\x00\x0c" # Paint.r1.value (12) - b"\x00\x00\x00\x00" - b"\x00" # ColorLine.Extend (0 or "pad") - b"\x00\x02" # ColorLine.StopCount (2) - b"\x00\x00" # ColorLine.ColorStop[0].StopOffset.value (0.0) - b"\x00\x00\x00\x00" - b"\x00\x06" # ColorLine.ColorStop[0].Color.PaletteIndex (6) - b"@\x00" # ColorLine.ColorStop[0].Color.Alpha.value (1.0) - b"\x00\x00\x00\x00" - b"@\x00" # ColorLine.ColorStop[1].StopOffset.value (1.0) - b"\x00\x00\x00\x00" - b"\x00\x07" # ColorLine.ColorStop[1].Color.PaletteIndex (7) - b"\x19\x9a" # ColorLine.ColorStop[1].Color.Alpha.value (0.4) - b"\x00\x00\x00\x00" +COLR_V1_SAMPLE = ( + (b"\x00\x01", "Version (1)"), + (b"\x00\x01", "BaseGlyphRecordCount (1)"), + ( + b"\x00\x00\x00\x1a", + "Offset to BaseGlyphRecordArray from beginning of table (26)", + ), + (b"\x00\x00\x00 ", "Offset to LayerRecordArray from beginning of table (32)"), + (b"\x00\x03", "LayerRecordCount (3)"), + (b"\x00\x00\x00,", "Offset to BaseGlyphV1List from beginning of table (44)"), + (b"\x00\x00\x00\x81", "Offset to LayerV1List from beginning of table (129)"), + (b"\x00\x00\x00\x00", "Offset to VarStore (NULL)"), + (b"\x00\x06", "BaseGlyphRecord[0].BaseGlyph (6)"), + (b"\x00\x00", "BaseGlyphRecord[0].FirstLayerIndex (0)"), + (b"\x00\x04", "BaseGlyphRecord[0].NumLayers (4)"), + (b"\x00\x07", "LayerRecord[0].LayerGlyph (7)"), + (b"\x00\x00", "LayerRecord[0].PaletteIndex (0)"), + (b"\x00\x08", "LayerRecord[1].LayerGlyph (8)"), + (b"\x00\x01", "LayerRecord[1].PaletteIndex (1)"), + (b"\x00\t", "LayerRecord[2].LayerGlyph (9)"), + (b"\x00\x02", "LayerRecord[2].PaletteIndex (2)"), + (b"\x00\x00\x00\x02", "BaseGlyphV1List.BaseGlyphCount (2)"), + (b"\x00\n", "BaseGlyphV1List.BaseGlyphV1Record[0].BaseGlyph (10)"), + ( + b"\x00\x00\x00\x10", + "Offset to Paint table from beginning of BaseGlyphV1List (16)", + ), + (b"\x00\x0e", "BaseGlyphV1List.BaseGlyphV1Record[1].BaseGlyph (14)"), + ( + b"\x00\x00\x00\x16", + "Offset to Paint table from beginning of BaseGlyphV1List (22)", + ), + (b"\x01", "BaseGlyphV1Record[0].Paint.Format (1)"), + (b"\x04", "BaseGlyphV1Record[0].Paint.NumLayers (4)"), + (b"\x00\x00\x00\x00", "BaseGlyphV1Record[0].Paint.FirstLayerIndex (0)"), + (b"\x0A", "BaseGlyphV1Record[1].Paint.Format (10)"), + (b"\x00\x00<", "Offset to SourcePaint from beginning of PaintComposite (60)"), + (b"\x03", "BaseGlyphV1Record[1].Paint.CompositeMode [SRC_OVER] (3)"), + (b"\x00\x00\x08", "Offset to BackdropPaint from beginning of PaintComposite (8)"), + (b"\x07", "BaseGlyphV1Record[1].Paint.BackdropPaint.Format (7)"), + (b"\x00\x00\x34", "Offset to Paint from beginning of PaintTransform (52)"), + (b"\x00\x01\x00\x00\x00\x00\x00\x00", "Affine2x3.xx.value (1.0)"), + (b"\x00\x00\x00\x00\x00\x00\x00\x00", "Affine2x3.xy.value (0.0)"), + (b"\x00\x00\x00\x00\x00\x00\x00\x00", "Affine2x3.yx.value (0.0)"), + (b"\x00\x01\x00\x00\x00\x00\x00\x00", "Affine2x3.yy.value (1.0)"), + (b"\x01\x2c\x00\x00\x00\x00\x00\x00", "Affine2x3.dx.value (300.0)"), + (b"\x00\x00\x00\x00\x00\x00\x00\x00", "Affine2x3.dy.value (0.0)"), + (b"\x06", "BaseGlyphV1Record[1].Paint.SourcePaint.Format (6)"), + (b"\x00\n", "BaseGlyphV1Record[1].Paint.SourcePaint.Glyph (10)"), + (b"\x00\x00\x00\x04", "LayerV1List.LayerCount (4)"), + ( + b"\x00\x00\x00\x14", + "First Offset to Paint table from beginning of LayerV1List (20)", + ), + ( + b"\x00\x00\x00\x1a", + "Second Offset to Paint table from beginning of LayerV1List (26)", + ), + ( + b"\x00\x00\x00u", + "Third Offset to Paint table from beginning of LayerV1List (117)", + ), + ( + b"\x00\x00\x00\xf6", + "Fourth Offset to Paint table from beginning of LayerV1List (246)", + ), + # PaintGlyph glyph00011 + (b"\x05", "LayerV1List.Paint[0].Format (5)"), + (b"\x00\x01\x28", "Offset24 to Paint subtable from beginning of PaintGlyph (296)"), + (b"\x00\x0b", "LayerV1List.Paint[0].Glyph (glyph00011)"), + # PaintGlyph glyph00012 + (b"\x05", "LayerV1List.Paint[1].Format (5)"), + (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), + (b"\x00\x0c", "LayerV1List.Paint[1].Glyph (glyph00012)"), + (b"\x03", "LayerV1List.Paint[1].Paint.Format (3)"), + (b"\x00\x00(", "Offset to ColorLine from beginning of PaintLinearGradient (40)"), + (b"\x00\x01", "Paint.x0.value (1)"), + (b"\x00\x00\x00\x00", "Paint.x0.varIdx (0)"), + (b"\x00\x02", "Paint.y0.value (2)"), + (b"\x00\x00\x00\x00", "Paint.y0.varIdx (0)"), + (b"\xff\xfd", "Paint.x1.value (-3)"), + (b"\x00\x00\x00\x00", "Paint.x1.varIdx (0)"), + (b"\xff\xfc", "Paint.y1.value (-4)"), + (b"\x00\x00\x00\x00", "Paint.y1.varIdx (0)"), + (b"\x00\x05", "Paint.x2.value (5)"), + (b"\x00\x00\x00\x00", "Paint.x2.varIdx (0)"), + (b"\x00\x06", "Paint.y2.value (6)"), + (b"\x00\x00\x00\x00", "Paint.y2.varIdx (0)"), + (b"\x01", "ColorLine.Extend (1; repeat)"), + (b"\x00\x03", "ColorLine.StopCount (3)"), + (b"\x00\x00", "ColorLine.ColorStop[0].StopOffset.value (0.0)"), + (b"\x00\x00\x00\x00", "ColorLine.ColorStop[0].StopOffset.varIdx (0)"), + (b"\x00\x03", "ColorLine.ColorStop[0].Color.PaletteIndex (3)"), + (b"@\x00", "ColorLine.ColorStop[0].Color.Alpha.value (1.0)"), + (b"\x00\x00\x00\x00", "ColorLine.ColorStop[0].Color.Alpha.varIdx (0)"), + (b" \x00", "ColorLine.ColorStop[1].StopOffset.value (0.5)"), + (b"\x00\x00\x00\x00", "ColorLine.ColorStop[1].StopOffset.varIdx (0)"), + (b"\x00\x04", "ColorLine.ColorStop[1].Color.PaletteIndex (4)"), + (b"@\x00", "ColorLine.ColorStop[1].Color.Alpha.value (1.0)"), + (b"\x00\x00\x00\x00", "ColorLine.ColorStop[1].Color.Alpha.varIdx (0)"), + (b"@\x00", "ColorLine.ColorStop[2].StopOffset.value (1.0)"), + (b"\x00\x00\x00\x00", "ColorLine.ColorStop[2].StopOffset.varIdx (0)"), + (b"\x00\x05", "ColorLine.ColorStop[2].Color.PaletteIndex (5)"), + (b"@\x00", "ColorLine.ColorStop[2].Color.Alpha.value (1.0)"), + (b"\x00\x00\x00\x00", "ColorLine.ColorStop[2].Color.Alpha.varIdx (0)"), + # PaintGlyph glyph00013 + (b"\x05", "LayerV1List.Paint[2].Format (5)"), + (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), + (b"\x00\r", "LayerV1List.Paint[2].Glyph (13)"), + (b"\x07", "LayerV1List.Paint[2].Paint.Format (5)"), + (b"\x00\x00\x34", "Offset to Paint subtable from beginning of PaintTransform (52)"), + (b"\xff\xf3\x00\x00\x00\x00\x00\x00", "Affine2x3.xx.value (-13)"), + (b"\x00\x0e\x00\x00\x00\x00\x00\x00", "Affine2x3.xy.value (14)"), + (b"\x00\x0f\x00\x00\x00\x00\x00\x00", "Affine2x3.yx.value (15)"), + (b"\xff\xef\x00\x00\x00\x00\x00\x00", "Affine2x3.yy.value (-17)"), + (b"\x00\x12\x00\x00\x00\x00\x00\x00", "Affine2x3.yy.value (18)"), + (b"\x00\x13\x00\x00\x00\x00\x00\x00", "Affine2x3.yy.value (19)"), + (b"\x04", "LayerV1List.Paint[2].Paint.Paint.Format (4)"), + (b"\x00\x00(", "Offset to ColorLine from beginning of PaintRadialGradient (40)"), + (b"\x00\x07\x00\x00\x00\x00", "Paint.x0.value (7)"), + (b"\x00\x08\x00\x00\x00\x00", "Paint.y0.value (8)"), + (b"\x00\t\x00\x00\x00\x00", "Paint.r0.value (9)"), + (b"\x00\n\x00\x00\x00\x00", "Paint.x1.value (10)"), + (b"\x00\x0b\x00\x00\x00\x00", "Paint.y1.value (11)"), + (b"\x00\x0c\x00\x00\x00\x00", "Paint.r1.value (12)"), + (b"\x00", "ColorLine.Extend (0; pad)"), + (b"\x00\x02", "ColorLine.StopCount (2)"), + (b"\x00\x00\x00\x00\x00\x00", "ColorLine.ColorStop[0].StopOffset.value (0.0)"), + (b"\x00\x06", "ColorLine.ColorStop[0].Color.PaletteIndex (6)"), + (b"@\x00\x00\x00\x00\x00", "ColorLine.ColorStop[0].Color.Alpha.value (1.0)"), + (b"@\x00\x00\x00\x00\x00", "ColorLine.ColorStop[1].StopOffset.value (1.0)"), + (b"\x00\x07", "ColorLine.ColorStop[1].Color.PaletteIndex (7)"), + (b"\x19\x9a\x00\x00\x00\x00", "ColorLine.ColorStop[1].Color.Alpha.value (0.4)"), + # PaintRotate + (b"\x08", "LayerV1List.Paint[3].Format (8)"), + (b"\x00\x00\x1c", "Offset to Paint subtable from beginning of PaintRotate (28)"), + (b"\x00\x2d\x00\x00\x00\x00\x00\x00", "angle.value (45)"), + (b"\x00\xff\x00\x00\x00\x00\x00\x00", "centerX.value (255)"), + (b"\x01\x00\x00\x00\x00\x00\x00\x00", "centerY.value (256)"), + # PaintSkew + (b"\x09", "LayerV1List.Paint[3].Format (9)"), + (b"\x00\x00\x24", "Offset to Paint subtable from beginning of PaintSkew (36)"), + (b"\xff\xf5\x00\x00\x00\x00\x00\x00", "xSkewAngle (-11)"), + (b"\x00\x05\x00\x00\x00\x00\x00\x00", "ySkewAngle (5)"), + (b"\x00\xfd\x00\x00\x00\x00\x00\x00", "centerX.value (253)"), + (b"\x00\xfe\x00\x00\x00\x00\x00\x00", "centerY.value (254)"), + # PaintGlyph + (b"\x05", "LayerV1List.Paint[2].Format (5)"), + (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), + (b"\x00\x0b", "LayerV1List.Paint[2].Glyph (11)"), + # PaintSolid + (b"\x02", "LayerV1List.Paint[0].Paint.Format (2)"), + (b"\x00\x02", "Paint.Color.PaletteIndex (2)"), + (b" \x00", "Paint.Color.Alpha.value (0.5)"), + (b"\x00\x00\x00\x00", "Paint.Color.Alpha.varIdx (0)"), ) +COLR_V1_DATA = b"".join(t[0] for t in COLR_V1_SAMPLE) COLR_V1_XML = [ '', @@ -209,7 +262,7 @@ COLR_V1_XML = [ ' ', ' ', ' ', - ' ', + ' ', " ", "", "", @@ -232,13 +285,13 @@ COLR_V1_XML = [ ' ', ' ', ' ', - ' ', + ' ', ' ', " ", " ", ' ', ' ', - ' ', + ' ', ' ', ' ', " ", @@ -260,7 +313,7 @@ COLR_V1_XML = [ " ", "", "", - " ", + " ", ' ', ' ', " ", @@ -345,6 +398,26 @@ COLR_V1_XML = [ " ", ' ', " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + " ", + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + ' ', + " ", "", ] @@ -353,7 +426,7 @@ class COLR_V1_Test(object): def test_decompile_and_compile(self, font): colr = table_C_O_L_R_() colr.decompile(COLR_V1_DATA, font) - assert colr.compile(font) == COLR_V1_DATA + diff_binary_fragments(colr.compile(font), COLR_V1_SAMPLE) def test_decompile_and_dump_xml(self, font): colr = table_C_O_L_R_() @@ -366,5 +439,14 @@ class COLR_V1_Test(object): colr = table_C_O_L_R_() for name, attrs, content in parseXML(COLR_V1_XML): colr.fromXML(name, attrs, content, font) + diff_binary_fragments(colr.compile(font), COLR_V1_SAMPLE) - assert colr.compile(font) == COLR_V1_DATA + def test_round_trip_xml(self, font): + colr = table_C_O_L_R_() + for name, attrs, content in parseXML(COLR_V1_XML): + colr.fromXML(name, attrs, content, font) + compiled = colr.compile(font) + + colr = table_C_O_L_R_() + colr.decompile(compiled, font) + assert getXML(colr.toXML, font) == COLR_V1_XML From f1f73299e80c2a96c54d49562ed1e6d1962a94ca Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 4 Dec 2020 19:20:35 +0000 Subject: [PATCH 006/167] Update changelog [skip ci] --- NEWS.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/NEWS.rst b/NEWS.rst index be8fb7823..5e547d560 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,21 @@ +- [COLRv1] Update to latest draft: added ``PaintRotate`` and ``PaintSkew`` (#2118). +- [woff2] Support new ``brotlicffi`` bindings for PyPy (#2117). +- [glifLib] Added ``expectContentsFile`` parameter to ``GlyphSet``, for use when + reading existing UFOs, to comply with the specification stating that a + ``contents.plist`` file must exist in a glyph set (#2114). +- [subset] Allow ``LangSys`` tags in ``--layout-scripts`` option (#2112). For example: + ``--layout-scripts=arab.dflt,arab.URD,latn``; this will keep ``DefaultLangSys`` + and ``URD`` language for ``arab`` script, and all languages for ``latn`` script. +- [varLib.interpolatable] Allow UFOs to be checked; report open paths, non existant + glyphs; add a ``--json`` option to produce a machine-readable list of + incompatibilities +- [pens] Added ``QuartzPen`` to create ``CGPath`` from glyph outlines on macOS. + Requires pyobjc (#2107). +- [feaLib] You can export ``FONTTOOLS_LOOKUP_DEBUGGING=1`` to enable feature file + debugging info stored in ``Debg`` table (#2106). +- [otlLib] Build more efficient format 1 and format 2 contextual lookups whenever + possible (#2101). + 4.17.1 (released 2020-11-16) ---------------------------- From f1d0dc4cdea29cef2d26cb25e81b2c3774b9fb7d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 4 Dec 2020 19:21:48 +0000 Subject: [PATCH 007/167] Release 4.18.0 --- Lib/fontTools/__init__.py | 2 +- NEWS.rst | 3 +++ setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index e0048bd39..8a8121ddf 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.17.2.dev0" +version = __version__ = "4.18.0" __all__ = ["version", "log", "configLogger"] diff --git a/NEWS.rst b/NEWS.rst index 5e547d560..bf3a72229 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +4.18.0 (released 2020-12-04) +---------------------------- + - [COLRv1] Update to latest draft: added ``PaintRotate`` and ``PaintSkew`` (#2118). - [woff2] Support new ``brotlicffi`` bindings for PyPy (#2117). - [glifLib] Added ``expectContentsFile`` parameter to ``GlyphSet``, for use when diff --git a/setup.cfg b/setup.cfg index d53704c75..db6ac55a7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.17.2.dev0 +current_version = 4.18.0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 8a04da167..df0100483 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.17.2.dev0", + version="4.18.0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From dcb8b2c907ac0cf32199303830f65997e50384b7 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 4 Dec 2020 19:21:49 +0000 Subject: [PATCH 008/167] =?UTF-8?q?Bump=20version:=204.18.0=20=E2=86=92=20?= =?UTF-8?q?4.18.1.dev0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Lib/fontTools/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 8a8121ddf..e27412020 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.18.0" +version = __version__ = "4.18.1.dev0" __all__ = ["version", "log", "configLogger"] diff --git a/setup.cfg b/setup.cfg index db6ac55a7..f998f15c2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.18.0 +current_version = 4.18.1.dev0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index df0100483..9f5c85ee2 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.18.0", + version="4.18.1.dev0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 4af216dde497ae47557433d836107be059bb2922 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 4 Dec 2020 19:32:05 +0000 Subject: [PATCH 009/167] publish.yml: fix tag pattern we don't use the leading 'v' for fonttools tags --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d43c8408e..ec6abe201 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,7 +7,7 @@ on: push: # Sequence of patterns matched against refs/tags tags: - - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + - '*.*.*' # e.g. 1.0.0 or 20.15.10 jobs: deploy: From 0957dc7ab3ce8d9d597961ce31579ff628f2fe29 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 7 Dec 2020 17:16:15 +0000 Subject: [PATCH 010/167] mutator: fix missing tab in interpolate_cff2_metrics Originally reported in https://twitter.com/ken_lunde/status/1335987620002709507?s=20 --- Lib/fontTools/varLib/mutator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/mutator.py b/Lib/fontTools/varLib/mutator.py index 199657864..ad76420a1 100644 --- a/Lib/fontTools/varLib/mutator.py +++ b/Lib/fontTools/varLib/mutator.py @@ -138,7 +138,7 @@ def interpolate_cff2_metrics(varfont, topDict, glyphOrder, loc): lsb_delta = 0 else: lsb = boundsPen.bounds[0] - lsb_delta = entry[1] - lsb + lsb_delta = entry[1] - lsb if lsb_delta or width_delta: if width_delta: From f0dcd335bba2c2a554df7346791a4eebd163a1fb Mon Sep 17 00:00:00 2001 From: rsheeter Date: Fri, 4 Dec 2020 11:42:14 -0800 Subject: [PATCH 011/167] Cache tuples --- Lib/fontTools/colorLib/builder.py | 44 ++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index d5084f456..530cca802 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -436,20 +436,6 @@ def _to_color_line(obj): raise TypeError(obj) -def _as_tuple(obj) -> Tuple[Any, ...]: - # start simple, who even cares about cyclic graphs or interesting field types - def _tuple_safe(value): - if isinstance(value, enum.Enum): - return value - elif hasattr(value, "__dict__"): - return tuple((k, _tuple_safe(v)) for k, v in value.__dict__.items()) - elif isinstance(value, collections.abc.MutableSequence): - return tuple(_tuple_safe(e) for e in value) - return value - - return tuple(_tuple_safe(obj)) - - def _reuse_ranges(num_layers: int) -> Generator[Tuple[int, int], None, None]: # TODO feels like something itertools might have already for lbound in range(num_layers): @@ -465,11 +451,35 @@ class LayerV1ListBuilder: slices: List[ot.Paint] layers: List[ot.Paint] reusePool: Mapping[Tuple[Any, ...], int] + tuples: Mapping[int, Tuple[Any, ...]] def __init__(self): self.slices = [] self.layers = [] self.reusePool = {} + self.tuples = {} + + def _paint_tuple(self, paint: ot.Paint): + # start simple, who even cares about cyclic graphs or interesting field types + def _tuple_safe(value): + if isinstance(value, enum.Enum): + return value + elif hasattr(value, "__dict__"): + return tuple((k, _tuple_safe(v)) for k, v in value.__dict__.items()) + elif isinstance(value, collections.abc.MutableSequence): + return tuple(_tuple_safe(e) for e in value) + return value + + # Cache the tuples for individual Paint instead of the a whole sequence + # because the seq could be a transient slice + result = self.tuples.get(id(paint), None) + if result is None: + result = _tuple_safe(paint) + self.tuples[id(paint)] = result + return result + + def _as_tuple(self, paints: Sequence[ot.Paint]) -> Tuple[Any, ...]: + return tuple(self._paint_tuple(p) for p in paints) def buildPaintSolid( self, paletteIndex: int, alpha: _ScalarInput = _DEFAULT_ALPHA @@ -605,7 +615,9 @@ class LayerV1ListBuilder: reverse=True, ) for lbound, ubound in ranges: - reuse_lbound = self.reusePool.get(_as_tuple(paints[lbound:ubound]), -1) + reuse_lbound = self.reusePool.get( + self._as_tuple(paints[lbound:ubound]), -1 + ) if reuse_lbound == -1: continue new_slice = ot.Paint() @@ -622,7 +634,7 @@ class LayerV1ListBuilder: # Register our parts for reuse for lbound, ubound in _reuse_ranges(len(paints)): - self.reusePool[_as_tuple(paints[lbound:ubound])] = ( + self.reusePool[self._as_tuple(paints[lbound:ubound])] = ( lbound + ot_paint.FirstLayerIndex ) From bd8861f10d545691156855031d009722707cab41 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sun, 6 Dec 2020 17:56:49 +0000 Subject: [PATCH 012/167] sort __dict__.items() when freezing paint tuples --- Lib/fontTools/colorLib/builder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 530cca802..7249bd9f2 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -465,7 +465,9 @@ class LayerV1ListBuilder: if isinstance(value, enum.Enum): return value elif hasattr(value, "__dict__"): - return tuple((k, _tuple_safe(v)) for k, v in value.__dict__.items()) + return tuple( + (k, _tuple_safe(v)) for k, v in sorted(value.__dict__.items()) + ) elif isinstance(value, collections.abc.MutableSequence): return tuple(_tuple_safe(e) for e in value) return value From 17395b0769340d84899e5fdb6fdb7f7b117d8807 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Mon, 7 Dec 2020 12:59:12 -0800 Subject: [PATCH 013/167] id() invalid if things disappear --- Lib/fontTools/colorLib/builder.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 7249bd9f2..4dac5c45e 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -452,12 +452,14 @@ class LayerV1ListBuilder: layers: List[ot.Paint] reusePool: Mapping[Tuple[Any, ...], int] tuples: Mapping[int, Tuple[Any, ...]] + keepAlive: List[ot.Paint] # we need id to remain valid def __init__(self): self.slices = [] self.layers = [] self.reusePool = {} self.tuples = {} + self.keepAlive = [] def _paint_tuple(self, paint: ot.Paint): # start simple, who even cares about cyclic graphs or interesting field types @@ -478,6 +480,7 @@ class LayerV1ListBuilder: if result is None: result = _tuple_safe(paint) self.tuples[id(paint)] = result + self.keepAlive.append(paint) return result def _as_tuple(self, paints: Sequence[ot.Paint]) -> Tuple[Any, ...]: From f24a640b65da9dba6710d48d47ecc76abc1c7687 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 8 Dec 2020 10:01:37 +0000 Subject: [PATCH 014/167] minor --- Lib/fontTools/colorLib/builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 4dac5c45e..74abb8afd 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -474,8 +474,8 @@ class LayerV1ListBuilder: return tuple(_tuple_safe(e) for e in value) return value - # Cache the tuples for individual Paint instead of the a whole sequence - # because the seq could be a transient slice + # Cache the tuples for individual Paint instead of the whole sequence + # because the seq could be a transient slice result = self.tuples.get(id(paint), None) if result is None: result = _tuple_safe(paint) From a6a4374bc70c5793d9f264f3466f5cc32a6fda08 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 9 Dec 2020 11:30:13 +0000 Subject: [PATCH 015/167] Update changelog [skip ci] --- NEWS.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NEWS.rst b/NEWS.rst index bf3a72229..dff6a8db4 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +- [colorLib] Speed optimization for ``LayerV1ListBuilder`` (#2119). +- [mutator] Fixed missing tab in ``interpolate_cff2_metrics`` (0957dc7a). + 4.18.0 (released 2020-12-04) ---------------------------- From f60f5370747049e87f12a4228475a88797a0b90c Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 9 Dec 2020 11:30:28 +0000 Subject: [PATCH 016/167] Release 4.18.1 --- Lib/fontTools/__init__.py | 2 +- NEWS.rst | 3 +++ setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index e27412020..00b962cd6 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.18.1.dev0" +version = __version__ = "4.18.1" __all__ = ["version", "log", "configLogger"] diff --git a/NEWS.rst b/NEWS.rst index dff6a8db4..e10d006b7 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +4.18.1 (released 2020-12-09) +---------------------------- + - [colorLib] Speed optimization for ``LayerV1ListBuilder`` (#2119). - [mutator] Fixed missing tab in ``interpolate_cff2_metrics`` (0957dc7a). diff --git a/setup.cfg b/setup.cfg index f998f15c2..d959ddba5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.18.1.dev0 +current_version = 4.18.1 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 9f5c85ee2..be33a7353 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.18.1.dev0", + version="4.18.1", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 5508374bb52c886f7902e29d4e0ee1eb22ce4f50 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 9 Dec 2020 11:30:29 +0000 Subject: [PATCH 017/167] =?UTF-8?q?Bump=20version:=204.18.1=20=E2=86=92=20?= =?UTF-8?q?4.18.2.dev0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Lib/fontTools/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 00b962cd6..1c1ada95f 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.18.1" +version = __version__ = "4.18.2.dev0" __all__ = ["version", "log", "configLogger"] diff --git a/setup.cfg b/setup.cfg index d959ddba5..aed2d4c45 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.18.1 +current_version = 4.18.2.dev0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index be33a7353..7735a33b9 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.18.1", + version="4.18.2.dev0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 22f71d0094f8f73c24d20088b07e03d037bffbb9 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 9 Dec 2020 11:34:00 +0000 Subject: [PATCH 018/167] Update CI badge --- README.rst | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 4bc7a3d5d..97d23e4bf 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -|Travis Build Status| |Appveyor Build status| |Coverage Status| |PyPI| |Gitter Chat| +|CI Build Status| |Coverage Status| |PyPI| |Gitter Chat| What is this? ~~~~~~~~~~~~~ @@ -240,10 +240,8 @@ Rights Reserved. Have fun! -.. |Travis Build Status| image:: https://travis-ci.org/fonttools/fonttools.svg - :target: https://travis-ci.org/fonttools/fonttools -.. |Appveyor Build status| image:: https://ci.appveyor.com/api/projects/status/0f7fmee9as744sl7/branch/master?svg=true - :target: https://ci.appveyor.com/project/fonttools/fonttools/branch/master +.. |CI Build Status| image:: https://github.com/fonttools/fonttools/workflows/Test/badge.svg + :target: https://github.com/fonttools/fonttools/actions?query=workflow%3ATest .. |Coverage Status| image:: https://codecov.io/gh/fonttools/fonttools/branch/master/graph/badge.svg :target: https://codecov.io/gh/fonttools/fonttools .. |PyPI| image:: https://img.shields.io/pypi/v/fonttools.svg From 51af02298e44d3482476c7358dadd5bd47f4bdc5 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 9 Dec 2020 15:20:16 +0000 Subject: [PATCH 019/167] MANIFEST.in: Add missing .ufoz and .json files Without this, running tox from a freshly unzipped sdist will fail. --- MANIFEST.in | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index 5c4d1274b..8bee1e61f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -39,3 +39,5 @@ recursive-include Tests *.txt README recursive-include Tests *.lwfn *.pfa *.pfb recursive-include Tests *.xml *.designspace *.bin recursive-include Tests *.afm +recursive-include Tests *.json +recursive-include Tests *.ufoz From 317d0198a406344d90f944865dac460b355b9368 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 9 Dec 2020 15:21:20 +0000 Subject: [PATCH 020/167] MANIFEST.in: add missing mypy.ini --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 8bee1e61f..31a9c256b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,6 +11,7 @@ include Lib/fontTools/ttLib/tables/table_API_readme.txt include *requirements.txt include tox.ini +include mypy.ini include run-tests.sh recursive-include Lib/fontTools py.typed From c990a5b4e0385ec9372615783da01251a32d453c Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 9 Dec 2020 15:21:39 +0000 Subject: [PATCH 021/167] tox.ini: also test on py39 by default --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 8ced886b8..bcbeeeddf 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 3.0 -envlist = mypy, py3{6,7,8}-cov, htmlcov +envlist = mypy, py3{6,7,8,9}-cov, htmlcov skip_missing_interpreters=true [testenv] From 1f33249d361624c4f7110889120c625fb47cffb8 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 14 Dec 2020 18:21:04 +0000 Subject: [PATCH 022/167] varLib.cff: fix unbound local variable Fixes https://github.com/fonttools/fonttools/issues/1787 Thanks Behdad. --- Lib/fontTools/varLib/cff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/cff.py b/Lib/fontTools/varLib/cff.py index 4e2672b3b..0a6ba220b 100644 --- a/Lib/fontTools/varLib/cff.py +++ b/Lib/fontTools/varLib/cff.py @@ -413,7 +413,7 @@ def merge_charstrings(glyphOrder, num_masters, top_dicts, masterModel): # in the PrivatDict, so we will build the default data for vsindex = 0. if not vsindex_dict: key = (True,) * num_masters - _add_new_vsindex(model, key, masterSupports, vsindex_dict, + _add_new_vsindex(masterModel, key, masterSupports, vsindex_dict, vsindex_by_key, varDataList) cvData = CVarData(varDataList=varDataList, masterSupports=masterSupports, vsindex_dict=vsindex_dict) From 538528d5a1f202e0fc38846cf69c01c2cda40da2 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 15 Dec 2020 16:51:08 +0000 Subject: [PATCH 023/167] COLRv1: Implement PaintTranslate https://github.com/googlefonts/colr-gradients-spec/pull/163 --- Lib/fontTools/colorLib/builder.py | 10 +++++ Lib/fontTools/ttLib/tables/otData.py | 17 ++++++--- Lib/fontTools/ttLib/tables/otTables.py | 7 ++-- Tests/colorLib/builder_test.py | 20 +++++++++- Tests/ttLib/tables/C_O_L_R_test.py | 51 +++++++++++++++----------- 5 files changed, 74 insertions(+), 31 deletions(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 74abb8afd..724136ab1 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -557,6 +557,16 @@ class LayerV1ListBuilder: ot_paint.Paint = self.buildPaint(paint) return ot_paint + def buildPaintTranslate( + self, paint: _PaintInput, dx: _ScalarInput, dy: _ScalarInput + ): + ot_paint = ot.Paint() + ot_paint.Format = int(ot.Paint.Format.PaintTranslate) + ot_paint.Paint = self.buildPaint(paint) + ot_paint.dx = _to_variable_f16dot16_float(dx) + ot_paint.dy = _to_variable_f16dot16_float(dy) + return ot_paint + def buildPaintRotate( self, paint: _PaintInput, diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py index 776cf75ba..a6f9619e6 100755 --- a/Lib/fontTools/ttLib/tables/otData.py +++ b/Lib/fontTools/ttLib/tables/otData.py @@ -1665,23 +1665,30 @@ otData = [ ('PaintFormat8', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 8'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTranslate table) to Paint subtable.'), + ('VarFixed', 'dx', None, None, 'Translation in x direction.'), + ('VarFixed', 'dy', None, None, 'Translation in y direction.'), + ]), + + ('PaintFormat9', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 9'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintRotate table) to Paint subtable.'), ('VarFixed', 'angle', None, None, ''), ('VarFixed', 'centerX', None, None, ''), ('VarFixed', 'centerY', None, None, ''), ]), - ('PaintFormat9', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 9'), - ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintRotate table) to Paint subtable.'), + ('PaintFormat10', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 10'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintSkew table) to Paint subtable.'), ('VarFixed', 'xSkewAngle', None, None, ''), ('VarFixed', 'ySkewAngle', None, None, ''), ('VarFixed', 'centerX', None, None, ''), ('VarFixed', 'centerY', None, None, ''), ]), - ('PaintFormat10', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 8'), + ('PaintFormat11', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 11'), ('LOffset24To(Paint)', 'SourcePaint', None, None, 'Offset (from beginning of PaintComposite table) to source Paint subtable.'), ('CompositeMode', 'CompositeMode', None, None, 'A CompositeMode enumeration value.'), ('LOffset24To(Paint)', 'BackdropPaint', None, None, 'Offset (from beginning of PaintComposite table) to backdrop Paint subtable.'), diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py index c4208a55f..7f42921d7 100644 --- a/Lib/fontTools/ttLib/tables/otTables.py +++ b/Lib/fontTools/ttLib/tables/otTables.py @@ -1334,9 +1334,10 @@ class Paint(getFormatSwitchingBaseTableClass("uint8")): PaintGlyph = 5 PaintColrGlyph = 6 PaintTransform = 7 - PaintRotate = 8 - PaintSkew = 9 - PaintComposite = 10 + PaintTranslate = 8 + PaintRotate = 9 + PaintSkew = 10 + PaintComposite = 11 def getFormatName(self): try: diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index 152e16e08..d1e94df93 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -544,7 +544,7 @@ def test_buildPaintComposite(): composite = layerBuilder.buildPaintComposite( mode=ot.CompositeMode.SRC_OVER, source={ - "format": 10, + "format": 11, "mode": "src_over", "source": {"format": 5, "glyph": "c", "paint": 2}, "backdrop": {"format": 5, "glyph": "b", "paint": 1}, @@ -574,6 +574,22 @@ def test_buildPaintComposite(): assert composite.BackdropPaint.Paint.Color.PaletteIndex == 0 +def test_buildPaintTranslate(): + layerBuilder = LayerV1ListBuilder() + paint = layerBuilder.buildPaintTranslate( + paint=layerBuilder.buildPaintGlyph( + "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) + ), + dx=123, + dy=-345, + ) + + assert paint.Format == ot.Paint.Format.PaintTranslate + assert paint.Paint.Format == ot.Paint.Format.PaintGlyph + assert paint.dx.value == 123 + assert paint.dy.value == -345 + + def test_buildPaintRotate(): layerBuilder = LayerV1ListBuilder() paint = layerBuilder.buildPaintRotate( @@ -592,7 +608,7 @@ def test_buildPaintRotate(): assert paint.centerY.value == 129 -def test_buildPaintRotate(): +def test_buildPaintSkew(): layerBuilder = LayerV1ListBuilder() paint = layerBuilder.buildPaintSkew( paint=layerBuilder.buildPaintGlyph( diff --git a/Tests/ttLib/tables/C_O_L_R_test.py b/Tests/ttLib/tables/C_O_L_R_test.py index 76e9e61a1..7f3f71ea2 100644 --- a/Tests/ttLib/tables/C_O_L_R_test.py +++ b/Tests/ttLib/tables/C_O_L_R_test.py @@ -131,7 +131,7 @@ COLR_V1_SAMPLE = ( (b"\x01", "BaseGlyphV1Record[0].Paint.Format (1)"), (b"\x04", "BaseGlyphV1Record[0].Paint.NumLayers (4)"), (b"\x00\x00\x00\x00", "BaseGlyphV1Record[0].Paint.FirstLayerIndex (0)"), - (b"\x0A", "BaseGlyphV1Record[1].Paint.Format (10)"), + (b"\x0B", "BaseGlyphV1Record[1].Paint.Format (11)"), (b"\x00\x00<", "Offset to SourcePaint from beginning of PaintComposite (60)"), (b"\x03", "BaseGlyphV1Record[1].Paint.CompositeMode [SRC_OVER] (3)"), (b"\x00\x00\x08", "Offset to BackdropPaint from beginning of PaintComposite (8)"), @@ -164,7 +164,7 @@ COLR_V1_SAMPLE = ( ), # PaintGlyph glyph00011 (b"\x05", "LayerV1List.Paint[0].Format (5)"), - (b"\x00\x01\x28", "Offset24 to Paint subtable from beginning of PaintGlyph (296)"), + (b"\x00\x01<", "Offset24 to Paint subtable from beginning of PaintGlyph (316)"), (b"\x00\x0b", "LayerV1List.Paint[0].Glyph (glyph00011)"), # PaintGlyph glyph00012 (b"\x05", "LayerV1List.Paint[1].Format (5)"), @@ -229,14 +229,19 @@ COLR_V1_SAMPLE = ( (b"@\x00\x00\x00\x00\x00", "ColorLine.ColorStop[1].StopOffset.value (1.0)"), (b"\x00\x07", "ColorLine.ColorStop[1].Color.PaletteIndex (7)"), (b"\x19\x9a\x00\x00\x00\x00", "ColorLine.ColorStop[1].Color.Alpha.value (0.4)"), - # PaintRotate + # PaintTranslate (b"\x08", "LayerV1List.Paint[3].Format (8)"), + (b"\x00\x00\x14", "Offset to Paint subtable from beginning of PaintTranslate (20)"), + (b"\x01\x01\x00\x00\x00\x00\x00\x00", "dx.value (257)"), + (b"\x01\x02\x00\x00\x00\x00\x00\x00", "dy.value (258)"), + # PaintRotate + (b"\x09", "LayerV1List.Paint[3].Paint.Format (9)"), (b"\x00\x00\x1c", "Offset to Paint subtable from beginning of PaintRotate (28)"), (b"\x00\x2d\x00\x00\x00\x00\x00\x00", "angle.value (45)"), (b"\x00\xff\x00\x00\x00\x00\x00\x00", "centerX.value (255)"), (b"\x01\x00\x00\x00\x00\x00\x00\x00", "centerY.value (256)"), # PaintSkew - (b"\x09", "LayerV1List.Paint[3].Format (9)"), + (b"\x0a", "LayerV1List.Paint[3].Paint.Paint.Format (10)"), (b"\x00\x00\x24", "Offset to Paint subtable from beginning of PaintSkew (36)"), (b"\xff\xf5\x00\x00\x00\x00\x00\x00", "xSkewAngle (-11)"), (b"\x00\x05\x00\x00\x00\x00\x00\x00", "ySkewAngle (5)"), @@ -291,7 +296,7 @@ COLR_V1_XML = [ " ", ' ', ' ', - ' ', + ' ', ' ', ' ', " ", @@ -398,25 +403,29 @@ COLR_V1_XML = [ " ", ' ', " ", - ' ', - ' ', - ' ', - ' ', - " ", - ' ', - ' ', - " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + " ", + " ", + ' ', " ", - ' ', + ' ', + ' ', + ' ', + ' ', " ", - ' ', - ' ', - ' ', - ' ', + ' ', + ' ', + ' ', " ", - ' ', - ' ', - ' ', + ' ', + ' ', " ", "", ] From c7d44be3387b4a36b684d1ca0381bd9d4972a3ec Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 16 Dec 2020 11:53:19 +0000 Subject: [PATCH 024/167] Update changelog [skip ci] --- NEWS.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NEWS.rst b/NEWS.rst index e10d006b7..5f0c25d70 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +- [COLRv1] Implemented ``PaintTranslate`` paint format (#2129). +- [varLib.cff] Fixed unbound local variable error (#1787). + 4.18.1 (released 2020-12-09) ---------------------------- From 8f50e22278304f3cdc3c8400a2d42adfd457c559 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 16 Dec 2020 11:56:09 +0000 Subject: [PATCH 025/167] test.workflow: respect 'skip ci' --- .github/workflows/test.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c3025871..837fb8c49 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,8 @@ on: jobs: lint: runs-on: ubuntu-latest + # https://github.community/t/github-actions-does-not-respect-skip-ci/17325/8 + if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" steps: - uses: actions/checkout@v2 - name: Set up Python 3.x @@ -22,6 +24,7 @@ jobs: test: runs-on: ${{ matrix.platform }} + if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" strategy: matrix: python-version: [3.6, 3.7, 3.8, 3.9] @@ -61,6 +64,7 @@ jobs: test-cython: runs-on: ubuntu-latest + if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" steps: - uses: actions/checkout@v2 - name: Set up Python 3.x @@ -74,6 +78,7 @@ jobs: test-pypy3: runs-on: ubuntu-latest + if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" steps: - uses: actions/checkout@v2 - name: Set up Python pypy3 From 8420981d200ef0de949c988b652b03334abd7192 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 16 Dec 2020 12:16:35 +0000 Subject: [PATCH 026/167] Update changelog [skip ci] --- NEWS.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NEWS.rst b/NEWS.rst index 5f0c25d70..d8e351eb4 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,5 +1,7 @@ - [COLRv1] Implemented ``PaintTranslate`` paint format (#2129). - [varLib.cff] Fixed unbound local variable error (#1787). +- [otlLib] Don't crash when creating OpenType class definitions if some glyphs + occur more than once (#2125). 4.18.1 (released 2020-12-09) ---------------------------- From dd29f83fa5a745861047f1996f9439cdc7352c6f Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 16 Dec 2020 12:16:49 +0000 Subject: [PATCH 027/167] Release 4.18.2 --- Lib/fontTools/__init__.py | 2 +- NEWS.rst | 3 +++ setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 1c1ada95f..09dce7666 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.18.2.dev0" +version = __version__ = "4.18.2" __all__ = ["version", "log", "configLogger"] diff --git a/NEWS.rst b/NEWS.rst index d8e351eb4..55542d069 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +4.18.2 (released 2020-12-16) +---------------------------- + - [COLRv1] Implemented ``PaintTranslate`` paint format (#2129). - [varLib.cff] Fixed unbound local variable error (#1787). - [otlLib] Don't crash when creating OpenType class definitions if some glyphs diff --git a/setup.cfg b/setup.cfg index aed2d4c45..285f8ef59 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.18.2.dev0 +current_version = 4.18.2 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 7735a33b9..5f24baff0 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.18.2.dev0", + version="4.18.2", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 70958dca86008aaa52086618876755968f3e82be Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 16 Dec 2020 12:16:50 +0000 Subject: [PATCH 028/167] =?UTF-8?q?Bump=20version:=204.18.2=20=E2=86=92=20?= =?UTF-8?q?4.18.3.dev0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Lib/fontTools/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 09dce7666..edbcda8f6 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.18.2" +version = __version__ = "4.18.3.dev0" __all__ = ["version", "log", "configLogger"] diff --git a/setup.cfg b/setup.cfg index 285f8ef59..b58b7e776 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.18.2 +current_version = 4.18.3.dev0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 5f24baff0..28b60c915 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.18.2", + version="4.18.3.dev0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From d8c42ef7f013b382248b91869aabf232d90c14e0 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 24 Dec 2020 20:37:52 +0100 Subject: [PATCH 029/167] codecs: handle errors different from 'strict' for extended mac encodings Fixes #2132 --- Lib/fontTools/encodings/codecs.py | 42 ++++++++++------------------- Tests/ttLib/tables/_n_a_m_e_test.py | 12 +++++++++ 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/Lib/fontTools/encodings/codecs.py b/Lib/fontTools/encodings/codecs.py index ac2b99094..c2288a777 100644 --- a/Lib/fontTools/encodings/codecs.py +++ b/Lib/fontTools/encodings/codecs.py @@ -16,43 +16,29 @@ class ExtendCodec(codecs.Codec): self.info = codecs.CodecInfo(name=self.name, encode=self.encode, decode=self.decode) codecs.register_error(name, self.error) - def encode(self, input, errors='strict'): - assert errors == 'strict' - #return codecs.encode(input, self.base_encoding, self.name), len(input) - - # The above line could totally be all we needed, relying on the error - # handling to replace the unencodable Unicode characters with our extended - # byte sequences. - # - # However, there seems to be a design bug in Python (probably intentional): - # the error handler for encoding is supposed to return a **Unicode** character, - # that then needs to be encodable itself... Ugh. - # - # So we implement what codecs.encode() should have been doing: which is expect - # error handler to return bytes() to be added to the output. - # - # This seems to have been fixed in Python 3.3. We should try using that and - # use fallback only if that failed. - # https://docs.python.org/3.3/library/codecs.html#codecs.register_error - + def _map(self, mapper, output_type, exc_type, input, errors): + base_error_handler = codecs.lookup_error(errors) length = len(input) - out = b'' + out = output_type() while input: + # first try to use self.error as the error handler try: - part = codecs.encode(input, self.base_encoding) + part = mapper(input, self.base_encoding, errors=self.name) out += part - input = '' # All converted - except UnicodeEncodeError as e: - # Convert the correct part - out += codecs.encode(input[:e.start], self.base_encoding) - replacement, pos = self.error(e) + break # All converted + except exc_type as e: + # else convert the correct part, handle error as requested and continue + out += mapper(input[:e.start], self.base_encoding, self.name) + replacement, pos = base_error_handler(e) out += replacement input = input[pos:] return out, length + def encode(self, input, errors='strict'): + return self._map(codecs.encode, bytes, UnicodeEncodeError, input, errors) + def decode(self, input, errors='strict'): - assert errors == 'strict' - return codecs.decode(input, self.base_encoding, self.name), len(input) + return self._map(codecs.decode, str, UnicodeDecodeError, input, errors) def error(self, e): if isinstance(e, UnicodeDecodeError): diff --git a/Tests/ttLib/tables/_n_a_m_e_test.py b/Tests/ttLib/tables/_n_a_m_e_test.py index bc4aab2f1..11aeebae9 100644 --- a/Tests/ttLib/tables/_n_a_m_e_test.py +++ b/Tests/ttLib/tables/_n_a_m_e_test.py @@ -432,6 +432,18 @@ class NameRecordTest(unittest.TestCase): name = makeName(b'\xfe', 123, 1, 1, 0) # Mac Japanese self.assertEqual(name.toUnicode(), unichr(0x2122)) + def test_extended_mac_encodings_errors(self): + s = "걉ä»Ŗ彩äŗ‘体ē®€" + name = makeName(s.encode("x_mac_simp_chinese_ttx"), 123, 1, 25, 0) + # first check we round-trip with 'strict' + self.assertEqual(name.toUnicode(errors="strict"), s) + + # append an incomplete invalid sequence and check that we handle + # errors with the requested error handler + name.string += b"\xba" + self.assertEqual(name.toUnicode(errors="backslashreplace"), s + "\\xba") + self.assertEqual(name.toUnicode(errors="replace"), s + "ļæ½") + def test_extended_unknown(self): name = makeName(b'\xfe', 123, 10, 11, 12) self.assertEqual(name.getEncoding(), "ascii") From 56310f38cfe074312b07a08caeeb6d7eb0d1a248 Mon Sep 17 00:00:00 2001 From: Tim Gates Date: Mon, 28 Dec 2020 09:06:44 +1100 Subject: [PATCH 030/167] docs: fix simple typo, ovverride -> override There is a small typo in Tests/ttLib/woff2_test.py. Should read `override` rather than `ovverride`. --- Tests/ttLib/woff2_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ttLib/woff2_test.py b/Tests/ttLib/woff2_test.py index 661fd4485..5923b7f23 100644 --- a/Tests/ttLib/woff2_test.py +++ b/Tests/ttLib/woff2_test.py @@ -203,7 +203,7 @@ def normalise_font(font, padding=4): # drop DSIG but keep a copy DSIG_copy = copy.deepcopy(font['DSIG']) del font['DSIG'] - # ovverride TTFont attributes + # override TTFont attributes origFlavor = font.flavor origRecalcBBoxes = font.recalcBBoxes origRecalcTimestamp = font.recalcTimestamp From e0de5a8413104067f59da651a86b04384dba8f8c Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Tue, 12 Jan 2021 15:26:57 +0000 Subject: [PATCH 031/167] Add MissingComponentError for pens --- Lib/fontTools/pens/basePen.py | 12 +++++++++--- Lib/fontTools/pens/hashPointPen.py | 6 +++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/pens/basePen.py b/Lib/fontTools/pens/basePen.py index 1593024f3..c8c4c5511 100644 --- a/Lib/fontTools/pens/basePen.py +++ b/Lib/fontTools/pens/basePen.py @@ -147,6 +147,10 @@ class LoggingPen(LogMixin, AbstractPen): pass +class MissingComponentError(KeyError): + """Indicates a component pointing to a non-existent glyph in the glyphset.""" + + class DecomposingPen(LoggingPen): """ Implements a 'addComponent' method that decomposes components @@ -155,10 +159,12 @@ class DecomposingPen(LoggingPen): You must override moveTo, lineTo, curveTo and qCurveTo. You may additionally override closePath, endPath and addComponent. + + By default a warning message is logged when a base glyph is missing; + set the class variable ``skipMissingComponents`` to False if you want + to raise a :class:`MissingComponentError` exception. """ - # By default a warning message is logged when a base glyph is missing; - # set this to False if you want to raise a 'KeyError' exception skipMissingComponents = True def __init__(self, glyphSet): @@ -176,7 +182,7 @@ class DecomposingPen(LoggingPen): glyph = self.glyphSet[glyphName] except KeyError: if not self.skipMissingComponents: - raise + raise MissingComponentError(glyphName) self.log.warning( "glyph '%s' is missing from glyphSet; skipped" % glyphName) else: diff --git a/Lib/fontTools/pens/hashPointPen.py b/Lib/fontTools/pens/hashPointPen.py index f3276f701..9aef5d870 100644 --- a/Lib/fontTools/pens/hashPointPen.py +++ b/Lib/fontTools/pens/hashPointPen.py @@ -1,6 +1,7 @@ # Modified from https://github.com/adobe-type-tools/psautohint/blob/08b346865710ed3c172f1eb581d6ef243b203f99/python/psautohint/ufoFont.py#L800-L838 import hashlib +from fontTools.pens.basePen import MissingComponentError from fontTools.pens.pointPen import AbstractPointPen @@ -69,5 +70,8 @@ class HashPointPen(AbstractPointPen): ): tr = "".join([f"{t:+}" for t in transformation]) self.data.append("[") - self.glyphset[baseGlyphName].drawPoints(self) + try: + self.glyphset[baseGlyphName].drawPoints(self) + except KeyError: + raise MissingComponentError(baseGlyphName) self.data.append(f"({tr})]") From 586144442dd34acc417a4b22a611a75f2b28f6bd Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Tue, 12 Jan 2021 17:21:22 +0000 Subject: [PATCH 032/167] Add test --- Tests/subset/data/CmapSubsetTest.subset.ttx | 14 ++ Tests/subset/data/CmapSubsetTest.ttx | 225 ++++++++++++++++++++ Tests/subset/subset_test.py | 7 + 3 files changed, 246 insertions(+) create mode 100644 Tests/subset/data/CmapSubsetTest.subset.ttx create mode 100644 Tests/subset/data/CmapSubsetTest.ttx diff --git a/Tests/subset/data/CmapSubsetTest.subset.ttx b/Tests/subset/data/CmapSubsetTest.subset.ttx new file mode 100644 index 000000000..10b94a346 --- /dev/null +++ b/Tests/subset/data/CmapSubsetTest.subset.ttx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Tests/subset/data/CmapSubsetTest.ttx b/Tests/subset/data/CmapSubsetTest.ttx new file mode 100644 index 000000000..ffbfae7fc --- /dev/null +++ b/Tests/subset/data/CmapSubsetTest.ttx @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New Font + + + Regular + + + 0.000;NONE;NewFont-Regular + + + New Font Regular + + + Version 0.000 + + + NewFont-Regular + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/subset/subset_test.py b/Tests/subset/subset_test.py index 0d2f9fe2d..d37634f12 100644 --- a/Tests/subset/subset_test.py +++ b/Tests/subset/subset_test.py @@ -753,6 +753,13 @@ class SubsetTest(unittest.TestCase): # check all glyphs are kept via GSUB closure, no changes expected self.expect_ttx(subsetfont, ttx) + def test_cmap_prune_format12(self): + _, fontpath = self.compile_font(self.getpath("CmapSubsetTest.ttx"), ".ttf") + subsetpath = self.temp_path(".ttf") + subset.main([fontpath, "--glyphs=a", "--output-file=%s" % subsetpath]) + subsetfont = TTFont(subsetpath) + self.expect_ttx(subsetfont, self.getpath("CmapSubsetTest.subset.ttx"), ["cmap"]) + @pytest.fixture def featureVarsTestFont(): From 06913cc715c3847770765a02a10c1801ca6a2988 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Wed, 13 Jan 2021 16:17:46 +0000 Subject: [PATCH 033/167] Implement format 12 cmap pruning --- Lib/fontTools/subset/__init__.py | 36 ++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py index d78aa8a8c..82605d51b 100644 --- a/Lib/fontTools/subset/__init__.py +++ b/Lib/fontTools/subset/__init__.py @@ -2207,7 +2207,17 @@ def prune_pre_subset(self, font, options): @_add_method(ttLib.getTableClass('cmap')) def subset_glyphs(self, s): s.glyphs = None # We use s.glyphs_requested and s.unicodes_requested only + + tables_format12_bmp = [] + table_plat0_enc3 = {} # Unicode platform, Unicode BMP only, keyed by language + table_plat3_enc1 = {} # Windows platform, Unicode BMP, keyed by language + for t in self.tables: + if t.platformID == 0 and t.platEncID == 3: + table_plat0_enc3[t.language] = t + if t.platformID == 3 and t.platEncID == 1: + table_plat3_enc1[t.language] = t + if t.format == 14: # TODO(behdad) We drop all the default-UVS mappings # for glyphs_requested. So it's the caller's responsibility to make @@ -2219,16 +2229,38 @@ def subset_glyphs(self, s): elif t.isUnicode(): t.cmap = {u:g for u,g in t.cmap.items() if g in s.glyphs_requested or u in s.unicodes_requested} + # Collect format 12 tables that hold only basic multilingual plane + # codepoints. + if t.format == 12 and t.cmap and max(t.cmap.keys()) < 0x10000: + tables_format12_bmp.append(t) else: t.cmap = {u:g for u,g in t.cmap.items() if g in s.glyphs_requested} + + # Fomat 12 tables are redundant if they contain just the same BMP codepoints + # their little BMP-only encoding siblings contain. + for t in tables_format12_bmp: + if ( + t.platformID == 0 # Unicode platform + and t.platEncID == 4 # Unicode full repertoire + and t.language in table_plat0_enc3 # Have a BMP-only sibling? + and table_plat0_enc3[t.language].cmap == t.cmap + ): + t.cmap.clear() + elif ( + t.platformID == 3 # Windows platform + and t.platEncID == 10 # Unicode full repertoire + and t.language in table_plat3_enc1 # Have a BMP-only sibling? + and table_plat3_enc1[t.language].cmap == t.cmap + ): + t.cmap.clear() + self.tables = [t for t in self.tables if (t.cmap if t.format != 14 else t.uvsDict)] self.numSubTables = len(self.tables) # TODO(behdad) Convert formats when needed. # In particular, if we have a format=12 without non-BMP - # characters, either drop format=12 one or convert it - # to format=4 if there's not one. + # characters, convert it to format=4 if there's not one. return True # Required table @_add_method(ttLib.getTableClass('DSIG')) From 03655e55d30e29eba4f0b2346caf92e4e88e197f Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Wed, 13 Jan 2021 16:17:53 +0000 Subject: [PATCH 034/167] Change test data to make it pass --- Tests/subset/data/TestContextSubstFormat3.ttx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Tests/subset/data/TestContextSubstFormat3.ttx b/Tests/subset/data/TestContextSubstFormat3.ttx index 899b037e3..3e9bfcd2e 100644 --- a/Tests/subset/data/TestContextSubstFormat3.ttx +++ b/Tests/subset/data/TestContextSubstFormat3.ttx @@ -17,7 +17,7 @@ - + @@ -142,15 +142,9 @@ - - - - - - From db14e6375ebeef170d757e201f0f25aa2d9d2db3 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 14 Jan 2021 17:13:22 +0000 Subject: [PATCH 035/167] COLRv1: avoid abrupt change after rounding c0 when too near c1's perimeter Fixes https://github.com/googlefonts/picosvg/issues/158 Also see https://github.com/googlefonts/colr-gradients-spec/issues/204 --- Lib/fontTools/colorLib/builder.py | 26 +++++-- Lib/fontTools/colorLib/geometry.py | 110 +++++++++++++++++++++++++++++ Tests/colorLib/builder_test.py | 50 +++++++++++++ 3 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 Lib/fontTools/colorLib/geometry.py diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 724136ab1..5cbe8d53d 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -34,6 +34,7 @@ from fontTools.ttLib.tables.otTables import ( VariableInt, ) from .errors import ColorLibError +from .geometry import nudge_start_circle_almost_inside # TODO move type aliases to colorLib.types? @@ -328,9 +329,9 @@ def _split_color_glyphs_by_version( def _to_variable_value( value: _ScalarInput, - minValue: _Number, - maxValue: _Number, - cls: Type[VariableValue], + cls: Type[VariableValue] = VariableFloat, + minValue: Optional[_Number] = None, + maxValue: Optional[_Number] = None, ) -> VariableValue: if not isinstance(value, cls): try: @@ -339,9 +340,9 @@ def _to_variable_value( value = cls(value) else: value = cls._make(it) - if value.value < minValue: + if minValue is not None and value.value < minValue: raise OverflowError(f"{cls.__name__}: {value.value} < {minValue}") - if value.value > maxValue: + if maxValue is not None and value.value > maxValue: raise OverflowError(f"{cls.__name__}: {value.value} < {maxValue}") return value @@ -526,7 +527,20 @@ class LayerV1ListBuilder: ot_paint.Format = int(ot.Paint.Format.PaintRadialGradient) ot_paint.ColorLine = _to_color_line(colorLine) - for i, (x, y), r in [(0, c0, r0), (1, c1, r1)]: + # normalize input types (which may or may not specify a varIdx) + x0, y0 = _to_variable_value(c0[0]), _to_variable_value(c0[1]) + r0 = _to_variable_value(r0) + x1, y1 = _to_variable_value(c1[0]), _to_variable_value(c1[1]) + r1 = _to_variable_value(r1) + + # avoid abrupt change after rounding when c0 is near c1's perimeter + c0x, c0y = nudge_start_circle_almost_inside( + (x0.value, y0.value), r0.value, (x1.value, y1.value), r1.value + ) + x0, y0 = x0._replace(value=c0x), y0._replace(value=c0y) + + for i, (x, y, r) in enumerate(((x0, y0, r0), (x1, y1, r1))): + # rounding happens here as floats are converted to integers setattr(ot_paint, f"x{i}", _to_variable_int16(x)) setattr(ot_paint, f"y{i}", _to_variable_int16(y)) setattr(ot_paint, f"r{i}", _to_variable_uint16(r)) diff --git a/Lib/fontTools/colorLib/geometry.py b/Lib/fontTools/colorLib/geometry.py new file mode 100644 index 000000000..2fbd68882 --- /dev/null +++ b/Lib/fontTools/colorLib/geometry.py @@ -0,0 +1,110 @@ +"""Helpers for manipulating 2D points and vectors in COLR table.""" + +from math import copysign, cos, sqrt, pi +from fontTools.misc.fixedTools import otRound + + +def _vector_between_points(a, b): + return (b[0] - a[0], b[1] - a[1]) + + +def _vector_length(vec): + return sqrt(vec[0] * vec[0] + vec[1] * vec[1]) + + +def _distance_between_points(a, b): + return _vector_length(_vector_between_points(a, b)) + + +def _round_point(pt): + return (otRound(pt[0]), otRound(pt[1])) + + +def _round_circle(centre, radius): + return _round_point(centre), otRound(radius) + + +def _unit_vector(vec): + length = _vector_length(vec) + if length == 0: + return None + return (vec[0] / length, vec[1] / length) + + +# This is the same tolerance used by Skia's SkTwoPointConicalGradient.cpp to detect +# when a radial gradient's focal point lies on the end circle. +_NEARLY_ZERO = 1 / (1 << 12) # 0.000244140625 + + +def _is_circle_inside_circle(c0, r0, c1, r1): + dist = r0 + _distance_between_points(c0, c1) + return abs(r1 - dist) <= _NEARLY_ZERO or r1 > dist + + +# The unit vector's X and Y components are respectively +# U = (cos(Ī±), sin(Ī±)) +# where Ī± is the angle between the unit vector and the positive x axis. +_UNIT_VECTOR_THRESHOLD = cos(3 / 8 * pi) # == sin(1/8 * pi) == 0.38268343236508984 + + +def _nudge_point(pt, direction): + # Nudge point coordinates -/+ 1.0 approximately based on the direction vector. + # We divide the unit circle in 8 equal slices oriented towards the cardinal + # (N, E, S, W) and intermediate (NE, SE, SW, NW) directions. To each slice we + # map one of the possible cases: -1, 0, +1 for either X and Y coordinate. + # E.g. Return (x + 1.0, y - 1.0) if unit vector is oriented towards SE, or + # (x - 1.0, y) if it's pointing West, etc. + uv = _unit_vector(direction) + if not uv: + return pt + + result = [] + for coord, uv_component in zip(pt, uv): + if -_UNIT_VECTOR_THRESHOLD <= uv_component < _UNIT_VECTOR_THRESHOLD: + # unit vector component near 0: direction almost orthogonal to the + # direction of the current axis, thus keep coordinate unchanged + result.append(coord) + else: + # nudge coord by +/- 1.0 in direction of unit vector + result.append(coord + copysign(1.0, uv_component)) + return tuple(result) + + +def nudge_start_circle_almost_inside(c0, r0, c1, r1): + """ Nudge c0 so it continues to be inside/outside c1 after rounding. + + The rounding of circle coordinates to integers may cause an abrupt change + if the start circle c0 is so close to the end circle c1's perimiter that + it ends up falling outside (or inside) as a result of the rounding. + To keep the gradient unchanged, we nudge it in the right direction. + + See: + https://github.com/googlefonts/colr-gradients-spec/issues/204 + https://github.com/googlefonts/picosvg/issues/158 + """ + inside_before_round = _is_circle_inside_circle(c0, r0, c1, r1) + rc0, rr0 = _round_circle(c0, r0) + rc1, rr1 = _round_circle(c1, r1) + inside_after_round = _is_circle_inside_circle(rc0, rr0, rc1, rr1) + + if inside_before_round != inside_after_round: + # at most 2 iterations ought to be enough to converge + for _ in range(2): + if rc0 == rc1: # nowhere to nudge along a zero vector, bail out + break + if inside_after_round: + direction = _vector_between_points(rc1, rc0) + else: + direction = _vector_between_points(rc0, rc1) + rc0 = _nudge_point(rc0, direction) + inside_after_round = _is_circle_inside_circle(rc0, rr0, rc1, rr1) + if inside_before_round == inside_after_round: + break + else: # ... or it's a bug + raise AssertionError( + f"Nudging circle " + f"{'inside' if inside_before_round else 'outside'} " + f" failed after two attempts!" + ) + + return rc0 diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index d1e94df93..f8eaea6e5 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -1,6 +1,11 @@ from fontTools.ttLib import newTable from fontTools.ttLib.tables import otTables as ot from fontTools.colorLib import builder +from fontTools.colorLib.geometry import ( + nudge_start_circle_almost_inside, + _is_circle_inside_circle, + _round_circle, +) from fontTools.colorLib.builder import LayerV1ListBuilder from fontTools.colorLib.errors import ColorLibError import pytest @@ -1055,3 +1060,48 @@ class BuildCOLRTest(object): assert hasattr(colr, "table") assert isinstance(colr.table, ot.COLR) assert colr.table.VarStore is None + + +class TrickyRadialGradientTest: + @staticmethod + def circle_inside_circle(c0, r0, c1, r1, rounded=False): + if rounded: + return _is_circle_inside_circle( + *_round_circle(c0, r0), *_round_circle(c1, r1) + ) + else: + return _is_circle_inside_circle(c0, r0, c1, r1) + + def nudge_start_circle_position(self, c0, r0, c1, r1, inside=True): + assert self.circle_inside_circle(c0, r0, c1, r1) is inside + assert self.circle_inside_circle(c0, r0, c1, r1, rounded=True) is not inside + c0 = nudge_start_circle_almost_inside(c0, r0, c1, r1) + assert self.circle_inside_circle(c0, r0, c1, r1, rounded=True) is inside + return c0 + + def test_noto_emoji_mosquito_u1f99f(self): + # https://github.com/googlefonts/picosvg/issues/158 + c0 = (385.23508, 70.56727999999998) + r0 = 0 + c1 = (642.99108, 104.70327999999995) + r1 = 260.0072 + rc0 = self.nudge_start_circle_position(c0, r0, c1, r1, inside=True) + assert rc0 == (386, 71) + + @pytest.mark.parametrize( + "c0, r0, c1, r1, inside, expected", + [ + # inside before round, outside after round + ((1.4, 0), 0, (2.6, 0), 1.3, True, (2, 0)), + ((1, 0), 0.6, (2.8, 0), 2.45, True, (2, 0)), + ((6.49, 6.49), 0, (0.49, 0.49), 8.49, True, (5, 5)), + # outside before round, inside after round + ((0, 0), 0, (2, 0), 1.5, False, (-1, 0)), + ((0, -0.5), 0, (0, -2.5), 1.5, False, (0, 1)), + # the following ones require two nudges to round correctly + ((0.5, 0), 0, (9.4, 0), 8.8, False, (-1, 0)), + ((1.5, 1.5), 0, (0.49, 0.49), 1.49, True, (0, 0)), + ], + ) + def test_nudge_start_circle_position(self, c0, r0, c1, r1, inside, expected): + assert self.nudge_start_circle_position(c0, r0, c1, r1, inside) == expected From 7f0788ca25afd9ad76cb42fd978e7f918c13db5a Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 14 Jan 2021 18:22:57 +0000 Subject: [PATCH 036/167] use math.hypot() as per review https://github.com/fonttools/fonttools/pull/2148#discussion_r557596711 --- Lib/fontTools/colorLib/geometry.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Lib/fontTools/colorLib/geometry.py b/Lib/fontTools/colorLib/geometry.py index 2fbd68882..d67f773a6 100644 --- a/Lib/fontTools/colorLib/geometry.py +++ b/Lib/fontTools/colorLib/geometry.py @@ -1,6 +1,6 @@ """Helpers for manipulating 2D points and vectors in COLR table.""" -from math import copysign, cos, sqrt, pi +from math import copysign, cos, hypot, pi from fontTools.misc.fixedTools import otRound @@ -8,12 +8,8 @@ def _vector_between_points(a, b): return (b[0] - a[0], b[1] - a[1]) -def _vector_length(vec): - return sqrt(vec[0] * vec[0] + vec[1] * vec[1]) - - def _distance_between_points(a, b): - return _vector_length(_vector_between_points(a, b)) + return hypot(*_vector_between_points(a, b)) def _round_point(pt): @@ -25,7 +21,7 @@ def _round_circle(centre, radius): def _unit_vector(vec): - length = _vector_length(vec) + length = hypot(*vec) if length == 0: return None return (vec[0] / length, vec[1] / length) From c403dbe83ab0d40494c41c2545dc2ec881ac07f7 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 14 Jan 2021 18:57:19 +0000 Subject: [PATCH 037/167] minor: rename function, remove redundant one --- Lib/fontTools/colorLib/geometry.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/Lib/fontTools/colorLib/geometry.py b/Lib/fontTools/colorLib/geometry.py index d67f773a6..d7439d693 100644 --- a/Lib/fontTools/colorLib/geometry.py +++ b/Lib/fontTools/colorLib/geometry.py @@ -4,12 +4,8 @@ from math import copysign, cos, hypot, pi from fontTools.misc.fixedTools import otRound -def _vector_between_points(a, b): - return (b[0] - a[0], b[1] - a[1]) - - -def _distance_between_points(a, b): - return hypot(*_vector_between_points(a, b)) +def _vector_between(origin, target): + return (target[0] - origin[0], target[1] - origin[1]) def _round_point(pt): @@ -33,7 +29,7 @@ _NEARLY_ZERO = 1 / (1 << 12) # 0.000244140625 def _is_circle_inside_circle(c0, r0, c1, r1): - dist = r0 + _distance_between_points(c0, c1) + dist = r0 + hypot(*_vector_between(c0, c1)) return abs(r1 - dist) <= _NEARLY_ZERO or r1 > dist @@ -89,9 +85,9 @@ def nudge_start_circle_almost_inside(c0, r0, c1, r1): if rc0 == rc1: # nowhere to nudge along a zero vector, bail out break if inside_after_round: - direction = _vector_between_points(rc1, rc0) + direction = _vector_between(rc1, rc0) else: - direction = _vector_between_points(rc0, rc1) + direction = _vector_between(rc0, rc1) rc0 = _nudge_point(rc0, direction) inside_after_round = _is_circle_inside_circle(rc0, rr0, rc1, rr1) if inside_before_round == inside_after_round: From 4f886cc2262cff3b15c5ba48ae61ab58882d7154 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 14 Jan 2021 19:06:33 +0000 Subject: [PATCH 038/167] reword _is_circle_inside_circle parameters for better readability --- Lib/fontTools/colorLib/geometry.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/colorLib/geometry.py b/Lib/fontTools/colorLib/geometry.py index d7439d693..cf83b67d0 100644 --- a/Lib/fontTools/colorLib/geometry.py +++ b/Lib/fontTools/colorLib/geometry.py @@ -28,9 +28,9 @@ def _unit_vector(vec): _NEARLY_ZERO = 1 / (1 << 12) # 0.000244140625 -def _is_circle_inside_circle(c0, r0, c1, r1): - dist = r0 + hypot(*_vector_between(c0, c1)) - return abs(r1 - dist) <= _NEARLY_ZERO or r1 > dist +def _is_circle_inside_circle(inner_centre, inner_radius, outer_centre, outer_radius): + dist = inner_radius + hypot(*_vector_between(inner_centre, outer_centre)) + return abs(outer_radius - dist) <= _NEARLY_ZERO or outer_radius > dist # The unit vector's X and Y components are respectively From 4f1102ac6ef1033a5bc8d1c080f89bbf79d472db Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 15 Jan 2021 16:59:17 +0000 Subject: [PATCH 039/167] add a Circle class, handle concentrical case, explain why 2 iterations are enough --- Lib/fontTools/colorLib/builder.py | 5 +- Lib/fontTools/colorLib/geometry.py | 101 ++++++++++++++++++++--------- Tests/colorLib/builder_test.py | 43 ++++++------ 3 files changed, 94 insertions(+), 55 deletions(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 5cbe8d53d..c0329abcc 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -534,10 +534,11 @@ class LayerV1ListBuilder: r1 = _to_variable_value(r1) # avoid abrupt change after rounding when c0 is near c1's perimeter - c0x, c0y = nudge_start_circle_almost_inside( + c = nudge_start_circle_almost_inside( (x0.value, y0.value), r0.value, (x1.value, y1.value), r1.value ) - x0, y0 = x0._replace(value=c0x), y0._replace(value=c0y) + x0, y0 = x0._replace(value=c.centre[0]), y0._replace(value=c.centre[1]) + r0 = r0._replace(value=c.radius) for i, (x, y, r) in enumerate(((x0, y0, r0), (x1, y1, r1))): # rounding happens here as floats are converted to integers diff --git a/Lib/fontTools/colorLib/geometry.py b/Lib/fontTools/colorLib/geometry.py index cf83b67d0..c23a84126 100644 --- a/Lib/fontTools/colorLib/geometry.py +++ b/Lib/fontTools/colorLib/geometry.py @@ -12,10 +12,6 @@ def _round_point(pt): return (otRound(pt[0]), otRound(pt[1])) -def _round_circle(centre, radius): - return _round_point(centre), otRound(radius) - - def _unit_vector(vec): length = hypot(*vec) if length == 0: @@ -28,11 +24,6 @@ def _unit_vector(vec): _NEARLY_ZERO = 1 / (1 << 12) # 0.000244140625 -def _is_circle_inside_circle(inner_centre, inner_radius, outer_centre, outer_radius): - dist = inner_radius + hypot(*_vector_between(inner_centre, outer_centre)) - return abs(outer_radius - dist) <= _NEARLY_ZERO or outer_radius > dist - - # The unit vector's X and Y components are respectively # U = (cos(Ī±), sin(Ī±)) # where Ī± is the angle between the unit vector and the positive x axis. @@ -62,8 +53,33 @@ def _nudge_point(pt, direction): return tuple(result) +class Circle: + def __init__(self, centre, radius): + self.centre = centre + self.radius = radius + + def __repr__(self): + return f"Circle(centre={self.centre}, radius={self.radius})" + + def round(self): + return Circle(_round_point(self.centre), otRound(self.radius)) + + def inside(self, outer_circle): + dist = self.radius + hypot(*_vector_between(self.centre, outer_circle.centre)) + return ( + abs(outer_circle.radius - dist) <= _NEARLY_ZERO + or outer_circle.radius > dist + ) + + def concentric(self, other): + return self.centre == other.centre + + def nudge_towards(self, direction): + self.centre = _nudge_point(self.centre, direction) + + def nudge_start_circle_almost_inside(c0, r0, c1, r1): - """ Nudge c0 so it continues to be inside/outside c1 after rounding. + """Nudge c0 so it continues to be inside/outside c1 after rounding. The rounding of circle coordinates to integers may cause an abrupt change if the start circle c0 is so close to the end circle c1's perimiter that @@ -74,29 +90,50 @@ def nudge_start_circle_almost_inside(c0, r0, c1, r1): https://github.com/googlefonts/colr-gradients-spec/issues/204 https://github.com/googlefonts/picosvg/issues/158 """ - inside_before_round = _is_circle_inside_circle(c0, r0, c1, r1) - rc0, rr0 = _round_circle(c0, r0) - rc1, rr1 = _round_circle(c1, r1) - inside_after_round = _is_circle_inside_circle(rc0, rr0, rc1, rr1) + start_circle, end_circle = Circle(c0, r0), Circle(c1, r1) - if inside_before_round != inside_after_round: - # at most 2 iterations ought to be enough to converge - for _ in range(2): - if rc0 == rc1: # nowhere to nudge along a zero vector, bail out - break + inside_before_round = start_circle.inside(end_circle) + + round_start = start_circle.round() + round_end = end_circle.round() + + # At most 3 iterations ought to be enough to converge. In the first, we + # check if the start circle keeps containment after normal rounding; then + # we continue adjusting by -/+ 1.0 until containment is restored. + # Normal rounding can at most move each coordinates -/+0.5; in the worst case + # both the start and end circle's centres and radii will be rounded in opposite + # directions, e.g. when they move along a 45 degree diagonal: + # c0 = (1.5, 1.5) ===> (2.0, 2.0) + # r0 = 0.5 ===> 1.0 + # c1 = (0.499, 0.499) ===> (0.0, 0.0) + # r1 = 2.499 ===> 2.0 + # In this example, the relative distance between the circles, calculated + # as r1 - (r0 + distance(c0, c1)) is initially 0.57437 (c0 is inside c1), and + # -1.82842 after rounding (c0 is now outside c1). Nudging c0 by -1.0 on both + # x and y axes moves it towards c1 by hypot(-1.0, -1.0) = 1.41421. Two of these + # moves cover twice that distance, which is enough to restore containment. + max_attempts = 3 + for _ in range(max_attempts): + inside_after_round = round_start.inside(round_end) + if inside_before_round == inside_after_round: + break + if round_start.concentric(round_end): + # can't move c0 towards c1 (they are the same), so we change the radius if inside_after_round: - direction = _vector_between(rc1, rc0) + round_start.radius += 1.0 else: - direction = _vector_between(rc0, rc1) - rc0 = _nudge_point(rc0, direction) - inside_after_round = _is_circle_inside_circle(rc0, rr0, rc1, rr1) - if inside_before_round == inside_after_round: - break - else: # ... or it's a bug - raise AssertionError( - f"Nudging circle " - f"{'inside' if inside_before_round else 'outside'} " - f" failed after two attempts!" - ) + round_start.radius -= 1.0 + else: + if inside_after_round: + direction = _vector_between(round_end.centre, round_start.centre) + else: + direction = _vector_between(round_start.centre, round_end.centre) + round_start.nudge_towards(direction) + else: # likely a bug + raise AssertionError( + f"Rounding circle {start_circle} " + f"{'inside' if inside_before_round else 'outside'} " + f"{end_circle} failed after {max_attempts} attempts!" + ) - return rc0 + return round_start diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index f8eaea6e5..d4376f455 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -1,11 +1,7 @@ from fontTools.ttLib import newTable from fontTools.ttLib.tables import otTables as ot from fontTools.colorLib import builder -from fontTools.colorLib.geometry import ( - nudge_start_circle_almost_inside, - _is_circle_inside_circle, - _round_circle, -) +from fontTools.colorLib.geometry import nudge_start_circle_almost_inside, Circle from fontTools.colorLib.builder import LayerV1ListBuilder from fontTools.colorLib.errors import ColorLibError import pytest @@ -1066,18 +1062,19 @@ class TrickyRadialGradientTest: @staticmethod def circle_inside_circle(c0, r0, c1, r1, rounded=False): if rounded: - return _is_circle_inside_circle( - *_round_circle(c0, r0), *_round_circle(c1, r1) - ) + return Circle(c0, r0).round().inside(Circle(c1, r1).round()) else: - return _is_circle_inside_circle(c0, r0, c1, r1) + return Circle(c0, r0).inside(Circle(c1, r1)) def nudge_start_circle_position(self, c0, r0, c1, r1, inside=True): assert self.circle_inside_circle(c0, r0, c1, r1) is inside assert self.circle_inside_circle(c0, r0, c1, r1, rounded=True) is not inside - c0 = nudge_start_circle_almost_inside(c0, r0, c1, r1) - assert self.circle_inside_circle(c0, r0, c1, r1, rounded=True) is inside - return c0 + r = nudge_start_circle_almost_inside(c0, r0, c1, r1) + assert ( + self.circle_inside_circle(r.centre, r.radius, c1, r1, rounded=True) + is inside + ) + return r.centre, r.radius def test_noto_emoji_mosquito_u1f99f(self): # https://github.com/googlefonts/picosvg/issues/158 @@ -1085,22 +1082,26 @@ class TrickyRadialGradientTest: r0 = 0 c1 = (642.99108, 104.70327999999995) r1 = 260.0072 - rc0 = self.nudge_start_circle_position(c0, r0, c1, r1, inside=True) - assert rc0 == (386, 71) + result = self.nudge_start_circle_position(c0, r0, c1, r1, inside=True) + assert result == ((386, 71), 0) @pytest.mark.parametrize( "c0, r0, c1, r1, inside, expected", [ # inside before round, outside after round - ((1.4, 0), 0, (2.6, 0), 1.3, True, (2, 0)), - ((1, 0), 0.6, (2.8, 0), 2.45, True, (2, 0)), - ((6.49, 6.49), 0, (0.49, 0.49), 8.49, True, (5, 5)), + ((1.4, 0), 0, (2.6, 0), 1.3, True, ((2, 0), 0)), + ((1, 0), 0.6, (2.8, 0), 2.45, True, ((2, 0), 1)), + ((6.49, 6.49), 0, (0.49, 0.49), 8.49, True, ((5, 5), 0)), # outside before round, inside after round - ((0, 0), 0, (2, 0), 1.5, False, (-1, 0)), - ((0, -0.5), 0, (0, -2.5), 1.5, False, (0, 1)), + ((0, 0), 0, (2, 0), 1.5, False, ((-1, 0), 0)), + ((0, -0.5), 0, (0, -2.5), 1.5, False, ((0, 1), 0)), # the following ones require two nudges to round correctly - ((0.5, 0), 0, (9.4, 0), 8.8, False, (-1, 0)), - ((1.5, 1.5), 0, (0.49, 0.49), 1.49, True, (0, 0)), + ((0.5, 0), 0, (9.4, 0), 8.8, False, ((-1, 0), 0)), + ((1.5, 1.5), 0, (0.49, 0.49), 1.49, True, ((0, 0), 0)), + # limit case when circle almost exactly overlap + ((0.5000001, 0), 0.5000001, (0.499999, 0), 0.4999999, True, ((0, 0), 0)), + # concentrical circles, r0 > r1 + ((0, 0), 1.49, (0, 0), 1, False, ((0, 0), 2)), ], ) def test_nudge_start_circle_position(self, c0, r0, c1, r1, inside, expected): From b465dcff751dbeadcec51df6e4d8742353de1306 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 15 Jan 2021 17:37:11 +0000 Subject: [PATCH 040/167] rename function to round_start_circle_stable_containment as suggested in https://github.com/fonttools/fonttools/pull/2148#discussion_r557656073 --- Lib/fontTools/colorLib/builder.py | 4 ++-- Lib/fontTools/colorLib/geometry.py | 4 ++-- Tests/colorLib/builder_test.py | 11 +++++------ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index c0329abcc..56a048de9 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -34,7 +34,7 @@ from fontTools.ttLib.tables.otTables import ( VariableInt, ) from .errors import ColorLibError -from .geometry import nudge_start_circle_almost_inside +from .geometry import round_start_circle_stable_containment # TODO move type aliases to colorLib.types? @@ -534,7 +534,7 @@ class LayerV1ListBuilder: r1 = _to_variable_value(r1) # avoid abrupt change after rounding when c0 is near c1's perimeter - c = nudge_start_circle_almost_inside( + c = round_start_circle_stable_containment( (x0.value, y0.value), r0.value, (x1.value, y1.value), r1.value ) x0, y0 = x0._replace(value=c.centre[0]), y0._replace(value=c.centre[1]) diff --git a/Lib/fontTools/colorLib/geometry.py b/Lib/fontTools/colorLib/geometry.py index c23a84126..bf7268823 100644 --- a/Lib/fontTools/colorLib/geometry.py +++ b/Lib/fontTools/colorLib/geometry.py @@ -78,8 +78,8 @@ class Circle: self.centre = _nudge_point(self.centre, direction) -def nudge_start_circle_almost_inside(c0, r0, c1, r1): - """Nudge c0 so it continues to be inside/outside c1 after rounding. +def round_start_circle_stable_containment(c0, r0, c1, r1): + """Round start circle so that it stays inside/outside end circle after rounding. The rounding of circle coordinates to integers may cause an abrupt change if the start circle c0 is so close to the end circle c1's perimiter that diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index d4376f455..003e53d97 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -1,7 +1,7 @@ from fontTools.ttLib import newTable from fontTools.ttLib.tables import otTables as ot from fontTools.colorLib import builder -from fontTools.colorLib.geometry import nudge_start_circle_almost_inside, Circle +from fontTools.colorLib.geometry import round_start_circle_stable_containment, Circle from fontTools.colorLib.builder import LayerV1ListBuilder from fontTools.colorLib.errors import ColorLibError import pytest @@ -1066,10 +1066,10 @@ class TrickyRadialGradientTest: else: return Circle(c0, r0).inside(Circle(c1, r1)) - def nudge_start_circle_position(self, c0, r0, c1, r1, inside=True): + def round_start_circle(self, c0, r0, c1, r1, inside=True): assert self.circle_inside_circle(c0, r0, c1, r1) is inside assert self.circle_inside_circle(c0, r0, c1, r1, rounded=True) is not inside - r = nudge_start_circle_almost_inside(c0, r0, c1, r1) + r = round_start_circle_stable_containment(c0, r0, c1, r1) assert ( self.circle_inside_circle(r.centre, r.radius, c1, r1, rounded=True) is inside @@ -1082,8 +1082,7 @@ class TrickyRadialGradientTest: r0 = 0 c1 = (642.99108, 104.70327999999995) r1 = 260.0072 - result = self.nudge_start_circle_position(c0, r0, c1, r1, inside=True) - assert result == ((386, 71), 0) + assert self.round_start_circle(c0, r0, c1, r1, inside=True) == ((386, 71), 0) @pytest.mark.parametrize( "c0, r0, c1, r1, inside, expected", @@ -1105,4 +1104,4 @@ class TrickyRadialGradientTest: ], ) def test_nudge_start_circle_position(self, c0, r0, c1, r1, inside, expected): - assert self.nudge_start_circle_position(c0, r0, c1, r1, inside) == expected + assert self.round_start_circle(c0, r0, c1, r1, inside) == expected From c9055871d6a68ca4ee3ddb5a548dbd7c9c08fa58 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 15 Jan 2021 18:17:08 +0000 Subject: [PATCH 041/167] minor: rename local vars without _circle suffix --- Lib/fontTools/colorLib/geometry.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/colorLib/geometry.py b/Lib/fontTools/colorLib/geometry.py index bf7268823..39b0c5832 100644 --- a/Lib/fontTools/colorLib/geometry.py +++ b/Lib/fontTools/colorLib/geometry.py @@ -90,12 +90,12 @@ def round_start_circle_stable_containment(c0, r0, c1, r1): https://github.com/googlefonts/colr-gradients-spec/issues/204 https://github.com/googlefonts/picosvg/issues/158 """ - start_circle, end_circle = Circle(c0, r0), Circle(c1, r1) + start, end = Circle(c0, r0), Circle(c1, r1) - inside_before_round = start_circle.inside(end_circle) + inside_before_round = start.inside(end) - round_start = start_circle.round() - round_end = end_circle.round() + round_start = start.round() + round_end = end.round() # At most 3 iterations ought to be enough to converge. In the first, we # check if the start circle keeps containment after normal rounding; then @@ -131,9 +131,9 @@ def round_start_circle_stable_containment(c0, r0, c1, r1): round_start.nudge_towards(direction) else: # likely a bug raise AssertionError( - f"Rounding circle {start_circle} " + f"Rounding circle {start} " f"{'inside' if inside_before_round else 'outside'} " - f"{end_circle} failed after {max_attempts} attempts!" + f"{end} failed after {max_attempts} attempts!" ) return round_start From c13b1cef847cc898af3980ac567191aa44b5bc8c Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 15 Jan 2021 19:11:10 +0000 Subject: [PATCH 042/167] determine direction to nudge pre-loop --- Lib/fontTools/colorLib/geometry.py | 33 +++++++++++++++++------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/Lib/fontTools/colorLib/geometry.py b/Lib/fontTools/colorLib/geometry.py index 39b0c5832..29ae36b69 100644 --- a/Lib/fontTools/colorLib/geometry.py +++ b/Lib/fontTools/colorLib/geometry.py @@ -96,9 +96,21 @@ def round_start_circle_stable_containment(c0, r0, c1, r1): round_start = start.round() round_end = end.round() + inside_after_round = round_start.inside(round_end) - # At most 3 iterations ought to be enough to converge. In the first, we - # check if the start circle keeps containment after normal rounding; then + if inside_before_round == inside_after_round: + return round_start + elif inside_after_round: + # start was outside before rounding: we need to push start away from end + direction = _vector_between(round_end.centre, round_start.centre) + radius_delta = +1.0 + else: + # start was inside before rounding: we need to push start towards end + direction = _vector_between(round_start.centre, round_end.centre) + radius_delta = -1.0 + + # At most 2 iterations ought to be enough to converge. Before the loop, we + # know the start circle didn't keep containment after normal rounding; thus # we continue adjusting by -/+ 1.0 until containment is restored. # Normal rounding can at most move each coordinates -/+0.5; in the worst case # both the start and end circle's centres and radii will be rounded in opposite @@ -112,23 +124,16 @@ def round_start_circle_stable_containment(c0, r0, c1, r1): # -1.82842 after rounding (c0 is now outside c1). Nudging c0 by -1.0 on both # x and y axes moves it towards c1 by hypot(-1.0, -1.0) = 1.41421. Two of these # moves cover twice that distance, which is enough to restore containment. - max_attempts = 3 + max_attempts = 2 for _ in range(max_attempts): - inside_after_round = round_start.inside(round_end) - if inside_before_round == inside_after_round: - break if round_start.concentric(round_end): # can't move c0 towards c1 (they are the same), so we change the radius - if inside_after_round: - round_start.radius += 1.0 - else: - round_start.radius -= 1.0 + round_start.radius += radius_delta + assert round_start.radius >= 0 else: - if inside_after_round: - direction = _vector_between(round_end.centre, round_start.centre) - else: - direction = _vector_between(round_start.centre, round_end.centre) round_start.nudge_towards(direction) + if inside_before_round == round_start.inside(round_end): + break else: # likely a bug raise AssertionError( f"Rounding circle {start} " From a3247ea472955674200599a107c30bc8aff2c1c9 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 18 Jan 2021 09:50:47 +0000 Subject: [PATCH 043/167] compute rounding (dx,dy) offset outside loop then simply do Circle.move() inside --- Lib/fontTools/colorLib/geometry.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/Lib/fontTools/colorLib/geometry.py b/Lib/fontTools/colorLib/geometry.py index 29ae36b69..ec6475357 100644 --- a/Lib/fontTools/colorLib/geometry.py +++ b/Lib/fontTools/colorLib/geometry.py @@ -30,26 +30,26 @@ _NEARLY_ZERO = 1 / (1 << 12) # 0.000244140625 _UNIT_VECTOR_THRESHOLD = cos(3 / 8 * pi) # == sin(1/8 * pi) == 0.38268343236508984 -def _nudge_point(pt, direction): - # Nudge point coordinates -/+ 1.0 approximately based on the direction vector. +def _rounding_offset(direction): + # Return 2-tuple of -/+ 1.0 or 0.0 approximately based on the direction vector. # We divide the unit circle in 8 equal slices oriented towards the cardinal # (N, E, S, W) and intermediate (NE, SE, SW, NW) directions. To each slice we # map one of the possible cases: -1, 0, +1 for either X and Y coordinate. - # E.g. Return (x + 1.0, y - 1.0) if unit vector is oriented towards SE, or - # (x - 1.0, y) if it's pointing West, etc. + # E.g. Return (+1.0, -1.0) if unit vector is oriented towards SE, or + # (-1.0, 0.0) if it's pointing West, etc. uv = _unit_vector(direction) if not uv: - return pt + return (0, 0) result = [] - for coord, uv_component in zip(pt, uv): + for uv_component in uv: if -_UNIT_VECTOR_THRESHOLD <= uv_component < _UNIT_VECTOR_THRESHOLD: # unit vector component near 0: direction almost orthogonal to the # direction of the current axis, thus keep coordinate unchanged - result.append(coord) + result.append(0) else: # nudge coord by +/- 1.0 in direction of unit vector - result.append(coord + copysign(1.0, uv_component)) + result.append(copysign(1.0, uv_component)) return tuple(result) @@ -74,8 +74,8 @@ class Circle: def concentric(self, other): return self.centre == other.centre - def nudge_towards(self, direction): - self.centre = _nudge_point(self.centre, direction) + def move(self, dx, dy): + self.centre = (self.centre[0] + dx, self.centre[1] + dy) def round_start_circle_stable_containment(c0, r0, c1, r1): @@ -108,6 +108,7 @@ def round_start_circle_stable_containment(c0, r0, c1, r1): # start was inside before rounding: we need to push start towards end direction = _vector_between(round_start.centre, round_end.centre) radius_delta = -1.0 + dx, dy = _rounding_offset(direction) # At most 2 iterations ought to be enough to converge. Before the loop, we # know the start circle didn't keep containment after normal rounding; thus @@ -131,7 +132,7 @@ def round_start_circle_stable_containment(c0, r0, c1, r1): round_start.radius += radius_delta assert round_start.radius >= 0 else: - round_start.nudge_towards(direction) + round_start.move(dx, dy) if inside_before_round == round_start.inside(round_end): break else: # likely a bug From 9d33afe04d6e80b4e89b369bbbc3b3f7ebba7d47 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 18 Jan 2021 17:25:59 +0000 Subject: [PATCH 044/167] COLRv1: support unlimited paints as 255-ary tree of PaintColrLayers Fixes https://github.com/googlefonts/nanoemoji/pull/225 E.g. BASKET noto-emoji U+1F9FA contains 364 layers --- Lib/fontTools/colorLib/builder.py | 48 ++++++++++++++++++- Tests/colorLib/builder_test.py | 80 ++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 56a048de9..998ab60d1 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -6,6 +6,7 @@ import collections import copy import enum from functools import partial +from math import ceil, log from typing import ( Any, Dict, @@ -632,7 +633,10 @@ class LayerV1ListBuilder: ot_paint.Format = int(ot.Paint.Format.PaintColrLayers) self.slices.append(ot_paint) - paints = [self.buildPaint(p) for p in paints] + paints = [ + self.buildPaint(p) + for p in _build_n_ary_tree(paints, n=MAX_PAINT_COLR_LAYER_COUNT) + ] # Look for reuse, with preference to longer sequences found_reuse = True @@ -776,3 +780,45 @@ def buildColrV1( glyphs.BaseGlyphCount = len(baseGlyphs) glyphs.BaseGlyphV1Record = baseGlyphs return (layers, glyphs) + + +def _build_n_ary_tree(leaves, n): + """Build N-ary tree from sequence of leaf nodes. + + Return a list of lists where each non-leaf node is a list containing + max n nodes. + """ + if not leaves: + return [] + + assert n > 1 + + depth = ceil(log(len(leaves), n)) + + if depth <= 1: + return list(leaves) + + # Fully populate complete subtrees of root until we have enough leaves left + root = [] + unassigned = None + full_step = n ** (depth - 1) + for i in range(0, len(leaves), full_step): + subtree = leaves[i : i + full_step] + if len(subtree) < full_step: + unassigned = subtree + break + while len(subtree) > n: + subtree = [subtree[k : k + n] for k in range(0, len(subtree), n)] + root.append(subtree) + + if unassigned: + # Recurse to fill the last subtree, which is the only partially populated one + subtree = _build_n_ary_tree(unassigned, n) + if len(subtree) <= n - len(root): + # replace last subtree with its children if they can still fit + root.extend(subtree) + else: + root.append(subtree) + assert len(root) <= n + + return root diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index 003e53d97..75aacfb1e 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -2,7 +2,7 @@ from fontTools.ttLib import newTable from fontTools.ttLib.tables import otTables as ot from fontTools.colorLib import builder from fontTools.colorLib.geometry import round_start_circle_stable_containment, Circle -from fontTools.colorLib.builder import LayerV1ListBuilder +from fontTools.colorLib.builder import LayerV1ListBuilder, _build_n_ary_tree from fontTools.colorLib.errors import ColorLibError import pytest from typing import List @@ -1105,3 +1105,81 @@ class TrickyRadialGradientTest: ) def test_nudge_start_circle_position(self, c0, r0, c1, r1, inside, expected): assert self.round_start_circle(c0, r0, c1, r1, inside) == expected + + +@pytest.mark.parametrize( + "lst, n, expected", + [ + ([0], 2, [0]), + ([0, 1], 2, [0, 1]), + ([0, 1, 2], 2, [[0, 1], 2]), + ([0, 1, 2], 3, [0, 1, 2]), + ([0, 1, 2, 3], 2, [[0, 1], [2, 3]]), + ([0, 1, 2, 3], 3, [[0, 1, 2], 3]), + ([0, 1, 2, 3, 4], 3, [[0, 1, 2], 3, 4]), + ([0, 1, 2, 3, 4, 5], 3, [[0, 1, 2], [3, 4, 5]]), + (list(range(7)), 3, [[0, 1, 2], [3, 4, 5], 6]), + (list(range(8)), 3, [[0, 1, 2], [3, 4, 5], [6, 7]]), + (list(range(9)), 3, [[0, 1, 2], [3, 4, 5], [6, 7, 8]]), + (list(range(10)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], 9]), + (list(range(11)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], 9, 10]), + (list(range(12)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [9, 10, 11]]), + (list(range(13)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [9, 10, 11], 12]), + ( + list(range(14)), + 3, + [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [[9, 10, 11], 12, 13]], + ), + ( + list(range(15)), + 3, + [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [9, 10, 11], [12, 13, 14]], + ), + ( + list(range(16)), + 3, + [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [[9, 10, 11], [12, 13, 14], 15]], + ), + ( + list(range(23)), + 3, + [ + [[0, 1, 2], [3, 4, 5], [6, 7, 8]], + [[9, 10, 11], [12, 13, 14], [15, 16, 17]], + [[18, 19, 20], 21, 22], + ], + ), + ( + list(range(27)), + 3, + [ + [[0, 1, 2], [3, 4, 5], [6, 7, 8]], + [[9, 10, 11], [12, 13, 14], [15, 16, 17]], + [[18, 19, 20], [21, 22, 23], [24, 25, 26]], + ], + ), + ( + list(range(28)), + 3, + [ + [ + [[0, 1, 2], [3, 4, 5], [6, 7, 8]], + [[9, 10, 11], [12, 13, 14], [15, 16, 17]], + [[18, 19, 20], [21, 22, 23], [24, 25, 26]], + ], + 27, + ], + ), + (list(range(257)), 256, [list(range(256)), 256]), + (list(range(258)), 256, [list(range(256)), 256, 257]), + (list(range(512)), 256, [list(range(256)), list(range(256, 512))]), + (list(range(512 + 1)), 256, [list(range(256)), list(range(256, 512)), 512]), + ( + list(range(256 ** 2)), + 256, + [list(range(k * 256, k * 256 + 256)) for k in range(256)], + ), + ], +) +def test_build_n_ary_tree(lst, n, expected): + assert _build_n_ary_tree(lst, n) == expected From 7516e0d95d88b7f45e4d89b75a41090dff9457da Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 18 Jan 2021 17:35:48 +0000 Subject: [PATCH 045/167] builder_test: add test with len(paints) > 255 --- Tests/colorLib/builder_test.py | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index 75aacfb1e..43ec96a41 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -674,6 +674,43 @@ def test_buildColrV1(): assert baseGlyphs.BaseGlyphV1Record[2].BaseGlyph == "g" +def test_buildColrV1_more_than_255_paints(): + num_paints = 364 + colorGlyphs = { + "a": [ + { + "format": 5, # PaintGlyph + "paint": 0, + "glyph": name, + } + for name in (f"glyph{i}" for i in range(num_paints)) + ], + } + layers, baseGlyphs = builder.buildColrV1(colorGlyphs) + paints = layers.Paint + + assert len(paints) == num_paints + 1 + + assert all(paints[i].Format == ot.Paint.Format.PaintGlyph for i in range(255)) + + assert paints[255].Format == ot.Paint.Format.PaintColrLayers + assert paints[255].FirstLayerIndex == 0 + assert paints[255].NumLayers == 255 + + assert all( + paints[i].Format == ot.Paint.Format.PaintGlyph + for i in range(256, num_paints + 1) + ) + + assert baseGlyphs.BaseGlyphCount == len(colorGlyphs) + assert baseGlyphs.BaseGlyphV1Record[0].BaseGlyph == "a" + assert ( + baseGlyphs.BaseGlyphV1Record[0].Paint.Format == ot.Paint.Format.PaintColrLayers + ) + assert baseGlyphs.BaseGlyphV1Record[0].Paint.FirstLayerIndex == 255 + assert baseGlyphs.BaseGlyphV1Record[0].Paint.NumLayers == num_paints + 1 - 255 + + def test_split_color_glyphs_by_version(): layerBuilder = LayerV1ListBuilder() colorGlyphs = { From 6023bab6272f9e4549577b0e3c6dea4fc4204b56 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Tue, 19 Jan 2021 16:42:40 +0000 Subject: [PATCH 046/167] Raise legible error message when script is missing a dflt langsys --- Lib/fontTools/varLib/featureVars.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index 76e8cc4af..45f3d8399 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -10,7 +10,7 @@ from fontTools.ttLib.tables import otTables as ot from fontTools.otlLib.builder import buildLookup, buildSingleSubstSubtable from collections import OrderedDict -from .errors import VarLibValidationError +from .errors import VarLibError, VarLibValidationError def addFeatureVariations(font, conditionalSubstitutions, featureTag='rvrn'): @@ -298,6 +298,11 @@ def addFeatureVariationsRaw(font, conditionalSubstitutions, featureTag='rvrn'): varFeatureIndex = gsub.FeatureList.FeatureRecord.index(varFeature) for scriptRecord in gsub.ScriptList.ScriptRecord: + if scriptRecord.Script.DefaultLangSys is None: + raise VarLibError( + "Feature variations require that the script " + f"'{scriptRecord.ScriptTag}' defines a default language system." + ) langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord] for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems: langSys.FeatureIndex.append(varFeatureIndex) From 7c6337facf79232f6a3a6c8bbc32a8f4af4b71d8 Mon Sep 17 00:00:00 2001 From: Tom Archer Date: Tue, 19 Jan 2021 17:53:53 +0000 Subject: [PATCH 047/167] Add exception and test --- Lib/fontTools/otlLib/builder.py | 3 ++- Tests/otlLib/builder_test.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index 7e1444518..7711fa4c5 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -2574,7 +2574,8 @@ class ClassDefBuilder(object): return self.classes_.add(glyphs) for glyph in glyphs: - assert glyph not in self.glyphs_ + if glyph in self.glyphs_: + raise TypeError(f"Glyph {glyph} already present in class.") self.glyphs_[glyph] = glyphs def classes(self): diff --git a/Tests/otlLib/builder_test.py b/Tests/otlLib/builder_test.py index bdfc64509..a2c51331d 100644 --- a/Tests/otlLib/builder_test.py +++ b/Tests/otlLib/builder_test.py @@ -1101,6 +1101,18 @@ class ClassDefBuilderTest(object): assert not b.canAdd({"d", "e", "f"}) assert not b.canAdd({"f"}) + def test_add_exception(self): + b = builder.ClassDefBuilder(useClass0=True) + b.add({"a", "b", "c", "d"}) + with pytest.raises(TypeError): + b.add({"a"}) + with pytest.raises(TypeError): + b.add({"b"}) + with pytest.raises(TypeError): + b.add({"c"}) + with pytest.raises(TypeError): + b.add({"d"}) + buildStatTable_test_data = [ ([ From 97124070540076056b51020aeaf32410ae355ea5 Mon Sep 17 00:00:00 2001 From: Tom Archer Date: Wed, 20 Jan 2021 11:19:43 +0000 Subject: [PATCH 048/167] Change TypeError to ValueError --- Lib/fontTools/otlLib/builder.py | 2 +- Tests/otlLib/builder_test.py | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index 7711fa4c5..5d16b83c2 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -2575,7 +2575,7 @@ class ClassDefBuilder(object): self.classes_.add(glyphs) for glyph in glyphs: if glyph in self.glyphs_: - raise TypeError(f"Glyph {glyph} already present in class.") + raise ValueError(f"Glyph {glyph} is already present in class.") self.glyphs_[glyph] = glyphs def classes(self): diff --git a/Tests/otlLib/builder_test.py b/Tests/otlLib/builder_test.py index a2c51331d..6bd05615b 100644 --- a/Tests/otlLib/builder_test.py +++ b/Tests/otlLib/builder_test.py @@ -1103,15 +1103,9 @@ class ClassDefBuilderTest(object): def test_add_exception(self): b = builder.ClassDefBuilder(useClass0=True) - b.add({"a", "b", "c", "d"}) - with pytest.raises(TypeError): - b.add({"a"}) - with pytest.raises(TypeError): - b.add({"b"}) - with pytest.raises(TypeError): - b.add({"c"}) - with pytest.raises(TypeError): - b.add({"d"}) + b.add({"a", "b", "c"}) + with pytest.raises(ValueError): + b.add({"a", "d"}) buildStatTable_test_data = [ From be898ec6f9079f5faa45b8abc3204055dca10d28 Mon Sep 17 00:00:00 2001 From: Tom Archer Date: Wed, 20 Jan 2021 14:30:49 +0000 Subject: [PATCH 049/167] Change ValueError to OpenTypeLibError --- Lib/fontTools/otlLib/builder.py | 2 +- Tests/otlLib/builder_test.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index 5d16b83c2..029aa3fc5 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -2575,7 +2575,7 @@ class ClassDefBuilder(object): self.classes_.add(glyphs) for glyph in glyphs: if glyph in self.glyphs_: - raise ValueError(f"Glyph {glyph} is already present in class.") + raise OpenTypeLibError(f"Glyph {glyph} is already present in class.", None) self.glyphs_[glyph] = glyphs def classes(self): diff --git a/Tests/otlLib/builder_test.py b/Tests/otlLib/builder_test.py index 6bd05615b..3ea5a7459 100644 --- a/Tests/otlLib/builder_test.py +++ b/Tests/otlLib/builder_test.py @@ -2,7 +2,7 @@ import io import struct from fontTools.misc.fixedTools import floatToFixed from fontTools.misc.testTools import getXML -from fontTools.otlLib import builder +from fontTools.otlLib import builder, error from fontTools import ttLib from fontTools.ttLib.tables import otTables import pytest @@ -1104,7 +1104,7 @@ class ClassDefBuilderTest(object): def test_add_exception(self): b = builder.ClassDefBuilder(useClass0=True) b.add({"a", "b", "c"}) - with pytest.raises(ValueError): + with pytest.raises(error.OpenTypeLibError): b.add({"a", "d"}) From 9ab1895b63f366a69dac35b28aa2f25c9cd5ca17 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 25 Jan 2021 17:36:01 +0000 Subject: [PATCH 050/167] Update changelog [skip ci] --- NEWS.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/NEWS.rst b/NEWS.rst index 55542d069..423166b70 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,16 @@ +- [codecs] Handle ``errors`` parameter different from 'strict' for the custom + extended mac encodings (#2137, #2132). +- [featureVars] Raise better error message when a script is missing the required + default language system (#2154). +- [COLRv1] Avoid abrupt change caused by rounding ``PaintRadialGradient.c0`` when + the start circle almost touches the end circle's perimeter (#2148). +- [COLRv1] Support building unlimited lists of paints as 255-ary trees of + ``PaintColrLayers`` tables (#2153). +- [subset] Prune redundant format-12 cmap subtables when all non-BMP characters + are dropped (#2146). +- [basePen] Raise ``MissingComponentError`` instead of bare ``KeyError`` when a + referenced component is missing (#2145). + 4.18.2 (released 2020-12-16) ---------------------------- From f8283bda7e4b6a10c3330ad88ec44fc3f6636ed3 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 25 Jan 2021 17:36:42 +0000 Subject: [PATCH 051/167] Release 4.19.0 --- Lib/fontTools/__init__.py | 2 +- NEWS.rst | 3 +++ setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index edbcda8f6..cc7d69f9d 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.18.3.dev0" +version = __version__ = "4.19.0" __all__ = ["version", "log", "configLogger"] diff --git a/NEWS.rst b/NEWS.rst index 423166b70..e22a0f62d 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +4.19.0 (released 2021-01-25) +---------------------------- + - [codecs] Handle ``errors`` parameter different from 'strict' for the custom extended mac encodings (#2137, #2132). - [featureVars] Raise better error message when a script is missing the required diff --git a/setup.cfg b/setup.cfg index b58b7e776..8f8c53082 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.18.3.dev0 +current_version = 4.19.0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 28b60c915..5e3226c47 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.18.3.dev0", + version="4.19.0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From d47250dd15fbeeb123f586a66cf6588c5315c615 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 25 Jan 2021 17:36:42 +0000 Subject: [PATCH 052/167] =?UTF-8?q?Bump=20version:=204.19.0=20=E2=86=92=20?= =?UTF-8?q?4.19.1.dev0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Lib/fontTools/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index cc7d69f9d..77484d811 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.19.0" +version = __version__ = "4.19.1.dev0" __all__ = ["version", "log", "configLogger"] diff --git a/setup.cfg b/setup.cfg index 8f8c53082..f78ccc0b7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.19.0 +current_version = 4.19.1.dev0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 5e3226c47..f1eb0bea8 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.19.0", + version="4.19.1.dev0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 822d3bf2616cff4c91679f4b527fffcb820ef9ca Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 25 Jan 2021 17:42:19 +0000 Subject: [PATCH 053/167] plistlib: remove unused 'type: ignore' comment to appease mypy --- Lib/fontTools/misc/plistlib/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/misc/plistlib/__init__.py b/Lib/fontTools/misc/plistlib/__init__.py index 1335e8cbe..d8391041d 100644 --- a/Lib/fontTools/misc/plistlib/__init__.py +++ b/Lib/fontTools/misc/plistlib/__init__.py @@ -543,7 +543,7 @@ def load( if not hasattr(fp, "read"): raise AttributeError("'%s' object has no attribute 'read'" % type(fp).__name__) target = PlistTarget(use_builtin_types=use_builtin_types, dict_type=dict_type) - parser = etree.XMLParser(target=target) # type: ignore + parser = etree.XMLParser(target=target) result = etree.parse(fp, parser=parser) # lxml returns the target object directly, while ElementTree wraps # it as the root of an ElementTree object From 52e3e03ed91c5dedbf6a691f709e50dea20f7e1b Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Thu, 28 Jan 2021 16:27:32 +0000 Subject: [PATCH 054/167] An off-curve should stay off even with flags --- Lib/fontTools/ttLib/woff2.py | 4 +- NEWS.rst | 3 + .../ttLib/data/woff2_overlap_offcurve_in.ttx | 306 ++++++++++++++++++ Tests/ttLib/woff2_test.py | 15 + 4 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 Tests/ttLib/data/woff2_overlap_offcurve_in.ttx diff --git a/Lib/fontTools/ttLib/woff2.py b/Lib/fontTools/ttLib/woff2.py index 75b55527c..d088b70f6 100644 --- a/Lib/fontTools/ttLib/woff2.py +++ b/Lib/fontTools/ttLib/woff2.py @@ -11,7 +11,7 @@ from fontTools.ttLib import (TTFont, TTLibError, getTableModule, getTableClass, from fontTools.ttLib.sfnt import (SFNTReader, SFNTWriter, DirectoryEntry, WOFFFlavorData, sfntDirectoryFormat, sfntDirectorySize, SFNTDirectoryEntry, sfntDirectoryEntrySize, calcChecksum) -from fontTools.ttLib.tables import ttProgram +from fontTools.ttLib.tables import ttProgram, _g_l_y_f import logging @@ -934,7 +934,7 @@ class WOFF2GlyfTable(getTableClass('glyf')): flags = array.array('B') triplets = array.array('B') for i in range(len(coordinates)): - onCurve = glyph.flags[i] + onCurve = glyph.flags[i] & _g_l_y_f.flagOnCurve x, y = coordinates[i] absX = abs(x) absY = abs(y) diff --git a/NEWS.rst b/NEWS.rst index e22a0f62d..8ab64cd04 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +- [woff2] An initial off-curve point with an overlap flag now stays an off-curve + point after compression. + 4.19.0 (released 2021-01-25) ---------------------------- diff --git a/Tests/ttLib/data/woff2_overlap_offcurve_in.ttx b/Tests/ttLib/data/woff2_overlap_offcurve_in.ttx new file mode 100644 index 000000000..a36dbf50d --- /dev/null +++ b/Tests/ttLib/data/woff2_overlap_offcurve_in.ttx @@ -0,0 +1,306 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Weight + + + Unnamed + + + Regular + + + 1.000;NONE;Unnamed-Regular + + + Unnamed Regular + + + Version 1.000 + + + Unnamed-Regular + + + Weight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wght + 0x0 + 400.0 + 400.0 + 700.0 + 256 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/ttLib/woff2_test.py b/Tests/ttLib/woff2_test.py index 5923b7f23..16276c039 100644 --- a/Tests/ttLib/woff2_test.py +++ b/Tests/ttLib/woff2_test.py @@ -1,6 +1,7 @@ from fontTools.misc.py23 import * from fontTools import ttLib from fontTools.ttLib import woff2 +from fontTools.ttLib.tables import _g_l_y_f from fontTools.ttLib.woff2 import ( WOFF2Reader, woff2DirectorySize, woff2DirectoryFormat, woff2FlagsSize, woff2UnknownTagSize, woff2Base128MaxSize, WOFF2DirectoryEntry, @@ -1220,6 +1221,20 @@ class WOFF2RoundtripTest(object): assert tmp.getvalue() == tmp2.getvalue() assert ttFont.flavor == "woff2" + def test_roundtrip_overlap_bit(self): + ttx = os.path.join(data_dir, "woff2_overlap_offcurve_in.ttx") + ttFont = ttLib.TTFont() + ttFont.importXML(ttx) + + assert ttFont["glyf"]["A"].flags[0] == _g_l_y_f.flagOverlapSimple + + ttFont.flavor = "woff2" + tmp = BytesIO() + ttFont.save(tmp) + + _, ttFont2 = self.roundtrip(tmp) + assert ttFont2.flavor == "woff2" + assert ttFont2["glyf"]["A"].flags[0] == 0 class MainTest(object): From 8569a8435f465b2d0b7d233799ffb84b997cdeab Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Thu, 28 Jan 2021 16:35:36 +0000 Subject: [PATCH 055/167] Update Tests/ttLib/woff2_test.py Co-authored-by: Cosimo Lupo --- Tests/ttLib/woff2_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ttLib/woff2_test.py b/Tests/ttLib/woff2_test.py index 16276c039..23aab4aa9 100644 --- a/Tests/ttLib/woff2_test.py +++ b/Tests/ttLib/woff2_test.py @@ -1221,7 +1221,7 @@ class WOFF2RoundtripTest(object): assert tmp.getvalue() == tmp2.getvalue() assert ttFont.flavor == "woff2" - def test_roundtrip_overlap_bit(self): + def test_roundtrip_off_curve_despite_overlap_bit(self): ttx = os.path.join(data_dir, "woff2_overlap_offcurve_in.ttx") ttFont = ttLib.TTFont() ttFont.importXML(ttx) From b0278cf8466d23112c19b4bde859fc984e820935 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Thu, 28 Jan 2021 17:06:32 +0000 Subject: [PATCH 056/167] Release 4.19.1 --- Lib/fontTools/__init__.py | 2 +- NEWS.rst | 3 +++ setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 77484d811..16ee8a4e1 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.19.1.dev0" +version = __version__ = "4.19.1" __all__ = ["version", "log", "configLogger"] diff --git a/NEWS.rst b/NEWS.rst index 8ab64cd04..776deef81 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +4.19.1 (released 2021-01-28) +---------------------------- + - [woff2] An initial off-curve point with an overlap flag now stays an off-curve point after compression. diff --git a/setup.cfg b/setup.cfg index f78ccc0b7..7691af57b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.19.1.dev0 +current_version = 4.19.1 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index f1eb0bea8..7f1312265 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.19.1.dev0", + version="4.19.1", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 8612319487c298f7feee3c8653ca34d9d8f8e607 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Thu, 28 Jan 2021 17:06:32 +0000 Subject: [PATCH 057/167] =?UTF-8?q?Bump=20version:=204.19.1=20=E2=86=92=20?= =?UTF-8?q?4.19.2.dev0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Lib/fontTools/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 16ee8a4e1..9c54124e8 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.19.1" +version = __version__ = "4.19.2.dev0" __all__ = ["version", "log", "configLogger"] diff --git a/setup.cfg b/setup.cfg index 7691af57b..f8af6a4ab 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.19.1 +current_version = 4.19.2.dev0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 7f1312265..33576fc47 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.19.1", + version="4.19.2.dev0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From a3acb1426bf884fd9a74a865e78dddc6fc49e347 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Fri, 29 Jan 2021 22:22:01 +0000 Subject: [PATCH 058/167] Remove py23 import --- Lib/fontTools/pens/__init__.py | 2 -- Lib/fontTools/pens/areaPen.py | 1 - Lib/fontTools/pens/basePen.py | 1 - Lib/fontTools/pens/boundsPen.py | 1 - Lib/fontTools/pens/cocoaPen.py | 1 - Lib/fontTools/pens/filterPen.py | 1 - Lib/fontTools/pens/momentsPen.py | 1 - Lib/fontTools/pens/perimeterPen.py | 1 - Lib/fontTools/pens/pointInsidePen.py | 1 - Lib/fontTools/pens/qtPen.py | 1 - Lib/fontTools/pens/quartzPen.py | 1 - Lib/fontTools/pens/recordingPen.py | 1 - Lib/fontTools/pens/reportLabPen.py | 1 - Lib/fontTools/pens/reverseContourPen.py | 1 - Lib/fontTools/pens/statisticsPen.py | 1 - Lib/fontTools/pens/svgPathPen.py | 1 - Lib/fontTools/pens/t2CharStringPen.py | 1 - Lib/fontTools/pens/teePen.py | 1 - Lib/fontTools/pens/transformPen.py | 1 - Lib/fontTools/pens/ttGlyphPen.py | 1 - Lib/fontTools/pens/wxPen.py | 1 - 21 files changed, 22 deletions(-) diff --git a/Lib/fontTools/pens/__init__.py b/Lib/fontTools/pens/__init__.py index b1760311b..156cb232a 100644 --- a/Lib/fontTools/pens/__init__.py +++ b/Lib/fontTools/pens/__init__.py @@ -1,3 +1 @@ """Empty __init__.py file to signal Python this directory is a package.""" - -from fontTools.misc.py23 import * diff --git a/Lib/fontTools/pens/areaPen.py b/Lib/fontTools/pens/areaPen.py index c9301542e..403afe7bc 100644 --- a/Lib/fontTools/pens/areaPen.py +++ b/Lib/fontTools/pens/areaPen.py @@ -1,6 +1,5 @@ """Calculate the area of a glyph.""" -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/basePen.py b/Lib/fontTools/pens/basePen.py index c8c4c5511..c9903c4c2 100644 --- a/Lib/fontTools/pens/basePen.py +++ b/Lib/fontTools/pens/basePen.py @@ -36,7 +36,6 @@ Coordinates are usually expressed as (x, y) tuples, but generally any sequence of length 2 will do. """ -from fontTools.misc.py23 import * from fontTools.misc.loggingTools import LogMixin __all__ = ["AbstractPen", "NullPen", "BasePen", diff --git a/Lib/fontTools/pens/boundsPen.py b/Lib/fontTools/pens/boundsPen.py index c76efdfb8..810715caa 100644 --- a/Lib/fontTools/pens/boundsPen.py +++ b/Lib/fontTools/pens/boundsPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.misc.arrayTools import updateBounds, pointInRect, unionRect from fontTools.misc.bezierTools import calcCubicBounds, calcQuadraticBounds from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/cocoaPen.py b/Lib/fontTools/pens/cocoaPen.py index 9ca6f3bb6..67482b4df 100644 --- a/Lib/fontTools/pens/cocoaPen.py +++ b/Lib/fontTools/pens/cocoaPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/filterPen.py b/Lib/fontTools/pens/filterPen.py index 7539efb5c..4355ba41e 100644 --- a/Lib/fontTools/pens/filterPen.py +++ b/Lib/fontTools/pens/filterPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import AbstractPen from fontTools.pens.pointPen import AbstractPointPen from fontTools.pens.recordingPen import RecordingPen diff --git a/Lib/fontTools/pens/momentsPen.py b/Lib/fontTools/pens/momentsPen.py index 694d6b029..8c90f70ac 100644 --- a/Lib/fontTools/pens/momentsPen.py +++ b/Lib/fontTools/pens/momentsPen.py @@ -1,6 +1,5 @@ """Pen calculating 0th, 1st, and 2nd moments of area of glyph shapes. This is low-level, autogenerated pen. Use statisticsPen instead.""" -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/perimeterPen.py b/Lib/fontTools/pens/perimeterPen.py index 36c7edb4d..9a09cb8f0 100644 --- a/Lib/fontTools/pens/perimeterPen.py +++ b/Lib/fontTools/pens/perimeterPen.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- """Calculate the perimeter of a glyph.""" -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen from fontTools.misc.bezierTools import approximateQuadraticArcLengthC, calcQuadraticArcLengthC, approximateCubicArcLengthC, calcCubicArcLengthC import math diff --git a/Lib/fontTools/pens/pointInsidePen.py b/Lib/fontTools/pens/pointInsidePen.py index 8de077c97..34597f406 100644 --- a/Lib/fontTools/pens/pointInsidePen.py +++ b/Lib/fontTools/pens/pointInsidePen.py @@ -2,7 +2,6 @@ for shapes. """ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen from fontTools.misc.bezierTools import solveQuadratic, solveCubic diff --git a/Lib/fontTools/pens/qtPen.py b/Lib/fontTools/pens/qtPen.py index 20d7e23a4..34736453c 100644 --- a/Lib/fontTools/pens/qtPen.py +++ b/Lib/fontTools/pens/qtPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/quartzPen.py b/Lib/fontTools/pens/quartzPen.py index d35a993bb..16b9c2d84 100644 --- a/Lib/fontTools/pens/quartzPen.py +++ b/Lib/fontTools/pens/quartzPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen from Quartz.CoreGraphics import CGPathCreateMutable, CGPathMoveToPoint diff --git a/Lib/fontTools/pens/recordingPen.py b/Lib/fontTools/pens/recordingPen.py index b25011d6d..abce1ad76 100644 --- a/Lib/fontTools/pens/recordingPen.py +++ b/Lib/fontTools/pens/recordingPen.py @@ -1,5 +1,4 @@ """Pen recording operations that can be accessed or replayed.""" -from fontTools.misc.py23 import * from fontTools.pens.basePen import AbstractPen, DecomposingPen from fontTools.pens.pointPen import AbstractPointPen diff --git a/Lib/fontTools/pens/reportLabPen.py b/Lib/fontTools/pens/reportLabPen.py index 51d213f73..c0a4610b7 100644 --- a/Lib/fontTools/pens/reportLabPen.py +++ b/Lib/fontTools/pens/reportLabPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen from reportlab.graphics.shapes import Path diff --git a/Lib/fontTools/pens/reverseContourPen.py b/Lib/fontTools/pens/reverseContourPen.py index abc0fa290..9b3241b6b 100644 --- a/Lib/fontTools/pens/reverseContourPen.py +++ b/Lib/fontTools/pens/reverseContourPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.misc.arrayTools import pairwise from fontTools.pens.filterPen import ContourFilterPen diff --git a/Lib/fontTools/pens/statisticsPen.py b/Lib/fontTools/pens/statisticsPen.py index 7d602067c..abd6ff5e7 100644 --- a/Lib/fontTools/pens/statisticsPen.py +++ b/Lib/fontTools/pens/statisticsPen.py @@ -1,6 +1,5 @@ """Pen calculating area, center of mass, variance and standard-deviation, covariance and correlation, and slant, of glyph shapes.""" -from fontTools.misc.py23 import * import math from fontTools.pens.momentsPen import MomentsPen diff --git a/Lib/fontTools/pens/svgPathPen.py b/Lib/fontTools/pens/svgPathPen.py index 803f3935e..4352ba478 100644 --- a/Lib/fontTools/pens/svgPathPen.py +++ b/Lib/fontTools/pens/svgPathPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/t2CharStringPen.py b/Lib/fontTools/pens/t2CharStringPen.py index 89340d1ee..a4b1d8f67 100644 --- a/Lib/fontTools/pens/t2CharStringPen.py +++ b/Lib/fontTools/pens/t2CharStringPen.py @@ -1,7 +1,6 @@ # Copyright (c) 2009 Type Supply LLC # Author: Tal Leming -from fontTools.misc.py23 import * from fontTools.misc.fixedTools import otRound from fontTools.misc.psCharStrings import T2CharString from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/teePen.py b/Lib/fontTools/pens/teePen.py index 49420dca5..2f30e922a 100644 --- a/Lib/fontTools/pens/teePen.py +++ b/Lib/fontTools/pens/teePen.py @@ -1,5 +1,4 @@ """Pen multiplexing drawing to one or more pens.""" -from fontTools.misc.py23 import * from fontTools.pens.basePen import AbstractPen diff --git a/Lib/fontTools/pens/transformPen.py b/Lib/fontTools/pens/transformPen.py index 6619ba739..2dcf83b1a 100644 --- a/Lib/fontTools/pens/transformPen.py +++ b/Lib/fontTools/pens/transformPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.filterPen import FilterPen, FilterPointPen diff --git a/Lib/fontTools/pens/ttGlyphPen.py b/Lib/fontTools/pens/ttGlyphPen.py index 0b64cb380..9bf8b7b1b 100644 --- a/Lib/fontTools/pens/ttGlyphPen.py +++ b/Lib/fontTools/pens/ttGlyphPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from array import array from fontTools.misc.fixedTools import MAX_F2DOT14, otRound, floatToFixedToFloat from fontTools.pens.basePen import LoggingPen diff --git a/Lib/fontTools/pens/wxPen.py b/Lib/fontTools/pens/wxPen.py index 5ff6c4719..1504f0890 100644 --- a/Lib/fontTools/pens/wxPen.py +++ b/Lib/fontTools/pens/wxPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen From 85c450b565cc2ee28ded4e4b2a55a54ff5b61314 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 1 Feb 2021 11:51:39 +0000 Subject: [PATCH 059/167] fontBuilder: pass glyphMap to buildCOLR to sort base records by GID COLR Base glyph records must be sorted by glyph index. The buildCOLR function has an optional glyphMap parameter that maps from glyph names to glyph indices (as returned from TTFont.getReversedGlyphMap()). FontBuilder knows all that so it should pass it on to colorLib. --- Lib/fontTools/fontBuilder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index f3fe92a83..0fbdee1d1 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -777,7 +777,8 @@ class FontBuilder(object): """ from fontTools.colorLib.builder import buildCOLR - self.font["COLR"] = buildCOLR(colorLayers) + glyphMap = self.font.getReverseGlyphMap() + self.font["COLR"] = buildCOLR(colorLayers, glyphMap=glyphMap) def setupCPAL( self, From 8343468689f17ee53f99b9ede63a3729bd56f894 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 1 Feb 2021 11:59:24 +0000 Subject: [PATCH 060/167] fontBuilder: pass through version and varStore parameter to buildCOLR --- Lib/fontTools/fontBuilder.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index 0fbdee1d1..6a04725f1 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -770,7 +770,7 @@ class FontBuilder(object): self.font, conditionalSubstitutions, featureTag=featureTag ) - def setupCOLR(self, colorLayers): + def setupCOLR(self, colorLayers, version=None, varStore=None): """Build new COLR table using color layers dictionary. Cf. `fontTools.colorLib.builder.buildCOLR`. @@ -778,7 +778,9 @@ class FontBuilder(object): from fontTools.colorLib.builder import buildCOLR glyphMap = self.font.getReverseGlyphMap() - self.font["COLR"] = buildCOLR(colorLayers, glyphMap=glyphMap) + self.font["COLR"] = buildCOLR( + colorLayers, version=version, glyphMap=glyphMap, varStore=varStore + ) def setupCPAL( self, From d1e85cb88809c87f7f3d56b11974f804d5debd14 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Wed, 3 Feb 2021 14:12:46 +0000 Subject: [PATCH 061/167] Allow 'sub X by NULL;' sequence to delete a glyph --- Lib/fontTools/feaLib/ast.py | 18 ++++++++++++- Lib/fontTools/feaLib/parser.py | 23 +++++++++++----- Tests/feaLib/builder_test.py | 2 +- Tests/feaLib/data/delete_glyph.fea | 3 +++ Tests/feaLib/data/delete_glyph.ttx | 43 ++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 Tests/feaLib/data/delete_glyph.fea create mode 100644 Tests/feaLib/data/delete_glyph.ttx diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 7ef9afd92..6c2bfce85 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -188,6 +188,21 @@ class Comment(Element): return self.text +class NullGlyph(Expression): + """The NULL glyph, used in glyph deletion substitutions.""" + + def __init__(self, location=None): + Expression.__init__(self, location) + #: The name itself as a string + + def glyphSet(self): + """The glyphs in this class as a tuple of :class:`GlyphName` objects.""" + return () + + def asFea(self, indent=""): + return "NULL" + + class GlyphName(Expression): """A single glyph name, such as ``cedilla``.""" @@ -1246,8 +1261,9 @@ class MultipleSubstStatement(Statement): res += " " + " ".join(map(asFea, self.suffix)) else: res += asFea(self.glyph) + replacement = self.replacement or [ NullGlyph() ] res += " by " - res += " ".join(map(asFea, self.replacement)) + res += " ".join(map(asFea, replacement)) res += ";" return res diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index 7439fbf34..7100cf656 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -314,10 +314,15 @@ class Parser(object): location, ) - def parse_glyphclass_(self, accept_glyphname): + def parse_glyphclass_(self, accept_glyphname, accept_null=False): # Parses a glyph class, either named or anonymous, or (if - # ``bool(accept_glyphname)``) a glyph name. + # ``bool(accept_glyphname)``) a glyph name. If ``bool(accept_null)`` then + # also accept the special NULL glyph. if accept_glyphname and self.next_token_type_ in (Lexer.NAME, Lexer.CID): + if accept_null and self.next_token_ == "NULL": + # If you want a glyph called NULL, you should escape it. + self.advance_lexer_() + return self.ast.NullGlyph(location=self.cur_token_location_) glyph = self.expect_glyph_() self.check_glyph_name_in_glyph_set(glyph) return self.ast.GlyphName(glyph, location=self.cur_token_location_) @@ -375,7 +380,8 @@ class Parser(object): self.expect_symbol_("-") range_end = self.expect_cid_() self.check_glyph_name_in_glyph_set( - f"cid{range_start:05d}", f"cid{range_end:05d}", + f"cid{range_start:05d}", + f"cid{range_end:05d}", ) glyphs.add_cid_range( range_start, @@ -804,7 +810,7 @@ class Parser(object): if self.next_token_ == "by": keyword = self.expect_keyword_("by") while self.next_token_ != ";": - gc = self.parse_glyphclass_(accept_glyphname=True) + gc = self.parse_glyphclass_(accept_glyphname=True, accept_null=True) new.append(gc) elif self.next_token_ == "from": keyword = self.expect_keyword_("from") @@ -837,6 +843,9 @@ class Parser(object): num_lookups = len([l for l in lookups if l is not None]) + if len(new) == 1 and len(new[0].glyphSet()) == 0: + new = [] # Deletion + # GSUB lookup type 1: Single substitution. # Format A: "substitute a by a.sc;" # Format B: "substitute [one.fitted one.oldstyle] by one;" @@ -863,8 +872,10 @@ class Parser(object): not reverse and len(old) == 1 and len(old[0].glyphSet()) == 1 - and len(new) > 1 - and max([len(n.glyphSet()) for n in new]) == 1 + and ( + (len(new) > 1 and max([len(n.glyphSet()) for n in new]) == 1) + or len(new) == 0 + ) and num_lookups == 0 ): return self.ast.MultipleSubstStatement( diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py index 151cd896a..279e8ca87 100644 --- a/Tests/feaLib/builder_test.py +++ b/Tests/feaLib/builder_test.py @@ -73,7 +73,7 @@ class BuilderTest(unittest.TestCase): LigatureSubtable AlternateSubtable MultipleSubstSubtable SingleSubstSubtable aalt_chain_contextual_subst AlternateChained MultipleLookupsPerGlyph MultipleLookupsPerGlyph2 GSUB_6_formats - GSUB_5_formats + GSUB_5_formats delete_glyph """.split() def __init__(self, methodName): diff --git a/Tests/feaLib/data/delete_glyph.fea b/Tests/feaLib/data/delete_glyph.fea new file mode 100644 index 000000000..36e0f0f9a --- /dev/null +++ b/Tests/feaLib/data/delete_glyph.fea @@ -0,0 +1,3 @@ +feature test { + sub a by NULL; +} test; diff --git a/Tests/feaLib/data/delete_glyph.ttx b/Tests/feaLib/data/delete_glyph.ttx new file mode 100644 index 000000000..777f6e364 --- /dev/null +++ b/Tests/feaLib/data/delete_glyph.ttx @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 56df97b5f26facbe1ed45375f3d0db646e70ef7a Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Wed, 3 Feb 2021 14:16:57 +0000 Subject: [PATCH 062/167] Prohibit non-functional "sub A B by NULL" --- Lib/fontTools/feaLib/parser.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index 7100cf656..23a496181 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -843,8 +843,10 @@ class Parser(object): num_lookups = len([l for l in lookups if l is not None]) + is_deletion = False if len(new) == 1 and len(new[0].glyphSet()) == 0: new = [] # Deletion + is_deletion = True # GSUB lookup type 1: Single substitution. # Format A: "substitute a by a.sc;" @@ -947,7 +949,7 @@ class Parser(object): ) # If there are remaining glyphs to parse, this is an invalid GSUB statement - if len(new) != 0: + if len(new) != 0 or is_deletion: raise FeatureLibError("Invalid substitution statement", location) # GSUB lookup type 6: Chaining contextual substitution. From 8f66a1e81306e8e71c01e737fff4c8681574e356 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 3 Feb 2021 16:47:59 +0000 Subject: [PATCH 063/167] COLRv1: add functions to un-build COLR otTables to raw dicts This adds an unbuildColrV1 which does the inverse of colorLib.builder.buildColrV1. Takes a LayerV1List and BaseGlypV1List and returns a map of base glyphs to raw data structures (list, dict, float, str, etc.). Useful not only for debugging purpose, but also for implementing COLRv1 subsetting (where we need to drop whole chunks of paints which may be reused by multiple glyphs). --- Lib/fontTools/colorLib/unbuilder.py | 201 ++++++++++++++++++++++++++++ Tests/colorLib/unbuilder_test.py | 141 +++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 Lib/fontTools/colorLib/unbuilder.py create mode 100644 Tests/colorLib/unbuilder_test.py diff --git a/Lib/fontTools/colorLib/unbuilder.py b/Lib/fontTools/colorLib/unbuilder.py new file mode 100644 index 000000000..6b7a09b30 --- /dev/null +++ b/Lib/fontTools/colorLib/unbuilder.py @@ -0,0 +1,201 @@ +from fontTools.ttLib.tables import otTables as ot + + +def unbuildColrV1(layerV1List, baseGlyphV1List, ignoreVarIdx=False): + unbuilder = LayerV1ListUnbuilder(layerV1List.Paint, ignoreVarIdx=ignoreVarIdx) + return { + rec.BaseGlyph: unbuilder.unbuildPaint(rec.Paint) + for rec in baseGlyphV1List.BaseGlyphV1Record + } + + +def _unbuildVariableValue(v, ignoreVarIdx=False): + return v.value if ignoreVarIdx else (v.value, v.varIdx) + + +def unbuildColorStop(colorStop, ignoreVarIdx=False): + return { + "offset": _unbuildVariableValue( + colorStop.StopOffset, ignoreVarIdx=ignoreVarIdx + ), + "paletteIndex": colorStop.Color.PaletteIndex, + "alpha": _unbuildVariableValue( + colorStop.Color.Alpha, ignoreVarIdx=ignoreVarIdx + ), + } + + +def unbuildColorLine(colorLine, ignoreVarIdx=False): + return { + "stops": [ + unbuildColorStop(stop, ignoreVarIdx=ignoreVarIdx) + for stop in colorLine.ColorStop + ], + "extend": colorLine.Extend.name.lower(), + } + + +def unbuildAffine2x3(transform, ignoreVarIdx=False): + return tuple( + _unbuildVariableValue(getattr(transform, attr), ignoreVarIdx=ignoreVarIdx) + for attr in ("xx", "yx", "xy", "yy", "dx", "dy") + ) + + +def _flatten(lst): + for el in lst: + if isinstance(el, list): + yield from _flatten(el) + else: + yield el + + +class LayerV1ListUnbuilder: + def __init__(self, layers, ignoreVarIdx=False): + self.layers = layers + self.ignoreVarIdx = ignoreVarIdx + + def unbuildPaint(self, paint): + try: + return self._unbuildFunctions[paint.Format](self, paint) + except KeyError: + raise ValueError(f"Unrecognized paint format: {paint.Format}") + + def unbuildVariableValue(self, value): + return _unbuildVariableValue(value, ignoreVarIdx=self.ignoreVarIdx) + + def unbuildPaintColrLayers(self, paint): + return list( + _flatten( + [ + self.unbuildPaint(childPaint) + for childPaint in self.layers[ + paint.FirstLayerIndex : paint.FirstLayerIndex + paint.NumLayers + ] + ] + ) + ) + + def unbuildPaintSolid(self, paint): + return { + "format": int(paint.Format), + "paletteIndex": paint.Color.PaletteIndex, + "alpha": self.unbuildVariableValue(paint.Color.Alpha), + } + + def unbuildPaintLinearGradient(self, paint): + p0 = (self.unbuildVariableValue(paint.x0), self.unbuildVariableValue(paint.y0)) + p1 = (self.unbuildVariableValue(paint.x1), self.unbuildVariableValue(paint.y1)) + p2 = (self.unbuildVariableValue(paint.x2), self.unbuildVariableValue(paint.y2)) + return { + "format": int(ot.Paint.Format.PaintLinearGradient), + "colorLine": unbuildColorLine( + paint.ColorLine, ignoreVarIdx=self.ignoreVarIdx + ), + "p0": p0, + "p1": p1, + "p2": p2, + } + + def unbuildPaintRadialGradient(self, paint): + c0 = (self.unbuildVariableValue(paint.x0), self.unbuildVariableValue(paint.y0)) + r0 = self.unbuildVariableValue(paint.r0) + c1 = (self.unbuildVariableValue(paint.x1), self.unbuildVariableValue(paint.y1)) + r1 = self.unbuildVariableValue(paint.r1) + return { + "format": int(ot.Paint.Format.PaintRadialGradient), + "colorLine": unbuildColorLine( + paint.ColorLine, ignoreVarIdx=self.ignoreVarIdx + ), + "c0": c0, + "r0": r0, + "c1": c1, + "r1": r1, + } + + def unbuildPaintGlyph(self, paint): + return { + "format": int(ot.Paint.Format.PaintGlyph), + "glyph": paint.Glyph, + "paint": self.unbuildPaint(paint.Paint), + } + + def unbuildPaintColrGlyph(self, paint): + return { + "format": int(ot.Paint.Format.PaintColrGlyph), + "glyph": paint.Glyph, + } + + def unbuildPaintTransform(self, paint): + return { + "format": int(ot.Paint.Format.PaintTransform), + "transform": unbuildAffine2x3( + paint.Transform, ignoreVarIdx=self.ignoreVarIdx + ), + "paint": self.unbuildPaint(paint.Paint), + } + + def unbuildPaintTranslate(self, paint): + return { + "format": int(ot.Paint.Format.PaintTranslate), + "dx": self.unbuildVariableValue(paint.dx), + "dy": self.unbuildVariableValue(paint.dy), + "paint": self.unbuildPaint(paint.Paint), + } + + def unbuildPaintRotate(self, paint): + return { + "format": int(ot.Paint.Format.PaintRotate), + "angle": self.unbuildVariableValue(paint.angle), + "centerX": self.unbuildVariableValue(paint.centerX), + "centerY": self.unbuildVariableValue(paint.centerY), + "paint": self.unbuildPaint(paint.Paint), + } + + def unbuildPaintSkew(self, paint): + return { + "format": int(ot.Paint.Format.PaintSkew), + "xSkewAngle": self.unbuildVariableValue(paint.xSkewAngle), + "ySkewAngle": self.unbuildVariableValue(paint.ySkewAngle), + "centerX": self.unbuildVariableValue(paint.centerX), + "centerY": self.unbuildVariableValue(paint.centerY), + "paint": self.unbuildPaint(paint.Paint), + } + + def unbuildPaintComposite(self, paint): + return { + "format": int(ot.Paint.Format.PaintComposite), + "mode": paint.CompositeMode.name.lower(), + "source": self.unbuildPaint(paint.SourcePaint), + "backdrop": self.unbuildPaint(paint.BackdropPaint), + } + + +LayerV1ListUnbuilder._unbuildFunctions = { + pf.value: getattr(LayerV1ListUnbuilder, "unbuild" + pf.name) + for pf in ot.Paint.Format +} + + +if __name__ == "__main__": + from pprint import pprint + import sys + from fontTools.ttLib import TTFont + + try: + fontfile = sys.argv[1] + except IndexError: + sys.exit("usage: fonttools colorLib.unbuilder FONTFILE") + + font = TTFont(fontfile) + colr = font["COLR"] + if colr.version < 1: + sys.exit(f"error: No COLR table version=1 found in {fontfile}") + + colorGlyphs = unbuildColrV1( + colr.table.LayerV1List, + colr.table.BaseGlyphV1List, + ignoreVarIdx=not colr.table.VarStore, + ) + + pprint(colorGlyphs) diff --git a/Tests/colorLib/unbuilder_test.py b/Tests/colorLib/unbuilder_test.py new file mode 100644 index 000000000..9d115b415 --- /dev/null +++ b/Tests/colorLib/unbuilder_test.py @@ -0,0 +1,141 @@ +from fontTools.ttLib.tables import otTables as ot +from fontTools.colorLib.builder import buildColrV1 +from fontTools.colorLib.unbuilder import unbuildColrV1 +import pytest + + +TEST_COLOR_GLYPHS = { + "glyph00010": [ + { + "format": int(ot.Paint.Format.PaintGlyph), + "glyph": "glyph00011", + "paint": { + "format": int(ot.Paint.Format.PaintSolid), + "paletteIndex": 2, + "alpha": 0.5, + }, + }, + { + "format": int(ot.Paint.Format.PaintGlyph), + "glyph": "glyph00012", + "paint": { + "format": int(ot.Paint.Format.PaintLinearGradient), + "colorLine": { + "stops": [ + {"offset": 0.0, "paletteIndex": 3, "alpha": 1.0}, + {"offset": 0.5, "paletteIndex": 4, "alpha": 1.0}, + {"offset": 1.0, "paletteIndex": 5, "alpha": 1.0}, + ], + "extend": "repeat", + }, + "p0": (1, 2), + "p1": (-3, -4), + "p2": (5, 6), + }, + }, + { + "format": int(ot.Paint.Format.PaintGlyph), + "glyph": "glyph00013", + "paint": { + "format": int(ot.Paint.Format.PaintTransform), + "transform": (-13.0, 14.0, 15.0, -17.0, 18.0, 19.0), + "paint": { + "format": int(ot.Paint.Format.PaintRadialGradient), + "colorLine": { + "stops": [ + {"offset": 0.0, "paletteIndex": 6, "alpha": 1.0}, + { + "offset": 1.0, + "paletteIndex": 7, + "alpha": 0.4, + }, + ], + "extend": "pad", + }, + "c0": (7, 8), + "r0": 9, + "c1": (10, 11), + "r1": 12, + }, + }, + }, + { + "format": int(ot.Paint.Format.PaintTranslate), + "dx": 257.0, + "dy": 258.0, + "paint": { + "format": int(ot.Paint.Format.PaintRotate), + "angle": 45.0, + "centerX": 255.0, + "centerY": 256.0, + "paint": { + "format": int(ot.Paint.Format.PaintSkew), + "xSkewAngle": -11.0, + "ySkewAngle": 5.0, + "centerX": 253.0, + "centerY": 254.0, + "paint": { + "format": int(ot.Paint.Format.PaintGlyph), + "glyph": "glyph00011", + "paint": { + "format": int(ot.Paint.Format.PaintSolid), + "paletteIndex": 2, + "alpha": 0.5, + }, + }, + }, + }, + }, + ], + "glyph00014": { + "format": int(ot.Paint.Format.PaintComposite), + "mode": "src_over", + "source": { + "format": int(ot.Paint.Format.PaintColrGlyph), + "glyph": "glyph00010", + }, + "backdrop": { + "format": int(ot.Paint.Format.PaintTransform), + "transform": (1.0, 0.0, 0.0, 1.0, 300.0, 0.0), + "paint": { + "format": int(ot.Paint.Format.PaintColrGlyph), + "glyph": "glyph00010", + }, + }, + }, + "glyph00015": [ + { + "format": int(ot.Paint.Format.PaintGlyph), + "glyph": "glyph00011", + "paint": { + "format": int(ot.Paint.Format.PaintSolid), + "paletteIndex": 2, + "alpha": 0.5, + }, + }, + { + "format": int(ot.Paint.Format.PaintGlyph), + "glyph": "glyph00012", + "paint": { + "format": int(ot.Paint.Format.PaintLinearGradient), + "colorLine": { + "stops": [ + {"offset": 0.0, "paletteIndex": 3, "alpha": 1.0}, + {"offset": 0.5, "paletteIndex": 4, "alpha": 1.0}, + {"offset": 1.0, "paletteIndex": 5, "alpha": 1.0}, + ], + "extend": "repeat", + }, + "p0": (1, 2), + "p1": (-3, -4), + "p2": (5, 6), + }, + }, + ], +} + + +def test_unbuildColrV1(): + layersV1, baseGlyphsV1 = buildColrV1(TEST_COLOR_GLYPHS) + colorGlyphs = unbuildColrV1(layersV1, baseGlyphsV1, ignoreVarIdx=True) + assert colorGlyphs == TEST_COLOR_GLYPHS From 93c23eaaf7c2ca83d6a6e69a6cd43e89c9204b7f Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 3 Feb 2021 18:21:03 +0000 Subject: [PATCH 064/167] COLRv1: define new PaintSweepGradient, amend tests with new format numbers --- Lib/fontTools/colorLib/builder.py | 17 ++++++++++ Lib/fontTools/ttLib/tables/otData.py | 31 +++++++++++------ Lib/fontTools/ttLib/tables/otTables.py | 15 +++++---- Tests/colorLib/builder_test.py | 34 +++++++++---------- Tests/ttLib/tables/C_O_L_R_test.py | 46 +++++++++++++------------- 5 files changed, 85 insertions(+), 58 deletions(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 998ab60d1..5a52edbc9 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -549,6 +549,23 @@ class LayerV1ListBuilder: return ot_paint + def buildPaintSweepGradient( + self, + colorLine: _ColorLineInput, + centerX: _ScalarInput, + centerY: _ScalarInput, + startAngle: _ScalarInput, + endAngle: _ScalarInput, + ) -> ot.Paint: + ot_paint = ot.Paint() + ot_paint.Format = int(ot.Paint.Format.PaintSweepGradient) + ot_paint.ColorLine = _to_color_line(colorLine) + ot_paint.centerX = _to_variable_int16(centerX) + ot_paint.centerY = _to_variable_int16(centerY) + ot_paint.startAngle = _to_variable_f16dot16_float(startAngle) + ot_paint.endAngle = _to_variable_f16dot16_float(endAngle) + return ot_paint + def buildPaintGlyph(self, glyph: str, paint: _PaintInput) -> ot.Paint: ot_paint = ot.Paint() ot_paint.Format = int(ot.Paint.Format.PaintGlyph) diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py index a6f9619e6..59ff40f65 100755 --- a/Lib/fontTools/ttLib/tables/otData.py +++ b/Lib/fontTools/ttLib/tables/otData.py @@ -1648,38 +1648,47 @@ otData = [ ('PaintFormat5', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 5'), - ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintGlyph table) to Paint subtable.'), - ('GlyphID', 'Glyph', None, None, 'Glyph ID for the source outline.'), + ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of PaintSweep table) to ColorLine subtable.'), + ('VarInt16', 'centerX', None, None, 'Center x coordinate.'), + ('VarInt16', 'centerY', None, None, 'Center y coordinate.'), + ('VarFixed', 'startAngle', None, None, 'Start of the angular range of the gradient.'), + ('VarFixed', 'endAngle', None, None, 'End of the angular range of the gradient.'), ]), ('PaintFormat6', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 6'), - ('GlyphID', 'Glyph', None, None, 'Virtual glyph ID for a BaseGlyphV1List base glyph.'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintGlyph table) to Paint subtable.'), + ('GlyphID', 'Glyph', None, None, 'Glyph ID for the source outline.'), ]), ('PaintFormat7', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 7'), - ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTransformed table) to Paint subtable.'), - ('Affine2x3', 'Transform', None, None, 'Offset (from beginning of PaintTrasformed table) to Affine2x3 subtable.'), + ('GlyphID', 'Glyph', None, None, 'Virtual glyph ID for a BaseGlyphV1List base glyph.'), ]), ('PaintFormat8', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 8'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTransformed table) to Paint subtable.'), + ('Affine2x3', 'Transform', None, None, 'Offset (from beginning of PaintTrasformed table) to Affine2x3 subtable.'), + ]), + + ('PaintFormat9', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 9'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTranslate table) to Paint subtable.'), ('VarFixed', 'dx', None, None, 'Translation in x direction.'), ('VarFixed', 'dy', None, None, 'Translation in y direction.'), ]), - ('PaintFormat9', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 9'), + ('PaintFormat10', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 10'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintRotate table) to Paint subtable.'), ('VarFixed', 'angle', None, None, ''), ('VarFixed', 'centerX', None, None, ''), ('VarFixed', 'centerY', None, None, ''), ]), - ('PaintFormat10', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 10'), + ('PaintFormat11', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 11'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintSkew table) to Paint subtable.'), ('VarFixed', 'xSkewAngle', None, None, ''), ('VarFixed', 'ySkewAngle', None, None, ''), @@ -1687,8 +1696,8 @@ otData = [ ('VarFixed', 'centerY', None, None, ''), ]), - ('PaintFormat11', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 11'), + ('PaintFormat12', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 12'), ('LOffset24To(Paint)', 'SourcePaint', None, None, 'Offset (from beginning of PaintComposite table) to source Paint subtable.'), ('CompositeMode', 'CompositeMode', None, None, 'A CompositeMode enumeration value.'), ('LOffset24To(Paint)', 'BackdropPaint', None, None, 'Offset (from beginning of PaintComposite table) to backdrop Paint subtable.'), diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py index 7f42921d7..ec5c5db4a 100644 --- a/Lib/fontTools/ttLib/tables/otTables.py +++ b/Lib/fontTools/ttLib/tables/otTables.py @@ -1331,13 +1331,14 @@ class Paint(getFormatSwitchingBaseTableClass("uint8")): PaintSolid = 2 PaintLinearGradient = 3 PaintRadialGradient = 4 - PaintGlyph = 5 - PaintColrGlyph = 6 - PaintTransform = 7 - PaintTranslate = 8 - PaintRotate = 9 - PaintSkew = 10 - PaintComposite = 11 + PaintSweepGradient = 5 + PaintGlyph = 6 + PaintColrGlyph = 7 + PaintTransform = 8 + PaintTranslate = 9 + PaintRotate = 10 + PaintSkew = 11 + PaintComposite = 12 def getFormatName(self): try: diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index 43ec96a41..fae00a419 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -545,10 +545,10 @@ def test_buildPaintComposite(): composite = layerBuilder.buildPaintComposite( mode=ot.CompositeMode.SRC_OVER, source={ - "format": 11, + "format": 12, "mode": "src_over", - "source": {"format": 5, "glyph": "c", "paint": 2}, - "backdrop": {"format": 5, "glyph": "b", "paint": 1}, + "source": {"format": 6, "glyph": "c", "paint": 2}, + "backdrop": {"format": 6, "glyph": "b", "paint": 1}, }, backdrop=layerBuilder.buildPaintGlyph( "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) @@ -679,7 +679,7 @@ def test_buildColrV1_more_than_255_paints(): colorGlyphs = { "a": [ { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": 0, "glyph": name, } @@ -775,18 +775,18 @@ def assertNoV0Content(colr): def test_build_layerv1list_empty(): - # Nobody uses PaintColrLayers (format 8), no layerlist + # Nobody uses PaintColrLayers (format 1), no layerlist colr = builder.buildCOLR( { "a": { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": {"format": 2, "paletteIndex": 2, "alpha": 0.8}, "glyph": "b", }, # A list of 1 shouldn't become a PaintColrLayers "b": [ { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": { "format": 3, "colorLine": { @@ -832,17 +832,17 @@ def test_build_layerv1list_simple(): # All layers use the same solid paint solid_paint = {"format": 2, "paletteIndex": 2, "alpha": 0.8} backdrop = { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": solid_paint, "glyph": "back", } a_foreground = { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": solid_paint, "glyph": "a_fore", } b_foreground = { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": solid_paint, "glyph": "b_fore", } @@ -882,33 +882,33 @@ def test_build_layerv1list_with_sharing(): solid_paint = {"format": 2, "paletteIndex": 2, "alpha": 0.8} backdrop = [ { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": solid_paint, "glyph": "back1", }, { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": solid_paint, "glyph": "back2", }, ] a_foreground = { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": solid_paint, "glyph": "a_fore", } b_background = { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": solid_paint, "glyph": "b_back", } b_foreground = { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": solid_paint, "glyph": "b_fore", } c_background = { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": solid_paint, "glyph": "c_back", } @@ -951,7 +951,7 @@ def test_build_layerv1list_with_sharing(): def test_build_layerv1list_with_overlaps(): paints = [ { - "format": 5, # PaintGlyph + "format": 6, # PaintGlyph "paint": {"format": 2, "paletteIndex": 2, "alpha": 0.8}, "glyph": c, } diff --git a/Tests/ttLib/tables/C_O_L_R_test.py b/Tests/ttLib/tables/C_O_L_R_test.py index 7f3f71ea2..b5ee9f5f1 100644 --- a/Tests/ttLib/tables/C_O_L_R_test.py +++ b/Tests/ttLib/tables/C_O_L_R_test.py @@ -131,11 +131,11 @@ COLR_V1_SAMPLE = ( (b"\x01", "BaseGlyphV1Record[0].Paint.Format (1)"), (b"\x04", "BaseGlyphV1Record[0].Paint.NumLayers (4)"), (b"\x00\x00\x00\x00", "BaseGlyphV1Record[0].Paint.FirstLayerIndex (0)"), - (b"\x0B", "BaseGlyphV1Record[1].Paint.Format (11)"), + (b"\x0C", "BaseGlyphV1Record[1].Paint.Format (12)"), (b"\x00\x00<", "Offset to SourcePaint from beginning of PaintComposite (60)"), (b"\x03", "BaseGlyphV1Record[1].Paint.CompositeMode [SRC_OVER] (3)"), (b"\x00\x00\x08", "Offset to BackdropPaint from beginning of PaintComposite (8)"), - (b"\x07", "BaseGlyphV1Record[1].Paint.BackdropPaint.Format (7)"), + (b"\x08", "BaseGlyphV1Record[1].Paint.BackdropPaint.Format (8)"), (b"\x00\x00\x34", "Offset to Paint from beginning of PaintTransform (52)"), (b"\x00\x01\x00\x00\x00\x00\x00\x00", "Affine2x3.xx.value (1.0)"), (b"\x00\x00\x00\x00\x00\x00\x00\x00", "Affine2x3.xy.value (0.0)"), @@ -143,7 +143,7 @@ COLR_V1_SAMPLE = ( (b"\x00\x01\x00\x00\x00\x00\x00\x00", "Affine2x3.yy.value (1.0)"), (b"\x01\x2c\x00\x00\x00\x00\x00\x00", "Affine2x3.dx.value (300.0)"), (b"\x00\x00\x00\x00\x00\x00\x00\x00", "Affine2x3.dy.value (0.0)"), - (b"\x06", "BaseGlyphV1Record[1].Paint.SourcePaint.Format (6)"), + (b"\x07", "BaseGlyphV1Record[1].Paint.SourcePaint.Format (7)"), (b"\x00\n", "BaseGlyphV1Record[1].Paint.SourcePaint.Glyph (10)"), (b"\x00\x00\x00\x04", "LayerV1List.LayerCount (4)"), ( @@ -163,11 +163,11 @@ COLR_V1_SAMPLE = ( "Fourth Offset to Paint table from beginning of LayerV1List (246)", ), # PaintGlyph glyph00011 - (b"\x05", "LayerV1List.Paint[0].Format (5)"), + (b"\x06", "LayerV1List.Paint[0].Format (6)"), (b"\x00\x01<", "Offset24 to Paint subtable from beginning of PaintGlyph (316)"), (b"\x00\x0b", "LayerV1List.Paint[0].Glyph (glyph00011)"), # PaintGlyph glyph00012 - (b"\x05", "LayerV1List.Paint[1].Format (5)"), + (b"\x06", "LayerV1List.Paint[1].Format (6)"), (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), (b"\x00\x0c", "LayerV1List.Paint[1].Glyph (glyph00012)"), (b"\x03", "LayerV1List.Paint[1].Paint.Format (3)"), @@ -202,10 +202,10 @@ COLR_V1_SAMPLE = ( (b"@\x00", "ColorLine.ColorStop[2].Color.Alpha.value (1.0)"), (b"\x00\x00\x00\x00", "ColorLine.ColorStop[2].Color.Alpha.varIdx (0)"), # PaintGlyph glyph00013 - (b"\x05", "LayerV1List.Paint[2].Format (5)"), + (b"\x06", "LayerV1List.Paint[2].Format (6)"), (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), (b"\x00\r", "LayerV1List.Paint[2].Glyph (13)"), - (b"\x07", "LayerV1List.Paint[2].Paint.Format (5)"), + (b"\x08", "LayerV1List.Paint[2].Paint.Format (8)"), (b"\x00\x00\x34", "Offset to Paint subtable from beginning of PaintTransform (52)"), (b"\xff\xf3\x00\x00\x00\x00\x00\x00", "Affine2x3.xx.value (-13)"), (b"\x00\x0e\x00\x00\x00\x00\x00\x00", "Affine2x3.xy.value (14)"), @@ -230,25 +230,25 @@ COLR_V1_SAMPLE = ( (b"\x00\x07", "ColorLine.ColorStop[1].Color.PaletteIndex (7)"), (b"\x19\x9a\x00\x00\x00\x00", "ColorLine.ColorStop[1].Color.Alpha.value (0.4)"), # PaintTranslate - (b"\x08", "LayerV1List.Paint[3].Format (8)"), + (b"\x09", "LayerV1List.Paint[3].Format (9)"), (b"\x00\x00\x14", "Offset to Paint subtable from beginning of PaintTranslate (20)"), (b"\x01\x01\x00\x00\x00\x00\x00\x00", "dx.value (257)"), (b"\x01\x02\x00\x00\x00\x00\x00\x00", "dy.value (258)"), # PaintRotate - (b"\x09", "LayerV1List.Paint[3].Paint.Format (9)"), + (b"\x0a", "LayerV1List.Paint[3].Paint.Format (10)"), (b"\x00\x00\x1c", "Offset to Paint subtable from beginning of PaintRotate (28)"), (b"\x00\x2d\x00\x00\x00\x00\x00\x00", "angle.value (45)"), (b"\x00\xff\x00\x00\x00\x00\x00\x00", "centerX.value (255)"), (b"\x01\x00\x00\x00\x00\x00\x00\x00", "centerY.value (256)"), # PaintSkew - (b"\x0a", "LayerV1List.Paint[3].Paint.Paint.Format (10)"), + (b"\x0b", "LayerV1List.Paint[3].Paint.Paint.Format (11)"), (b"\x00\x00\x24", "Offset to Paint subtable from beginning of PaintSkew (36)"), (b"\xff\xf5\x00\x00\x00\x00\x00\x00", "xSkewAngle (-11)"), (b"\x00\x05\x00\x00\x00\x00\x00\x00", "ySkewAngle (5)"), (b"\x00\xfd\x00\x00\x00\x00\x00\x00", "centerX.value (253)"), (b"\x00\xfe\x00\x00\x00\x00\x00\x00", "centerY.value (254)"), # PaintGlyph - (b"\x05", "LayerV1List.Paint[2].Format (5)"), + (b"\x06", "LayerV1List.Paint[2].Format (6)"), (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), (b"\x00\x0b", "LayerV1List.Paint[2].Glyph (11)"), # PaintSolid @@ -296,13 +296,13 @@ COLR_V1_XML = [ " ", ' ', ' ', - ' ', - ' ', + ' ', + ' ', ' ', " ", ' ', - ' ', - ' ', + ' ', + ' ', ' ', " ", " ", @@ -319,7 +319,7 @@ COLR_V1_XML = [ "", "", " ", - ' ', + ' ', ' ', " ", ' ', @@ -328,7 +328,7 @@ COLR_V1_XML = [ " ", ' ', " ", - ' ', + ' ', ' ', " ", ' ', @@ -364,8 +364,8 @@ COLR_V1_XML = [ " ", ' ', " ", - ' ', - ' ', + ' ', + ' ', ' ', " ", ' ', @@ -403,10 +403,10 @@ COLR_V1_XML = [ " ", ' ', " ", - ' ', - ' ', - ' ', - ' ', + ' ', + ' ', + ' ', + ' ', ' ', " ", ' ', From a7d145f027e136006c29b3fddaf17d03914a5691 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 3 Feb 2021 19:11:44 +0000 Subject: [PATCH 065/167] update tests for PaintSweepGradient --- Tests/colorLib/builder_test.py | 23 +++++++++ Tests/ttLib/tables/C_O_L_R_test.py | 77 ++++++++++++++++++++++++++---- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index fae00a419..73d6089ca 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -385,6 +385,29 @@ def test_buildPaintRadialGradient(): assert gradient.ColorLine.ColorStop == color_stops +def test_buildPaintSweepGradient(): + layerBuilder = LayerV1ListBuilder() + paint = layerBuilder.buildPaintSweepGradient( + colorLine=builder.buildColorLine( + stops=[ + builder.buildColorStop(0.0, 0), + builder.buildColorStop(0.5, 1), + builder.buildColorStop(1.0, 2, alpha=0.8), + ], + ), + centerX=127, + centerY=129, + startAngle=15, + endAngle=42, + ) + + assert paint.Format == ot.Paint.Format.PaintSweepGradient + assert paint.centerX.value == 127 + assert paint.centerY.value == 129 + assert paint.startAngle.value == 15 + assert paint.endAngle.value == 42 + + def test_buildPaintGlyph_Solid(): layerBuilder = LayerV1ListBuilder() layer = layerBuilder.buildPaintGlyph("a", 2) diff --git a/Tests/ttLib/tables/C_O_L_R_test.py b/Tests/ttLib/tables/C_O_L_R_test.py index b5ee9f5f1..0560084be 100644 --- a/Tests/ttLib/tables/C_O_L_R_test.py +++ b/Tests/ttLib/tables/C_O_L_R_test.py @@ -106,7 +106,7 @@ COLR_V1_SAMPLE = ( (b"\x00\x00\x00 ", "Offset to LayerRecordArray from beginning of table (32)"), (b"\x00\x03", "LayerRecordCount (3)"), (b"\x00\x00\x00,", "Offset to BaseGlyphV1List from beginning of table (44)"), - (b"\x00\x00\x00\x81", "Offset to LayerV1List from beginning of table (129)"), + (b"\x00\x00\x00\xcc", "Offset to LayerV1List from beginning of table (204)"), (b"\x00\x00\x00\x00", "Offset to VarStore (NULL)"), (b"\x00\x06", "BaseGlyphRecord[0].BaseGlyph (6)"), (b"\x00\x00", "BaseGlyphRecord[0].FirstLayerIndex (0)"), @@ -117,20 +117,28 @@ COLR_V1_SAMPLE = ( (b"\x00\x01", "LayerRecord[1].PaletteIndex (1)"), (b"\x00\t", "LayerRecord[2].LayerGlyph (9)"), (b"\x00\x02", "LayerRecord[2].PaletteIndex (2)"), - (b"\x00\x00\x00\x02", "BaseGlyphV1List.BaseGlyphCount (2)"), + # BaseGlyphV1List + (b"\x00\x00\x00\x03", "BaseGlyphV1List.BaseGlyphCount (3)"), (b"\x00\n", "BaseGlyphV1List.BaseGlyphV1Record[0].BaseGlyph (10)"), - ( - b"\x00\x00\x00\x10", - "Offset to Paint table from beginning of BaseGlyphV1List (16)", - ), - (b"\x00\x0e", "BaseGlyphV1List.BaseGlyphV1Record[1].BaseGlyph (14)"), ( b"\x00\x00\x00\x16", "Offset to Paint table from beginning of BaseGlyphV1List (22)", ), + (b"\x00\x0e", "BaseGlyphV1List.BaseGlyphV1Record[1].BaseGlyph (14)"), + ( + b"\x00\x00\x00\x1c", + "Offset to Paint table from beginning of BaseGlyphV1List (28)", + ), + (b"\x00\x0f", "BaseGlyphV1List.BaseGlyphV1Record[2].BaseGlyph (15)"), + ( + b"\x00\x00\x00\x5b", + "Offset to Paint table from beginning of BaseGlyphV1List (91)", + ), + # BaseGlyphV1Record[0] (b"\x01", "BaseGlyphV1Record[0].Paint.Format (1)"), (b"\x04", "BaseGlyphV1Record[0].Paint.NumLayers (4)"), (b"\x00\x00\x00\x00", "BaseGlyphV1Record[0].Paint.FirstLayerIndex (0)"), + # BaseGlyphV1Record[1] (b"\x0C", "BaseGlyphV1Record[1].Paint.Format (12)"), (b"\x00\x00<", "Offset to SourcePaint from beginning of PaintComposite (60)"), (b"\x03", "BaseGlyphV1Record[1].Paint.CompositeMode [SRC_OVER] (3)"), @@ -145,6 +153,29 @@ COLR_V1_SAMPLE = ( (b"\x00\x00\x00\x00\x00\x00\x00\x00", "Affine2x3.dy.value (0.0)"), (b"\x07", "BaseGlyphV1Record[1].Paint.SourcePaint.Format (7)"), (b"\x00\n", "BaseGlyphV1Record[1].Paint.SourcePaint.Glyph (10)"), + # BaseGlyphV1Record[2] + (b"\x06", "BaseGlyphV1Record[2].Paint.Format (6)"), + (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), + (b"\x00\x0b", "BaseGlyphV1Record[2].Paint.Glyph (11)"), + (b"\x05", "BaseGlyphV1Record[2].Paint.Paint.Format (5)"), + (b"\x00\x00 ", "Offset to ColorLine from beginning of PaintSweepGradient (32)"), + (b"\x01\x03\x00\x00\x00\x00", "centerX.value (259)"), + (b"\x01\x2c\x00\x00\x00\x00", "centerY.value (300)"), + (b"\x00\x2d\x00\x00\x00\x00\x00\x00", "startAngle (45.0)"), + (b"\x00\x87\x00\x00\x00\x00\x00\x00", "endAngle (135.0)"), + (b"\x00", "ColorLine.Extend (0; pad)"), + (b"\x00\x02", "ColorLine.StopCount (2)"), + (b"\x00\x00", "ColorLine.ColorStop[0].StopOffset.value (0.0)"), + (b"\x00\x00\x00\x00", "ColorLine.ColorStop[0].StopOffset.varIdx (0)"), + (b"\x00\x03", "ColorLine.ColorStop[0].Color.PaletteIndex (3)"), + (b"@\x00", "ColorLine.ColorStop[0].Color.Alpha.value (1.0)"), + (b"\x00\x00\x00\x00", "ColorLine.ColorStop[0].Color.Alpha.varIdx (0)"), + (b"@\x00", "ColorLine.ColorStop[1].StopOffset.value (1.0)"), + (b"\x00\x00\x00\x00", "ColorLine.ColorStop[1].StopOffset.varIdx (0)"), + (b"\x00\x05", "ColorLine.ColorStop[1].Color.PaletteIndex (5)"), + (b"@\x00", "ColorLine.ColorStop[1].Color.Alpha.value (1.0)"), + (b"\x00\x00\x00\x00", "ColorLine.ColorStop[1].Color.Alpha.varIdx (0)"), + # LayerV1List (b"\x00\x00\x00\x04", "LayerV1List.LayerCount (4)"), ( b"\x00\x00\x00\x14", @@ -286,7 +317,7 @@ COLR_V1_XML = [ "", "", "", - " ", + " ", ' ', ' ', ' ', @@ -316,6 +347,36 @@ COLR_V1_XML = [ " ", " ", " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + " ", + ' ', + ' ', + " ", + " ", + ' ', + ' ', + " ", + ' ', + ' ', + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + " ", "", "", " ", From f416a5cb175d40e63ba5e0e482a1d01e25de285f Mon Sep 17 00:00:00 2001 From: justvanrossum Date: Wed, 3 Feb 2021 20:24:04 +0100 Subject: [PATCH 066/167] fix Vector division --- Lib/fontTools/misc/arrayTools.py | 4 ++-- Tests/misc/arrayTools_test.py | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/misc/arrayTools.py b/Lib/fontTools/misc/arrayTools.py index 81b2418dc..e76ced7f8 100644 --- a/Lib/fontTools/misc/arrayTools.py +++ b/Lib/fontTools/misc/arrayTools.py @@ -313,9 +313,9 @@ class Vector(object): __rmul__ = __mul__ def __truediv__(self, other): - return Vector(self._scalarOp(other, operator.div), keep=True) + return Vector(self._scalarOp(other, operator.truediv), keep=True) def __itruediv__(self, other): - self.values = self._scalarOp(other, operator.div) + self.values = self._scalarOp(other, operator.truediv) return self def __pos__(self): diff --git a/Tests/misc/arrayTools_test.py b/Tests/misc/arrayTools_test.py index 127f153c8..73e0ab17e 100644 --- a/Tests/misc/arrayTools_test.py +++ b/Tests/misc/arrayTools_test.py @@ -1,7 +1,7 @@ from fontTools.misc.py23 import * from fontTools.misc.py23 import round3 from fontTools.misc.arrayTools import ( - calcBounds, calcIntBounds, updateBounds, pointInRect, pointsInRect, + Vector, calcBounds, calcIntBounds, updateBounds, pointInRect, pointsInRect, vectorLength, asInt16, normRect, scaleRect, offsetRect, insetRect, sectRect, unionRect, rectCenter, intRect) import math @@ -88,3 +88,14 @@ def test_rectCenter(): def test_intRect(): assert intRect((0.9, 2.9, 3.1, 4.1)) == (0, 2, 4, 5) + + +def test_Vector(): + v = Vector([100, 200]) + assert v == Vector([100, 200]) + assert v == [100, 200] + assert v + Vector([1, 2]) == [101, 202] + assert v - Vector([1, 2]) == [99, 198] + assert v * 2 == [200, 400] + assert v * 0.5 == [50, 100] + assert v / 2 == [50, 100] From a3d13abcffa52445f229b4f39acf556d9abccdc9 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 4 Feb 2021 11:32:22 +0000 Subject: [PATCH 067/167] otData: fix typo, add comments --- Lib/fontTools/ttLib/tables/otData.py | 34 ++++++++++++++-------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py index 59ff40f65..389ac5c42 100755 --- a/Lib/fontTools/ttLib/tables/otData.py +++ b/Lib/fontTools/ttLib/tables/otData.py @@ -1612,21 +1612,21 @@ otData = [ ('uint16', 'StopCount', None, None, 'Number of Color stops.'), ('ColorStop', 'ColorStop', 'StopCount', 0, 'Array of Color stops.'), ]), - + # PaintColrLayers ('PaintFormat1', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 1'), ('uint8', 'NumLayers', None, None, 'Number of offsets to Paint to read from LayerV1List.'), ('uint32', 'FirstLayerIndex', None, None, 'Index into LayerV1List.'), ]), - + # PaintSolid ('PaintFormat2', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 2'), ('ColorIndex', 'Color', None, None, 'A solid color paint.'), ]), - + # PaintLinearGradient ('PaintFormat3', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 3'), - ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of Paint table) to ColorLine subtable.'), + ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of PaintLinearGradient table) to ColorLine subtable.'), ('VarInt16', 'x0', None, None, ''), ('VarInt16', 'y0', None, None, ''), ('VarInt16', 'x1', None, None, ''), @@ -1634,10 +1634,10 @@ otData = [ ('VarInt16', 'x2', None, None, ''), ('VarInt16', 'y2', None, None, ''), ]), - + # PaintRadialGradient ('PaintFormat4', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 4'), - ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of Paint table) to ColorLine subtable.'), + ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of PaintRadialGradient table) to ColorLine subtable.'), ('VarInt16', 'x0', None, None, ''), ('VarInt16', 'y0', None, None, ''), ('VarUInt16', 'r0', None, None, ''), @@ -1645,40 +1645,40 @@ otData = [ ('VarInt16', 'y1', None, None, ''), ('VarUInt16', 'r1', None, None, ''), ]), - + # PaintSweepGradient ('PaintFormat5', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 5'), - ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of PaintSweep table) to ColorLine subtable.'), + ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of PaintSweepGradient table) to ColorLine subtable.'), ('VarInt16', 'centerX', None, None, 'Center x coordinate.'), ('VarInt16', 'centerY', None, None, 'Center y coordinate.'), ('VarFixed', 'startAngle', None, None, 'Start of the angular range of the gradient.'), ('VarFixed', 'endAngle', None, None, 'End of the angular range of the gradient.'), ]), - + # PaintGlyph ('PaintFormat6', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 6'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintGlyph table) to Paint subtable.'), ('GlyphID', 'Glyph', None, None, 'Glyph ID for the source outline.'), ]), - + # PaintColrGlyph ('PaintFormat7', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 7'), ('GlyphID', 'Glyph', None, None, 'Virtual glyph ID for a BaseGlyphV1List base glyph.'), ]), - + # PaintTransform ('PaintFormat8', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 8'), - ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTransformed table) to Paint subtable.'), - ('Affine2x3', 'Transform', None, None, 'Offset (from beginning of PaintTrasformed table) to Affine2x3 subtable.'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTransform table) to Paint subtable.'), + ('Affine2x3', 'Transform', None, None, '2x3 matrix for 2D affine transformations.'), ]), - + # PaintTranslate ('PaintFormat9', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 9'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTranslate table) to Paint subtable.'), ('VarFixed', 'dx', None, None, 'Translation in x direction.'), ('VarFixed', 'dy', None, None, 'Translation in y direction.'), ]), - + # PaintRotate ('PaintFormat10', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 10'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintRotate table) to Paint subtable.'), @@ -1686,7 +1686,7 @@ otData = [ ('VarFixed', 'centerX', None, None, ''), ('VarFixed', 'centerY', None, None, ''), ]), - + # PaintSkew ('PaintFormat11', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 11'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintSkew table) to Paint subtable.'), @@ -1695,7 +1695,7 @@ otData = [ ('VarFixed', 'centerX', None, None, ''), ('VarFixed', 'centerY', None, None, ''), ]), - + # PaintComposite ('PaintFormat12', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 12'), ('LOffset24To(Paint)', 'SourcePaint', None, None, 'Offset (from beginning of PaintComposite table) to source Paint subtable.'), From e20ccfcf9f77404317ac5334f375feb5ab45077f Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 4 Feb 2021 12:16:29 +0000 Subject: [PATCH 068/167] add unbuildPaintSweepGradient --- Lib/fontTools/colorLib/unbuilder.py | 12 ++++++++++++ Tests/colorLib/unbuilder_test.py | 20 +++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/colorLib/unbuilder.py b/Lib/fontTools/colorLib/unbuilder.py index 6b7a09b30..f7c745984 100644 --- a/Lib/fontTools/colorLib/unbuilder.py +++ b/Lib/fontTools/colorLib/unbuilder.py @@ -113,6 +113,18 @@ class LayerV1ListUnbuilder: "r1": r1, } + def unbuildPaintSweepGradient(self, paint): + return { + "format": int(ot.Paint.Format.PaintSweepGradient), + "colorLine": unbuildColorLine( + paint.ColorLine, ignoreVarIdx=self.ignoreVarIdx + ), + "centerX": self.unbuildVariableValue(paint.centerX), + "centerY": self.unbuildVariableValue(paint.centerY), + "startAngle": self.unbuildVariableValue(paint.startAngle), + "endAngle": self.unbuildVariableValue(paint.endAngle), + } + def unbuildPaintGlyph(self, paint): return { "format": int(ot.Paint.Format.PaintGlyph), diff --git a/Tests/colorLib/unbuilder_test.py b/Tests/colorLib/unbuilder_test.py index 9d115b415..9ba332018 100644 --- a/Tests/colorLib/unbuilder_test.py +++ b/Tests/colorLib/unbuilder_test.py @@ -103,7 +103,25 @@ TEST_COLOR_GLYPHS = { }, }, }, - "glyph00015": [ + "glyph00015": { + "format": int(ot.Paint.Format.PaintGlyph), + "glyph": "glyph00011", + "paint": { + "format": int(ot.Paint.Format.PaintSweepGradient), + "colorLine": { + "stops": [ + {"offset": 0.0, "paletteIndex": 3, "alpha": 1.0}, + {"offset": 1.0, "paletteIndex": 5, "alpha": 1.0}, + ], + "extend": "pad", + }, + "centerX": 259, + "centerY": 300, + "startAngle": 45.0, + "endAngle": 135.0, + }, + }, + "glyph00016": [ { "format": int(ot.Paint.Format.PaintGlyph), "glyph": "glyph00011", From 4b173013211fcf5405a5c084134a527b81a8d4da Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 4 Feb 2021 12:24:13 +0000 Subject: [PATCH 069/167] unbuilder: get Format attribute directly from the Paint instance --- Lib/fontTools/colorLib/unbuilder.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Lib/fontTools/colorLib/unbuilder.py b/Lib/fontTools/colorLib/unbuilder.py index f7c745984..e15199d0f 100644 --- a/Lib/fontTools/colorLib/unbuilder.py +++ b/Lib/fontTools/colorLib/unbuilder.py @@ -88,7 +88,7 @@ class LayerV1ListUnbuilder: p1 = (self.unbuildVariableValue(paint.x1), self.unbuildVariableValue(paint.y1)) p2 = (self.unbuildVariableValue(paint.x2), self.unbuildVariableValue(paint.y2)) return { - "format": int(ot.Paint.Format.PaintLinearGradient), + "format": int(paint.Format), "colorLine": unbuildColorLine( paint.ColorLine, ignoreVarIdx=self.ignoreVarIdx ), @@ -103,7 +103,7 @@ class LayerV1ListUnbuilder: c1 = (self.unbuildVariableValue(paint.x1), self.unbuildVariableValue(paint.y1)) r1 = self.unbuildVariableValue(paint.r1) return { - "format": int(ot.Paint.Format.PaintRadialGradient), + "format": int(paint.Format), "colorLine": unbuildColorLine( paint.ColorLine, ignoreVarIdx=self.ignoreVarIdx ), @@ -115,7 +115,7 @@ class LayerV1ListUnbuilder: def unbuildPaintSweepGradient(self, paint): return { - "format": int(ot.Paint.Format.PaintSweepGradient), + "format": int(paint.Format), "colorLine": unbuildColorLine( paint.ColorLine, ignoreVarIdx=self.ignoreVarIdx ), @@ -127,20 +127,20 @@ class LayerV1ListUnbuilder: def unbuildPaintGlyph(self, paint): return { - "format": int(ot.Paint.Format.PaintGlyph), + "format": int(paint.Format), "glyph": paint.Glyph, "paint": self.unbuildPaint(paint.Paint), } def unbuildPaintColrGlyph(self, paint): return { - "format": int(ot.Paint.Format.PaintColrGlyph), + "format": int(paint.Format), "glyph": paint.Glyph, } def unbuildPaintTransform(self, paint): return { - "format": int(ot.Paint.Format.PaintTransform), + "format": int(paint.Format), "transform": unbuildAffine2x3( paint.Transform, ignoreVarIdx=self.ignoreVarIdx ), @@ -149,7 +149,7 @@ class LayerV1ListUnbuilder: def unbuildPaintTranslate(self, paint): return { - "format": int(ot.Paint.Format.PaintTranslate), + "format": int(paint.Format), "dx": self.unbuildVariableValue(paint.dx), "dy": self.unbuildVariableValue(paint.dy), "paint": self.unbuildPaint(paint.Paint), @@ -157,7 +157,7 @@ class LayerV1ListUnbuilder: def unbuildPaintRotate(self, paint): return { - "format": int(ot.Paint.Format.PaintRotate), + "format": int(paint.Format), "angle": self.unbuildVariableValue(paint.angle), "centerX": self.unbuildVariableValue(paint.centerX), "centerY": self.unbuildVariableValue(paint.centerY), @@ -166,7 +166,7 @@ class LayerV1ListUnbuilder: def unbuildPaintSkew(self, paint): return { - "format": int(ot.Paint.Format.PaintSkew), + "format": int(paint.Format), "xSkewAngle": self.unbuildVariableValue(paint.xSkewAngle), "ySkewAngle": self.unbuildVariableValue(paint.ySkewAngle), "centerX": self.unbuildVariableValue(paint.centerX), @@ -176,7 +176,7 @@ class LayerV1ListUnbuilder: def unbuildPaintComposite(self, paint): return { - "format": int(ot.Paint.Format.PaintComposite), + "format": int(paint.Format), "mode": paint.CompositeMode.name.lower(), "source": self.unbuildPaint(paint.SourcePaint), "backdrop": self.unbuildPaint(paint.BackdropPaint), From 82c32cbf8852f2d2b6244d6c94a18b8812d1474a Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 5 Feb 2021 10:36:12 +0000 Subject: [PATCH 070/167] C_O_L_R_test: fix incorrect NumLayers there's actually 3 LayerRecords defined in the COLR_V1_SAMPLE, not 4. --- Tests/ttLib/tables/C_O_L_R_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/ttLib/tables/C_O_L_R_test.py b/Tests/ttLib/tables/C_O_L_R_test.py index 0560084be..c9bacd35e 100644 --- a/Tests/ttLib/tables/C_O_L_R_test.py +++ b/Tests/ttLib/tables/C_O_L_R_test.py @@ -110,7 +110,7 @@ COLR_V1_SAMPLE = ( (b"\x00\x00\x00\x00", "Offset to VarStore (NULL)"), (b"\x00\x06", "BaseGlyphRecord[0].BaseGlyph (6)"), (b"\x00\x00", "BaseGlyphRecord[0].FirstLayerIndex (0)"), - (b"\x00\x04", "BaseGlyphRecord[0].NumLayers (4)"), + (b"\x00\x03", "BaseGlyphRecord[0].NumLayers (3)"), (b"\x00\x07", "LayerRecord[0].LayerGlyph (7)"), (b"\x00\x00", "LayerRecord[0].PaletteIndex (0)"), (b"\x00\x08", "LayerRecord[1].LayerGlyph (8)"), @@ -298,7 +298,7 @@ COLR_V1_XML = [ ' ', ' ', ' ', - ' ', + ' ', " ", "", "", From df672a7ae413422cb2d639cb4ed530283682f6f3 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 5 Feb 2021 12:11:43 +0000 Subject: [PATCH 071/167] move PaintFormat enum outside of Paint class nested scope When a TTFont is loaded with lazy=True, the otTables are only loaded upon BaseTable.__getattr__ when the requested attribute is not found in the instance __dict__. Since the Paint.Format enum was defined at class level, every Paint instance, even when loaded lazily, will have a 'Format' attribute and the magic decompile-on-missing-attribute will not trigger, since the class attribute will be returned when the instance is missing one. For this reason, and to not add further special cases, it's better to simply move this Paint.Format enum class outside to the module level scope, and rename it PaintFormat. --- Lib/fontTools/colorLib/builder.py | 30 +++++----- Lib/fontTools/colorLib/unbuilder.py | 2 +- Lib/fontTools/ttLib/tables/otTables.py | 31 ++++++----- Tests/colorLib/builder_test.py | 76 +++++++++++++------------- Tests/colorLib/unbuilder_test.py | 44 +++++++-------- 5 files changed, 92 insertions(+), 91 deletions(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 5a52edbc9..840f8bca6 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -492,7 +492,7 @@ class LayerV1ListBuilder: self, paletteIndex: int, alpha: _ScalarInput = _DEFAULT_ALPHA ) -> ot.Paint: ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintSolid) + ot_paint.Format = int(ot.PaintFormat.PaintSolid) ot_paint.Color = buildColorIndex(paletteIndex, alpha) return ot_paint @@ -504,7 +504,7 @@ class LayerV1ListBuilder: p2: Optional[_PointTuple] = None, ) -> ot.Paint: ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintLinearGradient) + ot_paint.Format = int(ot.PaintFormat.PaintLinearGradient) ot_paint.ColorLine = _to_color_line(colorLine) if p2 is None: @@ -525,7 +525,7 @@ class LayerV1ListBuilder: ) -> ot.Paint: ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintRadialGradient) + ot_paint.Format = int(ot.PaintFormat.PaintRadialGradient) ot_paint.ColorLine = _to_color_line(colorLine) # normalize input types (which may or may not specify a varIdx) @@ -558,7 +558,7 @@ class LayerV1ListBuilder: endAngle: _ScalarInput, ) -> ot.Paint: ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintSweepGradient) + ot_paint.Format = int(ot.PaintFormat.PaintSweepGradient) ot_paint.ColorLine = _to_color_line(colorLine) ot_paint.centerX = _to_variable_int16(centerX) ot_paint.centerY = _to_variable_int16(centerY) @@ -568,14 +568,14 @@ class LayerV1ListBuilder: def buildPaintGlyph(self, glyph: str, paint: _PaintInput) -> ot.Paint: ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintGlyph) + ot_paint.Format = int(ot.PaintFormat.PaintGlyph) ot_paint.Glyph = glyph ot_paint.Paint = self.buildPaint(paint) return ot_paint def buildPaintColrGlyph(self, glyph: str) -> ot.Paint: ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintColrGlyph) + ot_paint.Format = int(ot.PaintFormat.PaintColrGlyph) ot_paint.Glyph = glyph return ot_paint @@ -583,7 +583,7 @@ class LayerV1ListBuilder: self, transform: _AffineInput, paint: _PaintInput ) -> ot.Paint: ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintTransform) + ot_paint.Format = int(ot.PaintFormat.PaintTransform) if not isinstance(transform, ot.Affine2x3): transform = buildAffine2x3(transform) ot_paint.Transform = transform @@ -594,7 +594,7 @@ class LayerV1ListBuilder: self, paint: _PaintInput, dx: _ScalarInput, dy: _ScalarInput ): ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintTranslate) + ot_paint.Format = int(ot.PaintFormat.PaintTranslate) ot_paint.Paint = self.buildPaint(paint) ot_paint.dx = _to_variable_f16dot16_float(dx) ot_paint.dy = _to_variable_f16dot16_float(dy) @@ -608,7 +608,7 @@ class LayerV1ListBuilder: centerY: _ScalarInput, ) -> ot.Paint: ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintRotate) + ot_paint.Format = int(ot.PaintFormat.PaintRotate) ot_paint.Paint = self.buildPaint(paint) ot_paint.angle = _to_variable_f16dot16_float(angle) ot_paint.centerX = _to_variable_f16dot16_float(centerX) @@ -624,7 +624,7 @@ class LayerV1ListBuilder: centerY: _ScalarInput, ) -> ot.Paint: ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintSkew) + ot_paint.Format = int(ot.PaintFormat.PaintSkew) ot_paint.Paint = self.buildPaint(paint) ot_paint.xSkewAngle = _to_variable_f16dot16_float(xSkewAngle) ot_paint.ySkewAngle = _to_variable_f16dot16_float(ySkewAngle) @@ -639,7 +639,7 @@ class LayerV1ListBuilder: backdrop: _PaintInput, ): ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintComposite) + ot_paint.Format = int(ot.PaintFormat.PaintComposite) ot_paint.SourcePaint = self.buildPaint(source) ot_paint.CompositeMode = _to_composite_mode(mode) ot_paint.BackdropPaint = self.buildPaint(backdrop) @@ -647,7 +647,7 @@ class LayerV1ListBuilder: def buildColrLayers(self, paints: List[_PaintInput]) -> ot.Paint: ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintColrLayers) + ot_paint.Format = int(ot.PaintFormat.PaintColrLayers) self.slices.append(ot_paint) paints = [ @@ -672,7 +672,7 @@ class LayerV1ListBuilder: if reuse_lbound == -1: continue new_slice = ot.Paint() - new_slice.Format = int(ot.Paint.Format.PaintColrLayers) + new_slice.Format = int(ot.PaintFormat.PaintColrLayers) new_slice.NumLayers = ubound - lbound new_slice.FirstLayerIndex = reuse_lbound paints = paints[:lbound] + [new_slice] + paints[ubound:] @@ -726,8 +726,8 @@ class LayerV1ListBuilder: LayerV1ListBuilder._buildFunctions = { pf.value: getattr(LayerV1ListBuilder, "build" + pf.name) - for pf in ot.Paint.Format - if pf != ot.Paint.Format.PaintColrLayers + for pf in ot.PaintFormat + if pf != ot.PaintFormat.PaintColrLayers } diff --git a/Lib/fontTools/colorLib/unbuilder.py b/Lib/fontTools/colorLib/unbuilder.py index e15199d0f..1c29d5dd2 100644 --- a/Lib/fontTools/colorLib/unbuilder.py +++ b/Lib/fontTools/colorLib/unbuilder.py @@ -185,7 +185,7 @@ class LayerV1ListUnbuilder: LayerV1ListUnbuilder._unbuildFunctions = { pf.value: getattr(LayerV1ListUnbuilder, "unbuild" + pf.name) - for pf in ot.Paint.Format + for pf in ot.PaintFormat } diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py index ec5c5db4a..f3401a705 100644 --- a/Lib/fontTools/ttLib/tables/otTables.py +++ b/Lib/fontTools/ttLib/tables/otTables.py @@ -1324,25 +1324,26 @@ class CompositeMode(IntEnum): HSL_LUMINOSITY = 26 -class Paint(getFormatSwitchingBaseTableClass("uint8")): +class PaintFormat(IntEnum): + PaintColrLayers = 1 + PaintSolid = 2 + PaintLinearGradient = 3 + PaintRadialGradient = 4 + PaintSweepGradient = 5 + PaintGlyph = 6 + PaintColrGlyph = 7 + PaintTransform = 8 + PaintTranslate = 9 + PaintRotate = 10 + PaintSkew = 11 + PaintComposite = 12 - class Format(IntEnum): - PaintColrLayers = 1 - PaintSolid = 2 - PaintLinearGradient = 3 - PaintRadialGradient = 4 - PaintSweepGradient = 5 - PaintGlyph = 6 - PaintColrGlyph = 7 - PaintTransform = 8 - PaintTranslate = 9 - PaintRotate = 10 - PaintSkew = 11 - PaintComposite = 12 + +class Paint(getFormatSwitchingBaseTableClass("uint8")): def getFormatName(self): try: - return self.__class__.Format(self.Format).name + return PaintFormat(self.Format).name except ValueError: raise NotImplementedError(f"Unknown Paint format: {self.Format}") diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index 73d6089ca..a705b38f3 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -241,13 +241,13 @@ def test_buildColorIndex(): def test_buildPaintSolid(): p = LayerV1ListBuilder().buildPaintSolid(0) - assert p.Format == ot.Paint.Format.PaintSolid + assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 0 assert p.Color.Alpha.value == 1.0 assert p.Color.Alpha.varIdx == 0 p = LayerV1ListBuilder().buildPaintSolid(1, alpha=0.5) - assert p.Format == ot.Paint.Format.PaintSolid + assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 1 assert p.Color.Alpha.value == 0.5 assert p.Color.Alpha.varIdx == 0 @@ -255,7 +255,7 @@ def test_buildPaintSolid(): p = LayerV1ListBuilder().buildPaintSolid( 3, alpha=builder.VariableFloat(0.5, varIdx=2) ) - assert p.Format == ot.Paint.Format.PaintSolid + assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 3 assert p.Color.Alpha.value == 0.5 assert p.Color.Alpha.varIdx == 2 @@ -371,7 +371,7 @@ def test_buildPaintRadialGradient(): r1 = builder.VariableInt(5) gradient = layerBuilder.buildPaintRadialGradient(color_line, c0, c1, r0, r1) - assert gradient.Format == ot.Paint.Format.PaintRadialGradient + assert gradient.Format == ot.PaintFormat.PaintRadialGradient assert gradient.ColorLine == color_line assert (gradient.x0, gradient.y0) == c0 assert (gradient.x1, gradient.y1) == c1 @@ -401,7 +401,7 @@ def test_buildPaintSweepGradient(): endAngle=42, ) - assert paint.Format == ot.Paint.Format.PaintSweepGradient + assert paint.Format == ot.PaintFormat.PaintSweepGradient assert paint.centerX.value == 127 assert paint.centerY.value == 129 assert paint.startAngle.value == 15 @@ -412,11 +412,11 @@ def test_buildPaintGlyph_Solid(): layerBuilder = LayerV1ListBuilder() layer = layerBuilder.buildPaintGlyph("a", 2) assert layer.Glyph == "a" - assert layer.Paint.Format == ot.Paint.Format.PaintSolid + assert layer.Paint.Format == ot.PaintFormat.PaintSolid assert layer.Paint.Color.PaletteIndex == 2 layer = layerBuilder.buildPaintGlyph("a", layerBuilder.buildPaintSolid(3, 0.9)) - assert layer.Paint.Format == ot.Paint.Format.PaintSolid + assert layer.Paint.Format == ot.PaintFormat.PaintSolid assert layer.Paint.Color.PaletteIndex == 3 assert layer.Paint.Color.Alpha.value == 0.9 @@ -429,7 +429,7 @@ def test_buildPaintGlyph_LinearGradient(): {"stops": [(0.0, 3), (1.0, 4)]}, (100, 200), (150, 250) ), ) - assert layer.Paint.Format == ot.Paint.Format.PaintLinearGradient + assert layer.Paint.Format == ot.PaintFormat.PaintLinearGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 assert layer.Paint.ColorLine.ColorStop[0].Color.PaletteIndex == 3 assert layer.Paint.ColorLine.ColorStop[1].StopOffset.value == 1.0 @@ -458,7 +458,7 @@ def test_buildPaintGlyph_RadialGradient(): 10, ), ) - assert layer.Paint.Format == ot.Paint.Format.PaintRadialGradient + assert layer.Paint.Format == ot.PaintFormat.PaintRadialGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 assert layer.Paint.ColorLine.ColorStop[0].Color.PaletteIndex == 5 assert layer.Paint.ColorLine.ColorStop[1].StopOffset.value == 0.5 @@ -478,7 +478,7 @@ def test_buildPaintGlyph_Dict_Solid(): layerBuilder = LayerV1ListBuilder() layer = layerBuilder.buildPaintGlyph("a", {"format": 2, "paletteIndex": 0}) assert layer.Glyph == "a" - assert layer.Paint.Format == ot.Paint.Format.PaintSolid + assert layer.Paint.Format == ot.PaintFormat.PaintSolid assert layer.Paint.Color.PaletteIndex == 0 @@ -493,7 +493,7 @@ def test_buildPaintGlyph_Dict_LinearGradient(): "p1": (10, 10), }, ) - assert layer.Paint.Format == ot.Paint.Format.PaintLinearGradient + assert layer.Paint.Format == ot.PaintFormat.PaintLinearGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 @@ -510,13 +510,13 @@ def test_buildPaintGlyph_Dict_RadialGradient(): "r1": 0, }, ) - assert layer.Paint.Format == ot.Paint.Format.PaintRadialGradient + assert layer.Paint.Format == ot.PaintFormat.PaintRadialGradient assert layer.Paint.r0.value == 4 def test_buildPaintColrGlyph(): paint = LayerV1ListBuilder().buildPaintColrGlyph("a") - assert paint.Format == ot.Paint.Format.PaintColrGlyph + assert paint.Format == ot.PaintFormat.PaintColrGlyph assert paint.Glyph == "a" @@ -530,9 +530,9 @@ def test_buildPaintTransform(): ), ) - assert paint.Format == ot.Paint.Format.PaintTransform - assert paint.Paint.Format == ot.Paint.Format.PaintGlyph - assert paint.Paint.Paint.Format == ot.Paint.Format.PaintSolid + assert paint.Format == ot.PaintFormat.PaintTransform + assert paint.Paint.Format == ot.PaintFormat.PaintGlyph + assert paint.Paint.Paint.Format == ot.PaintFormat.PaintSolid assert paint.Transform.xx.value == 1.0 assert paint.Transform.yx.value == 2.0 @@ -553,14 +553,14 @@ def test_buildPaintTransform(): }, ) - assert paint.Format == ot.Paint.Format.PaintTransform + assert paint.Format == ot.PaintFormat.PaintTransform assert paint.Transform.xx.value == 1.0 assert paint.Transform.yx.value == 0.0 assert paint.Transform.xy.value == 0.0 assert paint.Transform.yy.value == 0.3333 assert paint.Transform.dx.value == 10 assert paint.Transform.dy.value == 10 - assert paint.Paint.Format == ot.Paint.Format.PaintRadialGradient + assert paint.Paint.Format == ot.PaintFormat.PaintRadialGradient def test_buildPaintComposite(): @@ -578,23 +578,23 @@ def test_buildPaintComposite(): ), ) - assert composite.Format == ot.Paint.Format.PaintComposite - assert composite.SourcePaint.Format == ot.Paint.Format.PaintComposite - assert composite.SourcePaint.SourcePaint.Format == ot.Paint.Format.PaintGlyph + assert composite.Format == ot.PaintFormat.PaintComposite + assert composite.SourcePaint.Format == ot.PaintFormat.PaintComposite + assert composite.SourcePaint.SourcePaint.Format == ot.PaintFormat.PaintGlyph assert composite.SourcePaint.SourcePaint.Glyph == "c" - assert composite.SourcePaint.SourcePaint.Paint.Format == ot.Paint.Format.PaintSolid + assert composite.SourcePaint.SourcePaint.Paint.Format == ot.PaintFormat.PaintSolid assert composite.SourcePaint.SourcePaint.Paint.Color.PaletteIndex == 2 assert composite.SourcePaint.CompositeMode == ot.CompositeMode.SRC_OVER - assert composite.SourcePaint.BackdropPaint.Format == ot.Paint.Format.PaintGlyph + assert composite.SourcePaint.BackdropPaint.Format == ot.PaintFormat.PaintGlyph assert composite.SourcePaint.BackdropPaint.Glyph == "b" assert ( - composite.SourcePaint.BackdropPaint.Paint.Format == ot.Paint.Format.PaintSolid + composite.SourcePaint.BackdropPaint.Paint.Format == ot.PaintFormat.PaintSolid ) assert composite.SourcePaint.BackdropPaint.Paint.Color.PaletteIndex == 1 assert composite.CompositeMode == ot.CompositeMode.SRC_OVER - assert composite.BackdropPaint.Format == ot.Paint.Format.PaintGlyph + assert composite.BackdropPaint.Format == ot.PaintFormat.PaintGlyph assert composite.BackdropPaint.Glyph == "a" - assert composite.BackdropPaint.Paint.Format == ot.Paint.Format.PaintSolid + assert composite.BackdropPaint.Paint.Format == ot.PaintFormat.PaintSolid assert composite.BackdropPaint.Paint.Color.PaletteIndex == 0 @@ -608,8 +608,8 @@ def test_buildPaintTranslate(): dy=-345, ) - assert paint.Format == ot.Paint.Format.PaintTranslate - assert paint.Paint.Format == ot.Paint.Format.PaintGlyph + assert paint.Format == ot.PaintFormat.PaintTranslate + assert paint.Paint.Format == ot.PaintFormat.PaintGlyph assert paint.dx.value == 123 assert paint.dy.value == -345 @@ -625,8 +625,8 @@ def test_buildPaintRotate(): centerY=129, ) - assert paint.Format == ot.Paint.Format.PaintRotate - assert paint.Paint.Format == ot.Paint.Format.PaintGlyph + assert paint.Format == ot.PaintFormat.PaintRotate + assert paint.Paint.Format == ot.PaintFormat.PaintGlyph assert paint.angle.value == 15 assert paint.centerX.value == 127 assert paint.centerY.value == 129 @@ -644,8 +644,8 @@ def test_buildPaintSkew(): centerY=129, ) - assert paint.Format == ot.Paint.Format.PaintSkew - assert paint.Paint.Format == ot.Paint.Format.PaintGlyph + assert paint.Format == ot.PaintFormat.PaintSkew + assert paint.Paint.Format == ot.PaintFormat.PaintGlyph assert paint.xSkewAngle.value == 15 assert paint.ySkewAngle.value == 42 assert paint.centerX.value == 127 @@ -714,21 +714,21 @@ def test_buildColrV1_more_than_255_paints(): assert len(paints) == num_paints + 1 - assert all(paints[i].Format == ot.Paint.Format.PaintGlyph for i in range(255)) + assert all(paints[i].Format == ot.PaintFormat.PaintGlyph for i in range(255)) - assert paints[255].Format == ot.Paint.Format.PaintColrLayers + assert paints[255].Format == ot.PaintFormat.PaintColrLayers assert paints[255].FirstLayerIndex == 0 assert paints[255].NumLayers == 255 assert all( - paints[i].Format == ot.Paint.Format.PaintGlyph + paints[i].Format == ot.PaintFormat.PaintGlyph for i in range(256, num_paints + 1) ) assert baseGlyphs.BaseGlyphCount == len(colorGlyphs) assert baseGlyphs.BaseGlyphV1Record[0].BaseGlyph == "a" assert ( - baseGlyphs.BaseGlyphV1Record[0].Paint.Format == ot.Paint.Format.PaintColrLayers + baseGlyphs.BaseGlyphV1Record[0].Paint.Format == ot.PaintFormat.PaintColrLayers ) assert baseGlyphs.BaseGlyphV1Record[0].Paint.FirstLayerIndex == 255 assert baseGlyphs.BaseGlyphV1Record[0].Paint.NumLayers == num_paints + 1 - 255 @@ -841,9 +841,9 @@ def _paint_names(paints) -> List[str]: # semi-readable assertions on a LayerV1List order. result = [] for paint in paints: - if paint.Format == int(ot.Paint.Format.PaintGlyph): + if paint.Format == int(ot.PaintFormat.PaintGlyph): result.append(paint.Glyph) - elif paint.Format == int(ot.Paint.Format.PaintColrLayers): + elif paint.Format == int(ot.PaintFormat.PaintColrLayers): result.append( f"Layers[{paint.FirstLayerIndex}:{paint.FirstLayerIndex+paint.NumLayers}]" ) diff --git a/Tests/colorLib/unbuilder_test.py b/Tests/colorLib/unbuilder_test.py index 9ba332018..decd1856b 100644 --- a/Tests/colorLib/unbuilder_test.py +++ b/Tests/colorLib/unbuilder_test.py @@ -7,19 +7,19 @@ import pytest TEST_COLOR_GLYPHS = { "glyph00010": [ { - "format": int(ot.Paint.Format.PaintGlyph), + "format": int(ot.PaintFormat.PaintGlyph), "glyph": "glyph00011", "paint": { - "format": int(ot.Paint.Format.PaintSolid), + "format": int(ot.PaintFormat.PaintSolid), "paletteIndex": 2, "alpha": 0.5, }, }, { - "format": int(ot.Paint.Format.PaintGlyph), + "format": int(ot.PaintFormat.PaintGlyph), "glyph": "glyph00012", "paint": { - "format": int(ot.Paint.Format.PaintLinearGradient), + "format": int(ot.PaintFormat.PaintLinearGradient), "colorLine": { "stops": [ {"offset": 0.0, "paletteIndex": 3, "alpha": 1.0}, @@ -34,13 +34,13 @@ TEST_COLOR_GLYPHS = { }, }, { - "format": int(ot.Paint.Format.PaintGlyph), + "format": int(ot.PaintFormat.PaintGlyph), "glyph": "glyph00013", "paint": { - "format": int(ot.Paint.Format.PaintTransform), + "format": int(ot.PaintFormat.PaintTransform), "transform": (-13.0, 14.0, 15.0, -17.0, 18.0, 19.0), "paint": { - "format": int(ot.Paint.Format.PaintRadialGradient), + "format": int(ot.PaintFormat.PaintRadialGradient), "colorLine": { "stops": [ {"offset": 0.0, "paletteIndex": 6, "alpha": 1.0}, @@ -60,25 +60,25 @@ TEST_COLOR_GLYPHS = { }, }, { - "format": int(ot.Paint.Format.PaintTranslate), + "format": int(ot.PaintFormat.PaintTranslate), "dx": 257.0, "dy": 258.0, "paint": { - "format": int(ot.Paint.Format.PaintRotate), + "format": int(ot.PaintFormat.PaintRotate), "angle": 45.0, "centerX": 255.0, "centerY": 256.0, "paint": { - "format": int(ot.Paint.Format.PaintSkew), + "format": int(ot.PaintFormat.PaintSkew), "xSkewAngle": -11.0, "ySkewAngle": 5.0, "centerX": 253.0, "centerY": 254.0, "paint": { - "format": int(ot.Paint.Format.PaintGlyph), + "format": int(ot.PaintFormat.PaintGlyph), "glyph": "glyph00011", "paint": { - "format": int(ot.Paint.Format.PaintSolid), + "format": int(ot.PaintFormat.PaintSolid), "paletteIndex": 2, "alpha": 0.5, }, @@ -88,26 +88,26 @@ TEST_COLOR_GLYPHS = { }, ], "glyph00014": { - "format": int(ot.Paint.Format.PaintComposite), + "format": int(ot.PaintFormat.PaintComposite), "mode": "src_over", "source": { - "format": int(ot.Paint.Format.PaintColrGlyph), + "format": int(ot.PaintFormat.PaintColrGlyph), "glyph": "glyph00010", }, "backdrop": { - "format": int(ot.Paint.Format.PaintTransform), + "format": int(ot.PaintFormat.PaintTransform), "transform": (1.0, 0.0, 0.0, 1.0, 300.0, 0.0), "paint": { - "format": int(ot.Paint.Format.PaintColrGlyph), + "format": int(ot.PaintFormat.PaintColrGlyph), "glyph": "glyph00010", }, }, }, "glyph00015": { - "format": int(ot.Paint.Format.PaintGlyph), + "format": int(ot.PaintFormat.PaintGlyph), "glyph": "glyph00011", "paint": { - "format": int(ot.Paint.Format.PaintSweepGradient), + "format": int(ot.PaintFormat.PaintSweepGradient), "colorLine": { "stops": [ {"offset": 0.0, "paletteIndex": 3, "alpha": 1.0}, @@ -123,19 +123,19 @@ TEST_COLOR_GLYPHS = { }, "glyph00016": [ { - "format": int(ot.Paint.Format.PaintGlyph), + "format": int(ot.PaintFormat.PaintGlyph), "glyph": "glyph00011", "paint": { - "format": int(ot.Paint.Format.PaintSolid), + "format": int(ot.PaintFormat.PaintSolid), "paletteIndex": 2, "alpha": 0.5, }, }, { - "format": int(ot.Paint.Format.PaintGlyph), + "format": int(ot.PaintFormat.PaintGlyph), "glyph": "glyph00012", "paint": { - "format": int(ot.Paint.Format.PaintLinearGradient), + "format": int(ot.PaintFormat.PaintLinearGradient), "colorLine": { "stops": [ {"offset": 0.0, "paletteIndex": 3, "alpha": 1.0}, From 9cb126b10ad5b26c9c0efe1e7a1d2e5efd8df775 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Mon, 8 Feb 2021 11:30:39 +0000 Subject: [PATCH 072/167] Document expected arguments for _TTGlyphSet, _TTGlyph and TTGlyphPen --- Doc/source/ttLib/ttFont.rst | 3 ++- Lib/fontTools/pens/ttGlyphPen.py | 39 +++++++++++++++++++++----------- Lib/fontTools/ttLib/ttFont.py | 16 ++++++++++++- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/Doc/source/ttLib/ttFont.rst b/Doc/source/ttLib/ttFont.rst index 77882cd30..a571050c8 100644 --- a/Doc/source/ttLib/ttFont.rst +++ b/Doc/source/ttLib/ttFont.rst @@ -5,4 +5,5 @@ ttFont .. automodule:: fontTools.ttLib.ttFont :inherited-members: :members: - :undoc-members: \ No newline at end of file + :undoc-members: + :private-members: diff --git a/Lib/fontTools/pens/ttGlyphPen.py b/Lib/fontTools/pens/ttGlyphPen.py index 0b64cb380..866298be0 100644 --- a/Lib/fontTools/pens/ttGlyphPen.py +++ b/Lib/fontTools/pens/ttGlyphPen.py @@ -15,22 +15,34 @@ __all__ = ["TTGlyphPen"] class TTGlyphPen(LoggingPen): """Pen used for drawing to a TrueType glyph. - If `handleOverflowingTransforms` is True, the components' transform values - are checked that they don't overflow the limits of a F2Dot14 number: - -2.0 <= v < +2.0. If any transform value exceeds these, the composite - glyph is decomposed. - An exception to this rule is done for values that are very close to +2.0 - (both for consistency with the -2.0 case, and for the relative frequency - these occur in real fonts). When almost +2.0 values occur (and all other - values are within the range -2.0 <= x <= +2.0), they are clamped to the - maximum positive value that can still be encoded as an F2Dot14: i.e. - 1.99993896484375. - If False, no check is done and all components are translated unmodified - into the glyf table, followed by an inevitable `struct.error` once an - attempt is made to compile them. + This pen can be used to construct or modify glyphs in a TrueType format + font. After using the pen to draw, use the ``.glyph()`` method to retrieve + a :py:class:`~._g_l_y_f.Glyph` object representing the glyph. """ def __init__(self, glyphSet, handleOverflowingTransforms=True): + """Construct a new pen. + + Args: + glyphSet (ttLib._TTGlyphSet): A glyphset object, used to resolve components. + handleOverflowingTransforms (bool): See below. + + If ``handleOverflowingTransforms`` is True, the components' transform values + are checked that they don't overflow the limits of a F2Dot14 number: + -2.0 <= v < +2.0. If any transform value exceeds these, the composite + glyph is decomposed. + + An exception to this rule is done for values that are very close to +2.0 + (both for consistency with the -2.0 case, and for the relative frequency + these occur in real fonts). When almost +2.0 values occur (and all other + values are within the range -2.0 <= x <= +2.0), they are clamped to the + maximum positive value that can still be encoded as an F2Dot14: i.e. + 1.99993896484375. + + If False, no check is done and all components are translated unmodified + into the glyf table, followed by an inevitable ``struct.error`` once an + attempt is made to compile them. + """ self.glyphSet = glyphSet self.handleOverflowingTransforms = handleOverflowingTransforms self.init() @@ -136,6 +148,7 @@ class TTGlyphPen(LoggingPen): return components def glyph(self, componentFlags=0x4): + """Returns a :py:class:`~._g_l_y_f.Glyph` object representing the glyph.""" assert self._isClosed(), "Didn't close last contour." components = self._buildComponents(componentFlags) diff --git a/Lib/fontTools/ttLib/ttFont.py b/Lib/fontTools/ttLib/ttFont.py index ed1ec5e29..811cf003a 100644 --- a/Lib/fontTools/ttLib/ttFont.py +++ b/Lib/fontTools/ttLib/ttFont.py @@ -700,6 +700,13 @@ class _TTGlyphSet(object): """ def __init__(self, ttFont, glyphs, glyphType): + """Construct a new glyphset. + + Args: + font (TTFont): The font object (used to get metrics). + glyphs (dict): A dictionary mapping glyph names to ``_TTGlyph`` objects. + glyphType (class): Either ``_TTGlyphCFF`` or ``_TTGlyphGlyf``. + """ self._glyphs = glyphs self._hmtx = ttFont['hmtx'] self._vmtx = ttFont['vmtx'] if 'vmtx' in ttFont else None @@ -740,6 +747,13 @@ class _TTGlyph(object): """ def __init__(self, glyphset, glyph, horizontalMetrics, verticalMetrics=None): + """Construct a new _TTGlyph. + + Args: + glyphset (_TTGlyphSet): A glyphset object used to resolve components. + glyph (ttLib.tables._g_l_y_f.Glyph): The glyph object. + horizontalMetrics (int, int): The glyph's width and left sidebearing. + """ self._glyphset = glyphset self._glyph = glyph self.width, self.lsb = horizontalMetrics @@ -749,7 +763,7 @@ class _TTGlyph(object): self.height, self.tsb = None, None def draw(self, pen): - """Draw the glyph onto Pen. See fontTools.pens.basePen for details + """Draw the glyph onto ``pen``. See fontTools.pens.basePen for details how that works. """ self._glyph.draw(pen) From cf4a4087be7628ed433778b94897d13374695df6 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Thu, 4 Feb 2021 21:57:20 -0800 Subject: [PATCH 073/167] Generic build fns --- Lib/fontTools/colorLib/builder.py | 467 ++++--------- Lib/fontTools/colorLib/table_builder.py | 194 ++++++ Lib/fontTools/ttLib/tables/otData.py | 9 + Tests/colorLib/builder_test.py | 881 +++++++++++++++--------- Tests/colorLib/unbuilder_test.py | 277 ++++---- 5 files changed, 1048 insertions(+), 780 deletions(-) create mode 100644 Lib/fontTools/colorLib/table_builder.py diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 840f8bca6..586935c3e 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -25,7 +25,6 @@ from fontTools.misc.fixedTools import fixedToFloat from fontTools.ttLib.tables import C_O_L_R_ from fontTools.ttLib.tables import C_P_A_L_ from fontTools.ttLib.tables import _n_a_m_e -from fontTools.ttLib.tables.otBase import BaseTable from fontTools.ttLib.tables import otTables as ot from fontTools.ttLib.tables.otTables import ( ExtendMode, @@ -36,6 +35,11 @@ from fontTools.ttLib.tables.otTables import ( ) from .errors import ColorLibError from .geometry import round_start_circle_stable_containment +from .table_builder import ( + convertTupleClass, + BuildCallback, + TableBuilder, +) # TODO move type aliases to colorLib.types? @@ -45,21 +49,63 @@ _PaintInput = Union[int, _Kwargs, ot.Paint, Tuple[str, "_PaintInput"]] _PaintInputList = Sequence[_PaintInput] _ColorGlyphsDict = Dict[str, Union[_PaintInputList, _PaintInput]] _ColorGlyphsV0Dict = Dict[str, Sequence[Tuple[str, int]]] -_Number = Union[int, float] -_ScalarInput = Union[_Number, VariableValue, Tuple[_Number, int]] -_ColorStopTuple = Tuple[_ScalarInput, int] -_ColorStopInput = Union[_ColorStopTuple, _Kwargs, ot.ColorStop] -_ColorStopsList = Sequence[_ColorStopInput] -_ExtendInput = Union[int, str, ExtendMode] -_CompositeInput = Union[int, str, CompositeMode] -_ColorLineInput = Union[_Kwargs, ot.ColorLine] -_PointTuple = Tuple[_ScalarInput, _ScalarInput] -_AffineTuple = Tuple[ - _ScalarInput, _ScalarInput, _ScalarInput, _ScalarInput, _ScalarInput, _ScalarInput -] -_AffineInput = Union[_AffineTuple, ot.Affine2x3] + MAX_PAINT_COLR_LAYER_COUNT = 255 +_DEFAULT_ALPHA = VariableFloat(1.0) + + +def _beforeBuildPaintRadialGradient(paint, source): + # normalize input types (which may or may not specify a varIdx) + x0 = convertTupleClass(VariableFloat, source.get("x0", 0.0)) + y0 = convertTupleClass(VariableFloat, source.get("y0", 0.0)) + r0 = convertTupleClass(VariableFloat, source.get("r0", 0.0)) + x1 = convertTupleClass(VariableFloat, source.get("x1", 0.0)) + y1 = convertTupleClass(VariableFloat, source.get("y1", 0.0)) + r1 = convertTupleClass(VariableFloat, source.get("r1", 0.0)) + + # TODO apparently no builder_test confirms this works (?) + + # avoid abrupt change after rounding when c0 is near c1's perimeter + c = round_start_circle_stable_containment( + (x0.value, y0.value), r0.value, (x1.value, y1.value), r1.value + ) + x0, y0 = x0._replace(value=c.centre[0]), y0._replace(value=c.centre[1]) + r0 = r0._replace(value=c.radius) + + # update source to ensure paint is built with corrected values + source["x0"] = x0 + source["y0"] = y0 + source["r0"] = r0 + source["x1"] = x1 + source["y1"] = y1 + source["r1"] = r1 + + return paint, source + + +def _defaultColorIndex(): + colorIndex = ot.ColorIndex() + colorIndex.Alpha = _DEFAULT_ALPHA + return colorIndex + + +def _defaultColorLine(): + colorLine = ot.ColorLine() + colorLine.Extend = ExtendMode.PAD + return colorLine + + +def _buildPaintCallbacks(): + return { + ( + BuildCallback.BEFORE_BUILD, + ot.Paint, + ot.PaintFormat.PaintRadialGradient, + ): _beforeBuildPaintRadialGradient, + (BuildCallback.CREATE_DEFAULT, ot.ColorIndex): _defaultColorIndex, + (BuildCallback.CREATE_DEFAULT, ot.ColorLine): _defaultColorLine, + } def populateCOLRv0( @@ -112,7 +158,6 @@ def buildCOLR( varStore: Optional[ot.VarStore] = None, ) -> C_O_L_R_.table_C_O_L_R_: """Build COLR table from color layers mapping. - Args: colorGlyphs: map of base glyph name to, either list of (layer glyph name, color palette index) tuples for COLRv0; or a single Paint (dict) or @@ -124,7 +169,6 @@ def buildCOLR( glyphMap: a map from glyph names to glyph indices, as returned from TTFont.getReverseGlyphMap(), to optionally sort base records by GID. varStore: Optional ItemVarationStore for deltas associated with v1 layer. - Return: A new COLR table. """ @@ -295,8 +339,6 @@ def buildCPAL( # COLR v1 tables # See draft proposal at: https://github.com/googlefonts/colr-gradients-spec -_DEFAULT_ALPHA = VariableFloat(1.0) - def _is_colrv0_layer(layer: Any) -> bool: # Consider as COLRv0 layer any sequence of length 2 (be it tuple or list) in which @@ -328,116 +370,6 @@ def _split_color_glyphs_by_version( return colorGlyphsV0, colorGlyphsV1 -def _to_variable_value( - value: _ScalarInput, - cls: Type[VariableValue] = VariableFloat, - minValue: Optional[_Number] = None, - maxValue: Optional[_Number] = None, -) -> VariableValue: - if not isinstance(value, cls): - try: - it = iter(value) - except TypeError: # not iterable - value = cls(value) - else: - value = cls._make(it) - if minValue is not None and value.value < minValue: - raise OverflowError(f"{cls.__name__}: {value.value} < {minValue}") - if maxValue is not None and value.value > maxValue: - raise OverflowError(f"{cls.__name__}: {value.value} < {maxValue}") - return value - - -_to_variable_f16dot16_float = partial( - _to_variable_value, - cls=VariableFloat, - minValue=-(2 ** 15), - maxValue=fixedToFloat(2 ** 31 - 1, 16), -) -_to_variable_f2dot14_float = partial( - _to_variable_value, - cls=VariableFloat, - minValue=-2.0, - maxValue=fixedToFloat(2 ** 15 - 1, 14), -) -_to_variable_int16 = partial( - _to_variable_value, - cls=VariableInt, - minValue=-(2 ** 15), - maxValue=2 ** 15 - 1, -) -_to_variable_uint16 = partial( - _to_variable_value, - cls=VariableInt, - minValue=0, - maxValue=2 ** 16, -) - - -def buildColorIndex( - paletteIndex: int, alpha: _ScalarInput = _DEFAULT_ALPHA -) -> ot.ColorIndex: - self = ot.ColorIndex() - self.PaletteIndex = int(paletteIndex) - self.Alpha = _to_variable_f2dot14_float(alpha) - return self - - -def buildColorStop( - offset: _ScalarInput, - paletteIndex: int, - alpha: _ScalarInput = _DEFAULT_ALPHA, -) -> ot.ColorStop: - self = ot.ColorStop() - self.StopOffset = _to_variable_f2dot14_float(offset) - self.Color = buildColorIndex(paletteIndex, alpha) - return self - - -def _to_enum_value(v: Union[str, int, T], enumClass: Type[T]) -> T: - if isinstance(v, enumClass): - return v - elif isinstance(v, str): - try: - return getattr(enumClass, v.upper()) - except AttributeError: - raise ValueError(f"{v!r} is not a valid {enumClass.__name__}") - return enumClass(v) - - -def _to_extend_mode(v: _ExtendInput) -> ExtendMode: - return _to_enum_value(v, ExtendMode) - - -def _to_composite_mode(v: _CompositeInput) -> CompositeMode: - return _to_enum_value(v, CompositeMode) - - -def buildColorLine( - stops: _ColorStopsList, extend: _ExtendInput = ExtendMode.PAD -) -> ot.ColorLine: - self = ot.ColorLine() - self.Extend = _to_extend_mode(extend) - self.StopCount = len(stops) - self.ColorStop = [ - stop - if isinstance(stop, ot.ColorStop) - else buildColorStop(**stop) - if isinstance(stop, collections.abc.Mapping) - else buildColorStop(*stop) - for stop in stops - ] - return self - - -def _to_color_line(obj): - if isinstance(obj, ot.ColorLine): - return obj - elif isinstance(obj, collections.abc.Mapping): - return buildColorLine(**obj) - raise TypeError(obj) - - def _reuse_ranges(num_layers: int) -> Generator[Tuple[int, int], None, None]: # TODO feels like something itertools might have already for lbound in range(num_layers): @@ -463,6 +395,17 @@ class LayerV1ListBuilder: self.tuples = {} self.keepAlive = [] + # We need to intercept construction of PaintColrLayers + callbacks = _buildPaintCallbacks() + callbacks[ + ( + BuildCallback.BEFORE_BUILD, + ot.Paint, + ot.PaintFormat.PaintColrLayers, + ) + ] = self._beforeBuildPaintColrLayers + self.tableBuilder = TableBuilder(callbacks) + def _paint_tuple(self, paint: ot.Paint): # start simple, who even cares about cyclic graphs or interesting field types def _tuple_safe(value): @@ -488,186 +431,37 @@ class LayerV1ListBuilder: def _as_tuple(self, paints: Sequence[ot.Paint]) -> Tuple[Any, ...]: return tuple(self._paint_tuple(p) for p in paints) - def buildPaintSolid( - self, paletteIndex: int, alpha: _ScalarInput = _DEFAULT_ALPHA - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintSolid) - ot_paint.Color = buildColorIndex(paletteIndex, alpha) - return ot_paint + # COLR layers is unusual in that it modifies shared state + # so we need a callback into an object + def _beforeBuildPaintColrLayers(self, dest, source): + paint = ot.Paint() + paint.Format = int(ot.PaintFormat.PaintColrLayers) + self.slices.append(paint) - def buildPaintLinearGradient( - self, - colorLine: _ColorLineInput, - p0: _PointTuple, - p1: _PointTuple, - p2: Optional[_PointTuple] = None, - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintLinearGradient) - ot_paint.ColorLine = _to_color_line(colorLine) + # Sketchy gymnastics: a sequence input will have dropped it's layers + # into NumLayers; get it back + if isinstance(source.get("NumLayers", None), collections.abc.Sequence): + layers = source["NumLayers"] + else: + layers = source["Layers"] - if p2 is None: - p2 = copy.copy(p1) - for i, (x, y) in enumerate((p0, p1, p2)): - setattr(ot_paint, f"x{i}", _to_variable_int16(x)) - setattr(ot_paint, f"y{i}", _to_variable_int16(y)) - - return ot_paint - - def buildPaintRadialGradient( - self, - colorLine: _ColorLineInput, - c0: _PointTuple, - c1: _PointTuple, - r0: _ScalarInput, - r1: _ScalarInput, - ) -> ot.Paint: - - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintRadialGradient) - ot_paint.ColorLine = _to_color_line(colorLine) - - # normalize input types (which may or may not specify a varIdx) - x0, y0 = _to_variable_value(c0[0]), _to_variable_value(c0[1]) - r0 = _to_variable_value(r0) - x1, y1 = _to_variable_value(c1[0]), _to_variable_value(c1[1]) - r1 = _to_variable_value(r1) - - # avoid abrupt change after rounding when c0 is near c1's perimeter - c = round_start_circle_stable_containment( - (x0.value, y0.value), r0.value, (x1.value, y1.value), r1.value - ) - x0, y0 = x0._replace(value=c.centre[0]), y0._replace(value=c.centre[1]) - r0 = r0._replace(value=c.radius) - - for i, (x, y, r) in enumerate(((x0, y0, r0), (x1, y1, r1))): - # rounding happens here as floats are converted to integers - setattr(ot_paint, f"x{i}", _to_variable_int16(x)) - setattr(ot_paint, f"y{i}", _to_variable_int16(y)) - setattr(ot_paint, f"r{i}", _to_variable_uint16(r)) - - return ot_paint - - def buildPaintSweepGradient( - self, - colorLine: _ColorLineInput, - centerX: _ScalarInput, - centerY: _ScalarInput, - startAngle: _ScalarInput, - endAngle: _ScalarInput, - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintSweepGradient) - ot_paint.ColorLine = _to_color_line(colorLine) - ot_paint.centerX = _to_variable_int16(centerX) - ot_paint.centerY = _to_variable_int16(centerY) - ot_paint.startAngle = _to_variable_f16dot16_float(startAngle) - ot_paint.endAngle = _to_variable_f16dot16_float(endAngle) - return ot_paint - - def buildPaintGlyph(self, glyph: str, paint: _PaintInput) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintGlyph) - ot_paint.Glyph = glyph - ot_paint.Paint = self.buildPaint(paint) - return ot_paint - - def buildPaintColrGlyph(self, glyph: str) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintColrGlyph) - ot_paint.Glyph = glyph - return ot_paint - - def buildPaintTransform( - self, transform: _AffineInput, paint: _PaintInput - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintTransform) - if not isinstance(transform, ot.Affine2x3): - transform = buildAffine2x3(transform) - ot_paint.Transform = transform - ot_paint.Paint = self.buildPaint(paint) - return ot_paint - - def buildPaintTranslate( - self, paint: _PaintInput, dx: _ScalarInput, dy: _ScalarInput - ): - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintTranslate) - ot_paint.Paint = self.buildPaint(paint) - ot_paint.dx = _to_variable_f16dot16_float(dx) - ot_paint.dy = _to_variable_f16dot16_float(dy) - return ot_paint - - def buildPaintRotate( - self, - paint: _PaintInput, - angle: _ScalarInput, - centerX: _ScalarInput, - centerY: _ScalarInput, - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintRotate) - ot_paint.Paint = self.buildPaint(paint) - ot_paint.angle = _to_variable_f16dot16_float(angle) - ot_paint.centerX = _to_variable_f16dot16_float(centerX) - ot_paint.centerY = _to_variable_f16dot16_float(centerY) - return ot_paint - - def buildPaintSkew( - self, - paint: _PaintInput, - xSkewAngle: _ScalarInput, - ySkewAngle: _ScalarInput, - centerX: _ScalarInput, - centerY: _ScalarInput, - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintSkew) - ot_paint.Paint = self.buildPaint(paint) - ot_paint.xSkewAngle = _to_variable_f16dot16_float(xSkewAngle) - ot_paint.ySkewAngle = _to_variable_f16dot16_float(ySkewAngle) - ot_paint.centerX = _to_variable_f16dot16_float(centerX) - ot_paint.centerY = _to_variable_f16dot16_float(centerY) - return ot_paint - - def buildPaintComposite( - self, - mode: _CompositeInput, - source: _PaintInput, - backdrop: _PaintInput, - ): - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintComposite) - ot_paint.SourcePaint = self.buildPaint(source) - ot_paint.CompositeMode = _to_composite_mode(mode) - ot_paint.BackdropPaint = self.buildPaint(backdrop) - return ot_paint - - def buildColrLayers(self, paints: List[_PaintInput]) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintColrLayers) - self.slices.append(ot_paint) - - paints = [ - self.buildPaint(p) - for p in _build_n_ary_tree(paints, n=MAX_PAINT_COLR_LAYER_COUNT) - ] + # Convert maps seqs or whatever into typed objects + layers = [self.buildPaint(l) for l in layers] # Look for reuse, with preference to longer sequences + # This may make the layer list smaller found_reuse = True while found_reuse: found_reuse = False ranges = sorted( - _reuse_ranges(len(paints)), + _reuse_ranges(len(layers)), key=lambda t: (t[1] - t[0], t[1], t[0]), reverse=True, ) for lbound, ubound in ranges: reuse_lbound = self.reusePool.get( - self._as_tuple(paints[lbound:ubound]), -1 + self._as_tuple(layers[lbound:ubound]), -1 ) if reuse_lbound == -1: continue @@ -675,47 +469,43 @@ class LayerV1ListBuilder: new_slice.Format = int(ot.PaintFormat.PaintColrLayers) new_slice.NumLayers = ubound - lbound new_slice.FirstLayerIndex = reuse_lbound - paints = paints[:lbound] + [new_slice] + paints[ubound:] + layers = layers[:lbound] + [new_slice] + layers[ubound:] found_reuse = True break - ot_paint.NumLayers = len(paints) - ot_paint.FirstLayerIndex = len(self.layers) - self.layers.extend(paints) + # The layer list is now final; if it's too big we need to tree it + layers = _build_n_ary_tree(layers, n=MAX_PAINT_COLR_LAYER_COUNT) + + # We now have a tree of sequences with Paint leaves. + # Convert the sequences into PaintColrLayers. + def listToColrLayers(layer): + if isinstance(layer, collections.abc.Sequence): + return self.buildPaint( + { + "Format": ot.PaintFormat.PaintColrLayers, + "Layers": [listToColrLayers(l) for l in layer], + } + ) + return layer + + layers = [listToColrLayers(l) for l in layers] + + paint.NumLayers = len(layers) + paint.FirstLayerIndex = len(self.layers) + self.layers.extend(layers) # Register our parts for reuse - for lbound, ubound in _reuse_ranges(len(paints)): - self.reusePool[self._as_tuple(paints[lbound:ubound])] = ( - lbound + ot_paint.FirstLayerIndex + # TODO what if we made ourselves a lovely little tree + for lbound, ubound in _reuse_ranges(len(layers)): + self.reusePool[self._as_tuple(layers[lbound:ubound])] = ( + lbound + paint.FirstLayerIndex ) - return ot_paint + # we've fully built dest; empty source prevents generalized build from kicking in + return paint, {} def buildPaint(self, paint: _PaintInput) -> ot.Paint: - if isinstance(paint, ot.Paint): - return paint - elif isinstance(paint, int): - paletteIndex = paint - return self.buildPaintSolid(paletteIndex) - elif isinstance(paint, tuple): - layerGlyph, paint = paint - return self.buildPaintGlyph(layerGlyph, paint) - elif isinstance(paint, list): - # implicit PaintColrLayers for a list of > 1 - if len(paint) == 0: - raise ValueError("An empty list is hard to paint") - elif len(paint) == 1: - return self.buildPaint(paint[0]) - else: - return self.buildColrLayers(paint) - elif isinstance(paint, collections.abc.Mapping): - kwargs = dict(paint) - fmt = kwargs.pop("format") - try: - return LayerV1ListBuilder._buildFunctions[fmt](self, **kwargs) - except KeyError: - raise NotImplementedError(fmt) - raise TypeError(f"Not sure what to do with {type(paint).__name__}: {paint!r}") + return self.tableBuilder.build(ot.Paint, paint) def build(self) -> ot.LayerV1List: layers = ot.LayerV1List() @@ -724,31 +514,6 @@ class LayerV1ListBuilder: return layers -LayerV1ListBuilder._buildFunctions = { - pf.value: getattr(LayerV1ListBuilder, "build" + pf.name) - for pf in ot.PaintFormat - if pf != ot.PaintFormat.PaintColrLayers -} - - -def buildAffine2x3(transform: _AffineTuple) -> ot.Affine2x3: - if len(transform) != 6: - raise ValueError(f"Expected 6-tuple of floats, found: {transform!r}") - self = ot.Affine2x3() - # COLRv1 Affine2x3 uses the same column-major order to serialize a 2D - # Affine Transformation as the one used by fontTools.misc.transform. - # However, for historical reasons, the labels 'xy' and 'yx' are swapped. - # Their fundamental meaning is the same though. - # COLRv1 Affine2x3 follows the names found in FreeType and Cairo. - # In all case, the second element in the 6-tuple correspond to the - # y-part of the x basis vector, and the third to the x-part of the y - # basis vector. - # See https://github.com/googlefonts/colr-gradients-spec/pull/85 - for i, attr in enumerate(("xx", "yx", "xy", "yy", "dx", "dy")): - setattr(self, attr, _to_variable_f16dot16_float(transform[i])) - return self - - def buildBaseGlyphV1Record( baseGlyph: str, layerBuilder: LayerV1ListBuilder, paint: _PaintInput ) -> ot.BaseGlyphV1List: diff --git a/Lib/fontTools/colorLib/table_builder.py b/Lib/fontTools/colorLib/table_builder.py new file mode 100644 index 000000000..b80229754 --- /dev/null +++ b/Lib/fontTools/colorLib/table_builder.py @@ -0,0 +1,194 @@ +""" +colorLib.table_builder: Generic helper for filling in BaseTable derivatives from tuples and maps and such. + +""" + +import collections +import enum +from fontTools.ttLib.tables.otBase import ( + BaseTable, + FormatSwitchingBaseTable, + UInt8FormatSwitchingBaseTable, +) +from fontTools.ttLib.tables.otConverters import ( + ComputedInt, + GlyphID, + Struct, + Short, + UInt8, + UShort, + VarInt16, + VarUInt16, +) + + +def _to_glyph_id(value): + assert isinstance(value, str), "Expected a glyph name" + return value + + +_CONVERTER_OVERRIDES = { + Short: int, + UShort: int, + GlyphID: _to_glyph_id, +} + + +class BuildCallback(enum.Enum): + """Keyed on (BEFORE_BUILD, class[, Format if available]). + Receives (dest, source). + Should return (dest, source), which can be new objects. + """ + + BEFORE_BUILD = enum.auto() + + """Keyed on (AFTER_BUILD, class[, Format if available]). + Receives (dest). + Should return dest, which can be a new object. + """ + AFTER_BUILD = enum.auto() + + """Keyed on (CREATE_DEFAULT, class). + Receives no arguments. + Should return a new instance of class. + """ + CREATE_DEFAULT = enum.auto() + + +def _assignable(convertersByName): + return {k: v for k, v in convertersByName.items() if not isinstance(v, ComputedInt)} + + +def convertTupleClass(tupleClass, value): + if isinstance(value, tupleClass): + return value + if isinstance(value, tuple): + return tupleClass(*value) + return tupleClass(value) + + +def _isNonStrSequence(value): + return isinstance(value, collections.abc.Sequence) and not isinstance(value, str) + + +def _set_format(dest, source): + if _isNonStrSequence(source): + assert len(source) > 0, f"{type(dest)} needs at least format from {source}" + dest.Format = source[0] + source = source[1:] + elif isinstance(source, collections.abc.Mapping): + assert "Format" in source, f"{type(dest)} needs at least Format from {source}" + dest.Format = source["Format"] + else: + raise ValueError(f"Not sure how to populate {type(dest)} from {source}") + + assert isinstance( + dest.Format, collections.abc.Hashable + ), f"{type(dest)} Format is not hashable: {dest.Format}" + assert ( + dest.Format in dest.convertersByName + ), f"{dest.Format} invalid Format of {cls}" + + return source + + +class TableBuilder: + """ + Helps to populate things derived from BaseTable from maps, tuples, etc. + + A table of lifecycle callbacks may be provided to add logic beyond what is possible + based on otData info for the target class. See BuildCallbacks. + """ + + def __init__(self, callbackTable=None): + if callbackTable is None: + callbackTable = {} + self._callbackTable = callbackTable + + def _convert(self, dest, field, converter, value): + converter = _CONVERTER_OVERRIDES.get(type(converter), converter) + + enumClass = getattr(converter, "enumClass", None) + tupleClass = getattr(converter, "tupleClass", None) + if tupleClass: + value = convertTupleClass(tupleClass, value) + + elif enumClass: + if isinstance(value, enumClass): + pass + elif isinstance(value, str): + try: + value = getattr(enumClass, value.upper()) + except AttributeError: + raise ValueError(f"{value} is not a valid {enumClass}") + else: + value = enumClass(value) + + elif isinstance(converter, Struct): + if converter.repeat: + if _isNonStrSequence(value): + value = [self.build(converter.tableClass, v) for v in value] + else: + value = [self.build(converter.tableClass, value)] + setattr(dest, converter.repeat, len(value)) + else: + value = self.build(converter.tableClass, value) + elif callable(converter): + value = converter(value) + + setattr(dest, field, value) + + def build(self, cls, source): + assert issubclass(cls, BaseTable) + + if isinstance(source, cls): + return source + + callbackKey = (cls,) + dest = self._callbackTable.get( + (BuildCallback.CREATE_DEFAULT,) + callbackKey, lambda: cls() + )() + assert isinstance(dest, cls) + + convByName = _assignable(cls.convertersByName) + skippedFields = set() + + # For format switchers we need to resolve converters based on format + if issubclass(cls, FormatSwitchingBaseTable): + source = _set_format(dest, source) + + convByName = _assignable(convByName[dest.Format]) + skippedFields.add("Format") + callbackKey = (cls, dest.Format) + + # Convert sequence => mapping so before thunk only has to handle one format + if _isNonStrSequence(source): + # Sequence (typically list or tuple) assumed to match fields in declaration order + assert len(source) <= len( + convByName + ), f"Sequence of {len(source)} too long for {cls}; expected <= {len(convByName)} values" + source = dict(zip(convByName.keys(), source)) + + dest, source = self._callbackTable.get( + (BuildCallback.BEFORE_BUILD,) + callbackKey, lambda d, s: (d, s) + )(dest, source) + + if isinstance(source, collections.abc.Mapping): + for field, value in source.items(): + if field in skippedFields: + continue + converter = convByName.get(field, None) + if not converter: + raise ValueError( + f"Unrecognized field {field} for {cls}; expected one of {sorted(convByName.keys())}" + ) + self._convert(dest, field, converter, value) + else: + # let's try as a 1-tuple + dest = self.build(cls, (source,)) + + dest = self._callbackTable.get( + (BuildCallback.AFTER_BUILD,) + callbackKey, lambda d: d + )(dest) + + return dest diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py index 389ac5c42..a5c5ad600 100755 --- a/Lib/fontTools/ttLib/tables/otData.py +++ b/Lib/fontTools/ttLib/tables/otData.py @@ -1588,6 +1588,15 @@ otData = [ ('LOffset', 'Paint', 'LayerCount', 0, 'Array of offsets to Paint tables, from the start of the LayerV1List table.'), ]), + # COLRv1 Affine2x3 uses the same column-major order to serialize a 2D + # Affine Transformation as the one used by fontTools.misc.transform. + # However, for historical reasons, the labels 'xy' and 'yx' are swapped. + # Their fundamental meaning is the same though. + # COLRv1 Affine2x3 follows the names found in FreeType and Cairo. + # In all case, the second element in the 6-tuple correspond to the + # y-part of the x basis vector, and the third to the x-part of the y + # basis vector. + # See https://github.com/googlefonts/colr-gradients-spec/pull/85 ('Affine2x3', [ ('VarFixed', 'xx', None, None, 'x-part of x basis vector'), ('VarFixed', 'yx', None, None, 'y-part of x basis vector'), diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index a705b38f3..95962f637 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -3,11 +3,20 @@ from fontTools.ttLib.tables import otTables as ot from fontTools.colorLib import builder from fontTools.colorLib.geometry import round_start_circle_stable_containment, Circle from fontTools.colorLib.builder import LayerV1ListBuilder, _build_n_ary_tree +from fontTools.colorLib.table_builder import TableBuilder from fontTools.colorLib.errors import ColorLibError import pytest from typing import List +def _build(cls, source): + return LayerV1ListBuilder().tableBuilder.build(cls, source) + + +def _buildPaint(source): + return LayerV1ListBuilder().buildPaint(source) + + def test_buildCOLR_v0(): color_layer_lists = { "a": [("a.color0", 0), ("a.color1", 1)], @@ -223,105 +232,132 @@ def test_buildCPAL_invalid_color(): def test_buildColorIndex(): - c = builder.buildColorIndex(0) - assert c.PaletteIndex == 0 + c = _build(ot.ColorIndex, 1) + assert c.PaletteIndex == 1 assert c.Alpha.value == 1.0 assert c.Alpha.varIdx == 0 - c = builder.buildColorIndex(1, alpha=0.5) + +def test_buildColorIndex_Alpha(): + c = _build(ot.ColorIndex, (1, 0.5)) assert c.PaletteIndex == 1 assert c.Alpha.value == 0.5 assert c.Alpha.varIdx == 0 - c = builder.buildColorIndex(3, alpha=builder.VariableFloat(0.5, varIdx=2)) + +def test_buildColorIndex_Variable(): + c = _build(ot.ColorIndex, (3, builder.VariableFloat(0.5, varIdx=2))) assert c.PaletteIndex == 3 assert c.Alpha.value == 0.5 assert c.Alpha.varIdx == 2 def test_buildPaintSolid(): - p = LayerV1ListBuilder().buildPaintSolid(0) + p = _buildPaint((ot.PaintFormat.PaintSolid, 0)) assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 0 assert p.Color.Alpha.value == 1.0 assert p.Color.Alpha.varIdx == 0 - p = LayerV1ListBuilder().buildPaintSolid(1, alpha=0.5) + +def test_buildPaintSolid_Alpha(): + p = _buildPaint((ot.PaintFormat.PaintSolid, (1, 0.5))) assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 1 assert p.Color.Alpha.value == 0.5 assert p.Color.Alpha.varIdx == 0 - p = LayerV1ListBuilder().buildPaintSolid( - 3, alpha=builder.VariableFloat(0.5, varIdx=2) - ) + +def test_buildPaintSolid_Variable(): + p = _buildPaint((ot.PaintFormat.PaintSolid, (3, builder.VariableFloat(0.5, varIdx=2)))) assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 3 assert p.Color.Alpha.value == 0.5 assert p.Color.Alpha.varIdx == 2 -def test_buildColorStop(): - s = builder.buildColorStop(0.1, 2) +def test_buildColorStop_DefaultAlpha(): + s = _build(ot.ColorStop, (0.1, 2)) assert s.StopOffset == builder.VariableFloat(0.1) assert s.Color.PaletteIndex == 2 assert s.Color.Alpha == builder._DEFAULT_ALPHA - s = builder.buildColorStop(offset=0.2, paletteIndex=3, alpha=0.4) - assert s.StopOffset == builder.VariableFloat(0.2) - assert s.Color == builder.buildColorIndex(3, alpha=0.4) - s = builder.buildColorStop( - offset=builder.VariableFloat(0.0, varIdx=1), - paletteIndex=0, - alpha=builder.VariableFloat(0.3, varIdx=2), +def test_buildColorStop(): + s = _build( + ot.ColorStop, {"StopOffset": 0.2, "Color": {"PaletteIndex": 3, "Alpha": 0.4}} + ) + assert s.StopOffset == builder.VariableFloat(0.2) + assert s.Color == _build(ot.ColorIndex, (3, 0.4)) + + +def test_buildColorStop_Variable(): + s = _build( + ot.ColorStop, + { + "StopOffset": builder.VariableFloat(0.0, varIdx=1), + "Color": { + "PaletteIndex": 0, + "Alpha": builder.VariableFloat(0.3, varIdx=2), + }, + }, ) assert s.StopOffset == builder.VariableFloat(0.0, varIdx=1) assert s.Color.PaletteIndex == 0 assert s.Color.Alpha == builder.VariableFloat(0.3, varIdx=2) -def test_buildColorLine(): +def test_buildColorLine_StopList(): stops = [(0.0, 0), (0.5, 1), (1.0, 2)] - cline = builder.buildColorLine(stops) + cline = _build(ot.ColorLine, {"ColorStop": stops}) assert cline.Extend == builder.ExtendMode.PAD assert cline.StopCount == 3 assert [ (cs.StopOffset.value, cs.Color.PaletteIndex) for cs in cline.ColorStop ] == stops - cline = builder.buildColorLine(stops, extend="pad") + cline = _build(ot.ColorLine, {"Extend": "pad", "ColorStop": stops}) assert cline.Extend == builder.ExtendMode.PAD - cline = builder.buildColorLine(stops, extend=builder.ExtendMode.REPEAT) + cline = _build( + ot.ColorLine, {"ColorStop": stops, "Extend": builder.ExtendMode.REPEAT} + ) assert cline.Extend == builder.ExtendMode.REPEAT - cline = builder.buildColorLine(stops, extend=builder.ExtendMode.REFLECT) + cline = _build( + ot.ColorLine, {"ColorStop": stops, "Extend": builder.ExtendMode.REFLECT} + ) assert cline.Extend == builder.ExtendMode.REFLECT - cline = builder.buildColorLine([builder.buildColorStop(*s) for s in stops]) + cline = _build( + ot.ColorLine, {"ColorStop": [_build(ot.ColorStop, s) for s in stops]} + ) assert [ (cs.StopOffset.value, cs.Color.PaletteIndex) for cs in cline.ColorStop ] == stops + +def test_buildColorLine_StopMap_Variations(): stops = [ - {"offset": (0.0, 1), "paletteIndex": 0, "alpha": (0.5, 2)}, - {"offset": (1.0, 3), "paletteIndex": 1, "alpha": (0.3, 4)}, + {"StopOffset": (0.0, (1,)), "Color": {"PaletteIndex": 0, "Alpha": (0.5, 2)}}, + {"StopOffset": (1.0, (3,)), "Color": {"PaletteIndex": 1, "Alpha": (0.3, 4)}}, ] - cline = builder.buildColorLine(stops) + cline = _build(ot.ColorLine, {"ColorStop": stops}) assert [ { - "offset": cs.StopOffset, - "paletteIndex": cs.Color.PaletteIndex, - "alpha": cs.Color.Alpha, + "StopOffset": cs.StopOffset, + "Color": { + "PaletteIndex": cs.Color.PaletteIndex, + "Alpha": cs.Color.Alpha, + }, } for cs in cline.ColorStop ] == stops def test_buildAffine2x3(): - matrix = builder.buildAffine2x3((1.5, 0, 0.5, 2.0, 1.0, -3.0)) + matrix = _build(ot.Affine2x3, (1.5, 0, 0.5, 2.0, 1.0, -3.0)) assert matrix.xx == builder.VariableFloat(1.5) assert matrix.yx == builder.VariableFloat(0.0) assert matrix.xy == builder.VariableFloat(0.5) @@ -330,47 +366,55 @@ def test_buildAffine2x3(): assert matrix.dy == builder.VariableFloat(-3.0) -def test_buildPaintLinearGradient(): - layerBuilder = LayerV1ListBuilder() - color_stops = [ - builder.buildColorStop(0.0, 0), - builder.buildColorStop(0.5, 1), - builder.buildColorStop(1.0, 2, alpha=0.8), +def _sample_stops(): + return [ + _build(ot.ColorStop, (0.0, 0)), + _build(ot.ColorStop, (0.5, 1)), + _build(ot.ColorStop, (1.0, (2, 0.8))), ] - color_line = builder.buildColorLine(color_stops, extend=builder.ExtendMode.REPEAT) - p0 = (builder.VariableInt(100), builder.VariableInt(200)) - p1 = (builder.VariableInt(150), builder.VariableInt(250)) - gradient = layerBuilder.buildPaintLinearGradient(color_line, p0, p1) - assert gradient.Format == 3 - assert gradient.ColorLine == color_line - assert (gradient.x0, gradient.y0) == p0 - assert (gradient.x1, gradient.y1) == p1 - assert (gradient.x2, gradient.y2) == p1 - gradient = layerBuilder.buildPaintLinearGradient({"stops": color_stops}, p0, p1) +def test_buildPaintLinearGradient(): + color_stops = _sample_stops() + x0, y0, x1, y1, x2, y2 = tuple(builder.VariableInt(v) for v in (1, 2, 3, 4, 5, 6)) + gradient = _buildPaint( + { + "Format": ot.PaintFormat.PaintLinearGradient, + "ColorLine": {"ColorStop": color_stops}, + "x0": x0, + "y0": y0, + "x1": x1, + "y1": y1, + "x2": x2, + "y2": y2, + }, + ) assert gradient.ColorLine.Extend == builder.ExtendMode.PAD assert gradient.ColorLine.ColorStop == color_stops - gradient = layerBuilder.buildPaintLinearGradient(color_line, p0, p1, p2=(150, 230)) - assert (gradient.x2.value, gradient.y2.value) == (150, 230) - assert (gradient.x2, gradient.y2) != (gradient.x1, gradient.y1) + gradient = _buildPaint(gradient) + assert (gradient.x0.value, gradient.y0.value) == (1, 2) + assert (gradient.x1.value, gradient.y1.value) == (3, 4) + assert (gradient.x2.value, gradient.y2.value) == (5, 6) def test_buildPaintRadialGradient(): - layerBuilder = LayerV1ListBuilder() color_stops = [ - builder.buildColorStop(0.0, 0), - builder.buildColorStop(0.5, 1), - builder.buildColorStop(1.0, 2, alpha=0.8), + _build(ot.ColorStop, (0.0, (0,))), + _build(ot.ColorStop, (0.5, 1)), + _build(ot.ColorStop, (1.0, (2, 0.8))), ] - color_line = builder.buildColorLine(color_stops, extend=builder.ExtendMode.REPEAT) + color_line = _build( + ot.ColorLine, {"ColorStop": color_stops, "Extend": builder.ExtendMode.REPEAT} + ) c0 = (builder.VariableInt(100), builder.VariableInt(200)) c1 = (builder.VariableInt(150), builder.VariableInt(250)) r0 = builder.VariableInt(10) r1 = builder.VariableInt(5) - gradient = layerBuilder.buildPaintRadialGradient(color_line, c0, c1, r0, r1) + gradient = _build( + ot.Paint, (ot.PaintFormat.PaintRadialGradient, color_line, *c0, r0, *c1, r1) + ) assert gradient.Format == ot.PaintFormat.PaintRadialGradient assert gradient.ColorLine == color_line assert (gradient.x0, gradient.y0) == c0 @@ -378,27 +422,43 @@ def test_buildPaintRadialGradient(): assert gradient.r0 == r0 assert gradient.r1 == r1 - gradient = layerBuilder.buildPaintRadialGradient( - {"stops": color_stops}, c0, c1, r0, r1 + gradient = _build( + ot.Paint, + { + "Format": ot.PaintFormat.PaintRadialGradient, + "ColorLine": {"ColorStop": color_stops}, + "x0": c0[0], + "y0": c0[1], + "x1": c1[0], + "y1": c1[1], + "r0": r0, + "r1": r1, + }, ) assert gradient.ColorLine.Extend == builder.ExtendMode.PAD assert gradient.ColorLine.ColorStop == color_stops + assert (gradient.x0, gradient.y0) == c0 + assert (gradient.x1, gradient.y1) == c1 + assert gradient.r0 == r0 + assert gradient.r1 == r1 def test_buildPaintSweepGradient(): - layerBuilder = LayerV1ListBuilder() - paint = layerBuilder.buildPaintSweepGradient( - colorLine=builder.buildColorLine( - stops=[ - builder.buildColorStop(0.0, 0), - builder.buildColorStop(0.5, 1), - builder.buildColorStop(1.0, 2, alpha=0.8), - ], - ), - centerX=127, - centerY=129, - startAngle=15, - endAngle=42, + paint = _buildPaint( + { + "Format": ot.PaintFormat.PaintSweepGradient, + "ColorLine": { + "ColorStop": ( + (0.0, 0), + (0.5, 1), + (1.0, (2, 0.8)), + ) + }, + "centerX": 127, + "centerY": 129, + "startAngle": 15, + "endAngle": 42, + } ) assert paint.Format == ot.PaintFormat.PaintSweepGradient @@ -409,25 +469,53 @@ def test_buildPaintSweepGradient(): def test_buildPaintGlyph_Solid(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph("a", 2) + layer = _build( + ot.Paint, + ( + ot.PaintFormat.PaintGlyph, + ( + ot.PaintFormat.PaintSolid, + 2, + ), + "a", + ), + ) + assert layer.Format == ot.PaintFormat.PaintGlyph assert layer.Glyph == "a" assert layer.Paint.Format == ot.PaintFormat.PaintSolid assert layer.Paint.Color.PaletteIndex == 2 - layer = layerBuilder.buildPaintGlyph("a", layerBuilder.buildPaintSolid(3, 0.9)) + layer = _build( + ot.Paint, + ( + ot.PaintFormat.PaintGlyph, + ( + ot.PaintFormat.PaintSolid, + (3, 0.9), + ), + "a", + ), + ) assert layer.Paint.Format == ot.PaintFormat.PaintSolid assert layer.Paint.Color.PaletteIndex == 3 assert layer.Paint.Color.Alpha.value == 0.9 def test_buildPaintGlyph_LinearGradient(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph( - "a", - layerBuilder.buildPaintLinearGradient( - {"stops": [(0.0, 3), (1.0, 4)]}, (100, 200), (150, 250) - ), + layer = _build( + ot.Paint, + { + "Format": ot.PaintFormat.PaintGlyph, + "Glyph": "a", + "Paint": { + "Format": ot.PaintFormat.PaintLinearGradient, + "ColorLine": {"ColorStop": [(0.0, 3), (1.0, 4)]}, + "x0": 100, + "y0": 200, + "x1": 150, + "y1": 250, + }, + }, ) assert layer.Paint.Format == ot.PaintFormat.PaintLinearGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 @@ -441,23 +529,31 @@ def test_buildPaintGlyph_LinearGradient(): def test_buildPaintGlyph_RadialGradient(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph( - "a", - layerBuilder.buildPaintRadialGradient( - { - "stops": [ - (0.0, 5), - {"offset": 0.5, "paletteIndex": 6, "alpha": 0.8}, - (1.0, 7), - ] - }, - (50, 50), - (75, 75), - 30, - 10, + layer = _build( + ot.Paint, + ( + int(ot.PaintFormat.PaintGlyph), + ( + ot.PaintFormat.PaintRadialGradient, + ( + "pad", + [ + (0.0, 5), + {"StopOffset": 0.5, "Color": {"PaletteIndex": 6, "Alpha": 0.8}}, + (1.0, 7), + ], + ), + 50, + 50, + 30, + 75, + 75, + 10, + ), + "a", ), ) + assert layer.Format == ot.PaintFormat.PaintGlyph assert layer.Paint.Format == ot.PaintFormat.PaintRadialGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 assert layer.Paint.ColorLine.ColorStop[0].Color.PaletteIndex == 5 @@ -475,39 +571,58 @@ def test_buildPaintGlyph_RadialGradient(): def test_buildPaintGlyph_Dict_Solid(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph("a", {"format": 2, "paletteIndex": 0}) + layer = _build( + ot.Paint, + ( + int(ot.PaintFormat.PaintGlyph), + (int(ot.PaintFormat.PaintSolid), 1), + "a", + ), + ) + assert layer.Format == ot.PaintFormat.PaintGlyph + assert layer.Format == ot.PaintFormat.PaintGlyph assert layer.Glyph == "a" assert layer.Paint.Format == ot.PaintFormat.PaintSolid - assert layer.Paint.Color.PaletteIndex == 0 + assert layer.Paint.Color.PaletteIndex == 1 def test_buildPaintGlyph_Dict_LinearGradient(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph( - "a", + layer = _build( + ot.Paint, { - "format": 3, - "colorLine": {"stops": [(0.0, 0), (1.0, 1)]}, - "p0": (0, 0), - "p1": (10, 10), + "Format": ot.PaintFormat.PaintGlyph, + "Glyph": "a", + "Paint": { + "Format": 3, + "ColorLine": {"ColorStop": [(0.0, 0), (1.0, 1)]}, + "x0": 0, + "y0": 0, + "x1": 10, + "y1": 10, + }, }, ) + assert layer.Format == ot.PaintFormat.PaintGlyph + assert layer.Glyph == "a" assert layer.Paint.Format == ot.PaintFormat.PaintLinearGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 def test_buildPaintGlyph_Dict_RadialGradient(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph( - "a", + layer = _buildPaint( { - "format": 4, - "colorLine": {"stops": [(0.0, 0), (1.0, 1)]}, - "c0": (0, 0), - "c1": (10, 10), - "r0": 4, - "r1": 0, + "Glyph": "a", + "Paint": { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": {"ColorStop": [(0.0, 0), (1.0, 1)]}, + "x0": 0, + "y0": 0, + "r0": 4, + "x1": 10, + "y1": 10, + "r1": 0, + }, + "Format": int(ot.PaintFormat.PaintGlyph), }, ) assert layer.Paint.Format == ot.PaintFormat.PaintRadialGradient @@ -515,18 +630,17 @@ def test_buildPaintGlyph_Dict_RadialGradient(): def test_buildPaintColrGlyph(): - paint = LayerV1ListBuilder().buildPaintColrGlyph("a") + paint = _buildPaint((int(ot.PaintFormat.PaintColrGlyph), "a")) assert paint.Format == ot.PaintFormat.PaintColrGlyph assert paint.Glyph == "a" def test_buildPaintTransform(): - layerBuilder = LayerV1ListBuilder() - paint = layerBuilder.buildPaintTransform( - transform=builder.buildAffine2x3((1, 2, 3, 4, 5, 6)), - paint=layerBuilder.buildPaintGlyph( - glyph="a", - paint=layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0), + paint = _buildPaint( + ( + int(ot.PaintFormat.PaintTransform), + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, (0, 1.0)), "a"), + _build(ot.Affine2x3, (1, 2, 3, 4, 5, 6)), ), ) @@ -541,22 +655,28 @@ def test_buildPaintTransform(): assert paint.Transform.dx.value == 5.0 assert paint.Transform.dy.value == 6.0 - paint = layerBuilder.buildPaintTransform( - (1, 0, 0, 0.3333, 10, 10), + paint = _build( + ot.Paint, { - "format": 4, - "colorLine": {"stops": [(0.0, 0), (1.0, 1)]}, - "c0": (100, 100), - "c1": (100, 100), - "r0": 0, - "r1": 50, + "Format": ot.PaintFormat.PaintTransform, + "Transform": (1, 2, 3, 0.3333, 10, 10), + "Paint": { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": {"ColorStop": [(0.0, 0), (1.0, 1)]}, + "x0": 100, + "y0": 101, + "x1": 102, + "y1": 103, + "r0": 0, + "r1": 50, + }, }, ) assert paint.Format == ot.PaintFormat.PaintTransform assert paint.Transform.xx.value == 1.0 - assert paint.Transform.yx.value == 0.0 - assert paint.Transform.xy.value == 0.0 + assert paint.Transform.yx.value == 2.0 + assert paint.Transform.xy.value == 3.0 assert paint.Transform.yy.value == 0.3333 assert paint.Transform.dx.value == 10 assert paint.Transform.dy.value == 10 @@ -564,18 +684,34 @@ def test_buildPaintTransform(): def test_buildPaintComposite(): - layerBuilder = LayerV1ListBuilder() - composite = layerBuilder.buildPaintComposite( - mode=ot.CompositeMode.SRC_OVER, - source={ - "format": 12, - "mode": "src_over", - "source": {"format": 6, "glyph": "c", "paint": 2}, - "backdrop": {"format": 6, "glyph": "b", "paint": 1}, + composite = _build( + ot.Paint, + { + "Format": int(ot.PaintFormat.PaintComposite), + "CompositeMode": "src_over", + "SourcePaint": { + "Format": ot.PaintFormat.PaintComposite, + "CompositeMode": "src_over", + "SourcePaint": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "c", + "Paint": (ot.PaintFormat.PaintSolid, 2), + }, + "BackdropPaint": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "b", + "Paint": (ot.PaintFormat.PaintSolid, 1), + }, + }, + "BackdropPaint": { + "Format": ot.PaintFormat.PaintGlyph, + "Glyph": "a", + "Paint": { + "Format": ot.PaintFormat.PaintSolid, + "Color": (0, 1.0), + }, + }, }, - backdrop=layerBuilder.buildPaintGlyph( - "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) - ), ) assert composite.Format == ot.PaintFormat.PaintComposite @@ -587,9 +723,7 @@ def test_buildPaintComposite(): assert composite.SourcePaint.CompositeMode == ot.CompositeMode.SRC_OVER assert composite.SourcePaint.BackdropPaint.Format == ot.PaintFormat.PaintGlyph assert composite.SourcePaint.BackdropPaint.Glyph == "b" - assert ( - composite.SourcePaint.BackdropPaint.Paint.Format == ot.PaintFormat.PaintSolid - ) + assert composite.SourcePaint.BackdropPaint.Paint.Format == ot.PaintFormat.PaintSolid assert composite.SourcePaint.BackdropPaint.Paint.Color.PaletteIndex == 1 assert composite.CompositeMode == ot.CompositeMode.SRC_OVER assert composite.BackdropPaint.Format == ot.PaintFormat.PaintGlyph @@ -599,13 +733,18 @@ def test_buildPaintComposite(): def test_buildPaintTranslate(): - layerBuilder = LayerV1ListBuilder() - paint = layerBuilder.buildPaintTranslate( - paint=layerBuilder.buildPaintGlyph( - "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) - ), - dx=123, - dy=-345, + paint = _build( + ot.Paint, + { + "Format": ot.PaintFormat.PaintTranslate, + "Paint": ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, (0, 1.0)), + "a", + ), + "dx": 123, + "dy": -345, + }, ) assert paint.Format == ot.PaintFormat.PaintTranslate @@ -615,14 +754,19 @@ def test_buildPaintTranslate(): def test_buildPaintRotate(): - layerBuilder = LayerV1ListBuilder() - paint = layerBuilder.buildPaintRotate( - paint=layerBuilder.buildPaintGlyph( - "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) - ), - angle=15, - centerX=127, - centerY=129, + paint = _build( + ot.Paint, + { + "Format": ot.PaintFormat.PaintRotate, + "Paint": ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, (0, 1.0)), + "a", + ), + "angle": 15, + "centerX": 127, + "centerY": 129, + }, ) assert paint.Format == ot.PaintFormat.PaintRotate @@ -633,15 +777,20 @@ def test_buildPaintRotate(): def test_buildPaintSkew(): - layerBuilder = LayerV1ListBuilder() - paint = layerBuilder.buildPaintSkew( - paint=layerBuilder.buildPaintGlyph( - "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) - ), - xSkewAngle=15, - ySkewAngle=42, - centerX=127, - centerY=129, + paint = _build( + ot.Paint, + { + "Format": ot.PaintFormat.PaintSkew, + "Paint": ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, (0, 1.0)), + "a", + ), + "xSkewAngle": 15, + "ySkewAngle": 42, + "centerX": 127, + "centerY": 129, + }, ) assert paint.Format == ot.PaintFormat.PaintSkew @@ -654,22 +803,44 @@ def test_buildPaintSkew(): def test_buildColrV1(): colorGlyphs = { - "a": [("b", 0), ("c", 1)], - "d": [ - ("e", {"format": 2, "paletteIndex": 2, "alpha": 0.8}), - ( - "f", - { - "format": 4, - "colorLine": {"stops": [(0.0, 3), (1.0, 4)], "extend": "reflect"}, - "c0": (0, 0), - "c1": (0, 0), - "r0": 10, - "r1": 0, - }, - ), - ], - "g": [("h", 5)], + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 0), "b"), + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 1), "c"), + ], + ), + "d": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + {"Format": 2, "Color": {"PaletteIndex": 2, "Alpha": 0.8}}, + "e", + ), + ( + ot.PaintFormat.PaintGlyph, + { + "Format": 4, + "ColorLine": { + "ColorStop": [(0.0, 3), (1.0, 4)], + "Extend": "reflect", + }, + "x0": 0, + "y0": 0, + "x1": 0, + "y1": 0, + "r0": 10, + "r1": 0, + }, + "f", + ), + ], + ), + "g": ( + ot.PaintFormat.PaintColrLayers, + [(ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 5), "h")], + ), } glyphMap = { ".notdef": 0, @@ -700,14 +871,17 @@ def test_buildColrV1(): def test_buildColrV1_more_than_255_paints(): num_paints = 364 colorGlyphs = { - "a": [ - { - "format": 6, # PaintGlyph - "paint": 0, - "glyph": name, - } - for name in (f"glyph{i}" for i in range(num_paints)) - ], + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": (ot.PaintFormat.PaintSolid, 0), + "Glyph": name, + } + for name in (f"glyph{i}" for i in range(num_paints)) + ], + ), } layers, baseGlyphs = builder.buildColrV1(colorGlyphs) paints = layers.Paint @@ -750,9 +924,7 @@ def test_split_color_glyphs_by_version(): assert colorGlyphsV0 == {"a": [("b", 0), ("c", 1), ("d", 2), ("e", 3)]} assert not colorGlyphsV1 - colorGlyphs = { - "a": [("b", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=0.0))] - } + colorGlyphs = {"a": (ot.PaintFormat.PaintGlyph, 0, "b")} colorGlyphsV0, colorGlyphsV1 = builder._split_color_glyphs_by_version(colorGlyphs) @@ -798,32 +970,35 @@ def assertNoV0Content(colr): def test_build_layerv1list_empty(): - # Nobody uses PaintColrLayers (format 1), no layerlist + # Nobody uses PaintColrLayers, no layerlist colr = builder.buildCOLR( { - "a": { - "format": 6, # PaintGlyph - "paint": {"format": 2, "paletteIndex": 2, "alpha": 0.8}, - "glyph": "b", - }, - # A list of 1 shouldn't become a PaintColrLayers - "b": [ - { - "format": 6, # PaintGlyph - "paint": { - "format": 3, - "colorLine": { - "stops": [(0.0, 2), (1.0, 3)], - "extend": "reflect", - }, - "p0": (1, 2), - "p1": (3, 4), - "p2": (2, 2), + # BaseGlyph, tuple form + "a": ( + int(ot.PaintFormat.PaintGlyph), + (2, (2, 0.8)), + "b", + ), + # BaseGlyph, map form + "b": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "ColorStop": [(0.0, 2), (1.0, 3)], + "Extend": "reflect", }, - "glyph": "bb", - } - ], - } + "x0": 1, + "y0": 2, + "x1": 3, + "y1": 4, + "x2": 2, + "y2": 2, + }, + "Glyph": "bb", + }, + }, + version=1, ) assertIsColrV1(colr) @@ -853,35 +1028,42 @@ def _paint_names(paints) -> List[str]: def test_build_layerv1list_simple(): # Two colr glyphs, each with two layers the first of which is common # All layers use the same solid paint - solid_paint = {"format": 2, "paletteIndex": 2, "alpha": 0.8} + solid_paint = {"Format": 2, "Color": {"PaletteIndex": 2, "Alpha": 0.8}} backdrop = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "back", + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": solid_paint, + "Glyph": "back", } a_foreground = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "a_fore", + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": solid_paint, + "Glyph": "a_fore", } b_foreground = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "b_fore", + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": solid_paint, + "Glyph": "b_fore", } - # list => PaintColrLayers, which means contents should be in LayerV1List + # list => PaintColrLayers, contents should land in LayerV1List colr = builder.buildCOLR( { - "a": [ - backdrop, - a_foreground, - ], - "b": [ - backdrop, - b_foreground, - ], - } + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + backdrop, + a_foreground, + ], + ), + "b": { + "Format": ot.PaintFormat.PaintColrLayers, + "Layers": [ + backdrop, + b_foreground, + ], + }, + }, + version=1, ) assertIsColrV1(colr) @@ -902,47 +1084,51 @@ def test_build_layerv1list_simple(): def test_build_layerv1list_with_sharing(): # Three colr glyphs, each with two layers in common - solid_paint = {"format": 2, "paletteIndex": 2, "alpha": 0.8} + solid_paint = {"Format": 2, "Color": (2, 0.8)} backdrop = [ { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "back1", + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": solid_paint, + "Glyph": "back1", }, { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "back2", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "back2", }, ] a_foreground = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "a_fore", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "a_fore", } b_background = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "b_back", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "b_back", } b_foreground = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "b_fore", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "b_fore", } c_background = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "c_back", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "c_back", } # list => PaintColrLayers, which means contents should be in LayerV1List colr = builder.buildCOLR( { - "a": backdrop + [a_foreground], - "b": [b_background] + backdrop + [b_foreground], - "c": [c_background] + backdrop, - } + "a": (ot.PaintFormat.PaintColrLayers, backdrop + [a_foreground]), + "b": ( + ot.PaintFormat.PaintColrLayers, + [b_background] + backdrop + [b_foreground], + ), + "c": (ot.PaintFormat.PaintColrLayers, [c_background] + backdrop), + }, + version=1, ) assertIsColrV1(colr) @@ -974,9 +1160,12 @@ def test_build_layerv1list_with_sharing(): def test_build_layerv1list_with_overlaps(): paints = [ { - "format": 6, # PaintGlyph - "paint": {"format": 2, "paletteIndex": 2, "alpha": 0.8}, - "glyph": c, + "Format": ot.PaintFormat.PaintGlyph, + "Paint": { + "Format": ot.PaintFormat.PaintSolid, + "Color": {"PaletteIndex": 2, "Alpha": 0.8}, + }, + "Glyph": c, } for c in "abcdefghi" ] @@ -984,10 +1173,11 @@ def test_build_layerv1list_with_overlaps(): # list => PaintColrLayers, which means contents should be in LayerV1List colr = builder.buildCOLR( { - "a": paints[0:4], - "b": paints[0:6], - "c": paints[2:8], - } + "a": (ot.PaintFormat.PaintColrLayers, paints[0:4]), + "b": (ot.PaintFormat.PaintColrLayers, paints[0:6]), + "c": (ot.PaintFormat.PaintColrLayers, paints[2:8]), + }, + version=1, ) assertIsColrV1(colr) @@ -1017,6 +1207,26 @@ def test_build_layerv1list_with_overlaps(): assert colr.table.LayerV1List.LayerCount == 11 +def test_explicit_version_1(): + colr = builder.buildCOLR( + { + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 0), "b"), + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 1), "c"), + ], + ) + }, + version=1, + ) + assert colr.version == 1 + assert not hasattr(colr, "ColorLayers") + assert hasattr(colr, "table") + assert isinstance(colr.table, ot.COLR) + assert colr.table.VarStore is None + + class BuildCOLRTest(object): def test_automatic_version_all_solid_color_glyphs(self): colr = builder.buildCOLR({"a": [("b", 0), ("c", 1)]}) @@ -1028,38 +1238,55 @@ class BuildCOLRTest(object): def test_automatic_version_no_solid_color_glyphs(self): colr = builder.buildCOLR( { - "a": [ - ( - "b", - { - "format": 4, - "colorLine": { - "stops": [(0.0, 0), (1.0, 1)], - "extend": "repeat", + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": { + "ColorStop": [(0.0, 0), (1.0, 1)], + "Extend": "repeat", + }, + "x0": 1, + "y0": 0, + "x1": 10, + "y1": 0, + "r0": 4, + "r1": 2, }, - "c0": (1, 0), - "c1": (10, 0), - "r0": 4, - "r1": 2, - }, - ), - ("c", {"format": 2, "paletteIndex": 2, "alpha": 0.8}), - ], - "d": [ - ( - "e", + "b", + ), + ( + ot.PaintFormat.PaintGlyph, + {"Format": 2, "Color": {"PaletteIndex": 2, "Alpha": 0.8}}, + "c", + ), + ], + ), + "d": ( + ot.PaintFormat.PaintColrLayers, + [ { - "format": 3, - "colorLine": { - "stops": [(0.0, 2), (1.0, 3)], - "extend": "reflect", + "Format": ot.PaintFormat.PaintGlyph, + "Glyph": "e", + "Paint": { + "Format": ot.PaintFormat.PaintLinearGradient, + "ColorLine": { + "ColorStop": [(0.0, 2), (1.0, 3)], + "Extend": "reflect", + }, + "x0": 1, + "y0": 2, + "x1": 3, + "y1": 4, + "x2": 2, + "y2": 2, }, - "p0": (1, 2), - "p1": (3, 4), - "p2": (2, 2), - }, - ), - ], + } + ], + ), } ) assertIsColrV1(colr) @@ -1072,19 +1299,30 @@ class BuildCOLRTest(object): colr = builder.buildCOLR( { "a": [("b", 0), ("c", 1)], - "d": [ - ( - "e", - { - "format": 3, - "colorLine": {"stops": [(0.0, 2), (1.0, 3)]}, - "p0": (1, 2), - "p1": (3, 4), - "p2": (2, 2), - }, - ), - ("f", {"format": 2, "paletteIndex": 2, "alpha": 0.8}), - ], + "d": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + { + "Format": ot.PaintFormat.PaintLinearGradient, + "ColorLine": {"ColorStop": [(0.0, 2), (1.0, 3)]}, + "x0": 1, + "y0": 2, + "x1": 3, + "y1": 4, + "x2": 2, + "y2": 2, + }, + "e", + ), + ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, (2, 0.8)), + "f", + ), + ], + ), } ) assertIsColrV1(colr) @@ -1110,7 +1348,26 @@ class BuildCOLRTest(object): assert hasattr(colr, "ColorLayers") def test_explicit_version_1(self): - colr = builder.buildCOLR({"a": [("b", 0), ("c", 1)]}, version=1) + colr = builder.buildCOLR( + { + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, 0), + "b", + ), + ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, 1), + "c", + ), + ], + ) + }, + version=1, + ) assert colr.version == 1 assert not hasattr(colr, "ColorLayers") assert hasattr(colr, "table") diff --git a/Tests/colorLib/unbuilder_test.py b/Tests/colorLib/unbuilder_test.py index decd1856b..fb22abcdb 100644 --- a/Tests/colorLib/unbuilder_test.py +++ b/Tests/colorLib/unbuilder_test.py @@ -5,115 +5,141 @@ import pytest TEST_COLOR_GLYPHS = { - "glyph00010": [ - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00011", - "paint": { - "format": int(ot.PaintFormat.PaintSolid), - "paletteIndex": 2, - "alpha": 0.5, - }, - }, - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00012", - "paint": { - "format": int(ot.PaintFormat.PaintLinearGradient), - "colorLine": { - "stops": [ - {"offset": 0.0, "paletteIndex": 3, "alpha": 1.0}, - {"offset": 0.5, "paletteIndex": 4, "alpha": 1.0}, - {"offset": 1.0, "paletteIndex": 5, "alpha": 1.0}, - ], - "extend": "repeat", + "glyph00010": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "glyph00011", + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "Color": { + "PaletteIndex": 2, + "Alpha": 0.5, + }, }, - "p0": (1, 2), - "p1": (-3, -4), - "p2": (5, 6), }, - }, - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00013", - "paint": { - "format": int(ot.PaintFormat.PaintTransform), - "transform": (-13.0, 14.0, 15.0, -17.0, 18.0, 19.0), - "paint": { - "format": int(ot.PaintFormat.PaintRadialGradient), - "colorLine": { - "stops": [ - {"offset": 0.0, "paletteIndex": 6, "alpha": 1.0}, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "glyph00012", + "Paint": { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "ColorStop": [ { - "offset": 1.0, - "paletteIndex": 7, - "alpha": 0.4, + "StopOffset": 0.0, + "Color": {"PaletteIndex": 3, "Alpha": 1.0}, + }, + { + "StopOffset": 0.5, + "Color": {"PaletteIndex": 4, "Alpha": 1.0}, + }, + { + "StopOffset": 1.0, + "Color": {"PaletteIndex": 5, "Alpha": 1.0}, }, ], - "extend": "pad", + "Extend": "repeat", }, - "c0": (7, 8), - "r0": 9, - "c1": (10, 11), - "r1": 12, + "x0": 1, + "y0": 2, + "x1": -3, + "y1": -4, + "x2": 5, + "y2": 6, }, }, - }, - { - "format": int(ot.PaintFormat.PaintTranslate), - "dx": 257.0, - "dy": 258.0, - "paint": { - "format": int(ot.PaintFormat.PaintRotate), - "angle": 45.0, - "centerX": 255.0, - "centerY": 256.0, - "paint": { - "format": int(ot.PaintFormat.PaintSkew), - "xSkewAngle": -11.0, - "ySkewAngle": 5.0, - "centerX": 253.0, - "centerY": 254.0, - "paint": { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00011", - "paint": { - "format": int(ot.PaintFormat.PaintSolid), - "paletteIndex": 2, - "alpha": 0.5, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "glyph00013", + "Paint": { + "Format": int(ot.PaintFormat.PaintTransform), + "Transform": (-13.0, 14.0, 15.0, -17.0, 18.0, 19.0), + "Paint": { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": { + "ColorStop": [ + { + "StopOffset": 0.0, + "Color": {"PaletteIndex": 6, "Alpha": 1.0}, + }, + { + "StopOffset": 1.0, + "Color": { + "PaletteIndex": 7, + "Alpha": 0.4, + }, + }, + ], + "Extend": "pad", + }, + "x0": 7, + "y0": 8, + "r0": 9, + "x1": 10, + "y1": 11, + "r1": 12, + }, + }, + }, + { + "Format": int(ot.PaintFormat.PaintTranslate), + "dx": 257.0, + "dy": 258.0, + "Paint": { + "Format": int(ot.PaintFormat.PaintRotate), + "angle": 45.0, + "centerX": 255.0, + "centerY": 256.0, + "Paint": { + "Format": int(ot.PaintFormat.PaintSkew), + "xSkewAngle": -11.0, + "ySkewAngle": 5.0, + "centerX": 253.0, + "centerY": 254.0, + "Paint": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "glyph00011", + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "Color": { + "PaletteIndex": 2, + "Alpha": 0.5, + }, + }, }, }, }, }, - }, - ], + ], + ), "glyph00014": { - "format": int(ot.PaintFormat.PaintComposite), - "mode": "src_over", - "source": { - "format": int(ot.PaintFormat.PaintColrGlyph), - "glyph": "glyph00010", + "Format": int(ot.PaintFormat.PaintComposite), + "CompositeMode": "src_over", + "SourcePaint": { + "Format": int(ot.PaintFormat.PaintColrGlyph), + "Glyph": "glyph00010", }, - "backdrop": { - "format": int(ot.PaintFormat.PaintTransform), - "transform": (1.0, 0.0, 0.0, 1.0, 300.0, 0.0), - "paint": { - "format": int(ot.PaintFormat.PaintColrGlyph), - "glyph": "glyph00010", + "BackdropPaint": { + "Format": int(ot.PaintFormat.PaintTransform), + "Transform": (1.0, 0.0, 0.0, 1.0, 300.0, 0.0), + "Paint": { + "Format": int(ot.PaintFormat.PaintColrGlyph), + "Glyph": "glyph00010", }, }, }, "glyph00015": { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00011", - "paint": { - "format": int(ot.PaintFormat.PaintSweepGradient), - "colorLine": { - "stops": [ - {"offset": 0.0, "paletteIndex": 3, "alpha": 1.0}, - {"offset": 1.0, "paletteIndex": 5, "alpha": 1.0}, + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "glyph00011", + "Paint": { + "Format": int(ot.PaintFormat.PaintSweepGradient), + "ColorLine": { + "ColorStop": [ + {"StopOffset": 0.0, "Color": {"PaletteIndex": 3, "Alpha": 1.0}}, + {"StopOffset": 1.0, "Color": {"PaletteIndex": 5, "Alpha": 1.0}}, ], - "extend": "pad", + "Extend": "pad", }, "centerX": 259, "centerY": 300, @@ -121,35 +147,52 @@ TEST_COLOR_GLYPHS = { "endAngle": 135.0, }, }, - "glyph00016": [ - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00011", - "paint": { - "format": int(ot.PaintFormat.PaintSolid), - "paletteIndex": 2, - "alpha": 0.5, - }, - }, - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00012", - "paint": { - "format": int(ot.PaintFormat.PaintLinearGradient), - "colorLine": { - "stops": [ - {"offset": 0.0, "paletteIndex": 3, "alpha": 1.0}, - {"offset": 0.5, "paletteIndex": 4, "alpha": 1.0}, - {"offset": 1.0, "paletteIndex": 5, "alpha": 1.0}, - ], - "extend": "repeat", + "glyph00016": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "glyph00011", + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "Color": { + "PaletteIndex": 2, + "Alpha": 0.5, + }, }, - "p0": (1, 2), - "p1": (-3, -4), - "p2": (5, 6), }, - }, - ], + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "glyph00012", + "Paint": { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "ColorStop": [ + { + "StopOffset": 0.0, + "Color": {"PaletteIndex": 3, "Alpha": 1.0}, + }, + { + "StopOffset": 0.5, + "Color": {"PaletteIndex": 4, "Alpha": 1.0}, + }, + { + "StopOffset": 1.0, + "Color": {"PaletteIndex": 5, "Alpha": 1.0}, + }, + ], + "Extend": "repeat", + }, + "x0": 1, + "y0": 2, + "x1": -3, + "y1": -4, + "x2": 5, + "y2": 6, + }, + }, + ], + ), } From e542b60dde969cf93acf8cc2f545c3144b1ba84c Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 10 Feb 2021 18:44:28 +0000 Subject: [PATCH 074/167] colorLib: add generic TableUnbuilder, reverse of TableBuilder --- Lib/fontTools/colorLib/table_builder.py | 46 ++++++ Lib/fontTools/colorLib/unbuilder.py | 178 +++-------------------- Tests/colorLib/unbuilder_test.py | 184 ++++++++++++------------ 3 files changed, 164 insertions(+), 244 deletions(-) diff --git a/Lib/fontTools/colorLib/table_builder.py b/Lib/fontTools/colorLib/table_builder.py index b80229754..322673466 100644 --- a/Lib/fontTools/colorLib/table_builder.py +++ b/Lib/fontTools/colorLib/table_builder.py @@ -13,6 +13,7 @@ from fontTools.ttLib.tables.otBase import ( from fontTools.ttLib.tables.otConverters import ( ComputedInt, GlyphID, + SimpleValue, Struct, Short, UInt8, @@ -192,3 +193,48 @@ class TableBuilder: )(dest) return dest + + +class TableUnbuilder: + def __init__(self, callbackTable=None): + if callbackTable is None: + callbackTable = {} + self._callbackTable = callbackTable + + def unbuild(self, table): + assert isinstance(table, BaseTable) + + source = {} + + callbackKey = (type(table),) + if isinstance(table, FormatSwitchingBaseTable): + source["Format"] = int(table.Format) + callbackKey += (table.Format,) + + for converter in table.getConverters(): + if isinstance(converter, ComputedInt): + continue + value = getattr(table, converter.name) + + tupleClass = getattr(converter, "tupleClass", None) + enumClass = getattr(converter, "enumClass", None) + if tupleClass: + source[converter.name] = tuple(value) + elif enumClass: + source[converter.name] = value.name.lower() + elif isinstance(converter, Struct): + if converter.repeat: + source[converter.name] = [self.unbuild(v) for v in value] + else: + source[converter.name] = self.unbuild(value) + elif isinstance(converter, SimpleValue): + # "simple" values (e.g. int, float, str) need no further un-building + source[converter.name] = value + else: + raise NotImplementedError( + "Don't know how unbuild {value!r} with {converter!r}" + ) + + source = self._callbackTable.get(callbackKey, lambda s: s)(source) + + return source diff --git a/Lib/fontTools/colorLib/unbuilder.py b/Lib/fontTools/colorLib/unbuilder.py index 1c29d5dd2..43582bde3 100644 --- a/Lib/fontTools/colorLib/unbuilder.py +++ b/Lib/fontTools/colorLib/unbuilder.py @@ -1,47 +1,15 @@ from fontTools.ttLib.tables import otTables as ot +from .table_builder import TableUnbuilder -def unbuildColrV1(layerV1List, baseGlyphV1List, ignoreVarIdx=False): - unbuilder = LayerV1ListUnbuilder(layerV1List.Paint, ignoreVarIdx=ignoreVarIdx) +def unbuildColrV1(layerV1List, baseGlyphV1List): + unbuilder = LayerV1ListUnbuilder(layerV1List.Paint) return { rec.BaseGlyph: unbuilder.unbuildPaint(rec.Paint) for rec in baseGlyphV1List.BaseGlyphV1Record } -def _unbuildVariableValue(v, ignoreVarIdx=False): - return v.value if ignoreVarIdx else (v.value, v.varIdx) - - -def unbuildColorStop(colorStop, ignoreVarIdx=False): - return { - "offset": _unbuildVariableValue( - colorStop.StopOffset, ignoreVarIdx=ignoreVarIdx - ), - "paletteIndex": colorStop.Color.PaletteIndex, - "alpha": _unbuildVariableValue( - colorStop.Color.Alpha, ignoreVarIdx=ignoreVarIdx - ), - } - - -def unbuildColorLine(colorLine, ignoreVarIdx=False): - return { - "stops": [ - unbuildColorStop(stop, ignoreVarIdx=ignoreVarIdx) - for stop in colorLine.ColorStop - ], - "extend": colorLine.Extend.name.lower(), - } - - -def unbuildAffine2x3(transform, ignoreVarIdx=False): - return tuple( - _unbuildVariableValue(getattr(transform, attr), ignoreVarIdx=ignoreVarIdx) - for attr in ("xx", "yx", "xy", "yy", "dx", "dy") - ) - - def _flatten(lst): for el in lst: if isinstance(el, list): @@ -51,142 +19,40 @@ def _flatten(lst): class LayerV1ListUnbuilder: - def __init__(self, layers, ignoreVarIdx=False): + def __init__(self, layers): self.layers = layers - self.ignoreVarIdx = ignoreVarIdx + + callbacks = { + ( + ot.Paint, + ot.PaintFormat.PaintColrLayers, + ): self._unbuildPaintColrLayers, + } + self.tableUnbuilder = TableUnbuilder(callbacks) def unbuildPaint(self, paint): - try: - return self._unbuildFunctions[paint.Format](self, paint) - except KeyError: - raise ValueError(f"Unrecognized paint format: {paint.Format}") + assert isinstance(paint, ot.Paint) + return self.tableUnbuilder.unbuild(paint) - def unbuildVariableValue(self, value): - return _unbuildVariableValue(value, ignoreVarIdx=self.ignoreVarIdx) + def _unbuildPaintColrLayers(self, source): + assert source["Format"] == ot.PaintFormat.PaintColrLayers - def unbuildPaintColrLayers(self, paint): - return list( + layers = list( _flatten( [ self.unbuildPaint(childPaint) for childPaint in self.layers[ - paint.FirstLayerIndex : paint.FirstLayerIndex + paint.NumLayers + source["FirstLayerIndex"] : source["FirstLayerIndex"] + + source["NumLayers"] ] ] ) ) - def unbuildPaintSolid(self, paint): - return { - "format": int(paint.Format), - "paletteIndex": paint.Color.PaletteIndex, - "alpha": self.unbuildVariableValue(paint.Color.Alpha), - } + if len(layers) == 1: + return layers[0] - def unbuildPaintLinearGradient(self, paint): - p0 = (self.unbuildVariableValue(paint.x0), self.unbuildVariableValue(paint.y0)) - p1 = (self.unbuildVariableValue(paint.x1), self.unbuildVariableValue(paint.y1)) - p2 = (self.unbuildVariableValue(paint.x2), self.unbuildVariableValue(paint.y2)) - return { - "format": int(paint.Format), - "colorLine": unbuildColorLine( - paint.ColorLine, ignoreVarIdx=self.ignoreVarIdx - ), - "p0": p0, - "p1": p1, - "p2": p2, - } - - def unbuildPaintRadialGradient(self, paint): - c0 = (self.unbuildVariableValue(paint.x0), self.unbuildVariableValue(paint.y0)) - r0 = self.unbuildVariableValue(paint.r0) - c1 = (self.unbuildVariableValue(paint.x1), self.unbuildVariableValue(paint.y1)) - r1 = self.unbuildVariableValue(paint.r1) - return { - "format": int(paint.Format), - "colorLine": unbuildColorLine( - paint.ColorLine, ignoreVarIdx=self.ignoreVarIdx - ), - "c0": c0, - "r0": r0, - "c1": c1, - "r1": r1, - } - - def unbuildPaintSweepGradient(self, paint): - return { - "format": int(paint.Format), - "colorLine": unbuildColorLine( - paint.ColorLine, ignoreVarIdx=self.ignoreVarIdx - ), - "centerX": self.unbuildVariableValue(paint.centerX), - "centerY": self.unbuildVariableValue(paint.centerY), - "startAngle": self.unbuildVariableValue(paint.startAngle), - "endAngle": self.unbuildVariableValue(paint.endAngle), - } - - def unbuildPaintGlyph(self, paint): - return { - "format": int(paint.Format), - "glyph": paint.Glyph, - "paint": self.unbuildPaint(paint.Paint), - } - - def unbuildPaintColrGlyph(self, paint): - return { - "format": int(paint.Format), - "glyph": paint.Glyph, - } - - def unbuildPaintTransform(self, paint): - return { - "format": int(paint.Format), - "transform": unbuildAffine2x3( - paint.Transform, ignoreVarIdx=self.ignoreVarIdx - ), - "paint": self.unbuildPaint(paint.Paint), - } - - def unbuildPaintTranslate(self, paint): - return { - "format": int(paint.Format), - "dx": self.unbuildVariableValue(paint.dx), - "dy": self.unbuildVariableValue(paint.dy), - "paint": self.unbuildPaint(paint.Paint), - } - - def unbuildPaintRotate(self, paint): - return { - "format": int(paint.Format), - "angle": self.unbuildVariableValue(paint.angle), - "centerX": self.unbuildVariableValue(paint.centerX), - "centerY": self.unbuildVariableValue(paint.centerY), - "paint": self.unbuildPaint(paint.Paint), - } - - def unbuildPaintSkew(self, paint): - return { - "format": int(paint.Format), - "xSkewAngle": self.unbuildVariableValue(paint.xSkewAngle), - "ySkewAngle": self.unbuildVariableValue(paint.ySkewAngle), - "centerX": self.unbuildVariableValue(paint.centerX), - "centerY": self.unbuildVariableValue(paint.centerY), - "paint": self.unbuildPaint(paint.Paint), - } - - def unbuildPaintComposite(self, paint): - return { - "format": int(paint.Format), - "mode": paint.CompositeMode.name.lower(), - "source": self.unbuildPaint(paint.SourcePaint), - "backdrop": self.unbuildPaint(paint.BackdropPaint), - } - - -LayerV1ListUnbuilder._unbuildFunctions = { - pf.value: getattr(LayerV1ListUnbuilder, "unbuild" + pf.name) - for pf in ot.PaintFormat -} + return {"Format": source["Format"], "Layers": layers} if __name__ == "__main__": diff --git a/Tests/colorLib/unbuilder_test.py b/Tests/colorLib/unbuilder_test.py index fb22abcdb..6728720f6 100644 --- a/Tests/colorLib/unbuilder_test.py +++ b/Tests/colorLib/unbuilder_test.py @@ -5,198 +5,206 @@ import pytest TEST_COLOR_GLYPHS = { - "glyph00010": ( - ot.PaintFormat.PaintColrLayers, - [ + "glyph00010": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ { "Format": int(ot.PaintFormat.PaintGlyph), - "Glyph": "glyph00011", "Paint": { "Format": int(ot.PaintFormat.PaintSolid), - "Color": { - "PaletteIndex": 2, - "Alpha": 0.5, - }, + "Color": {"PaletteIndex": 2, "Alpha": (0.5, 0)}, }, + "Glyph": "glyph00011", }, { "Format": int(ot.PaintFormat.PaintGlyph), - "Glyph": "glyph00012", "Paint": { "Format": int(ot.PaintFormat.PaintLinearGradient), "ColorLine": { + "Extend": "repeat", "ColorStop": [ { - "StopOffset": 0.0, - "Color": {"PaletteIndex": 3, "Alpha": 1.0}, + "StopOffset": (0.0, 0), + "Color": {"PaletteIndex": 3, "Alpha": (1.0, 0)}, }, { - "StopOffset": 0.5, - "Color": {"PaletteIndex": 4, "Alpha": 1.0}, + "StopOffset": (0.5, 0), + "Color": {"PaletteIndex": 4, "Alpha": (1.0, 0)}, }, { - "StopOffset": 1.0, - "Color": {"PaletteIndex": 5, "Alpha": 1.0}, + "StopOffset": (1.0, 0), + "Color": {"PaletteIndex": 5, "Alpha": (1.0, 0)}, }, ], - "Extend": "repeat", }, - "x0": 1, - "y0": 2, - "x1": -3, - "y1": -4, - "x2": 5, - "y2": 6, + "x0": (1, 0), + "y0": (2, 0), + "x1": (-3, 0), + "y1": (-4, 0), + "x2": (5, 0), + "y2": (6, 0), }, + "Glyph": "glyph00012", }, { "Format": int(ot.PaintFormat.PaintGlyph), - "Glyph": "glyph00013", "Paint": { "Format": int(ot.PaintFormat.PaintTransform), - "Transform": (-13.0, 14.0, 15.0, -17.0, 18.0, 19.0), "Paint": { "Format": int(ot.PaintFormat.PaintRadialGradient), "ColorLine": { + "Extend": "pad", "ColorStop": [ { - "StopOffset": 0.0, - "Color": {"PaletteIndex": 6, "Alpha": 1.0}, + "StopOffset": (0.0, 0), + "Color": {"PaletteIndex": 6, "Alpha": (1.0, 0)}, }, { - "StopOffset": 1.0, - "Color": { - "PaletteIndex": 7, - "Alpha": 0.4, - }, + "StopOffset": (1.0, 0), + "Color": {"PaletteIndex": 7, "Alpha": (0.4, 0)}, }, ], - "Extend": "pad", }, - "x0": 7, - "y0": 8, - "r0": 9, - "x1": 10, - "y1": 11, - "r1": 12, + "x0": (7, 0), + "y0": (8, 0), + "r0": (9, 0), + "x1": (10, 0), + "y1": (11, 0), + "r1": (12, 0), + }, + "Transform": { + "xx": (-13.0, 0), + "yx": (14.0, 0), + "xy": (15.0, 0), + "yy": (-17.0, 0), + "dx": (18.0, 0), + "dy": (19.0, 0), }, }, + "Glyph": "glyph00013", }, { "Format": int(ot.PaintFormat.PaintTranslate), - "dx": 257.0, - "dy": 258.0, "Paint": { "Format": int(ot.PaintFormat.PaintRotate), - "angle": 45.0, - "centerX": 255.0, - "centerY": 256.0, "Paint": { "Format": int(ot.PaintFormat.PaintSkew), - "xSkewAngle": -11.0, - "ySkewAngle": 5.0, - "centerX": 253.0, - "centerY": 254.0, "Paint": { "Format": int(ot.PaintFormat.PaintGlyph), - "Glyph": "glyph00011", "Paint": { "Format": int(ot.PaintFormat.PaintSolid), - "Color": { - "PaletteIndex": 2, - "Alpha": 0.5, - }, + "Color": {"PaletteIndex": 2, "Alpha": (0.5, 0)}, }, + "Glyph": "glyph00011", }, + "xSkewAngle": (-11.0, 0), + "ySkewAngle": (5.0, 0), + "centerX": (253.0, 0), + "centerY": (254.0, 0), }, + "angle": (45.0, 0), + "centerX": (255.0, 0), + "centerY": (256.0, 0), }, + "dx": (257.0, 0), + "dy": (258.0, 0), }, ], - ), + }, "glyph00014": { "Format": int(ot.PaintFormat.PaintComposite), - "CompositeMode": "src_over", "SourcePaint": { "Format": int(ot.PaintFormat.PaintColrGlyph), "Glyph": "glyph00010", }, + "CompositeMode": "src_over", "BackdropPaint": { "Format": int(ot.PaintFormat.PaintTransform), - "Transform": (1.0, 0.0, 0.0, 1.0, 300.0, 0.0), "Paint": { "Format": int(ot.PaintFormat.PaintColrGlyph), "Glyph": "glyph00010", }, + "Transform": { + "xx": (1.0, 0), + "yx": (0.0, 0), + "xy": (0.0, 0), + "yy": (1.0, 0), + "dx": (300.0, 0), + "dy": (0.0, 0), + }, }, }, "glyph00015": { "Format": int(ot.PaintFormat.PaintGlyph), - "Glyph": "glyph00011", "Paint": { "Format": int(ot.PaintFormat.PaintSweepGradient), "ColorLine": { - "ColorStop": [ - {"StopOffset": 0.0, "Color": {"PaletteIndex": 3, "Alpha": 1.0}}, - {"StopOffset": 1.0, "Color": {"PaletteIndex": 5, "Alpha": 1.0}}, - ], "Extend": "pad", + "ColorStop": [ + { + "StopOffset": (0.0, 0), + "Color": {"PaletteIndex": 3, "Alpha": (1.0, 0)}, + }, + { + "StopOffset": (1.0, 0), + "Color": {"PaletteIndex": 5, "Alpha": (1.0, 0)}, + }, + ], }, - "centerX": 259, - "centerY": 300, - "startAngle": 45.0, - "endAngle": 135.0, + "centerX": (259, 0), + "centerY": (300, 0), + "startAngle": (45.0, 0), + "endAngle": (135.0, 0), }, + "Glyph": "glyph00011", }, - "glyph00016": ( - ot.PaintFormat.PaintColrLayers, - [ + "glyph00016": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ { "Format": int(ot.PaintFormat.PaintGlyph), - "Glyph": "glyph00011", "Paint": { "Format": int(ot.PaintFormat.PaintSolid), - "Color": { - "PaletteIndex": 2, - "Alpha": 0.5, - }, + "Color": {"PaletteIndex": 2, "Alpha": (0.5, 0)}, }, + "Glyph": "glyph00011", }, { "Format": int(ot.PaintFormat.PaintGlyph), - "Glyph": "glyph00012", "Paint": { "Format": int(ot.PaintFormat.PaintLinearGradient), "ColorLine": { + "Extend": "repeat", "ColorStop": [ { - "StopOffset": 0.0, - "Color": {"PaletteIndex": 3, "Alpha": 1.0}, + "StopOffset": (0.0, 0), + "Color": {"PaletteIndex": 3, "Alpha": (1.0, 0)}, }, { - "StopOffset": 0.5, - "Color": {"PaletteIndex": 4, "Alpha": 1.0}, + "StopOffset": (0.5, 0), + "Color": {"PaletteIndex": 4, "Alpha": (1.0, 0)}, }, { - "StopOffset": 1.0, - "Color": {"PaletteIndex": 5, "Alpha": 1.0}, + "StopOffset": (1.0, 0), + "Color": {"PaletteIndex": 5, "Alpha": (1.0, 0)}, }, ], - "Extend": "repeat", }, - "x0": 1, - "y0": 2, - "x1": -3, - "y1": -4, - "x2": 5, - "y2": 6, + "x0": (1, 0), + "y0": (2, 0), + "x1": (-3, 0), + "y1": (-4, 0), + "x2": (5, 0), + "y2": (6, 0), }, + "Glyph": "glyph00012", }, ], - ), + }, } def test_unbuildColrV1(): layersV1, baseGlyphsV1 = buildColrV1(TEST_COLOR_GLYPHS) - colorGlyphs = unbuildColrV1(layersV1, baseGlyphsV1, ignoreVarIdx=True) + colorGlyphs = unbuildColrV1(layersV1, baseGlyphsV1) assert colorGlyphs == TEST_COLOR_GLYPHS From 567aadcc72fac385d7921eda4820a79ffdacf634 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 11 Feb 2021 14:08:36 +0000 Subject: [PATCH 075/167] Document setupFvar --- Lib/fontTools/fontBuilder.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index 6a04725f1..581626a9b 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -628,6 +628,19 @@ class FontBuilder(object): self.calcGlyphBounds() def setupFvar(self, axes, instances): + """Adds an font variation table to the font. + + Args: + axes (list): See below. + instances (list): See below. + + The axes should be a list of iterables; each axis should be supplied + in the format ```tupletag, minValue, defaultValue, maxValue, name``. + The instances should be a list of dicts; each instance should be supplied + as a dict with keys ``location`` (mapping of axis tags to float values), + ``stylename`` and (optionally) ``postscriptfontname``. + """ + addFvar(self.font, axes, instances) def setupGvar(self, variations): From 9e03da03da46a6ca227fd2122bd85814d5c372f8 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 11 Feb 2021 14:09:41 +0000 Subject: [PATCH 076/167] Support localised axis names --- Lib/fontTools/fontBuilder.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index 581626a9b..cd16ea5e3 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -636,6 +636,9 @@ class FontBuilder(object): The axes should be a list of iterables; each axis should be supplied in the format ```tupletag, minValue, defaultValue, maxValue, name``. + The ``name`` is either a string, or a dict, mapping language codes + to strings, to allow localized name table entries. + The instances should be a list of dicts; each instance should be supplied as a dict with keys ``location`` (mapping of axis tags to float values), ``stylename`` and (optionally) ``postscriptfontname``. @@ -851,7 +854,10 @@ def addFvar(font, axes, instances): axis = Axis() axis.axisTag = Tag(tag) axis.minValue, axis.defaultValue, axis.maxValue = minValue, defaultValue, maxValue - axis.axisNameID = nameTable.addName(tounicode(name)) + if isinstance(name, basestring): + name = dict(en=tounicode(name)) + + axis.axisNameID = nameTable.addMultilingualName(name, ttFont=font) fvar.axes.append(axis) for instance in instances: From c51c61f436693490f5bdb5195eef84c43564073c Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 11 Feb 2021 15:15:35 +0000 Subject: [PATCH 077/167] Remove py23isms --- Lib/fontTools/fontBuilder.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index cd16ea5e3..07a649dcd 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -842,7 +842,6 @@ def buildCmapSubTable(cmapping, format, platformID, platEncID): def addFvar(font, axes, instances): - from .misc.py23 import Tag, tounicode from .ttLib.tables._f_v_a_r import Axis, NamedInstance assert axes @@ -852,23 +851,23 @@ def addFvar(font, axes, instances): for tag, minValue, defaultValue, maxValue, name in axes: axis = Axis() - axis.axisTag = Tag(tag) + axis.axisTag = tag axis.minValue, axis.defaultValue, axis.maxValue = minValue, defaultValue, maxValue - if isinstance(name, basestring): - name = dict(en=tounicode(name)) + if isinstance(name, str): + name = dict(en=name) axis.axisNameID = nameTable.addMultilingualName(name, ttFont=font) fvar.axes.append(axis) for instance in instances: coordinates = instance['location'] - name = tounicode(instance['stylename']) + name = instance['stylename'] psname = instance.get('postscriptfontname') inst = NamedInstance() inst.subfamilyNameID = nameTable.addName(name) if psname is not None: - psname = tounicode(psname) + psname = psname inst.postscriptNameID = nameTable.addName(psname) inst.coordinates = coordinates fvar.instances.append(inst) From 8a58225e6d1980662efca1c279a8a4d9ef7336c2 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 11 Feb 2021 15:27:40 +0000 Subject: [PATCH 078/167] Typo --- Lib/fontTools/fontBuilder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index 07a649dcd..b1525ed59 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -628,7 +628,7 @@ class FontBuilder(object): self.calcGlyphBounds() def setupFvar(self, axes, instances): - """Adds an font variation table to the font. + """Adds an font variations table to the font. Args: axes (list): See below. From 731f6a3107f3a106045f36fb4160b0af1654659e Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 11 Feb 2021 15:44:54 +0000 Subject: [PATCH 079/167] Take AxisDescriptor (or objects conforming to its interface) in addFvar --- Lib/fontTools/fontBuilder.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index b1525ed59..370425de2 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -634,8 +634,9 @@ class FontBuilder(object): axes (list): See below. instances (list): See below. - The axes should be a list of iterables; each axis should be supplied - in the format ```tupletag, minValue, defaultValue, maxValue, name``. + ``axes`` should be a list as axes, with each axis either supplied as + a py:class:`.designspaceLib.AxisDescriptor` object, or a tuple in the + format ```tupletag, minValue, defaultValue, maxValue, name``. The ``name`` is either a string, or a dict, mapping language codes to strings, to allow localized name table entries. @@ -843,16 +844,33 @@ def buildCmapSubTable(cmapping, format, platformID, platEncID): def addFvar(font, axes, instances): from .ttLib.tables._f_v_a_r import Axis, NamedInstance + from .designspaceLib import AxisDescriptor assert axes - fvar = newTable('fvar') - nameTable = font['name'] + fvar = newTable("fvar") + nameTable = font["name"] - for tag, minValue, defaultValue, maxValue, name in axes: + for axis_def in axes: axis = Axis() - axis.axisTag = tag - axis.minValue, axis.defaultValue, axis.maxValue = minValue, defaultValue, maxValue + + if isinstance(axis_def, tuple): + ( + axis.axisTag, + axis.minValue, + axis.defaultValue, + axis.maxValue, + name, + ) = axis_def + else: + (axis.axisTag, axis.minValue, axis.defaultValue, axis.maxValue, name) = ( + axis_def.tag, + axis_def.minimum, + axis_def.default, + axis_def.maximum, + axis_def.name, + ) + if isinstance(name, str): name = dict(en=name) From 4a8617fc92c6ee8f63a28fcece62bc2f8497cf7f Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 11 Feb 2021 15:46:05 +0000 Subject: [PATCH 080/167] New setAvar method --- Lib/fontTools/fontBuilder.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index 370425de2..fb62e98d5 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -136,6 +136,7 @@ from .ttLib.tables._c_m_a_p import cmap_classes from .ttLib.tables._n_a_m_e import NameRecord, makeName from .misc.timeTools import timestampNow import struct +from collections import OrderedDict _headDefaults = dict( @@ -647,6 +648,16 @@ class FontBuilder(object): addFvar(self.font, axes, instances) + def setupAvar(self, axes): + """Adds an axis variations table to the font. + + Args: + axes (list): A list of py:class:`.designspaceLib.AxisDescriptor` objects. + """ + from .varLib import _add_avar + + _add_avar(self.font, OrderedDict(enumerate(axes))) # Only values are used + def setupGvar(self, variations): gvar = self.font["gvar"] = newTable('gvar') gvar.version = 1 From 04f04474fdefe33b15019abae4bf4b5dc8957d71 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 11 Feb 2021 15:49:21 +0000 Subject: [PATCH 081/167] Reformat with black for ease of further maintenance --- Lib/fontTools/fontBuilder.py | 398 +++++++++++++++++++---------------- 1 file changed, 217 insertions(+), 181 deletions(-) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index fb62e98d5..0a6be203a 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -1,4 +1,3 @@ - __all__ = ["FontBuilder"] """ @@ -140,189 +139,188 @@ from collections import OrderedDict _headDefaults = dict( - tableVersion = 1.0, - fontRevision = 1.0, - checkSumAdjustment = 0, - magicNumber = 0x5F0F3CF5, - flags = 0x0003, - unitsPerEm = 1000, - created = 0, - modified = 0, - xMin = 0, - yMin = 0, - xMax = 0, - yMax = 0, - macStyle = 0, - lowestRecPPEM = 3, - fontDirectionHint = 2, - indexToLocFormat = 0, - glyphDataFormat = 0, + tableVersion=1.0, + fontRevision=1.0, + checkSumAdjustment=0, + magicNumber=0x5F0F3CF5, + flags=0x0003, + unitsPerEm=1000, + created=0, + modified=0, + xMin=0, + yMin=0, + xMax=0, + yMax=0, + macStyle=0, + lowestRecPPEM=3, + fontDirectionHint=2, + indexToLocFormat=0, + glyphDataFormat=0, ) _maxpDefaultsTTF = dict( - tableVersion = 0x00010000, - numGlyphs = 0, - maxPoints = 0, - maxContours = 0, - maxCompositePoints = 0, - maxCompositeContours = 0, - maxZones = 2, - maxTwilightPoints = 0, - maxStorage = 0, - maxFunctionDefs = 0, - maxInstructionDefs = 0, - maxStackElements = 0, - maxSizeOfInstructions = 0, - maxComponentElements = 0, - maxComponentDepth = 0, + tableVersion=0x00010000, + numGlyphs=0, + maxPoints=0, + maxContours=0, + maxCompositePoints=0, + maxCompositeContours=0, + maxZones=2, + maxTwilightPoints=0, + maxStorage=0, + maxFunctionDefs=0, + maxInstructionDefs=0, + maxStackElements=0, + maxSizeOfInstructions=0, + maxComponentElements=0, + maxComponentDepth=0, ) _maxpDefaultsOTF = dict( - tableVersion = 0x00005000, - numGlyphs = 0, + tableVersion=0x00005000, + numGlyphs=0, ) _postDefaults = dict( - formatType = 3.0, - italicAngle = 0, - underlinePosition = 0, - underlineThickness = 0, - isFixedPitch = 0, - minMemType42 = 0, - maxMemType42 = 0, - minMemType1 = 0, - maxMemType1 = 0, + formatType=3.0, + italicAngle=0, + underlinePosition=0, + underlineThickness=0, + isFixedPitch=0, + minMemType42=0, + maxMemType42=0, + minMemType1=0, + maxMemType1=0, ) _hheaDefaults = dict( - tableVersion = 0x00010000, - ascent = 0, - descent = 0, - lineGap = 0, - advanceWidthMax = 0, - minLeftSideBearing = 0, - minRightSideBearing = 0, - xMaxExtent = 0, - caretSlopeRise = 1, - caretSlopeRun = 0, - caretOffset = 0, - reserved0 = 0, - reserved1 = 0, - reserved2 = 0, - reserved3 = 0, - metricDataFormat = 0, - numberOfHMetrics = 0, + tableVersion=0x00010000, + ascent=0, + descent=0, + lineGap=0, + advanceWidthMax=0, + minLeftSideBearing=0, + minRightSideBearing=0, + xMaxExtent=0, + caretSlopeRise=1, + caretSlopeRun=0, + caretOffset=0, + reserved0=0, + reserved1=0, + reserved2=0, + reserved3=0, + metricDataFormat=0, + numberOfHMetrics=0, ) _vheaDefaults = dict( - tableVersion = 0x00010000, - ascent = 0, - descent = 0, - lineGap = 0, - advanceHeightMax = 0, - minTopSideBearing = 0, - minBottomSideBearing = 0, - yMaxExtent = 0, - caretSlopeRise = 0, - caretSlopeRun = 0, - reserved0 = 0, - reserved1 = 0, - reserved2 = 0, - reserved3 = 0, - reserved4 = 0, - metricDataFormat = 0, - numberOfVMetrics = 0, + tableVersion=0x00010000, + ascent=0, + descent=0, + lineGap=0, + advanceHeightMax=0, + minTopSideBearing=0, + minBottomSideBearing=0, + yMaxExtent=0, + caretSlopeRise=0, + caretSlopeRun=0, + reserved0=0, + reserved1=0, + reserved2=0, + reserved3=0, + reserved4=0, + metricDataFormat=0, + numberOfVMetrics=0, ) _nameIDs = dict( - copyright = 0, - familyName = 1, - styleName = 2, - uniqueFontIdentifier = 3, - fullName = 4, - version = 5, - psName = 6, - trademark = 7, - manufacturer = 8, - designer = 9, - description = 10, - vendorURL = 11, - designerURL = 12, - licenseDescription = 13, - licenseInfoURL = 14, - # reserved = 15, - typographicFamily = 16, - typographicSubfamily = 17, - compatibleFullName = 18, - sampleText = 19, - postScriptCIDFindfontName = 20, - wwsFamilyName = 21, - wwsSubfamilyName = 22, - lightBackgroundPalette = 23, - darkBackgroundPalette = 24, - variationsPostScriptNamePrefix = 25, + copyright=0, + familyName=1, + styleName=2, + uniqueFontIdentifier=3, + fullName=4, + version=5, + psName=6, + trademark=7, + manufacturer=8, + designer=9, + description=10, + vendorURL=11, + designerURL=12, + licenseDescription=13, + licenseInfoURL=14, + # reserved = 15, + typographicFamily=16, + typographicSubfamily=17, + compatibleFullName=18, + sampleText=19, + postScriptCIDFindfontName=20, + wwsFamilyName=21, + wwsSubfamilyName=22, + lightBackgroundPalette=23, + darkBackgroundPalette=24, + variationsPostScriptNamePrefix=25, ) # to insert in setupNameTable doc string: # print("\n".join(("%s (nameID %s)" % (k, v)) for k, v in sorted(_nameIDs.items(), key=lambda x: x[1]))) _panoseDefaults = dict( - bFamilyType = 0, - bSerifStyle = 0, - bWeight = 0, - bProportion = 0, - bContrast = 0, - bStrokeVariation = 0, - bArmStyle = 0, - bLetterForm = 0, - bMidline = 0, - bXHeight = 0, + bFamilyType=0, + bSerifStyle=0, + bWeight=0, + bProportion=0, + bContrast=0, + bStrokeVariation=0, + bArmStyle=0, + bLetterForm=0, + bMidline=0, + bXHeight=0, ) _OS2Defaults = dict( - version = 3, - xAvgCharWidth = 0, - usWeightClass = 400, - usWidthClass = 5, - fsType = 0x0004, # default: Preview & Print embedding - ySubscriptXSize = 0, - ySubscriptYSize = 0, - ySubscriptXOffset = 0, - ySubscriptYOffset = 0, - ySuperscriptXSize = 0, - ySuperscriptYSize = 0, - ySuperscriptXOffset = 0, - ySuperscriptYOffset = 0, - yStrikeoutSize = 0, - yStrikeoutPosition = 0, - sFamilyClass = 0, - panose = _panoseDefaults, - ulUnicodeRange1 = 0, - ulUnicodeRange2 = 0, - ulUnicodeRange3 = 0, - ulUnicodeRange4 = 0, - achVendID = "????", - fsSelection = 0, - usFirstCharIndex = 0, - usLastCharIndex = 0, - sTypoAscender = 0, - sTypoDescender = 0, - sTypoLineGap = 0, - usWinAscent = 0, - usWinDescent = 0, - ulCodePageRange1 = 0, - ulCodePageRange2 = 0, - sxHeight = 0, - sCapHeight = 0, - usDefaultChar = 0, # .notdef - usBreakChar = 32, # space - usMaxContext = 0, - usLowerOpticalPointSize = 0, - usUpperOpticalPointSize = 0, + version=3, + xAvgCharWidth=0, + usWeightClass=400, + usWidthClass=5, + fsType=0x0004, # default: Preview & Print embedding + ySubscriptXSize=0, + ySubscriptYSize=0, + ySubscriptXOffset=0, + ySubscriptYOffset=0, + ySuperscriptXSize=0, + ySuperscriptYSize=0, + ySuperscriptXOffset=0, + ySuperscriptYOffset=0, + yStrikeoutSize=0, + yStrikeoutPosition=0, + sFamilyClass=0, + panose=_panoseDefaults, + ulUnicodeRange1=0, + ulUnicodeRange2=0, + ulUnicodeRange3=0, + ulUnicodeRange4=0, + achVendID="????", + fsSelection=0, + usFirstCharIndex=0, + usLastCharIndex=0, + sTypoAscender=0, + sTypoDescender=0, + sTypoLineGap=0, + usWinAscent=0, + usWinDescent=0, + ulCodePageRange1=0, + ulCodePageRange2=0, + sxHeight=0, + sCapHeight=0, + usDefaultChar=0, # .notdef + usBreakChar=32, # space + usMaxContext=0, + usLowerOpticalPointSize=0, + usUpperOpticalPointSize=0, ) class FontBuilder(object): - def __init__(self, unitsPerEm=None, font=None, isTTF=True): """Initialize a FontBuilder instance. @@ -396,7 +394,7 @@ class FontBuilder(object): """ subTables = [] highestUnicode = max(cmapping) - if highestUnicode > 0xffff: + if highestUnicode > 0xFFFF: cmapping_3_1 = dict((k, v) for k, v in cmapping.items() if k < 0x10000) subTable_3_10 = buildCmapSubTable(cmapping, 12, 3, 10) subTables.append(subTable_3_10) @@ -409,7 +407,9 @@ class FontBuilder(object): except struct.error: # format 4 overflowed, fall back to format 12 if not allowFallback: - raise ValueError("cmap format 4 subtable overflowed; sort glyph order by unicode to fix.") + raise ValueError( + "cmap format 4 subtable overflowed; sort glyph order by unicode to fix." + ) format = 12 subTable_3_1 = buildCmapSubTable(cmapping_3_1, format, 3, 1) subTables.append(subTable_3_1) @@ -490,17 +490,33 @@ class FontBuilder(object): """ if "xAvgCharWidth" not in values: gs = self.font.getGlyphSet() - widths = [gs[glyphName].width for glyphName in gs.keys() if gs[glyphName].width > 0] + widths = [ + gs[glyphName].width + for glyphName in gs.keys() + if gs[glyphName].width > 0 + ] values["xAvgCharWidth"] = int(round(sum(widths) / float(len(widths)))) self._initTableWithValues("OS/2", _OS2Defaults, values) - if not ("ulUnicodeRange1" in values or "ulUnicodeRange2" in values or - "ulUnicodeRange3" in values or "ulUnicodeRange3" in values): - assert "cmap" in self.font, "the 'cmap' table must be setup before the 'OS/2' table" + if not ( + "ulUnicodeRange1" in values + or "ulUnicodeRange2" in values + or "ulUnicodeRange3" in values + or "ulUnicodeRange3" in values + ): + assert ( + "cmap" in self.font + ), "the 'cmap' table must be setup before the 'OS/2' table" self.font["OS/2"].recalcUnicodeRanges(self.font) def setupCFF(self, psName, fontInfo, charStringsDict, privateDict): - from .cffLib import CFFFontSet, TopDictIndex, TopDict, CharStrings, \ - GlobalSubrsIndex, PrivateDict + from .cffLib import ( + CFFFontSet, + TopDictIndex, + TopDict, + CharStrings, + GlobalSubrsIndex, + PrivateDict, + ) assert not self.isTTF self.font.sfntVersion = "OTTO" @@ -529,7 +545,9 @@ class FontBuilder(object): scale = 1 / self.font["head"].unitsPerEm topDict.FontMatrix = [scale, 0, 0, scale, 0, 0] - charStrings = CharStrings(None, topDict.charset, globalSubrs, private, fdSelect, fdArray) + charStrings = CharStrings( + None, topDict.charset, globalSubrs, private, fdSelect, fdArray + ) for glyphName, charString in charStringsDict.items(): charString.private = private charString.globalSubrs = globalSubrs @@ -542,8 +560,16 @@ class FontBuilder(object): self.font["CFF "].cff = fontSet def setupCFF2(self, charStringsDict, fdArrayList=None, regions=None): - from .cffLib import CFFFontSet, TopDictIndex, TopDict, CharStrings, \ - GlobalSubrsIndex, PrivateDict, FDArrayIndex, FontDict + from .cffLib import ( + CFFFontSet, + TopDictIndex, + TopDict, + CharStrings, + GlobalSubrsIndex, + PrivateDict, + FDArrayIndex, + FontDict, + ) assert not self.isTTF self.font.sfntVersion = "OTTO" @@ -659,7 +685,7 @@ class FontBuilder(object): _add_avar(self.font, OrderedDict(enumerate(axes))) # Only values are used def setupGvar(self, variations): - gvar = self.font["gvar"] = newTable('gvar') + gvar = self.font["gvar"] = newTable("gvar") gvar.version = 1 gvar.reserved = 0 gvar.variations = variations @@ -678,7 +704,7 @@ class FontBuilder(object): The `metrics` argument must be a dict, mapping glyph names to `(width, leftSidebearing)` tuples. """ - self.setupMetrics('hmtx', metrics) + self.setupMetrics("hmtx", metrics) def setupVerticalMetrics(self, metrics): """Create a new `vmtx` table, for horizontal metrics. @@ -686,7 +712,7 @@ class FontBuilder(object): The `metrics` argument must be a dict, mapping glyph names to `(height, topSidebearing)` tuples. """ - self.setupMetrics('vmtx', metrics) + self.setupMetrics("vmtx", metrics) def setupMetrics(self, tableTag, metrics): """See `setupHorizontalMetrics()` and `setupVerticalMetrics()`.""" @@ -727,8 +753,14 @@ class FontBuilder(object): bag[vorg] = 1 else: bag[vorg] += 1 - defaultVerticalOrigin = sorted(bag, key=lambda vorg: bag[vorg], reverse=True)[0] - self._initTableWithValues("VORG", {}, dict(VOriginRecords={}, defaultVertOriginY=defaultVerticalOrigin)) + defaultVerticalOrigin = sorted( + bag, key=lambda vorg: bag[vorg], reverse=True + )[0] + self._initTableWithValues( + "VORG", + {}, + dict(VOriginRecords={}, defaultVertOriginY=defaultVerticalOrigin), + ) vorgTable = self.font["VORG"] vorgTable.majorVersion = 1 vorgTable.minorVersion = 0 @@ -739,7 +771,7 @@ class FontBuilder(object): """Create a new `post` table and initialize it with default values, which can be overridden by keyword arguments. """ - isCFF2 = 'CFF2' in self.font + isCFF2 = "CFF2" in self.font postTable = self._initTableWithValues("post", _postDefaults, values) if (self.isTTF or isCFF2) and keepGlyphNames: postTable.formatType = 2.0 @@ -763,10 +795,10 @@ class FontBuilder(object): happy. This does not properly sign the font. """ values = dict( - ulVersion = 1, - usFlag = 0, - usNumSigs = 0, - signatureRecords = [], + ulVersion=1, + usFlag=0, + usNumSigs=0, + signatureRecords=[], ) self._initTableWithValues("DSIG", {}, values) @@ -782,7 +814,10 @@ class FontBuilder(object): `fontTools.feaLib` for details. """ from .feaLib.builder import addOpenTypeFeaturesFromString - addOpenTypeFeaturesFromString(self.font, features, filename=filename, tables=tables) + + addOpenTypeFeaturesFromString( + self.font, features, filename=filename, tables=tables + ) def addFeatureVariations(self, conditionalSubstitutions, featureTag="rvrn"): """Add conditional substitutions to a Variable Font. @@ -831,7 +866,7 @@ class FontBuilder(object): paletteTypes=paletteTypes, paletteLabels=paletteLabels, paletteEntryLabels=paletteEntryLabels, - nameTable=self.font.get("name") + nameTable=self.font.get("name"), ) def setupStat(self, axes, locations=None, elidedFallbackName=2): @@ -841,6 +876,7 @@ class FontBuilder(object): the arguments. """ from .otlLib.builder import buildStatTable + buildStatTable(self.font, axes, locations, elidedFallbackName) @@ -889,9 +925,9 @@ def addFvar(font, axes, instances): fvar.axes.append(axis) for instance in instances: - coordinates = instance['location'] - name = instance['stylename'] - psname = instance.get('postscriptfontname') + coordinates = instance["location"] + name = instance["stylename"] + psname = instance.get("postscriptfontname") inst = NamedInstance() inst.subfamilyNameID = nameTable.addName(name) @@ -901,4 +937,4 @@ def addFvar(font, axes, instances): inst.coordinates = coordinates fvar.instances.append(inst) - font['fvar'] = fvar + font["fvar"] = fvar From d470ea48709617562798777d07a56bff78daebad Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 11 Feb 2021 15:53:35 +0000 Subject: [PATCH 082/167] Typo --- Lib/fontTools/fontBuilder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index 0a6be203a..3b7127000 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -661,7 +661,7 @@ class FontBuilder(object): axes (list): See below. instances (list): See below. - ``axes`` should be a list as axes, with each axis either supplied as + ``axes`` should be a list of axes, with each axis either supplied as a py:class:`.designspaceLib.AxisDescriptor` object, or a tuple in the format ```tupletag, minValue, defaultValue, maxValue, name``. The ``name`` is either a string, or a dict, mapping language codes From 285f861e454536a74fa7263fa3ffa4d460972d1e Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 11 Feb 2021 16:05:02 +0000 Subject: [PATCH 083/167] Localize instance subfamily name too. --- Lib/fontTools/fontBuilder.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index 3b7127000..75513905f 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -670,6 +670,8 @@ class FontBuilder(object): The instances should be a list of dicts; each instance should be supplied as a dict with keys ``location`` (mapping of axis tags to float values), ``stylename`` and (optionally) ``postscriptfontname``. + The ``stylename`` is either a string, or a dict, mapping language codes + to strings, to allow localized name table entries. """ addFvar(self.font, axes, instances) @@ -928,9 +930,11 @@ def addFvar(font, axes, instances): coordinates = instance["location"] name = instance["stylename"] psname = instance.get("postscriptfontname") + if isinstance(name, str): + name = dict(en=name) inst = NamedInstance() - inst.subfamilyNameID = nameTable.addName(name) + inst.subfamilyNameID = nameTable.addMultilingualName(name, ttFont=font) if psname is not None: psname = psname inst.postscriptNameID = nameTable.addName(psname) From 1c259eee5fc95faffa323d2b6daac10f77c9af42 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 11 Feb 2021 16:07:05 +0000 Subject: [PATCH 084/167] Let instances optionally be InstanceDescriptors Because if you've got an AxisDescriptor, you've probably got one of those as well... --- Lib/fontTools/fontBuilder.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index 75513905f..d1c0d43cc 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -667,8 +667,9 @@ class FontBuilder(object): The ``name`` is either a string, or a dict, mapping language codes to strings, to allow localized name table entries. - The instances should be a list of dicts; each instance should be supplied - as a dict with keys ``location`` (mapping of axis tags to float values), + ```instances`` should be a list of instances, with each instance either + supplied as a py:class:`.designspaceLib.InstanceDescriptor` object, or a + dict with keys ``location`` (mapping of axis tags to float values), ``stylename`` and (optionally) ``postscriptfontname``. The ``stylename`` is either a string, or a dict, mapping language codes to strings, to allow localized name table entries. @@ -927,9 +928,15 @@ def addFvar(font, axes, instances): fvar.axes.append(axis) for instance in instances: - coordinates = instance["location"] - name = instance["stylename"] - psname = instance.get("postscriptfontname") + if isinstance(instance, dict): + coordinates = instance["location"] + name = instance["stylename"] + psname = instance.get("postscriptfontname") + else: + coordinates = instance.location + name = instance.localisedStyleName or instance.styleName + psname = instance.postScriptFontName + if isinstance(name, str): name = dict(en=name) From 3871537abd1a2b3da466af646694ae79c68b1054 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Fri, 12 Feb 2021 12:11:51 +0000 Subject: [PATCH 085/167] Update test expectations; multilingual names got shared instead of duplicated --- Tests/fontBuilder/data/test_var.otf.ttx | 10 ++-------- Tests/fontBuilder/data/test_var.ttf.ttx | 22 ++++++++-------------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/Tests/fontBuilder/data/test_var.otf.ttx b/Tests/fontBuilder/data/test_var.otf.ttx index ccf64dc62..09246e5bd 100644 --- a/Tests/fontBuilder/data/test_var.otf.ttx +++ b/Tests/fontBuilder/data/test_var.otf.ttx @@ -141,9 +141,6 @@ Test Axis - TotallyNormal - - TotallyTested @@ -165,9 +162,6 @@ Test Axis - TotallyNormal - - TotallyTested @@ -290,12 +284,12 @@ - + - + diff --git a/Tests/fontBuilder/data/test_var.ttf.ttx b/Tests/fontBuilder/data/test_var.ttf.ttx index ed8fd3075..781bb6460 100644 --- a/Tests/fontBuilder/data/test_var.ttf.ttx +++ b/Tests/fontBuilder/data/test_var.ttf.ttx @@ -199,12 +199,9 @@ Down - TotallyNormal - - Right Up - + Neutral @@ -235,12 +232,9 @@ Down - TotallyNormal - - Right Up - + Neutral @@ -400,7 +394,7 @@ - + @@ -412,7 +406,7 @@ - + @@ -424,7 +418,7 @@ - + @@ -436,7 +430,7 @@ - + @@ -492,7 +486,7 @@ - + @@ -500,7 +494,7 @@ - + From 4fb666fee92a9d4b9702f6b6da7e8299433b83af Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Fri, 12 Feb 2021 12:43:58 +0000 Subject: [PATCH 086/167] Remove tautology --- Lib/fontTools/fontBuilder.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index d1c0d43cc..f4c943f3d 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -943,7 +943,6 @@ def addFvar(font, axes, instances): inst = NamedInstance() inst.subfamilyNameID = nameTable.addMultilingualName(name, ttFont=font) if psname is not None: - psname = psname inst.postscriptNameID = nameTable.addName(psname) inst.coordinates = coordinates fvar.instances.append(inst) From ec77db36191f1257815c08563825f738fb80d1f5 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Thu, 11 Feb 2021 20:22:02 -0800 Subject: [PATCH 087/167] Update Lib/fontTools/colorLib/builder.py Co-authored-by: Cosimo Lupo --- Lib/fontTools/colorLib/builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 586935c3e..85778ac01 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -57,7 +57,7 @@ _DEFAULT_ALPHA = VariableFloat(1.0) def _beforeBuildPaintRadialGradient(paint, source): # normalize input types (which may or may not specify a varIdx) - x0 = convertTupleClass(VariableFloat, source.get("x0", 0.0)) + x0 = convertTupleClass(VariableFloat, source["x0"]) y0 = convertTupleClass(VariableFloat, source.get("y0", 0.0)) r0 = convertTupleClass(VariableFloat, source.get("r0", 0.0)) x1 = convertTupleClass(VariableFloat, source.get("x1", 0.0)) From 0353c809cd6262e295ca1aca41b5dcd05e004eed Mon Sep 17 00:00:00 2001 From: rsheeter Date: Thu, 11 Feb 2021 20:36:38 -0800 Subject: [PATCH 088/167] Update COLR build fns per review feedback --- Lib/fontTools/colorLib/builder.py | 34 +++++++++++++--------- Lib/fontTools/colorLib/table_builder.py | 22 ++++---------- Lib/fontTools/ttLib/tables/otConverters.py | 4 +++ Tests/colorLib/builder_test.py | 27 ++++++++++++++++- 4 files changed, 57 insertions(+), 30 deletions(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 85778ac01..cade429e6 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -53,16 +53,17 @@ _ColorGlyphsV0Dict = Dict[str, Sequence[Tuple[str, int]]] MAX_PAINT_COLR_LAYER_COUNT = 255 _DEFAULT_ALPHA = VariableFloat(1.0) +_MAX_REUSE_LEN = 32 def _beforeBuildPaintRadialGradient(paint, source): # normalize input types (which may or may not specify a varIdx) x0 = convertTupleClass(VariableFloat, source["x0"]) - y0 = convertTupleClass(VariableFloat, source.get("y0", 0.0)) - r0 = convertTupleClass(VariableFloat, source.get("r0", 0.0)) - x1 = convertTupleClass(VariableFloat, source.get("x1", 0.0)) - y1 = convertTupleClass(VariableFloat, source.get("y1", 0.0)) - r1 = convertTupleClass(VariableFloat, source.get("r1", 0.0)) + y0 = convertTupleClass(VariableFloat, source["y0"]) + r0 = convertTupleClass(VariableFloat, source["r0"]) + x1 = convertTupleClass(VariableFloat, source["x1"]) + y1 = convertTupleClass(VariableFloat, source["y1"]) + r1 = convertTupleClass(VariableFloat, source["r1"]) # TODO apparently no builder_test confirms this works (?) @@ -373,11 +374,12 @@ def _split_color_glyphs_by_version( def _reuse_ranges(num_layers: int) -> Generator[Tuple[int, int], None, None]: # TODO feels like something itertools might have already for lbound in range(num_layers): - # TODO may want a max length to limit scope of search # Reuse of very large #s of layers is relatively unlikely # +2: we want sequences of at least 2 # otData handles single-record duplication - for ubound in range(lbound + 2, num_layers + 1): + for ubound in range( + lbound + 2, min(num_layers + 1, lbound + 2 + _MAX_REUSE_LEN) + ): yield (lbound, ubound) @@ -448,6 +450,10 @@ class LayerV1ListBuilder: # Convert maps seqs or whatever into typed objects layers = [self.buildPaint(l) for l in layers] + # No reason to have a colr layers with just one entry + if len(layers) == 1: + return layers[0], {} + # Look for reuse, with preference to longer sequences # This may make the layer list smaller found_reuse = True @@ -474,6 +480,7 @@ class LayerV1ListBuilder: break # The layer list is now final; if it's too big we need to tree it + is_tree = len(layers) > MAX_PAINT_COLR_LAYER_COUNT layers = _build_n_ary_tree(layers, n=MAX_PAINT_COLR_LAYER_COUNT) # We now have a tree of sequences with Paint leaves. @@ -494,12 +501,13 @@ class LayerV1ListBuilder: paint.FirstLayerIndex = len(self.layers) self.layers.extend(layers) - # Register our parts for reuse - # TODO what if we made ourselves a lovely little tree - for lbound, ubound in _reuse_ranges(len(layers)): - self.reusePool[self._as_tuple(layers[lbound:ubound])] = ( - lbound + paint.FirstLayerIndex - ) + # Register our parts for reuse provided we aren't a tree + # If we are a tree the leaves registered for reuse and that will suffice + if not is_tree: + for lbound, ubound in _reuse_ranges(len(layers)): + self.reusePool[self._as_tuple(layers[lbound:ubound])] = ( + lbound + paint.FirstLayerIndex + ) # we've fully built dest; empty source prevents generalized build from kicking in return paint, {} diff --git a/Lib/fontTools/colorLib/table_builder.py b/Lib/fontTools/colorLib/table_builder.py index 322673466..2ce893a94 100644 --- a/Lib/fontTools/colorLib/table_builder.py +++ b/Lib/fontTools/colorLib/table_builder.py @@ -12,7 +12,6 @@ from fontTools.ttLib.tables.otBase import ( ) from fontTools.ttLib.tables.otConverters import ( ComputedInt, - GlyphID, SimpleValue, Struct, Short, @@ -23,18 +22,6 @@ from fontTools.ttLib.tables.otConverters import ( ) -def _to_glyph_id(value): - assert isinstance(value, str), "Expected a glyph name" - return value - - -_CONVERTER_OVERRIDES = { - Short: int, - UShort: int, - GlyphID: _to_glyph_id, -} - - class BuildCallback(enum.Enum): """Keyed on (BEFORE_BUILD, class[, Format if available]). Receives (dest, source). @@ -107,10 +94,10 @@ class TableBuilder: self._callbackTable = callbackTable def _convert(self, dest, field, converter, value): - converter = _CONVERTER_OVERRIDES.get(type(converter), converter) - - enumClass = getattr(converter, "enumClass", None) tupleClass = getattr(converter, "tupleClass", None) + enumClass = getattr(converter, "enumClass", None) + simpleValueClass = getattr(converter, "valueClass", None) + if tupleClass: value = convertTupleClass(tupleClass, value) @@ -125,6 +112,9 @@ class TableBuilder: else: value = enumClass(value) + elif simpleValueClass: + value = simpleValueClass(value) + elif isinstance(converter, Struct): if converter.repeat: if _isNonStrSequence(value): diff --git a/Lib/fontTools/ttLib/tables/otConverters.py b/Lib/fontTools/ttLib/tables/otConverters.py index 1b278410b..6bf6c623d 100644 --- a/Lib/fontTools/ttLib/tables/otConverters.py +++ b/Lib/fontTools/ttLib/tables/otConverters.py @@ -216,6 +216,8 @@ class SimpleValue(BaseConverter): return self.fromString(attrs["value"]) class IntValue(SimpleValue): + valueClass = int + @staticmethod def fromString(value): return int(value, 0) @@ -295,6 +297,7 @@ class Tag(SimpleValue): writer.writeTag(value) class GlyphID(SimpleValue): + valueClass = str staticSize = 2 typecode = "H" def readArray(self, reader, font, tableDict, count): @@ -334,6 +337,7 @@ class NameID(UShort): class FloatValue(SimpleValue): + valueClass = float @staticmethod def fromString(value): return float(value) diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index 95962f637..f9ffdd242 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -269,7 +269,9 @@ def test_buildPaintSolid_Alpha(): def test_buildPaintSolid_Variable(): - p = _buildPaint((ot.PaintFormat.PaintSolid, (3, builder.VariableFloat(0.5, varIdx=2)))) + p = _buildPaint( + (ot.PaintFormat.PaintSolid, (3, builder.VariableFloat(0.5, varIdx=2))) + ) assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 3 assert p.Color.Alpha.value == 0.5 @@ -1374,6 +1376,29 @@ class BuildCOLRTest(object): assert isinstance(colr.table, ot.COLR) assert colr.table.VarStore is None + def test_paint_one_colr_layers(self): + # A set of one layers should flip to just that layer + colr = builder.buildCOLR( + { + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, 0), + "b", + ), + ], + ) + }, + ) + + assert len(colr.table.LayerV1List.Paint) == 0, "PaintColrLayers should be gone" + assert colr.table.BaseGlyphV1List.BaseGlyphCount == 1 + paint = colr.table.BaseGlyphV1List.BaseGlyphV1Record[0].Paint + assert paint.Format == ot.PaintFormat.PaintGlyph + assert paint.Paint.Format == ot.PaintFormat.PaintSolid + class TrickyRadialGradientTest: @staticmethod From 5316ae4b8cef78dc8af3479ee6fa87bb95cc4a6c Mon Sep 17 00:00:00 2001 From: rsheeter Date: Fri, 12 Feb 2021 20:23:06 -0800 Subject: [PATCH 089/167] Add test to expose missed otRound + fix --- Lib/fontTools/colorLib/table_builder.py | 10 +++++++--- Lib/fontTools/ttLib/tables/otConverters.py | 4 ---- Tests/colorLib/table_builder_test.py | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 Tests/colorLib/table_builder_test.py diff --git a/Lib/fontTools/colorLib/table_builder.py b/Lib/fontTools/colorLib/table_builder.py index 2ce893a94..18e2de181 100644 --- a/Lib/fontTools/colorLib/table_builder.py +++ b/Lib/fontTools/colorLib/table_builder.py @@ -19,7 +19,10 @@ from fontTools.ttLib.tables.otConverters import ( UShort, VarInt16, VarUInt16, + IntValue, + FloatValue, ) +from fontTools.misc.fixedTools import otRound class BuildCallback(enum.Enum): @@ -96,7 +99,6 @@ class TableBuilder: def _convert(self, dest, field, converter, value): tupleClass = getattr(converter, "tupleClass", None) enumClass = getattr(converter, "enumClass", None) - simpleValueClass = getattr(converter, "valueClass", None) if tupleClass: value = convertTupleClass(tupleClass, value) @@ -112,8 +114,10 @@ class TableBuilder: else: value = enumClass(value) - elif simpleValueClass: - value = simpleValueClass(value) + elif isinstance(converter, IntValue): + value = otRound(value) + elif isinstance(converter, FloatValue): + value = float(value) elif isinstance(converter, Struct): if converter.repeat: diff --git a/Lib/fontTools/ttLib/tables/otConverters.py b/Lib/fontTools/ttLib/tables/otConverters.py index 6bf6c623d..1b278410b 100644 --- a/Lib/fontTools/ttLib/tables/otConverters.py +++ b/Lib/fontTools/ttLib/tables/otConverters.py @@ -216,8 +216,6 @@ class SimpleValue(BaseConverter): return self.fromString(attrs["value"]) class IntValue(SimpleValue): - valueClass = int - @staticmethod def fromString(value): return int(value, 0) @@ -297,7 +295,6 @@ class Tag(SimpleValue): writer.writeTag(value) class GlyphID(SimpleValue): - valueClass = str staticSize = 2 typecode = "H" def readArray(self, reader, font, tableDict, count): @@ -337,7 +334,6 @@ class NameID(UShort): class FloatValue(SimpleValue): - valueClass = float @staticmethod def fromString(value): return float(value) diff --git a/Tests/colorLib/table_builder_test.py b/Tests/colorLib/table_builder_test.py new file mode 100644 index 000000000..d0a76f5ad --- /dev/null +++ b/Tests/colorLib/table_builder_test.py @@ -0,0 +1,15 @@ +from fontTools.ttLib.tables import otTables # trigger setup to occur +from fontTools.ttLib.tables.otConverters import UShort +from fontTools.colorLib.table_builder import TableBuilder +import pytest + + +class WriteMe: + value = None + + +def test_intValue_otRound(): + dest = WriteMe() + converter = UShort("value", None, None) + TableBuilder()._convert(dest, "value", converter, 85.6) + assert dest.value == 86, "Should have used otRound" From f1ecccbbb83e9d4b1d9cfd12a7627e576d4da4c8 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Tue, 9 Feb 2021 16:25:09 -0800 Subject: [PATCH 090/167] Begin updating for static vs variable versions of COLR v1 Paint --- Lib/fontTools/ttLib/tables/otData.py | 139 +++++++++++++++++++++---- Lib/fontTools/ttLib/tables/otTables.py | 28 +++-- 2 files changed, 137 insertions(+), 30 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py index a5c5ad600..535da9237 100755 --- a/Lib/fontTools/ttLib/tables/otData.py +++ b/Lib/fontTools/ttLib/tables/otData.py @@ -1598,6 +1598,14 @@ otData = [ # basis vector. # See https://github.com/googlefonts/colr-gradients-spec/pull/85 ('Affine2x3', [ + ('Fixed', 'xx', None, None, 'x-part of x basis vector'), + ('Fixed', 'yx', None, None, 'y-part of x basis vector'), + ('Fixed', 'xy', None, None, 'x-part of y basis vector'), + ('Fixed', 'yy', None, None, 'y-part of y basis vector'), + ('Fixed', 'dx', None, None, 'Translation in x direction'), + ('Fixed', 'dy', None, None, 'Translation in y direction'), + ]), + ('VarAffine2x3', [ ('VarFixed', 'xx', None, None, 'x-part of x basis vector'), ('VarFixed', 'yx', None, None, 'y-part of x basis vector'), ('VarFixed', 'xy', None, None, 'x-part of y basis vector'), @@ -1607,35 +1615,67 @@ otData = [ ]), ('ColorIndex', [ + ('uint16', 'PaletteIndex', None, None, 'Index value to use with a selected color palette.'), + ('F2Dot14', 'Alpha', None, None, 'Values outsided [0.,1.] reserved'), + ]), + ('VarColorIndex', [ ('uint16', 'PaletteIndex', None, None, 'Index value to use with a selected color palette.'), ('VarF2Dot14', 'Alpha', None, None, 'Values outsided [0.,1.] reserved'), ]), ('ColorStop', [ - ('VarF2Dot14', 'StopOffset', None, None, ''), + ('F2Dot14', 'StopOffset', None, None, ''), ('ColorIndex', 'Color', None, None, ''), ]), + ('VarColorStop', [ + ('VarF2Dot14', 'StopOffset', None, None, ''), + ('VarColorIndex', 'Color', None, None, ''), + ]), ('ColorLine', [ ('ExtendMode', 'Extend', None, None, 'Enum {PAD = 0, REPEAT = 1, REFLECT = 2}'), ('uint16', 'StopCount', None, None, 'Number of Color stops.'), ('ColorStop', 'ColorStop', 'StopCount', 0, 'Array of Color stops.'), ]), + ('VarColorLine', [ + ('ExtendMode', 'Extend', None, None, 'Enum {PAD = 0, REPEAT = 1, REFLECT = 2}'), + ('uint16', 'StopCount', None, None, 'Number of Color stops.'), + ('VarColorStop', 'ColorStop', 'StopCount', 0, 'Array of Color stops.'), + ]), + # PaintColrLayers ('PaintFormat1', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 1'), ('uint8', 'NumLayers', None, None, 'Number of offsets to Paint to read from LayerV1List.'), ('uint32', 'FirstLayerIndex', None, None, 'Index into LayerV1List.'), ]), + # PaintSolid ('PaintFormat2', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 2'), ('ColorIndex', 'Color', None, None, 'A solid color paint.'), ]), - # PaintLinearGradient + # PaintVarSolid ('PaintFormat3', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 3'), + ('VarColorIndex', 'Color', None, None, 'A solid color paint.'), + ]), + + # PaintLinearGradient + ('PaintFormat4', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 4'), ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of PaintLinearGradient table) to ColorLine subtable.'), + ('int16', 'x0', None, None, ''), + ('int16', 'y0', None, None, ''), + ('int16', 'x1', None, None, ''), + ('int16', 'y1', None, None, ''), + ('int16', 'x2', None, None, ''), + ('int16', 'y2', None, None, ''), + ]), + # PaintVarLinearGradient + ('PaintFormat5', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 5'), + ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintLinearGradient table) to VarColorLine subtable.'), ('VarInt16', 'x0', None, None, ''), ('VarInt16', 'y0', None, None, ''), ('VarInt16', 'x1', None, None, ''), @@ -1643,10 +1683,22 @@ otData = [ ('VarInt16', 'x2', None, None, ''), ('VarInt16', 'y2', None, None, ''), ]), + # PaintRadialGradient - ('PaintFormat4', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 4'), + ('PaintFormat6', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 6'), ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of PaintRadialGradient table) to ColorLine subtable.'), + ('int16', 'x0', None, None, ''), + ('int16', 'y0', None, None, ''), + ('uint16', 'r0', None, None, ''), + ('int16', 'x1', None, None, ''), + ('int16', 'y1', None, None, ''), + ('uint16', 'r1', None, None, ''), + ]), + # PaintVarRadialGradient + ('PaintFormat7', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 7'), + ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintRadialGradient table) to VarColorLine subtable.'), ('VarInt16', 'x0', None, None, ''), ('VarInt16', 'y0', None, None, ''), ('VarUInt16', 'r0', None, None, ''), @@ -1654,59 +1706,106 @@ otData = [ ('VarInt16', 'y1', None, None, ''), ('VarUInt16', 'r1', None, None, ''), ]), + # PaintSweepGradient - ('PaintFormat5', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 5'), + ('PaintFormat8', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 8'), ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of PaintSweepGradient table) to ColorLine subtable.'), + ('int16', 'centerX', None, None, 'Center x coordinate.'), + ('int16', 'centerY', None, None, 'Center y coordinate.'), + ('Fixed', 'startAngle', None, None, 'Start of the angular range of the gradient.'), + ('Fixed', 'endAngle', None, None, 'End of the angular range of the gradient.'), + ]), + # PaintVarSweepGradient + ('PaintFormat9', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 9'), + ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintSweepGradient table) to VarColorLine subtable.'), ('VarInt16', 'centerX', None, None, 'Center x coordinate.'), ('VarInt16', 'centerY', None, None, 'Center y coordinate.'), ('VarFixed', 'startAngle', None, None, 'Start of the angular range of the gradient.'), ('VarFixed', 'endAngle', None, None, 'End of the angular range of the gradient.'), ]), + # PaintGlyph - ('PaintFormat6', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 6'), + ('PaintFormat10', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 10'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintGlyph table) to Paint subtable.'), ('GlyphID', 'Glyph', None, None, 'Glyph ID for the source outline.'), ]), + # PaintColrGlyph - ('PaintFormat7', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 7'), + ('PaintFormat11', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 11'), ('GlyphID', 'Glyph', None, None, 'Virtual glyph ID for a BaseGlyphV1List base glyph.'), ]), + # PaintTransform - ('PaintFormat8', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 8'), + ('PaintFormat12', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 12'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTransform table) to Paint subtable.'), ('Affine2x3', 'Transform', None, None, '2x3 matrix for 2D affine transformations.'), ]), + # PaintVarTransform + ('PaintFormat13', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 12'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTransform table) to Paint subtable.'), + ('VarAffine2x3', 'Transform', None, None, '2x3 matrix for 2D affine transformations.'), + ]), + # PaintTranslate - ('PaintFormat9', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 9'), + ('PaintFormat14', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 14'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTranslate table) to Paint subtable.'), + ('Fixed', 'dx', None, None, 'Translation in x direction.'), + ('Fixed', 'dy', None, None, 'Translation in y direction.'), + ]), + # PaintVarTranslate + ('PaintFormat15', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 15'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTranslate table) to Paint subtable.'), ('VarFixed', 'dx', None, None, 'Translation in x direction.'), ('VarFixed', 'dy', None, None, 'Translation in y direction.'), ]), + # PaintRotate - ('PaintFormat10', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 10'), + ('PaintFormat16', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 16'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintRotate table) to Paint subtable.'), + ('Fixed', 'angle', None, None, ''), + ('Fixed', 'centerX', None, None, ''), + ('Fixed', 'centerY', None, None, ''), + ]), + # PaintVarRotate + ('PaintFormat17', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 17'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintRotate table) to Paint subtable.'), ('VarFixed', 'angle', None, None, ''), ('VarFixed', 'centerX', None, None, ''), ('VarFixed', 'centerY', None, None, ''), ]), + # PaintSkew - ('PaintFormat11', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 11'), + ('PaintFormat18', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 18'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintSkew table) to Paint subtable.'), + ('Fixed', 'xSkewAngle', None, None, ''), + ('Fixed', 'ySkewAngle', None, None, ''), + ('Fixed', 'centerX', None, None, ''), + ('Fixed', 'centerY', None, None, ''), + ]), + # PaintVarSkew + ('PaintFormat19', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 19'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintSkew table) to Paint subtable.'), ('VarFixed', 'xSkewAngle', None, None, ''), ('VarFixed', 'ySkewAngle', None, None, ''), ('VarFixed', 'centerX', None, None, ''), ('VarFixed', 'centerY', None, None, ''), ]), + # PaintComposite - ('PaintFormat12', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 12'), + ('PaintFormat20', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 20'), ('LOffset24To(Paint)', 'SourcePaint', None, None, 'Offset (from beginning of PaintComposite table) to source Paint subtable.'), ('CompositeMode', 'CompositeMode', None, None, 'A CompositeMode enumeration value.'), ('LOffset24To(Paint)', 'BackdropPaint', None, None, 'Offset (from beginning of PaintComposite table) to backdrop Paint subtable.'), diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py index f3401a705..43c40d5c5 100644 --- a/Lib/fontTools/ttLib/tables/otTables.py +++ b/Lib/fontTools/ttLib/tables/otTables.py @@ -1327,16 +1327,24 @@ class CompositeMode(IntEnum): class PaintFormat(IntEnum): PaintColrLayers = 1 PaintSolid = 2 - PaintLinearGradient = 3 - PaintRadialGradient = 4 - PaintSweepGradient = 5 - PaintGlyph = 6 - PaintColrGlyph = 7 - PaintTransform = 8 - PaintTranslate = 9 - PaintRotate = 10 - PaintSkew = 11 - PaintComposite = 12 + PaintVarSolid = 3, + PaintLinearGradient = 4 + PaintVarLinearGradient = 5 + PaintRadialGradient = 6 + PaintVarRadialGradient = 7 + PaintSweepGradient = 8 + PaintVarSweepGradient = 9 + PaintGlyph = 10 + PaintColrGlyph = 11 + PaintTransform = 12 + PaintVarTransform = 13 + PaintTranslate = 14 + PaintVarTranslate = 15 + PaintRotate = 16 + PaintVarRotate = 17 + PaintSkew = 18 + PaintVarSkew = 19 + PaintComposite = 20 class Paint(getFormatSwitchingBaseTableClass("uint8")): From 2df3fed98a52092ea1536d5e2a8c42a81d8960f7 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Wed, 10 Feb 2021 21:27:35 -0800 Subject: [PATCH 091/167] Prevent otConverters from ignoring the 'template' type when field name is also a type --- Lib/fontTools/ttLib/tables/otConverters.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/otConverters.py b/Lib/fontTools/ttLib/tables/otConverters.py index 1b278410b..96d461a38 100644 --- a/Lib/fontTools/ttLib/tables/otConverters.py +++ b/Lib/fontTools/ttLib/tables/otConverters.py @@ -59,14 +59,20 @@ def buildConverters(tableSpec, tableNamespace): converterClass = Struct else: converterClass = eval(tp, tableNamespace, converterMapping) - if tp in ('MortChain', 'MortSubtable', 'MorxChain'): + + conv = converterClass(name, repeat, aux) + + if conv.tableClass: + # A "template" such as OffsetTo(AType) knowss the table class already + tableClass = conv.tableClass + elif tp in ('MortChain', 'MortSubtable', 'MorxChain'): tableClass = tableNamespace.get(tp) else: tableClass = tableNamespace.get(tableName) - if tableClass is not None: - conv = converterClass(name, repeat, aux, tableClass=tableClass) - else: - conv = converterClass(name, repeat, aux) + + if not conv.tableClass: + conv.tableClass = tableClass + if name in ["SubTable", "ExtSubTable", "SubStruct"]: conv.lookupTypes = tableNamespace['lookupTypes'] # also create reverse mapping From 7abd234929855a183fab1bac1fb6390db72bad06 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Wed, 10 Feb 2021 22:33:48 -0800 Subject: [PATCH 092/167] Update tests for new formats --- Lib/fontTools/colorLib/builder.py | 37 ++- Tests/colorLib/builder_test.py | 395 +++++++++++++++++++---------- Tests/colorLib/unbuilder_test.py | 70 ++--- Tests/ttLib/tables/C_O_L_R_test.py | 200 +++++++-------- 4 files changed, 425 insertions(+), 277 deletions(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index cade429e6..90fdd4f1f 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -56,7 +56,7 @@ _DEFAULT_ALPHA = VariableFloat(1.0) _MAX_REUSE_LEN = 32 -def _beforeBuildPaintRadialGradient(paint, source): +def _beforeBuildPaintVarRadialGradient(paint, source, srcMapFn=lambda v: v): # normalize input types (which may or may not specify a varIdx) x0 = convertTupleClass(VariableFloat, source["x0"]) y0 = convertTupleClass(VariableFloat, source["y0"]) @@ -75,18 +75,28 @@ def _beforeBuildPaintRadialGradient(paint, source): r0 = r0._replace(value=c.radius) # update source to ensure paint is built with corrected values - source["x0"] = x0 - source["y0"] = y0 - source["r0"] = r0 - source["x1"] = x1 - source["y1"] = y1 - source["r1"] = r1 + source["x0"] = srcMapFn(x0) + source["y0"] = srcMapFn(y0) + source["r0"] = srcMapFn(r0) + source["x1"] = srcMapFn(x1) + source["y1"] = srcMapFn(y1) + source["r1"] = srcMapFn(r1) return paint, source +def _beforeBuildPaintRadialGradient(paint, source): + return _beforeBuildPaintVarRadialGradient(paint, source, lambda v: v.value) + + def _defaultColorIndex(): colorIndex = ot.ColorIndex() + colorIndex.Alpha = _DEFAULT_ALPHA.value + return colorIndex + + +def _defaultVarColorIndex(): + colorIndex = ot.VarColorIndex() colorIndex.Alpha = _DEFAULT_ALPHA return colorIndex @@ -97,6 +107,12 @@ def _defaultColorLine(): return colorLine +def _defaultVarColorLine(): + colorLine = ot.VarColorLine() + colorLine.Extend = ExtendMode.PAD + return colorLine + + def _buildPaintCallbacks(): return { ( @@ -104,8 +120,15 @@ def _buildPaintCallbacks(): ot.Paint, ot.PaintFormat.PaintRadialGradient, ): _beforeBuildPaintRadialGradient, + ( + BuildCallback.BEFORE_BUILD, + ot.Paint, + ot.PaintFormat.PaintVarRadialGradient, + ): _beforeBuildPaintVarRadialGradient, (BuildCallback.CREATE_DEFAULT, ot.ColorIndex): _defaultColorIndex, + (BuildCallback.CREATE_DEFAULT, ot.VarColorIndex): _defaultVarColorIndex, (BuildCallback.CREATE_DEFAULT, ot.ColorLine): _defaultColorLine, + (BuildCallback.CREATE_DEFAULT, ot.VarColorLine): _defaultVarColorLine, } diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index f9ffdd242..81da28180 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -231,22 +231,27 @@ def test_buildCPAL_invalid_color(): builder.buildCPAL([[(0, 0, 0, 0)], [(1, 1, -1, 2)]]) -def test_buildColorIndex(): +def test_buildColorIndex_Minimal(): c = _build(ot.ColorIndex, 1) assert c.PaletteIndex == 1 + assert c.Alpha == 1.0 + + +def test_buildVarColorIndex_Minimal(): + c = _build(ot.VarColorIndex, 1) + assert c.PaletteIndex == 1 assert c.Alpha.value == 1.0 assert c.Alpha.varIdx == 0 -def test_buildColorIndex_Alpha(): +def test_buildColorIndex(): c = _build(ot.ColorIndex, (1, 0.5)) assert c.PaletteIndex == 1 - assert c.Alpha.value == 0.5 - assert c.Alpha.varIdx == 0 + assert c.Alpha == 0.5 -def test_buildColorIndex_Variable(): - c = _build(ot.ColorIndex, (3, builder.VariableFloat(0.5, varIdx=2))) +def test_buildVarColorIndex(): + c = _build(ot.VarColorIndex, (3, builder.VariableFloat(0.5, varIdx=2))) assert c.PaletteIndex == 3 assert c.Alpha.value == 0.5 assert c.Alpha.varIdx == 2 @@ -256,30 +261,35 @@ def test_buildPaintSolid(): p = _buildPaint((ot.PaintFormat.PaintSolid, 0)) assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 0 - assert p.Color.Alpha.value == 1.0 - assert p.Color.Alpha.varIdx == 0 + assert p.Color.Alpha == 1.0 def test_buildPaintSolid_Alpha(): p = _buildPaint((ot.PaintFormat.PaintSolid, (1, 0.5))) assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 1 - assert p.Color.Alpha.value == 0.5 - assert p.Color.Alpha.varIdx == 0 + assert p.Color.Alpha == 0.5 -def test_buildPaintSolid_Variable(): +def test_buildPaintVarSolid(): p = _buildPaint( - (ot.PaintFormat.PaintSolid, (3, builder.VariableFloat(0.5, varIdx=2))) + (ot.PaintFormat.PaintVarSolid, (3, builder.VariableFloat(0.5, varIdx=2))) ) - assert p.Format == ot.PaintFormat.PaintSolid + assert p.Format == ot.PaintFormat.PaintVarSolid assert p.Color.PaletteIndex == 3 assert p.Color.Alpha.value == 0.5 assert p.Color.Alpha.varIdx == 2 -def test_buildColorStop_DefaultAlpha(): +def test_buildVarColorStop_DefaultAlpha(): s = _build(ot.ColorStop, (0.1, 2)) + assert s.StopOffset == 0.1 + assert s.Color.PaletteIndex == 2 + assert s.Color.Alpha == builder._DEFAULT_ALPHA.value + + +def test_buildVarColorStop_DefaultAlpha(): + s = _build(ot.VarColorStop, (0.1, 2)) assert s.StopOffset == builder.VariableFloat(0.1) assert s.Color.PaletteIndex == 2 assert s.Color.Alpha == builder._DEFAULT_ALPHA @@ -289,13 +299,13 @@ def test_buildColorStop(): s = _build( ot.ColorStop, {"StopOffset": 0.2, "Color": {"PaletteIndex": 3, "Alpha": 0.4}} ) - assert s.StopOffset == builder.VariableFloat(0.2) + assert s.StopOffset == 0.2 assert s.Color == _build(ot.ColorIndex, (3, 0.4)) def test_buildColorStop_Variable(): s = _build( - ot.ColorStop, + ot.VarColorStop, { "StopOffset": builder.VariableFloat(0.0, varIdx=1), "Color": { @@ -315,9 +325,7 @@ def test_buildColorLine_StopList(): cline = _build(ot.ColorLine, {"ColorStop": stops}) assert cline.Extend == builder.ExtendMode.PAD assert cline.StopCount == 3 - assert [ - (cs.StopOffset.value, cs.Color.PaletteIndex) for cs in cline.ColorStop - ] == stops + assert [(cs.StopOffset, cs.Color.PaletteIndex) for cs in cline.ColorStop] == stops cline = _build(ot.ColorLine, {"Extend": "pad", "ColorStop": stops}) assert cline.Extend == builder.ExtendMode.PAD @@ -335,17 +343,15 @@ def test_buildColorLine_StopList(): cline = _build( ot.ColorLine, {"ColorStop": [_build(ot.ColorStop, s) for s in stops]} ) - assert [ - (cs.StopOffset.value, cs.Color.PaletteIndex) for cs in cline.ColorStop - ] == stops + assert [(cs.StopOffset, cs.Color.PaletteIndex) for cs in cline.ColorStop] == stops -def test_buildColorLine_StopMap_Variations(): +def test_buildVarColorLine_StopMap(): stops = [ {"StopOffset": (0.0, (1,)), "Color": {"PaletteIndex": 0, "Alpha": (0.5, 2)}}, {"StopOffset": (1.0, (3,)), "Color": {"PaletteIndex": 1, "Alpha": (0.3, 4)}}, ] - cline = _build(ot.ColorLine, {"ColorStop": stops}) + cline = _build(ot.VarColorLine, {"ColorStop": stops}) assert [ { "StopOffset": cs.StopOffset, @@ -358,30 +364,49 @@ def test_buildColorLine_StopMap_Variations(): ] == stops +def checkBuildAffine2x3(cls, resultMapFn): + matrix = _build(cls, (1.5, 0, 0.5, 2.0, 1.0, -3.0)) + assert matrix.xx == resultMapFn(1.5) + assert matrix.yx == resultMapFn(0.0) + assert matrix.xy == resultMapFn(0.5) + assert matrix.yy == resultMapFn(2.0) + assert matrix.dx == resultMapFn(1.0) + assert matrix.dy == resultMapFn(-3.0) + + def test_buildAffine2x3(): - matrix = _build(ot.Affine2x3, (1.5, 0, 0.5, 2.0, 1.0, -3.0)) - assert matrix.xx == builder.VariableFloat(1.5) - assert matrix.yx == builder.VariableFloat(0.0) - assert matrix.xy == builder.VariableFloat(0.5) - assert matrix.yy == builder.VariableFloat(2.0) - assert matrix.dx == builder.VariableFloat(1.0) - assert matrix.dy == builder.VariableFloat(-3.0) + checkBuildAffine2x3(ot.Affine2x3, lambda v: v) -def _sample_stops(): +def test_buildVarAffine2x3(): + checkBuildAffine2x3(ot.VarAffine2x3, builder.VariableFloat) + + +def _sample_stops(cls): return [ - _build(ot.ColorStop, (0.0, 0)), - _build(ot.ColorStop, (0.5, 1)), - _build(ot.ColorStop, (1.0, (2, 0.8))), + _build(cls, (0.0, 0)), + _build(cls, (0.5, 1)), + _build(cls, (1.0, (2, 0.8))), ] -def test_buildPaintLinearGradient(): - color_stops = _sample_stops() - x0, y0, x1, y1, x2, y2 = tuple(builder.VariableInt(v) for v in (1, 2, 3, 4, 5, 6)) +def _is_var(fmt): + return fmt.name.startswith("PaintVar") + + +def checkBuildPaintLinearGradient(fmt): + if _is_var(fmt): + inputMapFn = builder.VariableInt + outputMapFn = lambda v: v.value + color_stops = _sample_stops(ot.VarColorStop) + else: + inputMapFn = outputMapFn = lambda v: v + color_stops = _sample_stops(ot.ColorStop) + + x0, y0, x1, y1, x2, y2 = tuple(inputMapFn(v) for v in (1, 2, 3, 4, 5, 6)) gradient = _buildPaint( { - "Format": ot.PaintFormat.PaintLinearGradient, + "Format": fmt, "ColorLine": {"ColorStop": color_stops}, "x0": x0, "y0": y0, @@ -395,39 +420,52 @@ def test_buildPaintLinearGradient(): assert gradient.ColorLine.ColorStop == color_stops gradient = _buildPaint(gradient) - assert (gradient.x0.value, gradient.y0.value) == (1, 2) - assert (gradient.x1.value, gradient.y1.value) == (3, 4) - assert (gradient.x2.value, gradient.y2.value) == (5, 6) + assert (outputMapFn(gradient.x0), outputMapFn(gradient.y0)) == (1, 2) + assert (outputMapFn(gradient.x1), outputMapFn(gradient.y1)) == (3, 4) + assert (outputMapFn(gradient.x2), outputMapFn(gradient.y2)) == (5, 6) -def test_buildPaintRadialGradient(): - color_stops = [ - _build(ot.ColorStop, (0.0, (0,))), - _build(ot.ColorStop, (0.5, 1)), - _build(ot.ColorStop, (1.0, (2, 0.8))), - ] +def test_buildPaintLinearGradient(): + assert not _is_var(ot.PaintFormat.PaintLinearGradient) + checkBuildPaintLinearGradient(ot.PaintFormat.PaintLinearGradient) + + +def test_buildVarPaintLinearGradient(): + assert _is_var(ot.PaintFormat.PaintVarLinearGradient) + checkBuildPaintLinearGradient(ot.PaintFormat.PaintVarLinearGradient) + + +def checkBuildPaintRadialGradient(fmt): + if _is_var(fmt): + inputMapFn = builder.VariableInt + outputMapFn = lambda v: v + color_stops = _sample_stops(ot.VarColorStop) + line_cls = ot.VarColorLine + else: + inputMapFn = outputMapFn = lambda v: v + color_stops = _sample_stops(ot.ColorStop) + line_cls = ot.ColorLine + color_line = _build( - ot.ColorLine, {"ColorStop": color_stops, "Extend": builder.ExtendMode.REPEAT} + line_cls, {"ColorStop": color_stops, "Extend": builder.ExtendMode.REPEAT} ) - c0 = (builder.VariableInt(100), builder.VariableInt(200)) - c1 = (builder.VariableInt(150), builder.VariableInt(250)) - r0 = builder.VariableInt(10) - r1 = builder.VariableInt(5) + c0 = (inputMapFn(100), inputMapFn(200)) + c1 = (inputMapFn(150), inputMapFn(250)) + r0 = inputMapFn(10) + r1 = inputMapFn(5) - gradient = _build( - ot.Paint, (ot.PaintFormat.PaintRadialGradient, color_line, *c0, r0, *c1, r1) - ) - assert gradient.Format == ot.PaintFormat.PaintRadialGradient + gradient = _build(ot.Paint, (fmt, color_line, *c0, r0, *c1, r1)) + assert gradient.Format == fmt assert gradient.ColorLine == color_line - assert (gradient.x0, gradient.y0) == c0 - assert (gradient.x1, gradient.y1) == c1 - assert gradient.r0 == r0 - assert gradient.r1 == r1 + assert (outputMapFn(gradient.x0), outputMapFn(gradient.y0)) == c0 + assert (outputMapFn(gradient.x1), outputMapFn(gradient.y1)) == c1 + assert outputMapFn(gradient.r0) == r0 + assert outputMapFn(gradient.r1) == r1 gradient = _build( ot.Paint, { - "Format": ot.PaintFormat.PaintRadialGradient, + "Format": fmt, "ColorLine": {"ColorStop": color_stops}, "x0": c0[0], "y0": c0[1], @@ -439,16 +477,31 @@ def test_buildPaintRadialGradient(): ) assert gradient.ColorLine.Extend == builder.ExtendMode.PAD assert gradient.ColorLine.ColorStop == color_stops - assert (gradient.x0, gradient.y0) == c0 - assert (gradient.x1, gradient.y1) == c1 - assert gradient.r0 == r0 - assert gradient.r1 == r1 + assert (outputMapFn(gradient.x0), outputMapFn(gradient.y0)) == c0 + assert (outputMapFn(gradient.x1), outputMapFn(gradient.y1)) == c1 + assert outputMapFn(gradient.r0) == r0 + assert outputMapFn(gradient.r1) == r1 -def test_buildPaintSweepGradient(): +def test_buildPaintRadialGradient(): + assert not _is_var(ot.PaintFormat.PaintRadialGradient) + checkBuildPaintRadialGradient(ot.PaintFormat.PaintRadialGradient) + + +def test_buildPaintVarRadialGradient(): + assert _is_var(ot.PaintFormat.PaintVarRadialGradient) + checkBuildPaintRadialGradient(ot.PaintFormat.PaintVarRadialGradient) + + +def checkPaintSweepGradient(fmt): + if _is_var(fmt): + outputMapFn = lambda v: v.value + else: + outputMapFn = lambda v: v + paint = _buildPaint( { - "Format": ot.PaintFormat.PaintSweepGradient, + "Format": fmt, "ColorLine": { "ColorStop": ( (0.0, 0), @@ -463,11 +516,21 @@ def test_buildPaintSweepGradient(): } ) - assert paint.Format == ot.PaintFormat.PaintSweepGradient - assert paint.centerX.value == 127 - assert paint.centerY.value == 129 - assert paint.startAngle.value == 15 - assert paint.endAngle.value == 42 + assert paint.Format == fmt + assert outputMapFn(paint.centerX) == 127 + assert outputMapFn(paint.centerY) == 129 + assert outputMapFn(paint.startAngle) == 15 + assert outputMapFn(paint.endAngle) == 42 + + +def test_buildPaintSweepGradient(): + assert not _is_var(ot.PaintFormat.PaintSweepGradient) + checkPaintSweepGradient(ot.PaintFormat.PaintSweepGradient) + + +def test_buildPaintVarSweepGradient(): + assert _is_var(ot.PaintFormat.PaintVarSweepGradient) + checkPaintSweepGradient(ot.PaintFormat.PaintVarSweepGradient) def test_buildPaintGlyph_Solid(): @@ -500,17 +563,17 @@ def test_buildPaintGlyph_Solid(): ) assert layer.Paint.Format == ot.PaintFormat.PaintSolid assert layer.Paint.Color.PaletteIndex == 3 - assert layer.Paint.Color.Alpha.value == 0.9 + assert layer.Paint.Color.Alpha == 0.9 -def test_buildPaintGlyph_LinearGradient(): +def test_buildPaintGlyph_VarLinearGradient(): layer = _build( ot.Paint, { "Format": ot.PaintFormat.PaintGlyph, "Glyph": "a", "Paint": { - "Format": ot.PaintFormat.PaintLinearGradient, + "Format": ot.PaintFormat.PaintVarLinearGradient, "ColorLine": {"ColorStop": [(0.0, 3), (1.0, 4)]}, "x0": 100, "y0": 200, @@ -519,7 +582,10 @@ def test_buildPaintGlyph_LinearGradient(): }, }, ) - assert layer.Paint.Format == ot.PaintFormat.PaintLinearGradient + + assert layer.Format == ot.PaintFormat.PaintGlyph + assert layer.Glyph == "a" + assert layer.Paint.Format == ot.PaintFormat.PaintVarLinearGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 assert layer.Paint.ColorLine.ColorStop[0].Color.PaletteIndex == 3 assert layer.Paint.ColorLine.ColorStop[1].StopOffset.value == 1.0 @@ -557,19 +623,19 @@ def test_buildPaintGlyph_RadialGradient(): ) assert layer.Format == ot.PaintFormat.PaintGlyph assert layer.Paint.Format == ot.PaintFormat.PaintRadialGradient - assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 + assert layer.Paint.ColorLine.ColorStop[0].StopOffset == 0.0 assert layer.Paint.ColorLine.ColorStop[0].Color.PaletteIndex == 5 - assert layer.Paint.ColorLine.ColorStop[1].StopOffset.value == 0.5 + assert layer.Paint.ColorLine.ColorStop[1].StopOffset == 0.5 assert layer.Paint.ColorLine.ColorStop[1].Color.PaletteIndex == 6 - assert layer.Paint.ColorLine.ColorStop[1].Color.Alpha.value == 0.8 - assert layer.Paint.ColorLine.ColorStop[2].StopOffset.value == 1.0 + assert layer.Paint.ColorLine.ColorStop[1].Color.Alpha == 0.8 + assert layer.Paint.ColorLine.ColorStop[2].StopOffset == 1.0 assert layer.Paint.ColorLine.ColorStop[2].Color.PaletteIndex == 7 - assert layer.Paint.x0.value == 50 - assert layer.Paint.y0.value == 50 - assert layer.Paint.r0.value == 30 - assert layer.Paint.x1.value == 75 - assert layer.Paint.y1.value == 75 - assert layer.Paint.r1.value == 10 + assert layer.Paint.x0 == 50 + assert layer.Paint.y0 == 50 + assert layer.Paint.r0 == 30 + assert layer.Paint.x1 == 75 + assert layer.Paint.y1 == 75 + assert layer.Paint.r1 == 10 def test_buildPaintGlyph_Dict_Solid(): @@ -588,14 +654,14 @@ def test_buildPaintGlyph_Dict_Solid(): assert layer.Paint.Color.PaletteIndex == 1 -def test_buildPaintGlyph_Dict_LinearGradient(): +def test_buildPaintGlyph_Dict_VarLinearGradient(): layer = _build( ot.Paint, { "Format": ot.PaintFormat.PaintGlyph, "Glyph": "a", "Paint": { - "Format": 3, + "Format": int(ot.PaintFormat.PaintVarLinearGradient), "ColorLine": {"ColorStop": [(0.0, 0), (1.0, 1)]}, "x0": 0, "y0": 0, @@ -606,7 +672,7 @@ def test_buildPaintGlyph_Dict_LinearGradient(): ) assert layer.Format == ot.PaintFormat.PaintGlyph assert layer.Glyph == "a" - assert layer.Paint.Format == ot.PaintFormat.PaintLinearGradient + assert layer.Paint.Format == ot.PaintFormat.PaintVarLinearGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 @@ -628,7 +694,7 @@ def test_buildPaintGlyph_Dict_RadialGradient(): }, ) assert layer.Paint.Format == ot.PaintFormat.PaintRadialGradient - assert layer.Paint.r0.value == 4 + assert layer.Paint.r0 == 4 def test_buildPaintColrGlyph(): @@ -637,30 +703,38 @@ def test_buildPaintColrGlyph(): assert paint.Glyph == "a" -def test_buildPaintTransform(): +def checkBuildPaintTransform(fmt): + if _is_var(fmt): + inputMapFn = builder.VariableFloat + outputMapFn = lambda v: v.value + affine_cls = ot.VarAffine2x3 + else: + inputMapFn = outputMapFn = lambda v: v + affine_cls = ot.Affine2x3 + paint = _buildPaint( ( - int(ot.PaintFormat.PaintTransform), + int(fmt), (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, (0, 1.0)), "a"), - _build(ot.Affine2x3, (1, 2, 3, 4, 5, 6)), + _build(affine_cls, (1, 2, 3, 4, 5, 6)), ), ) - assert paint.Format == ot.PaintFormat.PaintTransform + assert paint.Format == fmt assert paint.Paint.Format == ot.PaintFormat.PaintGlyph assert paint.Paint.Paint.Format == ot.PaintFormat.PaintSolid - assert paint.Transform.xx.value == 1.0 - assert paint.Transform.yx.value == 2.0 - assert paint.Transform.xy.value == 3.0 - assert paint.Transform.yy.value == 4.0 - assert paint.Transform.dx.value == 5.0 - assert paint.Transform.dy.value == 6.0 + assert outputMapFn(paint.Transform.xx) == 1.0 + assert outputMapFn(paint.Transform.yx) == 2.0 + assert outputMapFn(paint.Transform.xy) == 3.0 + assert outputMapFn(paint.Transform.yy) == 4.0 + assert outputMapFn(paint.Transform.dx) == 5.0 + assert outputMapFn(paint.Transform.dy) == 6.0 paint = _build( ot.Paint, { - "Format": ot.PaintFormat.PaintTransform, + "Format": fmt, "Transform": (1, 2, 3, 0.3333, 10, 10), "Paint": { "Format": int(ot.PaintFormat.PaintRadialGradient), @@ -675,16 +749,26 @@ def test_buildPaintTransform(): }, ) - assert paint.Format == ot.PaintFormat.PaintTransform - assert paint.Transform.xx.value == 1.0 - assert paint.Transform.yx.value == 2.0 - assert paint.Transform.xy.value == 3.0 - assert paint.Transform.yy.value == 0.3333 - assert paint.Transform.dx.value == 10 - assert paint.Transform.dy.value == 10 + assert paint.Format == fmt + assert outputMapFn(paint.Transform.xx) == 1.0 + assert outputMapFn(paint.Transform.yx) == 2.0 + assert outputMapFn(paint.Transform.xy) == 3.0 + assert outputMapFn(paint.Transform.yy) == 0.3333 + assert outputMapFn(paint.Transform.dx) == 10 + assert outputMapFn(paint.Transform.dy) == 10 assert paint.Paint.Format == ot.PaintFormat.PaintRadialGradient +def test_buildPaintTransform(): + assert not _is_var(ot.PaintFormat.PaintTransform) + checkBuildPaintTransform(ot.PaintFormat.PaintTransform) + + +def test_buildPaintVarTransform(): + assert _is_var(ot.PaintFormat.PaintVarTransform) + checkBuildPaintTransform(ot.PaintFormat.PaintVarTransform) + + def test_buildPaintComposite(): composite = _build( ot.Paint, @@ -734,11 +818,17 @@ def test_buildPaintComposite(): assert composite.BackdropPaint.Paint.Color.PaletteIndex == 0 -def test_buildPaintTranslate(): +def checkBuildPaintTranslate(fmt): + if _is_var(fmt): + inputMapFn = builder.VariableInt + outputMapFn = lambda v: v.value + else: + inputMapFn = outputMapFn = lambda v: v + paint = _build( ot.Paint, { - "Format": ot.PaintFormat.PaintTranslate, + "Format": fmt, "Paint": ( ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, (0, 1.0)), @@ -749,17 +839,33 @@ def test_buildPaintTranslate(): }, ) - assert paint.Format == ot.PaintFormat.PaintTranslate + assert paint.Format == fmt assert paint.Paint.Format == ot.PaintFormat.PaintGlyph - assert paint.dx.value == 123 - assert paint.dy.value == -345 + assert outputMapFn(paint.dx) == 123 + assert outputMapFn(paint.dy) == -345 -def test_buildPaintRotate(): +def test_buildPaintTranslate(): + assert not _is_var(ot.PaintFormat.PaintTranslate) + checkBuildPaintTranslate(ot.PaintFormat.PaintTranslate) + + +def test_buildPaintVarTranslate(): + assert _is_var(ot.PaintFormat.PaintVarTranslate) + checkBuildPaintTranslate(ot.PaintFormat.PaintVarTranslate) + + +def checkBuildPaintRotate(fmt): + if _is_var(fmt): + inputMapFn = builder.VariableInt + outputMapFn = lambda v: v.value + else: + inputMapFn = outputMapFn = lambda v: v + paint = _build( ot.Paint, { - "Format": ot.PaintFormat.PaintRotate, + "Format": fmt, "Paint": ( ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, (0, 1.0)), @@ -771,18 +877,34 @@ def test_buildPaintRotate(): }, ) - assert paint.Format == ot.PaintFormat.PaintRotate + assert paint.Format == fmt assert paint.Paint.Format == ot.PaintFormat.PaintGlyph - assert paint.angle.value == 15 - assert paint.centerX.value == 127 - assert paint.centerY.value == 129 + assert outputMapFn(paint.angle) == 15 + assert outputMapFn(paint.centerX) == 127 + assert outputMapFn(paint.centerY) == 129 -def test_buildPaintSkew(): +def test_buildPaintRotate(): + assert not _is_var(ot.PaintFormat.PaintRotate) + checkBuildPaintRotate(ot.PaintFormat.PaintRotate) + + +def test_buildPaintVarRotate(): + assert _is_var(ot.PaintFormat.PaintVarRotate) + checkBuildPaintRotate(ot.PaintFormat.PaintVarRotate) + + +def checkBuildPaintSkew(fmt): + if _is_var(fmt): + inputMapFn = builder.VariableInt + outputMapFn = lambda v: v.value + else: + inputMapFn = outputMapFn = lambda v: v + paint = _build( ot.Paint, { - "Format": ot.PaintFormat.PaintSkew, + "Format": fmt, "Paint": ( ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, (0, 1.0)), @@ -795,12 +917,22 @@ def test_buildPaintSkew(): }, ) - assert paint.Format == ot.PaintFormat.PaintSkew + assert paint.Format == fmt assert paint.Paint.Format == ot.PaintFormat.PaintGlyph - assert paint.xSkewAngle.value == 15 - assert paint.ySkewAngle.value == 42 - assert paint.centerX.value == 127 - assert paint.centerY.value == 129 + assert outputMapFn(paint.xSkewAngle) == 15 + assert outputMapFn(paint.ySkewAngle) == 42 + assert outputMapFn(paint.centerX) == 127 + assert outputMapFn(paint.centerY) == 129 + + +def test_buildPaintSkew(): + assert not _is_var(ot.PaintFormat.PaintSkew) + checkBuildPaintSkew(ot.PaintFormat.PaintSkew) + + +def test_buildPaintVarSkew(): + assert _is_var(ot.PaintFormat.PaintVarSkew) + checkBuildPaintSkew(ot.PaintFormat.PaintVarSkew) def test_buildColrV1(): @@ -809,7 +941,7 @@ def test_buildColrV1(): ot.PaintFormat.PaintColrLayers, [ (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 0), "b"), - (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 1), "c"), + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintVarSolid, 1), "c"), ], ), "d": ( @@ -817,13 +949,16 @@ def test_buildColrV1(): [ ( ot.PaintFormat.PaintGlyph, - {"Format": 2, "Color": {"PaletteIndex": 2, "Alpha": 0.8}}, + { + "Format": int(ot.PaintFormat.PaintSolid), + "Color": {"PaletteIndex": 2, "Alpha": 0.8}, + }, "e", ), ( ot.PaintFormat.PaintGlyph, { - "Format": 4, + "Format": int(ot.PaintFormat.PaintVarRadialGradient), "ColorLine": { "ColorStop": [(0.0, 3), (1.0, 4)], "Extend": "reflect", diff --git a/Tests/colorLib/unbuilder_test.py b/Tests/colorLib/unbuilder_test.py index 6728720f6..81169e03a 100644 --- a/Tests/colorLib/unbuilder_test.py +++ b/Tests/colorLib/unbuilder_test.py @@ -12,14 +12,14 @@ TEST_COLOR_GLYPHS = { "Format": int(ot.PaintFormat.PaintGlyph), "Paint": { "Format": int(ot.PaintFormat.PaintSolid), - "Color": {"PaletteIndex": 2, "Alpha": (0.5, 0)}, + "Color": {"PaletteIndex": 2, "Alpha": 0.5}, }, "Glyph": "glyph00011", }, { "Format": int(ot.PaintFormat.PaintGlyph), "Paint": { - "Format": int(ot.PaintFormat.PaintLinearGradient), + "Format": int(ot.PaintFormat.PaintVarLinearGradient), "ColorLine": { "Extend": "repeat", "ColorStop": [ @@ -49,28 +49,28 @@ TEST_COLOR_GLYPHS = { { "Format": int(ot.PaintFormat.PaintGlyph), "Paint": { - "Format": int(ot.PaintFormat.PaintTransform), + "Format": int(ot.PaintFormat.PaintVarTransform), "Paint": { "Format": int(ot.PaintFormat.PaintRadialGradient), "ColorLine": { "Extend": "pad", "ColorStop": [ { - "StopOffset": (0.0, 0), - "Color": {"PaletteIndex": 6, "Alpha": (1.0, 0)}, + "StopOffset": 0, + "Color": {"PaletteIndex": 6, "Alpha": 1.0}, }, { - "StopOffset": (1.0, 0), - "Color": {"PaletteIndex": 7, "Alpha": (0.4, 0)}, + "StopOffset": 1.0, + "Color": {"PaletteIndex": 7, "Alpha": 0.4}, }, ], }, - "x0": (7, 0), - "y0": (8, 0), - "r0": (9, 0), - "x1": (10, 0), - "y1": (11, 0), - "r1": (12, 0), + "x0": 7, + "y0": 8, + "r0": 9, + "x1": 10, + "y1": 11, + "r1": 12, }, "Transform": { "xx": (-13.0, 0), @@ -84,16 +84,16 @@ TEST_COLOR_GLYPHS = { "Glyph": "glyph00013", }, { - "Format": int(ot.PaintFormat.PaintTranslate), + "Format": int(ot.PaintFormat.PaintVarTranslate), "Paint": { "Format": int(ot.PaintFormat.PaintRotate), "Paint": { - "Format": int(ot.PaintFormat.PaintSkew), + "Format": int(ot.PaintFormat.PaintVarSkew), "Paint": { "Format": int(ot.PaintFormat.PaintGlyph), "Paint": { "Format": int(ot.PaintFormat.PaintSolid), - "Color": {"PaletteIndex": 2, "Alpha": (0.5, 0)}, + "Color": {"PaletteIndex": 2, "Alpha": 0.5}, }, "Glyph": "glyph00011", }, @@ -102,9 +102,9 @@ TEST_COLOR_GLYPHS = { "centerX": (253.0, 0), "centerY": (254.0, 0), }, - "angle": (45.0, 0), - "centerX": (255.0, 0), - "centerY": (256.0, 0), + "angle": 45.0, + "centerX": 255.0, + "centerY": 256.0, }, "dx": (257.0, 0), "dy": (258.0, 0), @@ -125,12 +125,12 @@ TEST_COLOR_GLYPHS = { "Glyph": "glyph00010", }, "Transform": { - "xx": (1.0, 0), - "yx": (0.0, 0), - "xy": (0.0, 0), - "yy": (1.0, 0), - "dx": (300.0, 0), - "dy": (0.0, 0), + "xx": 1.0, + "yx": 0.0, + "xy": 0.0, + "yy": 1.0, + "dx": 300.0, + "dy": 0.0, }, }, }, @@ -142,19 +142,19 @@ TEST_COLOR_GLYPHS = { "Extend": "pad", "ColorStop": [ { - "StopOffset": (0.0, 0), - "Color": {"PaletteIndex": 3, "Alpha": (1.0, 0)}, + "StopOffset": 0.0, + "Color": {"PaletteIndex": 3, "Alpha": 1.0}, }, { - "StopOffset": (1.0, 0), - "Color": {"PaletteIndex": 5, "Alpha": (1.0, 0)}, + "StopOffset": 1.0, + "Color": {"PaletteIndex": 5, "Alpha": 1.0}, }, ], }, - "centerX": (259, 0), - "centerY": (300, 0), - "startAngle": (45.0, 0), - "endAngle": (135.0, 0), + "centerX": 259, + "centerY": 300, + "startAngle": 45.0, + "endAngle": 135.0, }, "Glyph": "glyph00011", }, @@ -164,7 +164,7 @@ TEST_COLOR_GLYPHS = { { "Format": int(ot.PaintFormat.PaintGlyph), "Paint": { - "Format": int(ot.PaintFormat.PaintSolid), + "Format": int(ot.PaintFormat.PaintVarSolid), "Color": {"PaletteIndex": 2, "Alpha": (0.5, 0)}, }, "Glyph": "glyph00011", @@ -172,7 +172,7 @@ TEST_COLOR_GLYPHS = { { "Format": int(ot.PaintFormat.PaintGlyph), "Paint": { - "Format": int(ot.PaintFormat.PaintLinearGradient), + "Format": int(ot.PaintFormat.PaintVarLinearGradient), "ColorLine": { "Extend": "repeat", "ColorStop": [ diff --git a/Tests/ttLib/tables/C_O_L_R_test.py b/Tests/ttLib/tables/C_O_L_R_test.py index c9bacd35e..4855f58f5 100644 --- a/Tests/ttLib/tables/C_O_L_R_test.py +++ b/Tests/ttLib/tables/C_O_L_R_test.py @@ -46,13 +46,15 @@ def dump(table, ttFont=None): def diff_binary_fragments(font_bytes, expected_fragments): pos = 0 prev_desc = "" + errors = 0 for expected_bytes, description in expected_fragments: actual_bytes = font_bytes[pos : pos + len(expected_bytes)] - assert ( - actual_bytes == expected_bytes - ), f'{description} (previous "{prev_desc}", bytes: {str(font_bytes[pos:pos+16])}' + if actual_bytes != expected_bytes: + print(f'{description} (previous "{prev_desc}", actual_bytes: {"".join("%02x" % v for v in actual_bytes)} bytes: {str(font_bytes[pos:pos+16])}') + errors += 1 pos += len(expected_bytes) prev_desc = description + assert errors == 0 assert pos == len( font_bytes ), f"Leftover font bytes, used {pos} of {len(font_bytes)}" @@ -106,7 +108,7 @@ COLR_V1_SAMPLE = ( (b"\x00\x00\x00 ", "Offset to LayerRecordArray from beginning of table (32)"), (b"\x00\x03", "LayerRecordCount (3)"), (b"\x00\x00\x00,", "Offset to BaseGlyphV1List from beginning of table (44)"), - (b"\x00\x00\x00\xcc", "Offset to LayerV1List from beginning of table (204)"), + (b"\x00\x00\x00\xac", "Offset to LayerV1List from beginning of table (172)"), (b"\x00\x00\x00\x00", "Offset to VarStore (NULL)"), (b"\x00\x06", "BaseGlyphRecord[0].BaseGlyph (6)"), (b"\x00\x00", "BaseGlyphRecord[0].FirstLayerIndex (0)"), @@ -139,42 +141,38 @@ COLR_V1_SAMPLE = ( (b"\x04", "BaseGlyphV1Record[0].Paint.NumLayers (4)"), (b"\x00\x00\x00\x00", "BaseGlyphV1Record[0].Paint.FirstLayerIndex (0)"), # BaseGlyphV1Record[1] - (b"\x0C", "BaseGlyphV1Record[1].Paint.Format (12)"), + (b"\x14", "BaseGlyphV1Record[1].Paint.Format (20)"), (b"\x00\x00<", "Offset to SourcePaint from beginning of PaintComposite (60)"), (b"\x03", "BaseGlyphV1Record[1].Paint.CompositeMode [SRC_OVER] (3)"), (b"\x00\x00\x08", "Offset to BackdropPaint from beginning of PaintComposite (8)"), - (b"\x08", "BaseGlyphV1Record[1].Paint.BackdropPaint.Format (8)"), - (b"\x00\x00\x34", "Offset to Paint from beginning of PaintTransform (52)"), + (b"\x0d", "BaseGlyphV1Record[1].Paint.BackdropPaint.Format (13)"), + (b"\x00\x00\x34", "Offset to Paint from beginning of PaintVarTransform (52)"), (b"\x00\x01\x00\x00\x00\x00\x00\x00", "Affine2x3.xx.value (1.0)"), (b"\x00\x00\x00\x00\x00\x00\x00\x00", "Affine2x3.xy.value (0.0)"), (b"\x00\x00\x00\x00\x00\x00\x00\x00", "Affine2x3.yx.value (0.0)"), (b"\x00\x01\x00\x00\x00\x00\x00\x00", "Affine2x3.yy.value (1.0)"), (b"\x01\x2c\x00\x00\x00\x00\x00\x00", "Affine2x3.dx.value (300.0)"), (b"\x00\x00\x00\x00\x00\x00\x00\x00", "Affine2x3.dy.value (0.0)"), - (b"\x07", "BaseGlyphV1Record[1].Paint.SourcePaint.Format (7)"), + (b"\x0b", "BaseGlyphV1Record[1].Paint.SourcePaint.Format (11)"), (b"\x00\n", "BaseGlyphV1Record[1].Paint.SourcePaint.Glyph (10)"), # BaseGlyphV1Record[2] - (b"\x06", "BaseGlyphV1Record[2].Paint.Format (6)"), + (b"\x0a", "BaseGlyphV1Record[2].Paint.Format (10)"), (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), (b"\x00\x0b", "BaseGlyphV1Record[2].Paint.Glyph (11)"), - (b"\x05", "BaseGlyphV1Record[2].Paint.Paint.Format (5)"), - (b"\x00\x00 ", "Offset to ColorLine from beginning of PaintSweepGradient (32)"), - (b"\x01\x03\x00\x00\x00\x00", "centerX.value (259)"), - (b"\x01\x2c\x00\x00\x00\x00", "centerY.value (300)"), - (b"\x00\x2d\x00\x00\x00\x00\x00\x00", "startAngle (45.0)"), - (b"\x00\x87\x00\x00\x00\x00\x00\x00", "endAngle (135.0)"), + (b"\x08", "BaseGlyphV1Record[2].Paint.Paint.Format (8)"), + (b"\x00\x00\x10", "Offset to ColorLine from beginning of PaintSweepGradient (16)"), + (b"\x01\x03", "centerX (259)"), + (b"\x01\x2c", "centerY (300)"), + (b"\x00\x2d\x00\x00", "startAngle (45.0)"), + (b"\x00\x87\x00\x00", "endAngle (135.0)"), (b"\x00", "ColorLine.Extend (0; pad)"), (b"\x00\x02", "ColorLine.StopCount (2)"), - (b"\x00\x00", "ColorLine.ColorStop[0].StopOffset.value (0.0)"), - (b"\x00\x00\x00\x00", "ColorLine.ColorStop[0].StopOffset.varIdx (0)"), + (b"\x00\x00", "ColorLine.ColorStop[0].StopOffset (0.0)"), (b"\x00\x03", "ColorLine.ColorStop[0].Color.PaletteIndex (3)"), - (b"@\x00", "ColorLine.ColorStop[0].Color.Alpha.value (1.0)"), - (b"\x00\x00\x00\x00", "ColorLine.ColorStop[0].Color.Alpha.varIdx (0)"), - (b"@\x00", "ColorLine.ColorStop[1].StopOffset.value (1.0)"), - (b"\x00\x00\x00\x00", "ColorLine.ColorStop[1].StopOffset.varIdx (0)"), + (b"@\x00", "ColorLine.ColorStop[0].Color.Alpha (1.0)"), + (b"@\x00", "ColorLine.ColorStop[1].StopOffset (1.0)"), (b"\x00\x05", "ColorLine.ColorStop[1].Color.PaletteIndex (5)"), - (b"@\x00", "ColorLine.ColorStop[1].Color.Alpha.value (1.0)"), - (b"\x00\x00\x00\x00", "ColorLine.ColorStop[1].Color.Alpha.varIdx (0)"), + (b"@\x00", "ColorLine.ColorStop[1].Color.Alpha (1.0)"), # LayerV1List (b"\x00\x00\x00\x04", "LayerV1List.LayerCount (4)"), ( @@ -182,70 +180,63 @@ COLR_V1_SAMPLE = ( "First Offset to Paint table from beginning of LayerV1List (20)", ), ( - b"\x00\x00\x00\x1a", - "Second Offset to Paint table from beginning of LayerV1List (26)", + b"\x00\x00\x00\x23", + "Second Offset to Paint table from beginning of LayerV1List (35)", ), ( - b"\x00\x00\x00u", - "Third Offset to Paint table from beginning of LayerV1List (117)", + b"\x00\x00\x00\x4e", + "Third Offset to Paint table from beginning of LayerV1List (78)", ), ( - b"\x00\x00\x00\xf6", - "Fourth Offset to Paint table from beginning of LayerV1List (246)", + b"\x00\x00\x00\xb7", + "Fourth Offset to Paint table from beginning of LayerV1List (183)", ), # PaintGlyph glyph00011 - (b"\x06", "LayerV1List.Paint[0].Format (6)"), - (b"\x00\x01<", "Offset24 to Paint subtable from beginning of PaintGlyph (316)"), + (b"\x0a", "LayerV1List.Paint[0].Format (10)"), + (b"\x00\x00\x06", "Offset24 to Paint subtable from beginning of PaintGlyph (6)"), (b"\x00\x0b", "LayerV1List.Paint[0].Glyph (glyph00011)"), + # PaintVarSolid + (b"\x03", "LayerV1List.Paint[0].Paint.Format (3)"), + (b"\x00\x02", "Paint.Color.PaletteIndex (2)"), + (b" \x00", "Paint.Color.Alpha.value (0.5)"), + (b"\x00\x00\x00\x00", "Paint.Color.Alpha.varIdx (0)"), # PaintGlyph glyph00012 - (b"\x06", "LayerV1List.Paint[1].Format (6)"), + (b"\x0a", "LayerV1List.Paint[1].Format (10)"), (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), (b"\x00\x0c", "LayerV1List.Paint[1].Glyph (glyph00012)"), - (b"\x03", "LayerV1List.Paint[1].Paint.Format (3)"), - (b"\x00\x00(", "Offset to ColorLine from beginning of PaintLinearGradient (40)"), - (b"\x00\x01", "Paint.x0.value (1)"), - (b"\x00\x00\x00\x00", "Paint.x0.varIdx (0)"), - (b"\x00\x02", "Paint.y0.value (2)"), - (b"\x00\x00\x00\x00", "Paint.y0.varIdx (0)"), - (b"\xff\xfd", "Paint.x1.value (-3)"), - (b"\x00\x00\x00\x00", "Paint.x1.varIdx (0)"), - (b"\xff\xfc", "Paint.y1.value (-4)"), - (b"\x00\x00\x00\x00", "Paint.y1.varIdx (0)"), - (b"\x00\x05", "Paint.x2.value (5)"), - (b"\x00\x00\x00\x00", "Paint.x2.varIdx (0)"), - (b"\x00\x06", "Paint.y2.value (6)"), - (b"\x00\x00\x00\x00", "Paint.y2.varIdx (0)"), + (b"\x04", "LayerV1List.Paint[1].Paint.Format (4)"), + (b"\x00\x00\x10", "Offset to ColorLine from beginning of PaintLinearGradient (16)"), + (b"\x00\x01", "Paint.x0 (1)"), + (b"\x00\x02", "Paint.y0 (2)"), + (b"\xff\xfd", "Paint.x1 (-3)"), + (b"\xff\xfc", "Paint.y1 (-4)"), + (b"\x00\x05", "Paint.x2 (5)"), + (b"\x00\x06", "Paint.y2 (6)"), (b"\x01", "ColorLine.Extend (1; repeat)"), (b"\x00\x03", "ColorLine.StopCount (3)"), - (b"\x00\x00", "ColorLine.ColorStop[0].StopOffset.value (0.0)"), - (b"\x00\x00\x00\x00", "ColorLine.ColorStop[0].StopOffset.varIdx (0)"), + (b"\x00\x00", "ColorLine.ColorStop[0].StopOffset (0.0)"), (b"\x00\x03", "ColorLine.ColorStop[0].Color.PaletteIndex (3)"), - (b"@\x00", "ColorLine.ColorStop[0].Color.Alpha.value (1.0)"), - (b"\x00\x00\x00\x00", "ColorLine.ColorStop[0].Color.Alpha.varIdx (0)"), - (b" \x00", "ColorLine.ColorStop[1].StopOffset.value (0.5)"), - (b"\x00\x00\x00\x00", "ColorLine.ColorStop[1].StopOffset.varIdx (0)"), + (b"@\x00", "ColorLine.ColorStop[0].Color.Alpha (1.0)"), + (b" \x00", "ColorLine.ColorStop[1].StopOffset (0.5)"), (b"\x00\x04", "ColorLine.ColorStop[1].Color.PaletteIndex (4)"), - (b"@\x00", "ColorLine.ColorStop[1].Color.Alpha.value (1.0)"), - (b"\x00\x00\x00\x00", "ColorLine.ColorStop[1].Color.Alpha.varIdx (0)"), - (b"@\x00", "ColorLine.ColorStop[2].StopOffset.value (1.0)"), - (b"\x00\x00\x00\x00", "ColorLine.ColorStop[2].StopOffset.varIdx (0)"), + (b"@\x00", "ColorLine.ColorStop[1].Color.Alpha (1.0)"), + (b"@\x00", "ColorLine.ColorStop[2].StopOffset (1.0)"), (b"\x00\x05", "ColorLine.ColorStop[2].Color.PaletteIndex (5)"), - (b"@\x00", "ColorLine.ColorStop[2].Color.Alpha.value (1.0)"), - (b"\x00\x00\x00\x00", "ColorLine.ColorStop[2].Color.Alpha.varIdx (0)"), + (b"@\x00", "ColorLine.ColorStop[2].Color.Alpha (1.0)"), # PaintGlyph glyph00013 - (b"\x06", "LayerV1List.Paint[2].Format (6)"), + (b"\x0a", "LayerV1List.Paint[2].Format (10)"), (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), (b"\x00\r", "LayerV1List.Paint[2].Glyph (13)"), - (b"\x08", "LayerV1List.Paint[2].Paint.Format (8)"), - (b"\x00\x00\x34", "Offset to Paint subtable from beginning of PaintTransform (52)"), - (b"\xff\xf3\x00\x00\x00\x00\x00\x00", "Affine2x3.xx.value (-13)"), - (b"\x00\x0e\x00\x00\x00\x00\x00\x00", "Affine2x3.xy.value (14)"), - (b"\x00\x0f\x00\x00\x00\x00\x00\x00", "Affine2x3.yx.value (15)"), - (b"\xff\xef\x00\x00\x00\x00\x00\x00", "Affine2x3.yy.value (-17)"), - (b"\x00\x12\x00\x00\x00\x00\x00\x00", "Affine2x3.yy.value (18)"), - (b"\x00\x13\x00\x00\x00\x00\x00\x00", "Affine2x3.yy.value (19)"), - (b"\x04", "LayerV1List.Paint[2].Paint.Paint.Format (4)"), - (b"\x00\x00(", "Offset to ColorLine from beginning of PaintRadialGradient (40)"), + (b"\x0c", "LayerV1List.Paint[2].Paint.Format (12)"), + (b"\x00\x00\x1c", "Offset to Paint subtable from beginning of PaintTransform (28)"), + (b"\xff\xf3\x00\x00", "Affine2x3.xx (-13)"), + (b"\x00\x0e\x00\x00", "Affine2x3.xy (14)"), + (b"\x00\x0f\x00\x00", "Affine2x3.yx (15)"), + (b"\xff\xef\x00\x00", "Affine2x3.yy (-17)"), + (b"\x00\x12\x00\x00", "Affine2x3.yy (18)"), + (b"\x00\x13\x00\x00", "Affine2x3.yy (19)"), + (b"\x07", "LayerV1List.Paint[2].Paint.Paint.Format (7)"), + (b"\x00\x00(", "Offset to ColorLine from beginning of PaintVarRadialGradient (40)"), (b"\x00\x07\x00\x00\x00\x00", "Paint.x0.value (7)"), (b"\x00\x08\x00\x00\x00\x00", "Paint.y0.value (8)"), (b"\x00\t\x00\x00\x00\x00", "Paint.r0.value (9)"), @@ -261,32 +252,31 @@ COLR_V1_SAMPLE = ( (b"\x00\x07", "ColorLine.ColorStop[1].Color.PaletteIndex (7)"), (b"\x19\x9a\x00\x00\x00\x00", "ColorLine.ColorStop[1].Color.Alpha.value (0.4)"), # PaintTranslate - (b"\x09", "LayerV1List.Paint[3].Format (9)"), - (b"\x00\x00\x14", "Offset to Paint subtable from beginning of PaintTranslate (20)"), - (b"\x01\x01\x00\x00\x00\x00\x00\x00", "dx.value (257)"), - (b"\x01\x02\x00\x00\x00\x00\x00\x00", "dy.value (258)"), + (b"\x0e", "LayerV1List.Paint[3].Format (14)"), + (b"\x00\x00\x0c", "Offset to Paint subtable from beginning of PaintTranslate (12)"), + (b"\x01\x01\x00\x00", "dx (257)"), + (b"\x01\x02\x00\x00", "dy (258)"), # PaintRotate - (b"\x0a", "LayerV1List.Paint[3].Paint.Format (10)"), - (b"\x00\x00\x1c", "Offset to Paint subtable from beginning of PaintRotate (28)"), - (b"\x00\x2d\x00\x00\x00\x00\x00\x00", "angle.value (45)"), - (b"\x00\xff\x00\x00\x00\x00\x00\x00", "centerX.value (255)"), - (b"\x01\x00\x00\x00\x00\x00\x00\x00", "centerY.value (256)"), + (b"\x10", "LayerV1List.Paint[3].Paint.Format (16)"), + (b"\x00\x00\x10", "Offset to Paint subtable from beginning of PaintRotate (16)"), + (b"\x00\x2d\x00\x00", "angle (45)"), + (b"\x00\xff\x00\x00", "centerX (255)"), + (b"\x01\x00\x00\x00", "centerY (256)"), # PaintSkew - (b"\x0b", "LayerV1List.Paint[3].Paint.Paint.Format (11)"), - (b"\x00\x00\x24", "Offset to Paint subtable from beginning of PaintSkew (36)"), - (b"\xff\xf5\x00\x00\x00\x00\x00\x00", "xSkewAngle (-11)"), - (b"\x00\x05\x00\x00\x00\x00\x00\x00", "ySkewAngle (5)"), - (b"\x00\xfd\x00\x00\x00\x00\x00\x00", "centerX.value (253)"), - (b"\x00\xfe\x00\x00\x00\x00\x00\x00", "centerY.value (254)"), + (b"\x12", "LayerV1List.Paint[3].Paint.Paint.Format (18)"), + (b"\x00\x00\x14", "Offset to Paint subtable from beginning of PaintSkew (20)"), + (b"\xff\xf5\x00\x00", "xSkewAngle (-11)"), + (b"\x00\x05\x00\x00", "ySkewAngle (5)"), + (b"\x00\xfd\x00\x00", "centerX.value (253)"), + (b"\x00\xfe\x00\x00", "centerY.value (254)"), # PaintGlyph - (b"\x06", "LayerV1List.Paint[2].Format (6)"), + (b"\x0a", "LayerV1List.Paint[3].Paint.Paint.Paint.Format (10)"), (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), (b"\x00\x0b", "LayerV1List.Paint[2].Glyph (11)"), # PaintSolid - (b"\x02", "LayerV1List.Paint[0].Paint.Format (2)"), + (b"\x02", "LayerV1List.Paint[0].Paint.Paint.Paint.Paint.Format (2)"), (b"\x00\x02", "Paint.Color.PaletteIndex (2)"), - (b" \x00", "Paint.Color.Alpha.value (0.5)"), - (b"\x00\x00\x00\x00", "Paint.Color.Alpha.varIdx (0)"), + (b" \x00", "Paint.Color.Alpha (0.5)"), ) COLR_V1_DATA = b"".join(t[0] for t in COLR_V1_SAMPLE) @@ -327,13 +317,13 @@ COLR_V1_XML = [ " ", ' ', ' ', - ' ', - ' ', + ' ', + ' ', ' ', " ", ' ', - ' ', - ' ', + ' ', + ' ', ' ', " ", " ", @@ -349,8 +339,8 @@ COLR_V1_XML = [ " ", ' ', ' ', - ' ', - ' ', + ' ', + ' ', " ", ' ', " ", @@ -380,8 +370,8 @@ COLR_V1_XML = [ "", "", " ", - ' ', - ' ', + ' ', + ' ', " ", ' ', ' ', @@ -389,8 +379,8 @@ COLR_V1_XML = [ " ", ' ', " ", - ' ', - ' ', + ' ', + ' ', " ", ' ', " ", @@ -425,9 +415,9 @@ COLR_V1_XML = [ " ", ' ', " ", - ' ', - ' ', - ' ', + ' ', + ' ', + ' ', " ", ' ', " ", @@ -464,10 +454,10 @@ COLR_V1_XML = [ " ", ' ', " ", - ' ', - ' ', - ' ', - ' ', + ' ', + ' ', + ' ', + ' ', ' ', " ", ' ', From 404072ee99fa4764781df2e68088983c7e97f368 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Fri, 12 Feb 2021 10:07:01 -0800 Subject: [PATCH 093/167] Fix descriptions in Lib/fontTools/ttLib/tables/otData.py Co-authored-by: Cosimo Lupo --- Lib/fontTools/ttLib/tables/otData.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py index 535da9237..28b40c478 100755 --- a/Lib/fontTools/ttLib/tables/otData.py +++ b/Lib/fontTools/ttLib/tables/otData.py @@ -1675,7 +1675,7 @@ otData = [ # PaintVarLinearGradient ('PaintFormat5', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 5'), - ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintLinearGradient table) to VarColorLine subtable.'), + ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintVarLinearGradient table) to VarColorLine subtable.'), ('VarInt16', 'x0', None, None, ''), ('VarInt16', 'y0', None, None, ''), ('VarInt16', 'x1', None, None, ''), @@ -1698,7 +1698,7 @@ otData = [ # PaintVarRadialGradient ('PaintFormat7', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 7'), - ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintRadialGradient table) to VarColorLine subtable.'), + ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintVarRadialGradient table) to VarColorLine subtable.'), ('VarInt16', 'x0', None, None, ''), ('VarInt16', 'y0', None, None, ''), ('VarUInt16', 'r0', None, None, ''), @@ -1719,7 +1719,7 @@ otData = [ # PaintVarSweepGradient ('PaintFormat9', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 9'), - ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintSweepGradient table) to VarColorLine subtable.'), + ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintVarSweepGradient table) to VarColorLine subtable.'), ('VarInt16', 'centerX', None, None, 'Center x coordinate.'), ('VarInt16', 'centerY', None, None, 'Center y coordinate.'), ('VarFixed', 'startAngle', None, None, 'Start of the angular range of the gradient.'), @@ -1747,8 +1747,8 @@ otData = [ ]), # PaintVarTransform ('PaintFormat13', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 12'), - ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTransform table) to Paint subtable.'), + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 13'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarTransform table) to Paint subtable.'), ('VarAffine2x3', 'Transform', None, None, '2x3 matrix for 2D affine transformations.'), ]), @@ -1762,7 +1762,7 @@ otData = [ # PaintVarTranslate ('PaintFormat15', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 15'), - ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTranslate table) to Paint subtable.'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarTranslate table) to Paint subtable.'), ('VarFixed', 'dx', None, None, 'Translation in x direction.'), ('VarFixed', 'dy', None, None, 'Translation in y direction.'), ]), @@ -1778,7 +1778,7 @@ otData = [ # PaintVarRotate ('PaintFormat17', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 17'), - ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintRotate table) to Paint subtable.'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarRotate table) to Paint subtable.'), ('VarFixed', 'angle', None, None, ''), ('VarFixed', 'centerX', None, None, ''), ('VarFixed', 'centerY', None, None, ''), @@ -1796,7 +1796,7 @@ otData = [ # PaintVarSkew ('PaintFormat19', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 19'), - ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintSkew table) to Paint subtable.'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarSkew table) to Paint subtable.'), ('VarFixed', 'xSkewAngle', None, None, ''), ('VarFixed', 'ySkewAngle', None, None, ''), ('VarFixed', 'centerX', None, None, ''), From dda10e0f00fc415355bae52e76f58c57897d8b70 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 1 Feb 2021 16:35:32 +0000 Subject: [PATCH 094/167] WIP: add test_subset_COLRv1 --- Tests/subset/subset_test.py | 161 ++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/Tests/subset/subset_test.py b/Tests/subset/subset_test.py index d37634f12..83bfcc25c 100644 --- a/Tests/subset/subset_test.py +++ b/Tests/subset/subset_test.py @@ -3,7 +3,9 @@ from fontTools.misc.py23 import * from fontTools.misc.testTools import getXML from fontTools import subset from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen from fontTools.ttLib import TTFont, newTable +from fontTools.ttLib.tables import otTables as ot from fontTools.misc.loggingTools import CapturingLogHandler import difflib import logging @@ -930,5 +932,164 @@ def test_subset_empty_glyf(tmp_path, ttf_path): assert all(loc == 0 for loc in loca) +@pytest.fixture +def colrv1_path(tmp_path): + base_glyph_names = ["uni%04X" % i for i in range(0xE000, 0xE000 + 10)] + layer_glyph_names = ["glyph%05d" % i for i in range(10, 20)] + glyph_order = [".notdef"] + base_glyph_names + layer_glyph_names + + pen = TTGlyphPen(glyphSet=None) + pen.moveTo((0, 0)) + pen.lineTo((0, 500)) + pen.lineTo((500, 500)) + pen.lineTo((500, 0)) + pen.closePath() + glyph = pen.glyph() + glyphs = {g: glyph for g in glyph_order} + + fb = FontBuilder(unitsPerEm=1024, isTTF=True) + fb.setupGlyphOrder(glyph_order) + fb.setupCharacterMap({int(name[3:], 16): name for name in base_glyph_names}) + fb.setupGlyf(glyphs) + fb.setupHorizontalMetrics({g: (500, 0) for g in glyph_order}) + fb.setupHorizontalHeader() + fb.setupOS2() + fb.setupPost() + fb.setupNameTable({"familyName": "TestCOLRv1", "styleName": "Regular"}) + + fb.setupCOLR( + { + "uniE000": [ + { + "format": int(ot.PaintFormat.PaintGlyph), + "glyph": "glyph00010", + "paint": { + "format": int(ot.PaintFormat.PaintSolid), + "paletteIndex": 0, + }, + }, + { + "format": int(ot.PaintFormat.PaintGlyph), + "glyph": "glyph00011", + "paint": { + "format": int(ot.PaintFormat.PaintSolid), + "paletteIndex": 2, + "alpha": 0.3, + }, + }, + ], + "uniE001": [ + { + "format": int(ot.PaintFormat.PaintTransform), + "transform": (0.7071, 0.7071, -0.7071, 0.7071, 0, 0), + "paint": { + "format": int(ot.PaintFormat.PaintGlyph), + "glyph": "glyph00012", + "paint": { + "format": int(ot.PaintFormat.PaintRadialGradient), + "c0": (250, 250), + "r0": 250, + "c1": (200, 200), + "r1": 0, + "colorLine": { + "stops": [(0.0, 0), (1.0, 1)], "extend": "repeat" + }, + }, + }, + }, + { + "format": int(ot.PaintFormat.PaintGlyph), + "glyph": "glyph00013", + "paint": { + "format": int(ot.PaintFormat.PaintSolid), + "paletteIndex": 1, + "alpha": 0.5, + }, + }, + ], + "uniE002": [ + { + "format": int(ot.PaintFormat.PaintGlyph), + "glyph": "glyph00014", + "paint": { + "format": int(ot.PaintFormat.PaintLinearGradient), + "p0": (0, 0), + "p1": (500, 500), + "colorLine": {"stops": [(0.0, 0), (1.0, 2)]}, + }, + }, + { + "format": int(ot.PaintFormat.PaintTransform), + "transform": (1, 0, 0, 1, 400, 400), + "paint": { + "format": int(ot.PaintFormat.PaintGlyph), + "glyph": "glyph00015", + "paint": { + "format": int(ot.PaintFormat.PaintSolid), + "paletteIndex": 1, + }, + }, + }, + ], + "uniE003": { + "format": int(ot.PaintFormat.PaintRotate), + "angle": 45, + "centerX": 250, + "centerY": 250, + "paint": { + "format": int(ot.PaintFormat.PaintColrGlyph), + "glyph": "uniE001", + }, + }, + }, + ) + fb.setupCPAL( + [ + [ + (1.0, 0.0, 0.0, 1.0), # red + (0.0, 1.0, 0.0, 1.0), # green + (0.0, 0.0, 1.0, 1.0), # blue + ], + ], + ) + + output_path = tmp_path / "TestCOLRv1.ttf" + fb.save(output_path) + + return output_path + + +def test_subset_COLRv1(colrv1_path): + subset_path = colrv1_path.parent / (colrv1_path.name + ".subset") + + subset.main( + [ + str(colrv1_path), + "--glyph-names", + f"--output-file={subset_path}", + "--unicodes=E002,E003", + ] + ) + subset_font = TTFont(subset_path) + + glyph_set = set(subset_font.getGlyphOrder()) + + # uniE000 and its children are excluded from subset + assert "uniE000" not in glyph_set + assert "glyph00010" not in glyph_set + assert "glyph00011" not in glyph_set + + # uniE001 and children are pulled in indirectly as PaintColrGlyph by uniE003 + assert "uniE001" in glyph_set + assert "glyph00012" in glyph_set + assert "glyph00013" in glyph_set + + assert "uniE002" in glyph_set + assert "glyph00014" in glyph_set + assert "glyph00015" in glyph_set + + assert "uniE003" in glyph_set + + if __name__ == "__main__": sys.exit(unittest.main()) From 4036512a27a8dfba6f78d96a39d0af64ef652b3a Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 5 Feb 2021 16:31:42 +0000 Subject: [PATCH 095/167] C_O_L_R_: factor out method to convert v0 layer records to dict of list we'll need that when we subset the COLRv0 portion of a COLRv1 table --- Lib/fontTools/colorLib/builder.py | 2 +- Lib/fontTools/ttLib/tables/C_O_L_R_.py | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 90fdd4f1f..821244af0 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -229,7 +229,7 @@ def buildCOLR( self.version = colr.Version = version if version == 0: - self._fromOTTable(colr) + self.ColorLayers = self._decompileColorLayersV0(colr) else: colr.VarStore = varStore self.table = colr diff --git a/Lib/fontTools/ttLib/tables/C_O_L_R_.py b/Lib/fontTools/ttLib/tables/C_O_L_R_.py index 7a9442ded..db490520c 100644 --- a/Lib/fontTools/ttLib/tables/C_O_L_R_.py +++ b/Lib/fontTools/ttLib/tables/C_O_L_R_.py @@ -14,9 +14,11 @@ class table_C_O_L_R_(DefaultTable.DefaultTable): ttFont['COLR'][] = will set the color layers for any glyph. """ - def _fromOTTable(self, table): - self.version = 0 - self.ColorLayers = colorLayerLists = {} + @staticmethod + def _decompileColorLayersV0(table): + if not table.LayerRecordArray: + return {} + colorLayerLists = {} layerRecords = table.LayerRecordArray.LayerRecord numLayerRecords = len(layerRecords) for baseRec in table.BaseGlyphRecordArray.BaseGlyphRecord: @@ -31,6 +33,7 @@ class table_C_O_L_R_(DefaultTable.DefaultTable): LayerRecord(layerRec.LayerGlyph, layerRec.PaletteIndex) ) colorLayerLists[baseGlyph] = layers + return colorLayerLists def _toOTTable(self, ttFont): from . import otTables @@ -61,12 +64,12 @@ class table_C_O_L_R_(DefaultTable.DefaultTable): table = tableClass() table.decompile(reader, ttFont) - if table.Version == 0: - self._fromOTTable(table) + self.version = table.Version + if self.version == 0: + self.ColorLayers = self._decompileColorLayersV0(table) else: # for new versions, keep the raw otTables around self.table = table - self.version = table.Version def compile(self, ttFont): from .otBase import OTTableWriter @@ -120,6 +123,7 @@ class table_C_O_L_R_(DefaultTable.DefaultTable): self.table = tableClass() self.table.fromXML(name, attrs, content, ttFont) self.table.populateDefaults() + self.version = self.table.Version def __getitem__(self, glyphName): if not isinstance(glyphName, str): From 4dcc7f83d20a889bc0d63e98e1f0312477f5c45d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 5 Feb 2021 17:17:34 +0000 Subject: [PATCH 096/167] otTables: add Paint.traverse method paint graph traversal mostly adapted from rsheeter's https://github.com/googlefonts/nanoemoji/pull/233 --- Lib/fontTools/ttLib/tables/otTables.py | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py index 43c40d5c5..008909bdd 100644 --- a/Lib/fontTools/ttLib/tables/otTables.py +++ b/Lib/fontTools/ttLib/tables/otTables.py @@ -1367,6 +1367,40 @@ class Paint(getFormatSwitchingBaseTableClass("uint8")): xmlWriter.endtag(tableName) xmlWriter.newline() + def getChildren(self, colr): + if self.Format == PaintFormat.PaintColrLayers: + return colr.LayerV1List.Paint[ + self.FirstLayerIndex : self.FirstLayerIndex + self.NumLayers + ] + + if self.Format == PaintFormat.PaintColrGlyph: + for record in colr.BaseGlyphV1List.BaseGlyphV1Record: + if record.BaseGlyph == self.Glyph: + return [record.Paint] + else: + raise KeyError(f"{self.Glyph!r} not in colr.BaseGlyphV1List") + + children = [] + for conv in self.getConverters(): + if conv.tableClass is not None and issubclass(conv.tableClass, type(self)): + children.append(getattr(self, conv.name)) + + return children + + def traverse(self, colr: COLR, callback): + """Depth-first traversal of graph rooted at self, callback on each node.""" + if not callable(callback): + raise TypeError("callback must be callable") + stack = [self] + visited = set() + while stack: + current = stack.pop() + if id(current) in visited: + continue + callback(current) + visited.add(id(current)) + stack.extend(reversed(current.getChildren(colr))) + # For each subtable format there is a class. However, we don't really distinguish # between "field name" and "format name": often these are the same. Yet there's From 109c07d16c0fd4b25f85c0112ac83b5cd406a2e8 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 5 Feb 2021 17:25:28 +0000 Subject: [PATCH 097/167] subset: support subsetting COLRv1 base glyphs In COLR.closure_glyphs augment the subset with the glyphs rechable from the COLRv1 base glyphs already in the subset. In COLR.subset_glyphs, subset and rebuild LayerV1List and BaseGlyphV1List with the base glyphs to keep. Drop COLR if emptied --- Lib/fontTools/subset/__init__.py | 63 ++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py index 82605d51b..af5b7ffcd 100644 --- a/Lib/fontTools/subset/__init__.py +++ b/Lib/fontTools/subset/__init__.py @@ -1983,22 +1983,79 @@ def subset_glyphs(self, s): else: assert False, "unknown 'prop' format %s" % prop.Format +def _paint_glyph_names(paint, colr): + result = set() + + def callback(paint): + if paint.Format in { + otTables.PaintFormat.PaintGlyph, + otTables.PaintFormat.PaintColrGlyph, + }: + result.add(paint.Glyph) + + paint.traverse(colr, callback) + return result + @_add_method(ttLib.getTableClass('COLR')) def closure_glyphs(self, s): + if self.version > 0: + # on decompiling COLRv1, we only keep around the raw otTables + # but for subsetting we need dicts with fully decompiled layers; + # we store them temporarily in the C_O_L_R_ instance and delete + # them after we have finished subsetting. + self.ColorLayers = self._decompileColorLayersV0(self.table) + self.ColorLayersV1 = { + rec.BaseGlyph: rec.Paint + for rec in self.table.BaseGlyphV1List.BaseGlyphV1Record + } + decompose = s.glyphs while decompose: layers = set() for g in decompose: - for l in self.ColorLayers.get(g, []): - layers.add(l.name) + for layer in self.ColorLayers.get(g, []): + layers.add(layer.name) + + if self.version > 0: + paint = self.ColorLayersV1.get(g) + if paint is not None: + layers.update(_paint_glyph_names(paint, self.table)) + layers -= s.glyphs s.glyphs.update(layers) decompose = layers @_add_method(ttLib.getTableClass('COLR')) def subset_glyphs(self, s): + from fontTools.colorLib.unbuilder import unbuildColrV1 + from fontTools.colorLib.builder import buildColrV1, populateCOLRv0 + self.ColorLayers = {g: self.ColorLayers[g] for g in s.glyphs if g in self.ColorLayers} - return bool(self.ColorLayers) + if self.version == 0: + return bool(self.ColorLayers) + + layersV0 = self.ColorLayers + populateCOLRv0( + self.table, + { + g: [(layer.name, layer.colorID) for layer in layersV0[g]] + for g in layersV0 + }, + ) + del self.ColorLayers + + colorGlyphsV1 = unbuildColrV1( + self.table.LayerV1List, + self.table.BaseGlyphV1List, + ignoreVarIdx=not self.table.VarStore, + ) + self.table.LayerV1List, self.table.BaseGlyphV1List = buildColrV1( + {g: colorGlyphsV1[g] for g in colorGlyphsV1 if g in s.glyphs} + ) + del self.ColorLayersV1 + + # TODO: also prune ununsed varIndices in COLR.VarStore + return bool(layersV0) or bool(self.table.BaseGlyphV1List.BaseGlyphV1Record) # TODO: prune unused palettes @_add_method(ttLib.getTableClass('CPAL')) From 056aba8e4afbaccc363f665c79394fbd56b4c380 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 11 Feb 2021 17:49:27 +0000 Subject: [PATCH 098/167] prune unused CPAL palette entries after subsetting COLR glyphs Fixes https://github.com/fonttools/fonttools/issues/2174 --- Lib/fontTools/subset/__init__.py | 49 +++++++++++++++++++++++++++-- Tests/subset/subset_test.py | 54 ++++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py index af5b7ffcd..e5d9f1551 100644 --- a/Lib/fontTools/subset/__init__.py +++ b/Lib/fontTools/subset/__init__.py @@ -14,7 +14,7 @@ import sys import struct import array import logging -from collections import Counter +from collections import Counter, defaultdict from types import MethodType __usage__ = "pyftsubset font-file [glyph...] [--option=value]..." @@ -2057,10 +2057,53 @@ def subset_glyphs(self, s): # TODO: also prune ununsed varIndices in COLR.VarStore return bool(layersV0) or bool(self.table.BaseGlyphV1List.BaseGlyphV1Record) -# TODO: prune unused palettes @_add_method(ttLib.getTableClass('CPAL')) def prune_post_subset(self, font, options): - return True + colr = font.get("COLR") + if not colr: # drop CPAL if COLR was subsetted to empty + return False + + colors_by_index = defaultdict(list) + + def collect_colors_by_index(paint): + if hasattr(paint, "Color"): # either solid colors... + colors_by_index[paint.Color.PaletteIndex].append(paint.Color) + elif hasattr(paint, "ColorLine"): # ... or gradient color stops + for stop in paint.ColorLine.ColorStop: + colors_by_index[stop.Color.PaletteIndex].append(stop.Color) + + if colr.version == 0: + for layers in colr.ColorLayers.values(): + for layer in layers: + colors_by_index[layer.colorID].append(layer) + else: + if colr.table.LayerRecordArray: + for layer in colr.table.LayerRecordArray.LayerRecord: + colors_by_index[layer.PaletteIndex].append(layer) + for record in colr.table.BaseGlyphV1List.BaseGlyphV1Record: + record.Paint.traverse(colr.table, collect_colors_by_index) + + retained_palette_indices = set(colors_by_index.keys()) + for palette in self.palettes: + palette[:] = [c for i, c in enumerate(palette) if i in retained_palette_indices] + assert len(palette) == len(retained_palette_indices) + + for new_index, old_index in enumerate(sorted(retained_palette_indices)): + for record in colors_by_index[old_index]: + if hasattr(record, "colorID"): # v0 + record.colorID = new_index + elif hasattr(record, "PaletteIndex"): # v1 + record.PaletteIndex = new_index + else: + raise AssertionError(record) + + self.numPaletteEntries = len(self.palettes[0]) + + if self.version == 1: + self.paletteEntryLabels = [ + label for i, label in self.paletteEntryLabels if i in retained_palette_indices + ] + return bool(self.numPaletteEntries) @_add_method(otTables.MathGlyphConstruction) def closure_glyphs(self, glyphs): diff --git a/Tests/subset/subset_test.py b/Tests/subset/subset_test.py index 83bfcc25c..3808489ef 100644 --- a/Tests/subset/subset_test.py +++ b/Tests/subset/subset_test.py @@ -992,7 +992,7 @@ def colrv1_path(tmp_path): "c1": (200, 200), "r1": 0, "colorLine": { - "stops": [(0.0, 0), (1.0, 1)], "extend": "repeat" + "stops": [(0.0, 1), (1.0, 2)], "extend": "repeat" }, }, }, @@ -1015,7 +1015,7 @@ def colrv1_path(tmp_path): "format": int(ot.PaintFormat.PaintLinearGradient), "p0": (0, 0), "p1": (500, 500), - "colorLine": {"stops": [(0.0, 0), (1.0, 2)]}, + "colorLine": {"stops": [(0.0, 1), (1.0, 2)]}, }, }, { @@ -1059,7 +1059,7 @@ def colrv1_path(tmp_path): return output_path -def test_subset_COLRv1(colrv1_path): +def test_subset_COLRv1_and_CPAL(colrv1_path): subset_path = colrv1_path.parent / (colrv1_path.name + ".subset") subset.main( @@ -1090,6 +1090,54 @@ def test_subset_COLRv1(colrv1_path): assert "uniE003" in glyph_set + assert "COLR" in subset_font + colr = subset_font["COLR"].table + assert len(colr.BaseGlyphV1List.BaseGlyphV1Record) == 3 # was 4 + + base = colr.BaseGlyphV1List.BaseGlyphV1Record[0] + assert base.BaseGlyph == "uniE001" + layers = colr.LayerV1List.Paint[ + base.Paint.FirstLayerIndex: base.Paint.FirstLayerIndex + base.Paint.NumLayers + ] + assert len(layers) == 2 + # check palette indices were remapped + assert layers[0].Paint.Paint.ColorLine.ColorStop[0].Color.PaletteIndex == 0 + assert layers[0].Paint.Paint.ColorLine.ColorStop[1].Color.PaletteIndex == 1 + assert layers[1].Paint.Color.PaletteIndex == 0 + + assert "CPAL" in subset_font + cpal = subset_font["CPAL"] + assert [ + tuple(v / 255 for v in (c.red, c.green, c.blue, c.alpha)) + for c in cpal.palettes[0] + ] == [ + # the first color 'red' was pruned + (0.0, 1.0, 0.0, 1.0), # green + (0.0, 0.0, 1.0, 1.0), # blue + ] + + +def test_subset_COLRv1_and_CPAL_drop_empty(colrv1_path): + subset_path = colrv1_path.parent / (colrv1_path.name + ".subset") + + subset.main( + [ + str(colrv1_path), + "--glyph-names", + f"--output-file={subset_path}", + "--glyphs=glyph00010", + ] + ) + subset_font = TTFont(subset_path) + + glyph_set = set(subset_font.getGlyphOrder()) + + assert "glyph00010" in glyph_set + assert "uniE000" not in glyph_set + + assert "COLR" not in subset_font + assert "CPAL" not in subset_font + if __name__ == "__main__": sys.exit(unittest.main()) From 55c9a0a1efcda16d694a934615af5f400f171332 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 15 Feb 2021 11:03:02 +0000 Subject: [PATCH 099/167] update subset_test.py using new COLRv1 dict format --- Lib/fontTools/subset/__init__.py | 6 +- Tests/subset/subset_test.py | 140 ++++++++++++++++--------------- 2 files changed, 72 insertions(+), 74 deletions(-) diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py index e5d9f1551..c92117bfb 100644 --- a/Lib/fontTools/subset/__init__.py +++ b/Lib/fontTools/subset/__init__.py @@ -2044,11 +2044,7 @@ def subset_glyphs(self, s): ) del self.ColorLayers - colorGlyphsV1 = unbuildColrV1( - self.table.LayerV1List, - self.table.BaseGlyphV1List, - ignoreVarIdx=not self.table.VarStore, - ) + colorGlyphsV1 = unbuildColrV1(self.table.LayerV1List, self.table.BaseGlyphV1List) self.table.LayerV1List, self.table.BaseGlyphV1List = buildColrV1( {g: colorGlyphsV1[g] for g in colorGlyphsV1 if g in s.glyphs} ) diff --git a/Tests/subset/subset_test.py b/Tests/subset/subset_test.py index 3808489ef..72c4d255b 100644 --- a/Tests/subset/subset_test.py +++ b/Tests/subset/subset_test.py @@ -959,87 +959,89 @@ def colrv1_path(tmp_path): fb.setupCOLR( { - "uniE000": [ - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00010", - "paint": { - "format": int(ot.PaintFormat.PaintSolid), - "paletteIndex": 0, + "uniE000": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": (ot.PaintFormat.PaintSolid, 0), + "Glyph": "glyph00010", }, - }, - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00011", - "paint": { - "format": int(ot.PaintFormat.PaintSolid), - "paletteIndex": 2, - "alpha": 0.3, + { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": (ot.PaintFormat.PaintSolid, (2, 0.3)), + "Glyph": "glyph00011", }, - }, - ], - "uniE001": [ - { - "format": int(ot.PaintFormat.PaintTransform), - "transform": (0.7071, 0.7071, -0.7071, 0.7071, 0, 0), - "paint": { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00012", - "paint": { - "format": int(ot.PaintFormat.PaintRadialGradient), - "c0": (250, 250), - "r0": 250, - "c1": (200, 200), - "r1": 0, - "colorLine": { - "stops": [(0.0, 1), (1.0, 2)], "extend": "repeat" + ], + ), + "uniE001": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": ot.PaintFormat.PaintTransform, + "Paint": { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": { + "Format": ot.PaintFormat.PaintRadialGradient, + "x0": 250, + "y0": 250, + "r0": 250, + "x1": 200, + "y1": 200, + "r1": 0, + "ColorLine": { + "ColorStop": [(0.0, 1), (1.0, 2)], + "Extend": "repeat", + }, }, + "Glyph": "glyph00012", }, + "Transform": (0.7071, 0.7071, -0.7071, 0.7071, 0, 0), }, - }, - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00013", - "paint": { - "format": int(ot.PaintFormat.PaintSolid), - "paletteIndex": 1, - "alpha": 0.5, + { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": (ot.PaintFormat.PaintSolid, (1, 0.5)), + "Glyph": "glyph00013", }, - }, - ], - "uniE002": [ - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00014", - "paint": { - "format": int(ot.PaintFormat.PaintLinearGradient), - "p0": (0, 0), - "p1": (500, 500), - "colorLine": {"stops": [(0.0, 1), (1.0, 2)]}, - }, - }, - { - "format": int(ot.PaintFormat.PaintTransform), - "transform": (1, 0, 0, 1, 400, 400), - "paint": { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00015", - "paint": { - "format": int(ot.PaintFormat.PaintSolid), - "paletteIndex": 1, + ], + ), + "uniE002": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": { + "Format": ot.PaintFormat.PaintLinearGradient, + "x0": 0, + "y0": 0, + "x1": 500, + "y1": 500, + "x2": -500, + "y2": 500, + "ColorLine": {"ColorStop": [(0.0, 1), (1.0, 2)]}, }, + "Glyph": "glyph00014", }, - }, - ], + { + "Format": ot.PaintFormat.PaintTransform, + "Paint": { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": (ot.PaintFormat.PaintSolid, 1), + "Glyph": "glyph00015", + }, + "Transform": (1, 0, 0, 1, 400, 400), + }, + ], + ), "uniE003": { - "format": int(ot.PaintFormat.PaintRotate), + "Format": ot.PaintFormat.PaintRotate, + "Paint": { + "Format": ot.PaintFormat.PaintColrGlyph, + "Glyph": "uniE001", + }, "angle": 45, "centerX": 250, "centerY": 250, - "paint": { - "format": int(ot.PaintFormat.PaintColrGlyph), - "glyph": "uniE001", - }, }, }, ) From 09af39ab5389b7616868f0aa9fb4bcdf3fcd2be1 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 15 Feb 2021 11:58:56 +0000 Subject: [PATCH 100/167] subset: donwgrade COLRv1 to v0 if all v1 glyphs are dropped --- Lib/fontTools/subset/__init__.py | 29 ++++++++++++-------- Tests/subset/subset_test.py | 46 ++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py index c92117bfb..8162c09c2 100644 --- a/Lib/fontTools/subset/__init__.py +++ b/Lib/fontTools/subset/__init__.py @@ -2034,24 +2034,31 @@ def subset_glyphs(self, s): if self.version == 0: return bool(self.ColorLayers) - layersV0 = self.ColorLayers - populateCOLRv0( - self.table, - { - g: [(layer.name, layer.colorID) for layer in layersV0[g]] - for g in layersV0 - }, - ) - del self.ColorLayers - colorGlyphsV1 = unbuildColrV1(self.table.LayerV1List, self.table.BaseGlyphV1List) self.table.LayerV1List, self.table.BaseGlyphV1List = buildColrV1( {g: colorGlyphsV1[g] for g in colorGlyphsV1 if g in s.glyphs} ) del self.ColorLayersV1 + layersV0 = self.ColorLayers + if not self.table.BaseGlyphV1List.BaseGlyphV1Record: + # no more COLRv1 glyphs: downgrade to version 0 + self.version = 0 + del self.table + return bool(layersV0) + + if layersV0: + populateCOLRv0( + self.table, + { + g: [(layer.name, layer.colorID) for layer in layersV0[g]] + for g in layersV0 + }, + ) + del self.ColorLayers + # TODO: also prune ununsed varIndices in COLR.VarStore - return bool(layersV0) or bool(self.table.BaseGlyphV1List.BaseGlyphV1Record) + return True @_add_method(ttLib.getTableClass('CPAL')) def prune_post_subset(self, font, options): diff --git a/Tests/subset/subset_test.py b/Tests/subset/subset_test.py index 72c4d255b..370f9b626 100644 --- a/Tests/subset/subset_test.py +++ b/Tests/subset/subset_test.py @@ -1043,6 +1043,10 @@ def colrv1_path(tmp_path): "centerX": 250, "centerY": 250, }, + "uniE004": [ + ("glyph00016", 1), + ("glyph00017", 2), + ], }, ) fb.setupCPAL( @@ -1069,7 +1073,7 @@ def test_subset_COLRv1_and_CPAL(colrv1_path): str(colrv1_path), "--glyph-names", f"--output-file={subset_path}", - "--unicodes=E002,E003", + "--unicodes=E002,E003,E004", ] ) subset_font = TTFont(subset_path) @@ -1092,8 +1096,14 @@ def test_subset_COLRv1_and_CPAL(colrv1_path): assert "uniE003" in glyph_set + assert "uniE004" in glyph_set + assert "glyph00016" in glyph_set + assert "glyph00017" in glyph_set + assert "COLR" in subset_font colr = subset_font["COLR"].table + assert colr.Version == 1 + assert len(colr.BaseGlyphRecordArray.BaseGlyphRecord) == 1 assert len(colr.BaseGlyphV1List.BaseGlyphV1Record) == 3 # was 4 base = colr.BaseGlyphV1List.BaseGlyphV1Record[0] @@ -1102,11 +1112,19 @@ def test_subset_COLRv1_and_CPAL(colrv1_path): base.Paint.FirstLayerIndex: base.Paint.FirstLayerIndex + base.Paint.NumLayers ] assert len(layers) == 2 - # check palette indices were remapped + # check v1 palette indices were remapped assert layers[0].Paint.Paint.ColorLine.ColorStop[0].Color.PaletteIndex == 0 assert layers[0].Paint.Paint.ColorLine.ColorStop[1].Color.PaletteIndex == 1 assert layers[1].Paint.Color.PaletteIndex == 0 + baseRecV0 = colr.BaseGlyphRecordArray.BaseGlyphRecord[0] + assert baseRecV0.BaseGlyph == "uniE004" + layersV0 = colr.LayerRecordArray.LayerRecord + assert len(layersV0) == 2 + # check v0 palette indices were remapped + assert layersV0[0].PaletteIndex == 0 + assert layersV0[1].PaletteIndex == 1 + assert "CPAL" in subset_font cpal = subset_font["CPAL"] assert [ @@ -1141,5 +1159,29 @@ def test_subset_COLRv1_and_CPAL_drop_empty(colrv1_path): assert "CPAL" not in subset_font +def test_subset_COLRv1_downgrade_version(colrv1_path): + subset_path = colrv1_path.parent / (colrv1_path.name + ".subset") + + subset.main( + [ + str(colrv1_path), + "--glyph-names", + f"--output-file={subset_path}", + "--unicodes=E004", + ] + ) + subset_font = TTFont(subset_path) + + assert set(subset_font.getGlyphOrder()) == { + ".notdef", + "uniE004", + "glyph00016", + "glyph00017", + } + + assert "COLR" in subset_font + assert subset_font["COLR"].version == 0 + + if __name__ == "__main__": sys.exit(unittest.main()) From 0219c4871d461bedac2c7950cffda077fbd4ff35 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 15 Feb 2021 17:08:10 +0000 Subject: [PATCH 101/167] Update changelog [skip ci] --- NEWS.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/NEWS.rst b/NEWS.rst index 776deef81..6f0c2c635 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,19 @@ +- [COLRv1] Added ``unbuildColrV1`` to deconstruct COLRv1 otTables to raw json-able + data structure; it does the reverse of ``buildColrV1`` (#2171). +- [feaLib] Allow ``sub X by NULL`` sequence to delete a glyph (#2170). +- [arrayTools] Fixed ``Vector`` division (#2173). +- [COLRv1] Define new ``PaintSweepGradient`` (#2172). +- [otTables] Moved ``Paint.Format`` enum class outside of ``Paint`` class definition, + now named ``PaintFormat``. It was clashing with paint instance ``Format`` attribute + and thus was breaking lazy load of COLR table which relies on magic ``__getattr__`` + (#2175). +- [COLRv1] Replace hand-coded builder functions with otData-driven dynamic + implementation (#2181). +- [COLRv1] Define additional static (non-variable) Paint formats (#2181). +- [fontBuilder] Allow ``setupFvar`` to optionally take ``designspaceLib.AxisDescriptor`` + objects. Added new ``setupAvar`` method. Support localised names for axes and + named instances (#2185). + 4.19.1 (released 2021-01-28) ---------------------------- From f426430dc1ff74551d8ad15fbd1e7e408de03304 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 15 Feb 2021 17:08:53 +0000 Subject: [PATCH 102/167] Release 4.20.0 --- Lib/fontTools/__init__.py | 2 +- NEWS.rst | 3 +++ setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 9c54124e8..19040e48c 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.19.2.dev0" +version = __version__ = "4.20.0" __all__ = ["version", "log", "configLogger"] diff --git a/NEWS.rst b/NEWS.rst index 6f0c2c635..393795cde 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +4.20.0 (released 2021-02-15) +---------------------------- + - [COLRv1] Added ``unbuildColrV1`` to deconstruct COLRv1 otTables to raw json-able data structure; it does the reverse of ``buildColrV1`` (#2171). - [feaLib] Allow ``sub X by NULL`` sequence to delete a glyph (#2170). diff --git a/setup.cfg b/setup.cfg index f8af6a4ab..75ddb71be 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.19.2.dev0 +current_version = 4.20.0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 33576fc47..e3e7b526c 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.19.2.dev0", + version="4.20.0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 9c4228222ca3483a353ee0507e0d663b8ac6c43d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 15 Feb 2021 17:08:54 +0000 Subject: [PATCH 103/167] =?UTF-8?q?Bump=20version:=204.20.0=20=E2=86=92=20?= =?UTF-8?q?4.20.1.dev0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Lib/fontTools/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 19040e48c..bdc337d91 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.20.0" +version = __version__ = "4.20.1.dev0" __all__ = ["version", "log", "configLogger"] diff --git a/setup.cfg b/setup.cfg index 75ddb71be..006ac234b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.20.0 +current_version = 4.20.1.dev0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index e3e7b526c..355e037f7 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.20.0", + version="4.20.1.dev0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 51882b3dd5aa84849d5742306771eafb963dd435 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 15 Feb 2021 17:30:22 +0000 Subject: [PATCH 104/167] Update NEWS.rst forgot to mention that v4.20.0 also added support for COLRv1 and CPAL subsetting --- NEWS.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/NEWS.rst b/NEWS.rst index 393795cde..6e36c3977 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -13,6 +13,7 @@ - [COLRv1] Replace hand-coded builder functions with otData-driven dynamic implementation (#2181). - [COLRv1] Define additional static (non-variable) Paint formats (#2181). +- [subset] Added support for subsetting COLR v1 and CPAL tables (#2174, #2177). - [fontBuilder] Allow ``setupFvar`` to optionally take ``designspaceLib.AxisDescriptor`` objects. Added new ``setupAvar`` method. Support localised names for axes and named instances (#2185). From 8e42f693a7f2e1a03f57cf715156a15754c97d74 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Feb 2021 10:48:09 +0000 Subject: [PATCH 105/167] Add function to compute bounding box area --- Lib/fontTools/misc/arrayTools.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Lib/fontTools/misc/arrayTools.py b/Lib/fontTools/misc/arrayTools.py index e76ced7f8..138ad8f36 100644 --- a/Lib/fontTools/misc/arrayTools.py +++ b/Lib/fontTools/misc/arrayTools.py @@ -228,6 +228,19 @@ def rectCenter(rect): (xMin, yMin, xMax, yMax) = rect return (xMin+xMax)/2, (yMin+yMax)/2 +def rectArea(rect): + """Determine rectangle area. + + Args: + rect: Bounding rectangle, expressed as tuples + ``(xMin, yMin, xMax, yMax)``. + + Returns: + The area of the rectangle. + """ + (xMin, yMin, xMax, yMax) = rect + return (yMax - yMin) * (xMax - xMin) + def intRect(rect): """Round a rectangle to integer values. From 01957a9b942169aded015bbea8d14bd5782f54d9 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Feb 2021 10:49:52 +0000 Subject: [PATCH 106/167] Intersection and point-at-time functions from beziers.py --- Lib/fontTools/misc/bezierTools.py | 384 +++++++++++++++++++++++++++++- 1 file changed, 381 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 659de34e2..3264fb650 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -2,9 +2,13 @@ """fontTools.misc.bezierTools.py -- tools for working with Bezier path segments. """ -from fontTools.misc.arrayTools import calcBounds +from fontTools.misc.arrayTools import calcBounds, sectRect, rectArea +from fontTools.misc.transform import Offset, Identity from fontTools.misc.py23 import * import math +from collections import namedtuple + +Intersection = namedtuple("Intersection", ["pt", "t1", "t2"]) __all__ = [ @@ -25,6 +29,14 @@ __all__ = [ "splitCubicAtT", "solveQuadratic", "solveCubic", + "quadraticPointAtT", + "cubicPointAtT", + "linePointAtT", + "segmentPointAtT", + "lineLineIntersections", + "curveLineIntersections", + "curveCurveIntersections", + "segmentSegmentIntersections", ] @@ -753,10 +765,376 @@ def calcCubicPoints(a, b, c, d): return (x1, y1), (x2, y2), (x3, y3), (x4, y4) +# +# Point at time +# + + +def linePointAtT(pt1, pt2, t): + """Finds the point at time `t` on a line. + + Args: + pt1, pt2: Coordinates of the line as 2D tuples. + t: The time along the line. + + Returns: + A 2D tuple with the coordinates of the point. + """ + return ((pt1[0] * (1 - t) + pt2[0] * t), (pt1[1] * (1 - t) + pt2[1] * t)) + + +def quadraticPointAtT(pt1, pt2, pt3, t): + """Finds the point at time `t` on a quadratic curve. + + Args: + pt1, pt2, pt3: Coordinates of the curve as 2D tuples. + t: The time along the curve. + + Returns: + A 2D tuple with the coordinates of the point. + """ + x = (1 - t) * (1 - t) * pt1[0] + 2 * (1 - t) * t * pt2[0] + t * t * pt3[0] + y = (1 - t) * (1 - t) * pt1[1] + 2 * (1 - t) * t * pt2[1] + t * t * pt3[1] + return (x, y) + + +def cubicPointAtT(pt1, pt2, pt3, pt4, t): + """Finds the point at time `t` on a cubic curve. + + Args: + pt1, pt2, pt3, pt4: Coordinates of the curve as 2D tuples. + t: The time along the curve. + + Returns: + A 2D tuple with the coordinates of the point. + """ + x = ( + (1 - t) * (1 - t) * (1 - t) * pt1[0] + + 3 * (1 - t) * (1 - t) * t * pt2[0] + + 3 * (1 - t) * t * t * pt3[0] + + t * t * t * pt4[0] + ) + y = ( + (1 - t) * (1 - t) * (1 - t) * pt1[1] + + 3 * (1 - t) * (1 - t) * t * pt2[1] + + 3 * (1 - t) * t * t * pt3[1] + + t * t * t * pt4[1] + ) + return (x, y) + + +def segmentPointAtT(seg, t): + if len(seg) == 2: + return linePointAtT(*seg, t) + elif len(seg) == 3: + return quadraticPointAtT(*seg, t) + elif len(seg) == 4: + return cubicPointAtT(*seg, t) + raise ValueError("Unknown curve degree") + + +# +# Intersection finders +# + + +def _line_t_of_pt(s, e, pt): + sx, sy = s + ex, ey = e + px, py = pt + if not math.isclose(sx, ex): + return (px - sx) / (ex - sx) + if not math.isclose(sy, ey): + return (py - sy) / (ey - sy) + # Line is a point! + return -1 + + +def _both_points_are_on_same_side_of_origin(a, b, c): + xDiff = (a[0] - c[0]) * (b[0] - c[0]) + yDiff = (a[1] - c[1]) * (b[1] - c[1]) + return not (xDiff <= 0.0 and yDiff <= 0.0) + + +def lineLineIntersections(s1, e1, s2, e2): + """Finds intersections between two line segments. + + Args: + s1, e1: Coordinates of the first line as 2D tuples. + s2, e2: Coordinates of the second line as 2D tuples. + + Returns: + A list of ``Intersection`` objects, each object having ``pt``, ``t1`` + and ``t2`` attributes containing the intersection point, time on first + segment and time on second segment respectively. + + Examples:: + + >>> a = lineLineIntersections( (310,389), (453, 222), (289, 251), (447, 367)) + >>> len(a) + 1 + >>> intersection = a[0] + >>> intersection.pt + (374.44882952482897, 313.73458370177315) + >>> (intersection.t1, intersection.t2) + (0.45069111555824454, 0.5408153767394238) + """ + ax, ay = s1 + bx, by = e1 + cx, cy = s2 + dx, dy = e2 + if math.isclose(cx, dx) and math.isclose(ax, bx): + return [] + if math.isclose(cy, dy) and math.isclose(ay, by): + return [] + if math.isclose(cx, dx) and math.isclose(cy, dy): + return [] + if math.isclose(ax, bx) and math.isclose(ay, by): + return [] + if math.isclose(bx, ax): + x = ax + slope34 = (dy - xy) / (dx - cx) + y = slope34 * (x - cx) + cy + pt = (x, y) + return [ + Intersection( + pt=pt, t1=_line_t_of_pt(s1, e1, pt), t2=_line_t_of_pt(s2, e2, pt) + ) + ] + if math.isclose(cx, dx): + x = cx + slope12 = (by - ay) / (bx - ax) + y = slope12 * (x - ax) + ay + pt = (x, y) + return [ + Intersection( + pt=pt, t1=_line_t_of_pt(s1, e1, pt), t2=_line_t_of_pt(s2, e2, pt) + ) + ] + + slope12 = (by - ay) / (bx - ax) + slope34 = (dy - cy) / (dx - cx) + if math.isclose(slope12, slope34): + return [] + x = (slope12 * ax - ay - slope34 * cx + cy) / (slope12 - slope34) + y = slope12 * (x - ax) + ay + pt = (x, y) + if _both_points_are_on_same_side_of_origin( + pt, e1, s1 + ) and _both_points_are_on_same_side_of_origin(pt, s2, e2): + return [ + Intersection( + pt=pt, t1=_line_t_of_pt(s1, e1, pt), t2=_line_t_of_pt(s2, e2, pt) + ) + ] + return [] + + +def _alignment_transformation(segment): + start = segment[0] + end = segment[-1] + m = Offset(-start[0], -start[1]) + endpt = m.transformPoint(end) + angle = math.atan2(endpt[1], endpt[0]) + return m.reverseTransform(Identity.rotate(-angle)) + + +def _curve_line_intersections_t(curve, line): + aligned_curve = _alignment_transformation(line).transformPoints(curve) + if len(curve) == 3: + a, b, c = calcCubicParameters(*aligned_curve) + intersections = solveQuadratic(a[0], b[0], c[0]) + intersections.extend(solveQuadratic(a[1], b[1], c[1])) + elif len(curve) == 4: + a, b, c, d = calcCubicParameters(*aligned_curve) + intersections = solveCubic(a[0], b[0], c[0], d[0]) + intersections.extend(solveCubic(a[1], b[1], c[1], d[1])) + else: + raise ValueError("Unknown curve degree") + return sorted([i for i in intersections if (0.0 <= i <= 1)]) + + +def curveLineIntersections(curve, line): + """Finds intersections between a curve and a line. + + Args: + curve: List of coordinates of the curve segment as 2D tuples. + line: List of coordinates of the line segment as 2D tuples. + + Returns: + A list of ``Intersection`` objects, each object having ``pt``, ``t1`` + and ``t2`` attributes containing the intersection point, time on first + segment and time on second segment respectively. + + Examples:: + >>> curve = [ (100, 240), (30, 60), (210, 230), (160, 30) ] + >>> line = [ (25, 260), (230, 20) ] + >>> intersections = curveLineIntersections(curve, line) + >>> len(intersections) + 3 + >>> intersections[0].pt + (84.90010344084885, 189.87306176459828) + """ + if len(curve) == 3: + pointFinder = quadraticPointAtT + elif len(curve) == 4: + pointFinder = cubicPointAtT + else: + raise ValueError("Unknown curve degree") + intersections = [] + for t in _curve_line_intersections_t(curve, line): + pt = pointFinder(*curve, t) + intersections.append(Intersection(pt=pt, t1=t, t2=_line_t_of_pt(*line, pt))) + return intersections + + +def _curve_bounds(c): + if len(c) == 3: + return calcQuadraticBounds(*c) + elif len(c) == 4: + return calcCubicBounds(*c) + + +def _split_curve_at_t(c, t): + if len(c) == 3: + return splitQuadraticAtT(*c, t) + elif len(c) == 4: + return splitCubicAtT(*c, t) + + +def _curve_curve_intersections_t( + curve1, curve2, precision=1e-3, range1=None, range2=None +): + bounds1 = _curve_bounds(curve1) + bounds2 = _curve_bounds(curve2) + + if not range1: + range1 = (0.0, 1.0) + if not range2: + range2 = (0.0, 1.0) + + # If bounds don't intersect, go home + intersects, _ = sectRect(bounds1, bounds2) + if not intersects: + return [] + + def midpoint(r): + return 0.5 * (r[0] + r[1]) + + # If they do overlap but they're tiny, approximate + if rectArea(bounds1) < precision and rectArea(bounds2) < precision: + return [(midpoint(range1), midpoint(range2))] + + c11, c12 = _split_curve_at_t(curve1, 0.5) + c11_range = (range1[0], midpoint(range1)) + c12_range = (midpoint(range1), range1[1]) + + c21, c22 = _split_curve_at_t(curve2, 0.5) + c21_range = (range2[0], midpoint(range2)) + c22_range = (midpoint(range2), range2[1]) + + found = [] + found.extend( + _curve_curve_intersections_t( + c11, c21, precision, range1=c11_range, range2=c21_range + ) + ) + found.extend( + _curve_curve_intersections_t( + c12, c21, precision, range1=c12_range, range2=c21_range + ) + ) + found.extend( + _curve_curve_intersections_t( + c11, c22, precision, range1=c11_range, range2=c22_range + ) + ) + found.extend( + _curve_curve_intersections_t( + c12, c22, precision, range1=c12_range, range2=c22_range + ) + ) + unique_key = lambda ts: int(ts[0] / precision) + seen = set() + return [ + seen.add(unique_key(ts)) or ts for ts in found if unique_key(ts) not in seen + ] + + +def curveCurveIntersections(curve1, curve2): + """Finds intersections between a curve and a curve. + + Args: + curve1: List of coordinates of the first curve segment as 2D tuples. + curve2: List of coordinates of the second curve segment as 2D tuples. + + Returns: + A list of ``Intersection`` objects, each object having ``pt``, ``t1`` + and ``t2`` attributes containing the intersection point, time on first + segment and time on second segment respectively. + + Examples:: + >>> curve1 = [ (10,100), (90,30), (40,140), (220,220) ] + >>> curve2 = [ (5,150), (180,20), (80,250), (210,190) ] + >>> intersections = curveCurveIntersections(curve1, curve2) + >>> len(intersections) + 3 + >>> intersections[0].pt + (81.7831487395506, 109.88904552375288) + """ + intersection_ts = _curve_curve_intersections_t(curve1, curve2) + return [ + Intersection(pt=segmentPointAtT(curve1, ts[0]), t1=ts[0], t2=ts[1]) + for ts in intersection_ts + ] + + +def segmentSegmentIntersections(seg1, seg2): + """Finds intersections between two segments. + + Args: + seg1: List of coordinates of the first segment as 2D tuples. + seg2: List of coordinates of the second segment as 2D tuples. + + Returns: + A list of ``Intersection`` objects, each object having ``pt``, ``t1`` + and ``t2`` attributes containing the intersection point, time on first + segment and time on second segment respectively. + + Examples:: + >>> curve1 = [ (10,100), (90,30), (40,140), (220,220) ] + >>> curve2 = [ (5,150), (180,20), (80,250), (210,190) ] + >>> intersections = segmentSegmentIntersections(curve1, curve2) + >>> len(intersections) + 3 + >>> intersections[0].pt + (81.7831487395506, 109.88904552375288) + >>> curve3 = [ (100, 240), (30, 60), (210, 230), (160, 30) ] + >>> line = [ (25, 260), (230, 20) ] + >>> intersections = segmentSegmentIntersections(curve3, line) + >>> len(intersections) + 3 + >>> intersections[0].pt + (84.90010344084885, 189.87306176459828) + + """ + # Arrange by degree + if len(seg2) > len(seg1): + seg2, seg1 = seg1, seg2 + if len(seg1) > 2: + if len(seg2) > 2: + return curveCurveIntersections(seg1, seg2) + else: + return curveLineIntersections(seg1, seg2) + elif len(seg1) == 2 and len(seg2) == 2: + return lineLineIntersections(seg1, seg2) + raise ValueError("Couldn't work out which intersection function to use") + + def _segmentrepr(obj): """ - >>> _segmentrepr([1, [2, 3], [], [[2, [3, 4], [0.1, 2.2]]]]) - '(1, (2, 3), (), ((2, (3, 4), (0.1, 2.2))))' + >>> _segmentrepr([1, [2, 3], [], [[2, [3, 4], [0.1, 2.2]]]]) + '(1, (2, 3), (), ((2, (3, 4), (0.1, 2.2))))' """ try: it = iter(obj) From c17b1c9e9f0ca2fa44f57b25b3da452fc6ebcb39 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Feb 2021 11:20:45 +0000 Subject: [PATCH 107/167] Oops, this has a different interface to the curve-based ones --- Lib/fontTools/misc/bezierTools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 3264fb650..98b9a4c1c 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -1127,7 +1127,7 @@ def segmentSegmentIntersections(seg1, seg2): else: return curveLineIntersections(seg1, seg2) elif len(seg1) == 2 and len(seg2) == 2: - return lineLineIntersections(seg1, seg2) + return lineLineIntersections(*seg1, *seg2) raise ValueError("Couldn't work out which intersection function to use") From 2e211194c8606ca5baf0d50e4e4ea0b43daf08c8 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Feb 2021 11:23:24 +0000 Subject: [PATCH 108/167] No harm in handling all segment types here --- Lib/fontTools/misc/bezierTools.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 98b9a4c1c..7144e1987 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -995,7 +995,11 @@ def _curve_bounds(c): return calcCubicBounds(*c) -def _split_curve_at_t(c, t): +def _split_segment_at_t(c, t): + if len(c) == 2: + s, e = c + midpoint = linePointAtT(s, e, t) + return [ ( s, midpoint), (midpoint, e) ] if len(c) == 3: return splitQuadraticAtT(*c, t) elif len(c) == 4: @@ -1025,11 +1029,11 @@ def _curve_curve_intersections_t( if rectArea(bounds1) < precision and rectArea(bounds2) < precision: return [(midpoint(range1), midpoint(range2))] - c11, c12 = _split_curve_at_t(curve1, 0.5) + c11, c12 = _split_segment_at_t(curve1, 0.5) c11_range = (range1[0], midpoint(range1)) c12_range = (midpoint(range1), range1[1]) - c21, c22 = _split_curve_at_t(curve2, 0.5) + c21, c22 = _split_segment_at_t(curve2, 0.5) c21_range = (range2[0], midpoint(range2)) c22_range = (midpoint(range2), range2[1]) From 6a223e5f93d85b03365703039933b76a4f84b9ef Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Feb 2021 11:38:40 +0000 Subject: [PATCH 109/167] Rename parameters --- Lib/fontTools/misc/bezierTools.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 7144e1987..71c2ecc7d 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -850,9 +850,9 @@ def _line_t_of_pt(s, e, pt): return -1 -def _both_points_are_on_same_side_of_origin(a, b, c): - xDiff = (a[0] - c[0]) * (b[0] - c[0]) - yDiff = (a[1] - c[1]) * (b[1] - c[1]) +def _both_points_are_on_same_side_of_origin(a, b, origin): + xDiff = (a[0] - origin[0]) * (b[0] - origin[0]) + yDiff = (a[1] - origin[1]) * (b[1] - origin[1]) return not (xDiff <= 0.0 and yDiff <= 0.0) From 80a3227f44cc3784aa18c149e16dc1337c6ee9f6 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Feb 2021 11:39:01 +0000 Subject: [PATCH 110/167] Raise error in unknown curve degrees --- Lib/fontTools/misc/bezierTools.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 71c2ecc7d..d93772177 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -993,6 +993,7 @@ def _curve_bounds(c): return calcQuadraticBounds(*c) elif len(c) == 4: return calcCubicBounds(*c) + raise ValueError("Unknown curve degree") def _split_segment_at_t(c, t): @@ -1004,6 +1005,7 @@ def _split_segment_at_t(c, t): return splitQuadraticAtT(*c, t) elif len(c) == 4: return splitCubicAtT(*c, t) + raise ValueError("Unknown curve degree") def _curve_curve_intersections_t( From c082616c6f3a06fb3bae4f3f427719c195c2282c Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Feb 2021 11:39:36 +0000 Subject: [PATCH 111/167] Make hacky uniquifying thing more explicit --- Lib/fontTools/misc/bezierTools.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index d93772177..141877d28 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -1060,11 +1060,18 @@ def _curve_curve_intersections_t( c12, c22, precision, range1=c12_range, range2=c22_range ) ) + unique_key = lambda ts: int(ts[0] / precision) seen = set() - return [ - seen.add(unique_key(ts)) or ts for ts in found if unique_key(ts) not in seen - ] + unique_values = [] + + for ts in found: + if unique_key(ts) in seen: + continue + seen.add(unique_key(ts)) + unique_values.append(ts) + + return unique_values def curveCurveIntersections(curve1, curve2): From fdceeee0c6592fa02a56412803a3bd516af1fd33 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Feb 2021 11:39:46 +0000 Subject: [PATCH 112/167] Unnecessary parens --- Lib/fontTools/misc/bezierTools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 141877d28..98edfb432 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -951,7 +951,7 @@ def _curve_line_intersections_t(curve, line): intersections.extend(solveCubic(a[1], b[1], c[1], d[1])) else: raise ValueError("Unknown curve degree") - return sorted([i for i in intersections if (0.0 <= i <= 1)]) + return sorted([i for i in intersections if 0.0 <= i <= 1]) def curveLineIntersections(curve, line): From 1795ee98b276f379b042ced1a90f2a1323daa740 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Feb 2021 14:54:27 +0000 Subject: [PATCH 113/167] Address various feedback --- Lib/fontTools/misc/bezierTools.py | 57 +++++++++++++++++-------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 98edfb432..31de6828c 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -879,32 +879,36 @@ def lineLineIntersections(s1, e1, s2, e2): >>> (intersection.t1, intersection.t2) (0.45069111555824454, 0.5408153767394238) """ - ax, ay = s1 - bx, by = e1 - cx, cy = s2 - dx, dy = e2 - if math.isclose(cx, dx) and math.isclose(ax, bx): + s1x, s1y = s1 + e1x, e1y = e1 + s2x, s2y = s2 + e2x, e2y = e2 + if ( + math.isclose(s2x, e2x) and math.isclose(s1x, e1x) and not math.isclose(s1x, s2x) + ): # Parallel vertical return [] - if math.isclose(cy, dy) and math.isclose(ay, by): + if ( + math.isclose(s2y, e2y) and math.isclose(s1y, e1y) and not math.isclose(s1y, s2y) + ): # Parallel horizontal return [] - if math.isclose(cx, dx) and math.isclose(cy, dy): + if math.isclose(s2x, e2x) and math.isclose(s2y, e2y): # Line segment is tiny return [] - if math.isclose(ax, bx) and math.isclose(ay, by): + if math.isclose(s1x, e1x) and math.isclose(s1y, e1y): # Line segment is tiny return [] - if math.isclose(bx, ax): - x = ax - slope34 = (dy - xy) / (dx - cx) - y = slope34 * (x - cx) + cy + if math.isclose(e1x, s1x): + x = s1x + slope34 = (e2y - s2y) / (e2x - s2x) + y = slope34 * (x - s2x) + s2y pt = (x, y) return [ Intersection( pt=pt, t1=_line_t_of_pt(s1, e1, pt), t2=_line_t_of_pt(s2, e2, pt) ) ] - if math.isclose(cx, dx): - x = cx - slope12 = (by - ay) / (bx - ax) - y = slope12 * (x - ax) + ay + if math.isclose(s2x, e2x): + x = s2x + slope12 = (e1y - s1y) / (e1x - s1x) + y = slope12 * (x - s1x) + s1y pt = (x, y) return [ Intersection( @@ -912,12 +916,12 @@ def lineLineIntersections(s1, e1, s2, e2): ) ] - slope12 = (by - ay) / (bx - ax) - slope34 = (dy - cy) / (dx - cx) + slope12 = (e1y - s1y) / (e1x - s1x) + slope34 = (e2y - s2y) / (e2x - s2x) if math.isclose(slope12, slope34): return [] - x = (slope12 * ax - ay - slope34 * cx + cy) / (slope12 - slope34) - y = slope12 * (x - ax) + ay + x = (slope12 * s1x - s1y - slope34 * s2x + s2y) / (slope12 - slope34) + y = slope12 * (x - s1x) + s1y pt = (x, y) if _both_points_are_on_same_side_of_origin( pt, e1, s1 @@ -931,6 +935,9 @@ def lineLineIntersections(s1, e1, s2, e2): def _alignment_transformation(segment): + # Returns a transformation which aligns a segment horizontally at the + # origin. Apply this transformation to curves and root-find to find + # intersections with the segment. start = segment[0] end = segment[-1] m = Offset(-start[0], -start[1]) @@ -942,13 +949,11 @@ def _alignment_transformation(segment): def _curve_line_intersections_t(curve, line): aligned_curve = _alignment_transformation(line).transformPoints(curve) if len(curve) == 3: - a, b, c = calcCubicParameters(*aligned_curve) - intersections = solveQuadratic(a[0], b[0], c[0]) - intersections.extend(solveQuadratic(a[1], b[1], c[1])) + a, b, c = calcQuadraticParameters(*aligned_curve) + intersections = solveQuadratic(a[1], b[1], c[1]) elif len(curve) == 4: a, b, c, d = calcCubicParameters(*aligned_curve) - intersections = solveCubic(a[0], b[0], c[0], d[0]) - intersections.extend(solveCubic(a[1], b[1], c[1], d[1])) + intersections = solveCubic(a[1], b[1], c[1], d[1]) else: raise ValueError("Unknown curve degree") return sorted([i for i in intersections if 0.0 <= i <= 1]) @@ -1000,7 +1005,7 @@ def _split_segment_at_t(c, t): if len(c) == 2: s, e = c midpoint = linePointAtT(s, e, t) - return [ ( s, midpoint), (midpoint, e) ] + return [(s, midpoint), (midpoint, e)] if len(c) == 3: return splitQuadraticAtT(*c, t) elif len(c) == 4: From b77d520b4893bee43ab4966fc801d07bc51b2b02 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 18 Feb 2021 15:39:52 +0000 Subject: [PATCH 114/167] remame 'master' -> 'main' in worflows/test.yml --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 837fb8c49..89d668d05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Test on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] jobs: lint: From e47101d6835434ce150e51829ef9863f1ce17ef8 Mon Sep 17 00:00:00 2001 From: Denis Moyogo Jacquerye Date: Thu, 18 Feb 2021 12:22:39 +0000 Subject: [PATCH 115/167] [feaLib] indent anchor statements --- Lib/fontTools/feaLib/ast.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 6c2bfce85..c1abe8d9c 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -1146,7 +1146,9 @@ class MarkBasePosStatement(Statement): def asFea(self, indent=""): res = "pos base {}".format(self.base.asFea()) for a, m in self.marks: - res += " {} mark @{}".format(a.asFea(), m.name) + res += ("\n" + indent + SHIFT + "{} mark @{}".format( + a.asFea(), m.name) + ) res += ";" return res @@ -1192,10 +1194,12 @@ class MarkLigPosStatement(Statement): for l in self.marks: temp = "" if l is None or not len(l): - temp = " " + temp = "\n" + indent + SHIFT * 2 + "" else: for a, m in l: - temp += " {} mark @{}".format(a.asFea(), m.name) + temp += ("\n" + indent + SHIFT * 2 + "{} mark @{}".format( + a.asFea(), m.name) + ) ligs.append(temp) res += ("\n" + indent + SHIFT + "ligComponent").join(ligs) res += ";" @@ -1218,7 +1222,9 @@ class MarkMarkPosStatement(Statement): def asFea(self, indent=""): res = "pos mark {}".format(self.baseMarks.asFea()) for a, m in self.marks: - res += " {} mark @{}".format(a.asFea(), m.name) + res += ("\n" + indent + SHIFT + "{} mark @{}".format( + a.asFea(), m.name) + ) res += ";" return res From 537fabcee94a210f371a0ec1cc26d6a15ecbc287 Mon Sep 17 00:00:00 2001 From: Denis Moyogo Jacquerye Date: Thu, 18 Feb 2021 12:58:30 +0000 Subject: [PATCH 116/167] [feaLib] update tests for indented anchors --- Tests/feaLib/data/GPOS_4.fea | 10 +++++++--- Tests/feaLib/data/GPOS_5.fea | 29 ++++++++++++++++++++++------- Tests/feaLib/data/GPOS_6.fea | 7 +++++-- Tests/feaLib/data/bug453.fea | 6 ++++-- Tests/feaLib/data/spec6d2.fea | 10 +++++++--- Tests/feaLib/data/spec6e.fea | 9 ++++++--- Tests/feaLib/data/spec6f.fea | 3 ++- Tests/feaLib/data/spec6h_ii.fea | 6 ++++-- 8 files changed, 57 insertions(+), 23 deletions(-) diff --git a/Tests/feaLib/data/GPOS_4.fea b/Tests/feaLib/data/GPOS_4.fea index cfd2d757c..7c90ab631 100644 --- a/Tests/feaLib/data/GPOS_4.fea +++ b/Tests/feaLib/data/GPOS_4.fea @@ -6,7 +6,11 @@ markClass [cedilla] @BOTTOM_MARKS; markClass [ogonek] @SIDE_MARKS; feature test { - pos base a mark @TOP_MARKS mark @BOTTOM_MARKS; - pos base [b c] mark @BOTTOM_MARKS; - pos base d mark @SIDE_MARKS; + pos base a + mark @TOP_MARKS + mark @BOTTOM_MARKS; + pos base [b c] + mark @BOTTOM_MARKS; + pos base d + mark @SIDE_MARKS; } test; diff --git a/Tests/feaLib/data/GPOS_5.fea b/Tests/feaLib/data/GPOS_5.fea index b116539aa..a8f8536e2 100644 --- a/Tests/feaLib/data/GPOS_5.fea +++ b/Tests/feaLib/data/GPOS_5.fea @@ -5,14 +5,29 @@ markClass [ogonek] @OGONEK; feature test { - pos ligature [c_t s_t] mark @TOP_MARKS mark @BOTTOM_MARKS - ligComponent mark @TOP_MARKS mark @BOTTOM_MARKS mark @OGONEK; + pos ligature [c_t s_t] + mark @TOP_MARKS + mark @BOTTOM_MARKS + ligComponent + mark @TOP_MARKS + mark @BOTTOM_MARKS + mark @OGONEK; - pos ligature f_l mark @TOP_MARKS mark @BOTTOM_MARKS - ligComponent mark @TOP_MARKS mark @BOTTOM_MARKS; + pos ligature f_l + mark @TOP_MARKS + mark @BOTTOM_MARKS + ligComponent + mark @TOP_MARKS + mark @BOTTOM_MARKS; - pos ligature [f_f_l] mark @TOP_MARKS mark @BOTTOM_MARKS - ligComponent mark @TOP_MARKS mark @BOTTOM_MARKS - ligComponent mark @TOP_MARKS mark @BOTTOM_MARKS; + pos ligature [f_f_l] + mark @TOP_MARKS + mark @BOTTOM_MARKS + ligComponent + mark @TOP_MARKS + mark @BOTTOM_MARKS + ligComponent + mark @TOP_MARKS + mark @BOTTOM_MARKS; } test; diff --git a/Tests/feaLib/data/GPOS_6.fea b/Tests/feaLib/data/GPOS_6.fea index 37b293659..e54ff6e3e 100644 --- a/Tests/feaLib/data/GPOS_6.fea +++ b/Tests/feaLib/data/GPOS_6.fea @@ -5,6 +5,9 @@ markClass macron @TOP_MARKS; markClass [cedilla] @BOTTOM_MARKS; feature test { - pos mark [acute grave macron ogonek] mark @TOP_MARKS mark @BOTTOM_MARKS; - pos mark [dieresis caron] mark @TOP_MARKS; + pos mark [acute grave macron ogonek] + mark @TOP_MARKS + mark @BOTTOM_MARKS; + pos mark [dieresis caron] + mark @TOP_MARKS; } test; diff --git a/Tests/feaLib/data/bug453.fea b/Tests/feaLib/data/bug453.fea index 486632ee2..ed0e6f943 100644 --- a/Tests/feaLib/data/bug453.fea +++ b/Tests/feaLib/data/bug453.fea @@ -2,10 +2,12 @@ feature mark { lookup mark1 { markClass [acute] @TOP_MARKS; - pos base [e] mark @TOP_MARKS; + pos base [e] + mark @TOP_MARKS; } mark1; lookup mark2 { markClass [acute] @TOP_MARKS_2; - pos base [e] mark @TOP_MARKS_2; + pos base [e] + mark @TOP_MARKS_2; } mark2; } mark; diff --git a/Tests/feaLib/data/spec6d2.fea b/Tests/feaLib/data/spec6d2.fea index ead224fea..5c2620d2f 100644 --- a/Tests/feaLib/data/spec6d2.fea +++ b/Tests/feaLib/data/spec6d2.fea @@ -9,7 +9,11 @@ markClass [dieresis umlaut] @TOP_MARKS; markClass [cedilla] @BOTTOM_MARKS; feature test { - pos base [e o] mark @TOP_MARKS mark @BOTTOM_MARKS; -#test-fea2fea: pos base [a u] mark @TOP_MARKS mark @BOTTOM_MARKS; - position base [a u] mark @TOP_MARKS mark @BOTTOM_MARKS; + pos base [e o] + mark @TOP_MARKS + mark @BOTTOM_MARKS; +#test-fea2fea: pos base [a u] + position base [a u] + mark @TOP_MARKS + mark @BOTTOM_MARKS; } test; diff --git a/Tests/feaLib/data/spec6e.fea b/Tests/feaLib/data/spec6e.fea index ed956c8f3..646122326 100644 --- a/Tests/feaLib/data/spec6e.fea +++ b/Tests/feaLib/data/spec6e.fea @@ -4,7 +4,10 @@ markClass sukun @TOP_MARKS; markClass kasratan @BOTTOM_MARKS; feature test { - pos ligature lam_meem_jeem mark @TOP_MARKS # mark above lam - ligComponent mark @BOTTOM_MARKS # mark below meem - ligComponent ; # jeem has no marks + pos ligature lam_meem_jeem + mark @TOP_MARKS # mark above lam + ligComponent + mark @BOTTOM_MARKS # mark below meem + ligComponent + ; # jeem has no marks } test; diff --git a/Tests/feaLib/data/spec6f.fea b/Tests/feaLib/data/spec6f.fea index 8d32008cb..277bdb464 100644 --- a/Tests/feaLib/data/spec6f.fea +++ b/Tests/feaLib/data/spec6f.fea @@ -2,5 +2,6 @@ languagesystem DFLT dflt; feature test { markClass damma @MARK_CLASS_1; - pos mark hamza mark @MARK_CLASS_1; + pos mark hamza + mark @MARK_CLASS_1; } test; diff --git a/Tests/feaLib/data/spec6h_ii.fea b/Tests/feaLib/data/spec6h_ii.fea index 36a1f032f..690d2a353 100644 --- a/Tests/feaLib/data/spec6h_ii.fea +++ b/Tests/feaLib/data/spec6h_ii.fea @@ -12,8 +12,10 @@ lookup CNTXT_PAIR_POS { } CNTXT_PAIR_POS; lookup CNTXT_MARK_TO_BASE { - pos base o mark @ALL_MARKS; - pos base c mark @ALL_MARKS; + pos base o + mark @ALL_MARKS; + pos base c + mark @ALL_MARKS; } CNTXT_MARK_TO_BASE; feature test { From 2563b1df7ea4629b2fa82137dc2700c6343dd8e1 Mon Sep 17 00:00:00 2001 From: Denis Moyogo Jacquerye Date: Thu, 18 Feb 2021 15:07:10 +0000 Subject: [PATCH 117/167] [feaLib] black ast.py --- Lib/fontTools/feaLib/ast.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index c1abe8d9c..64ed2259c 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -1146,9 +1146,7 @@ class MarkBasePosStatement(Statement): def asFea(self, indent=""): res = "pos base {}".format(self.base.asFea()) for a, m in self.marks: - res += ("\n" + indent + SHIFT + "{} mark @{}".format( - a.asFea(), m.name) - ) + res += "\n" + indent + SHIFT + "{} mark @{}".format(a.asFea(), m.name) res += ";" return res @@ -1197,8 +1195,11 @@ class MarkLigPosStatement(Statement): temp = "\n" + indent + SHIFT * 2 + "" else: for a, m in l: - temp += ("\n" + indent + SHIFT * 2 + "{} mark @{}".format( - a.asFea(), m.name) + temp += ( + "\n" + + indent + + SHIFT * 2 + + "{} mark @{}".format(a.asFea(), m.name) ) ligs.append(temp) res += ("\n" + indent + SHIFT + "ligComponent").join(ligs) @@ -1222,9 +1223,7 @@ class MarkMarkPosStatement(Statement): def asFea(self, indent=""): res = "pos mark {}".format(self.baseMarks.asFea()) for a, m in self.marks: - res += ("\n" + indent + SHIFT + "{} mark @{}".format( - a.asFea(), m.name) - ) + res += "\n" + indent + SHIFT + "{} mark @{}".format(a.asFea(), m.name) res += ";" return res @@ -1267,7 +1266,7 @@ class MultipleSubstStatement(Statement): res += " " + " ".join(map(asFea, self.suffix)) else: res += asFea(self.glyph) - replacement = self.replacement or [ NullGlyph() ] + replacement = self.replacement or [NullGlyph()] res += " by " res += " ".join(map(asFea, replacement)) res += ";" From 2ef7964e9039782efe14cb0829c9095059a4db0e Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Feb 2021 22:12:20 +0000 Subject: [PATCH 118/167] If we swapped the segments, we must swap them back when returning results --- Lib/fontTools/misc/bezierTools.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 31de6828c..06e00422a 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -1137,16 +1137,22 @@ def segmentSegmentIntersections(seg1, seg2): """ # Arrange by degree + swapped = False if len(seg2) > len(seg1): seg2, seg1 = seg1, seg2 + swapped = True if len(seg1) > 2: if len(seg2) > 2: - return curveCurveIntersections(seg1, seg2) + intersections = curveCurveIntersections(seg1, seg2) else: - return curveLineIntersections(seg1, seg2) + intersections = curveLineIntersections(seg1, seg2) elif len(seg1) == 2 and len(seg2) == 2: - return lineLineIntersections(*seg1, *seg2) - raise ValueError("Couldn't work out which intersection function to use") + intersections = lineLineIntersections(*seg1, *seg2) + else: + raise ValueError("Couldn't work out which intersection function to use") + if not swapped: + return intersections + return [Intersection(pt=i.pt, t1=i.t2, t2=i.t1) for i in intersections] def _segmentrepr(obj): From ab1883da1d59481f6c13e45cc70d2218d6a89d05 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Sun, 21 Feb 2021 10:20:37 +0000 Subject: [PATCH 119/167] Turn AbstractPen and AbstractPointPen into ABCs plus typing --- Lib/fontTools/pens/basePen.py | 51 ++++++++++++++++++++++++++----- Lib/fontTools/pens/pointPen.py | 56 +++++++++++++++++++++++++++------- Tests/pens/basePen_test.py | 26 +++++++++++++++- Tests/pens/pointPen_test.py | 17 +++++++++++ 4 files changed, 130 insertions(+), 20 deletions(-) diff --git a/Lib/fontTools/pens/basePen.py b/Lib/fontTools/pens/basePen.py index c9903c4c2..1583ddc33 100644 --- a/Lib/fontTools/pens/basePen.py +++ b/Lib/fontTools/pens/basePen.py @@ -36,25 +36,52 @@ Coordinates are usually expressed as (x, y) tuples, but generally any sequence of length 2 will do. """ +import abc +from typing import Any, Tuple + from fontTools.misc.loggingTools import LogMixin __all__ = ["AbstractPen", "NullPen", "BasePen", "decomposeSuperBezierSegment", "decomposeQuadraticSegment"] -class AbstractPen(object): +class AbstractPen(abc.ABC): + @classmethod + def __subclasshook__(cls, subclass: Any) -> bool: + if cls is not AbstractPen: + return NotImplemented + return ( + hasattr(subclass, "moveTo") + and callable(subclass.moveTo) + and hasattr(subclass, "lineTo") + and callable(subclass.lineTo) + and hasattr(subclass, "curveTo") + and callable(subclass.curveTo) + and hasattr(subclass, "qCurveTo") + and callable(subclass.qCurveTo) + and hasattr(subclass, "closePath") + and callable(subclass.closePath) + and hasattr(subclass, "endPath") + and callable(subclass.endPath) + and hasattr(subclass, "addComponent") + and callable(subclass.addComponent) + or NotImplemented + ) - def moveTo(self, pt): + @abc.abstractmethod + def moveTo(self, pt: Tuple[float, float]) -> None: """Begin a new sub path, set the current point to 'pt'. You must end each sub path with a call to pen.closePath() or pen.endPath(). """ raise NotImplementedError - def lineTo(self, pt): + @abc.abstractmethod + def lineTo(self, pt: Tuple[float, float]) -> None: """Draw a straight line from the current point to 'pt'.""" raise NotImplementedError - def curveTo(self, *points): + @abc.abstractmethod + def curveTo(self, *points: Tuple[float, float]) -> None: """Draw a cubic bezier with an arbitrary number of control points. The last point specified is on-curve, all others are off-curve @@ -75,7 +102,8 @@ class AbstractPen(object): """ raise NotImplementedError - def qCurveTo(self, *points): + @abc.abstractmethod + def qCurveTo(self, *points: Tuple[float, float]) -> None: """Draw a whole string of quadratic curve segments. The last point specified is on-curve, all others are off-curve @@ -92,19 +120,26 @@ class AbstractPen(object): """ raise NotImplementedError - def closePath(self): + @abc.abstractmethod + def closePath(self) -> None: """Close the current sub path. You must call either pen.closePath() or pen.endPath() after each sub path. """ pass - def endPath(self): + @abc.abstractmethod + def endPath(self) -> None: """End the current sub path, but don't close it. You must call either pen.closePath() or pen.endPath() after each sub path. """ pass - def addComponent(self, glyphName, transformation): + @abc.abstractmethod + def addComponent( + self, + glyphName: str, + transformation: Tuple[float, float, float, float, float, float] + ) -> None: """Add a sub glyph. The 'transformation' argument must be a 6-tuple containing an affine transformation, or a Transform object from the fontTools.misc.transform module. More precisely: it should be a diff --git a/Lib/fontTools/pens/pointPen.py b/Lib/fontTools/pens/pointPen.py index 558321814..cd9e30ef3 100644 --- a/Lib/fontTools/pens/pointPen.py +++ b/Lib/fontTools/pens/pointPen.py @@ -11,8 +11,12 @@ steps through all the points in a call from glyph.drawPoints(). This allows the caller to provide more data for each point. For instance, whether or not a point is smooth, and its name. """ -from fontTools.pens.basePen import AbstractPen + +import abc import math +from typing import Any, List, Optional, Tuple + +from fontTools.pens.basePen import AbstractPen __all__ = [ "AbstractPointPen", @@ -24,26 +28,56 @@ __all__ = [ ] -class AbstractPointPen(object): - """ - Baseclass for all PointPens. - """ +class AbstractPointPen(abc.ABC): + """Baseclass for all PointPens.""" - def beginPath(self, identifier=None, **kwargs): + @classmethod + def __subclasshook__(cls, subclass: Any) -> bool: + if cls is not AbstractPointPen: + return NotImplemented + return ( + hasattr(subclass, "beginPath") + and callable(subclass.beginPath) + and hasattr(subclass, "endPath") + and callable(subclass.endPath) + and hasattr(subclass, "addPoint") + and callable(subclass.addPoint) + and hasattr(subclass, "addComponent") + and callable(subclass.addComponent) + or NotImplemented + ) + + @abc.abstractmethod + def beginPath(self, identifier: Optional[str] = None, **kwargs: Any) -> None: """Start a new sub path.""" raise NotImplementedError - def endPath(self): + @abc.abstractmethod + def endPath(self) -> None: """End the current sub path.""" raise NotImplementedError - def addPoint(self, pt, segmentType=None, smooth=False, name=None, - identifier=None, **kwargs): + @abc.abstractmethod + def addPoint( + self, + pt: Tuple[float, float], + segmentType: Optional[str] = None, + smooth: bool = False, + name: Optional[str] = None, + identifier: Optional[str] = None, + **kwargs: Any + ) -> None: """Add a point to the current sub path.""" raise NotImplementedError - def addComponent(self, baseGlyphName, transformation, identifier=None, - **kwargs): + @abc.abstractmethod + def addComponent( + self, + baseGlyphName: str, + transformation: Tuple[float, float, float, float, float, float], + identifier: Optional[str] = None, + **kwargs: Any + ) -> None: """Add a sub glyph.""" raise NotImplementedError diff --git a/Tests/pens/basePen_test.py b/Tests/pens/basePen_test.py index 05ef4c625..edef0ecbf 100644 --- a/Tests/pens/basePen_test.py +++ b/Tests/pens/basePen_test.py @@ -1,10 +1,34 @@ from fontTools.misc.py23 import * from fontTools.pens.basePen import \ - BasePen, decomposeSuperBezierSegment, decomposeQuadraticSegment + AbstractPen, BasePen, decomposeSuperBezierSegment, decomposeQuadraticSegment +from fontTools.pens.pointPen import AbstractPointPen from fontTools.misc.loggingTools import CapturingLogHandler import unittest +def test_subclasshook(): + class NullPen: + def moveTo(self, pt): + pass + def lineTo(self, pt): + pass + def curveTo(self, *points): + pass + def qCurveTo(self, *points): + pass + def closePath(self): + pass + def endPath(self): + pass + def addComponent(self, glyphName, transformation): + pass + + assert issubclass(NullPen, AbstractPen) + assert isinstance(NullPen(), AbstractPen) + assert not issubclass(NullPen, AbstractPointPen) + assert not isinstance(NullPen(), AbstractPointPen) + + class _TestPen(BasePen): def __init__(self): BasePen.__init__(self, glyphSet={}) diff --git a/Tests/pens/pointPen_test.py b/Tests/pens/pointPen_test.py index 7dfdf594b..e6ecdf9a9 100644 --- a/Tests/pens/pointPen_test.py +++ b/Tests/pens/pointPen_test.py @@ -7,6 +7,23 @@ from fontTools.pens.pointPen import AbstractPointPen, PointToSegmentPen, \ SegmentToPointPen, GuessSmoothPointPen, ReverseContourPointPen +def test_subclasshook(): + class NullPen: + def beginPath(self, identifier, **kwargs) -> None: + pass + def endPath(self) -> None: + pass + def addPoint(self, pt, segmentType, smooth, name, identifier, **kwargs) -> None: + pass + def addComponent(self, baseGlyphName, transformation, identifier, **kwargs) -> None: + pass + + assert issubclass(NullPen, AbstractPointPen) + assert isinstance(NullPen(), AbstractPointPen) + assert not issubclass(NullPen, AbstractPen) + assert not isinstance(NullPen(), AbstractPen) + + class _TestSegmentPen(AbstractPen): def __init__(self): From 8a55182f3745da557218c3e26a5686b8e1d5373f Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Sun, 21 Feb 2021 10:20:48 +0000 Subject: [PATCH 120/167] Make NullPen inherit from AbstractPen --- Lib/fontTools/pens/basePen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/pens/basePen.py b/Lib/fontTools/pens/basePen.py index 1583ddc33..7202a9e45 100644 --- a/Lib/fontTools/pens/basePen.py +++ b/Lib/fontTools/pens/basePen.py @@ -148,7 +148,7 @@ class AbstractPen(abc.ABC): raise NotImplementedError -class NullPen(object): +class NullPen(AbstractPen): """A pen that does nothing. """ From f7546baf009454fdf161bf5f5eaa77610a9fa0a7 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Sun, 21 Feb 2021 10:20:59 +0000 Subject: [PATCH 121/167] Complete implementation of TTGlyphPen --- Lib/fontTools/pens/ttGlyphPen.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/fontTools/pens/ttGlyphPen.py b/Lib/fontTools/pens/ttGlyphPen.py index 9bf8b7b1b..6c08f2c53 100644 --- a/Lib/fontTools/pens/ttGlyphPen.py +++ b/Lib/fontTools/pens/ttGlyphPen.py @@ -60,6 +60,9 @@ class TTGlyphPen(LoggingPen): assert self._isClosed(), '"move"-type point must begin a new contour.' self._addPoint(pt, 1) + def curveTo(self, *points): + raise NotImplementedError + def qCurveTo(self, *points): assert len(points) >= 1 for pt in points[:-1]: From 839c57a50d19dd6198cd07632ec2708c90c6fd2c Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Sun, 21 Feb 2021 10:30:17 +0000 Subject: [PATCH 122/167] Remove some Python 2 vestiges from pen tests --- Tests/pens/areaPen_test.py | 1 - Tests/pens/basePen_test.py | 1 - Tests/pens/boundsPen_test.py | 1 - Tests/pens/cocoaPen_test.py | 3 +-- Tests/pens/perimeterPen_test.py | 1 - Tests/pens/pointInsidePen_test.py | 10 +++++----- Tests/pens/pointPen_test.py | 4 +--- Tests/pens/quartzPen_test.py | 3 +-- Tests/pens/t2CharStringPen_test.py | 9 ++------- Tests/pens/ttGlyphPen_test.py | 2 -- Tests/pens/utils.py | 2 +- 11 files changed, 11 insertions(+), 26 deletions(-) diff --git a/Tests/pens/areaPen_test.py b/Tests/pens/areaPen_test.py index f99e9fc73..c3f3f80c4 100644 --- a/Tests/pens/areaPen_test.py +++ b/Tests/pens/areaPen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.areaPen import AreaPen import unittest diff --git a/Tests/pens/basePen_test.py b/Tests/pens/basePen_test.py index edef0ecbf..059945c77 100644 --- a/Tests/pens/basePen_test.py +++ b/Tests/pens/basePen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import \ AbstractPen, BasePen, decomposeSuperBezierSegment, decomposeQuadraticSegment from fontTools.pens.pointPen import AbstractPointPen diff --git a/Tests/pens/boundsPen_test.py b/Tests/pens/boundsPen_test.py index 1d4b45e46..c0c56108b 100644 --- a/Tests/pens/boundsPen_test.py +++ b/Tests/pens/boundsPen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.boundsPen import BoundsPen, ControlBoundsPen import unittest diff --git a/Tests/pens/cocoaPen_test.py b/Tests/pens/cocoaPen_test.py index 51795e121..11077c0b5 100644 --- a/Tests/pens/cocoaPen_test.py +++ b/Tests/pens/cocoaPen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * import unittest try: @@ -48,7 +47,7 @@ class CocoaPenTest(unittest.TestCase): "moveto 50.0 0.0 lineto 50.0 500.0 lineto 200.0 500.0 curveto 350.0 500.0 450.0 400.0 450.0 250.0 curveto 450.0 100.0 350.0 0.0 200.0 0.0 close ", cocoaPathToString(pen.path) ) - + def test_empty(self): pen = CocoaPen(None) self.assertEqual("", cocoaPathToString(pen.path)) diff --git a/Tests/pens/perimeterPen_test.py b/Tests/pens/perimeterPen_test.py index 9feff18c6..1b6453451 100644 --- a/Tests/pens/perimeterPen_test.py +++ b/Tests/pens/perimeterPen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.perimeterPen import PerimeterPen import unittest diff --git a/Tests/pens/pointInsidePen_test.py b/Tests/pens/pointInsidePen_test.py index 4f27210f7..b561c43f9 100644 --- a/Tests/pens/pointInsidePen_test.py +++ b/Tests/pens/pointInsidePen_test.py @@ -1,4 +1,4 @@ -from fontTools.misc.py23 import * +from io import StringIO from fontTools.pens.pointInsidePen import PointInsidePen import unittest @@ -72,16 +72,16 @@ class PointInsidePenTest(unittest.TestCase): @staticmethod def render(draw_function, even_odd): - result = BytesIO() + result = StringIO() for y in range(5): for x in range(10): pen = PointInsidePen(None, (x + 0.5, y + 0.5), even_odd) draw_function(pen) if pen.getResult(): - result.write(b"*") + result.write("*") else: - result.write(b" ") - return tounicode(result.getvalue()) + result.write(" ") + return result.getvalue() def test_contour_no_solutions(self): diff --git a/Tests/pens/pointPen_test.py b/Tests/pens/pointPen_test.py index e6ecdf9a9..80098ee6a 100644 --- a/Tests/pens/pointPen_test.py +++ b/Tests/pens/pointPen_test.py @@ -1,5 +1,3 @@ -from fontTools.misc.py23 import * -from fontTools.misc.loggingTools import CapturingLogHandler import unittest from fontTools.pens.basePen import AbstractPen @@ -60,7 +58,7 @@ def _reprKwargs(kwargs): items = [] for key in sorted(kwargs): value = kwargs[key] - if isinstance(value, basestring): + if isinstance(value, str): items.append("%s='%s'" % (key, value)) else: items.append("%s=%s" % (key, value)) diff --git a/Tests/pens/quartzPen_test.py b/Tests/pens/quartzPen_test.py index 12fbd2921..3a81d97f7 100644 --- a/Tests/pens/quartzPen_test.py +++ b/Tests/pens/quartzPen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * import unittest try: @@ -68,7 +67,7 @@ class QuartzPenTest(unittest.TestCase): "moveto 50.0 0.0 lineto 50.0 500.0 lineto 200.0 500.0 curveto 350.0 500.0 450.0 400.0 450.0 250.0 curveto 450.0 100.0 350.0 0.0 200.0 0.0 close ", quartzPathToString(pen.path) ) - + def test_empty(self): pen = QuartzPen(None) self.assertEqual("", quartzPathToString(pen.path)) diff --git a/Tests/pens/t2CharStringPen_test.py b/Tests/pens/t2CharStringPen_test.py index 5de700ae7..b710df55b 100644 --- a/Tests/pens/t2CharStringPen_test.py +++ b/Tests/pens/t2CharStringPen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.t2CharStringPen import T2CharStringPen import unittest @@ -7,16 +6,12 @@ class T2CharStringPenTest(unittest.TestCase): def __init__(self, methodName): unittest.TestCase.__init__(self, methodName) - # Python 3 renamed assertRaisesRegexp to assertRaisesRegex, - # and fires deprecation warnings if a program uses the old name. - if not hasattr(self, "assertRaisesRegex"): - self.assertRaisesRegex = self.assertRaisesRegexp def assertAlmostEqualProgram(self, expected, actual): self.assertEqual(len(expected), len(actual)) for i1, i2 in zip(expected, actual): - if isinstance(i1, basestring): - self.assertIsInstance(i2, basestring) + if isinstance(i1, str): + self.assertIsInstance(i2, str) self.assertEqual(i1, i2) else: self.assertAlmostEqual(i1, i2) diff --git a/Tests/pens/ttGlyphPen_test.py b/Tests/pens/ttGlyphPen_test.py index f6ad84859..53db025ce 100644 --- a/Tests/pens/ttGlyphPen_test.py +++ b/Tests/pens/ttGlyphPen_test.py @@ -1,5 +1,3 @@ -from fontTools.misc.py23 import * - import os import unittest import struct diff --git a/Tests/pens/utils.py b/Tests/pens/utils.py index 05f438c88..dced3c1be 100644 --- a/Tests/pens/utils.py +++ b/Tests/pens/utils.py @@ -14,7 +14,7 @@ from . import CUBIC_GLYPHS from fontTools.pens.pointPen import PointToSegmentPen, SegmentToPointPen -from fontTools.misc.py23 import isclose +from math import isclose import unittest From 0434b1a9172aae71374069eee0a331a49802f628 Mon Sep 17 00:00:00 2001 From: Zachary Scheuren Date: Tue, 11 Aug 2020 21:21:30 -0700 Subject: [PATCH 123/167] Add feaLib support for STAT table --- Lib/fontTools/feaLib/ast.py | 138 ++++++++++ Lib/fontTools/feaLib/builder.py | 175 +++++++++++++ Lib/fontTools/feaLib/parser.py | 236 +++++++++++++++++- Lib/fontTools/ttLib/tables/otConverters.py | 14 +- Lib/fontTools/ttLib/tables/otData.py | 8 +- Tests/feaLib/STAT2.fea | 4 + Tests/feaLib/builder_test.py | 199 ++++++++++++++- Tests/feaLib/data/STAT_bad.fea | 95 +++++++ Tests/feaLib/data/STAT_test.fea | 109 ++++++++ Tests/feaLib/data/STAT_test.ttx | 228 +++++++++++++++++ Tests/feaLib/parser_test.py | 70 ++++++ Tests/fontBuilder/data/test_var.ttf.ttx | 8 +- Tests/otlLib/builder_test.py | 12 +- Tests/ttLib/tables/S_T_A_T_test.py | 4 +- ...tialInstancerTest2-VF-instance-100,100.ttx | 2 +- ...tialInstancerTest2-VF-instance-400,100.ttx | 4 +- ...ialInstancerTest2-VF-instance-400,62.5.ttx | 2 +- ...tialInstancerTest2-VF-instance-900,100.ttx | 2 +- 18 files changed, 1285 insertions(+), 25 deletions(-) create mode 100644 Tests/feaLib/STAT2.fea create mode 100644 Tests/feaLib/data/STAT_bad.fea create mode 100644 Tests/feaLib/data/STAT_test.fea create mode 100644 Tests/feaLib/data/STAT_test.ttx diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 64ed2259c..949d318aa 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -4,6 +4,7 @@ from fontTools.feaLib.location import FeatureLibLocation from fontTools.misc.encodingTools import getEncoding from collections import OrderedDict import itertools +from typing import NamedTuple SHIFT = " " * 4 @@ -28,12 +29,15 @@ __all__ = [ "Anchor", "AnchorDefinition", "AttachStatement", + "AxisValueLocation", "BaseAxis", "CVParametersNameStatement", "ChainContextPosStatement", "ChainContextSubstStatement", "CharacterStatement", "CursivePosStatement", + "ElidedFallbackName", + "ElidedFallbackNameID", "Expression", "FeatureNameStatement", "FeatureReferenceStatement", @@ -62,6 +66,9 @@ __all__ = [ "SingleSubstStatement", "SizeParameters", "Statement", + "STATAxisValueRecord", + "STATDesignAxis", + "STATNameStatement", "SubtableStatement", "TableBlock", "ValueRecord", @@ -77,6 +84,7 @@ def deviceToString(device): return "" % ", ".join("%d %d" % t for t in device) + fea_keywords = set( [ "anchor", @@ -1704,6 +1712,16 @@ class FeatureNameStatement(NameRecord): return '{} {}"{}";'.format(tag, plat, self.string) +class STATNameStatement(NameRecord): + """Represents a STAT table ``name`` statement.""" + + def asFea(self, indent=""): + plat = simplify_name_attributes(self.platformID, self.platEncID, self.langID) + if plat != "": + plat += " " + return 'name {}"{}";'.format(plat, self.string) + + class SizeParameters(Statement): """A ``parameters`` statement.""" @@ -1882,3 +1900,123 @@ class VheaField(Statement): fields = ("VertTypoAscender", "VertTypoDescender", "VertTypoLineGap") keywords = dict([(x.lower(), x) for x in fields]) return "{} {};".format(keywords[self.key], self.value) + + +class STATDesignAxis(Statement): + """A STAT table Design Axis + + Args: + tag (str): a 4 letter axis tag + axisOrder (int): an int + names (list): a list of :class:`STATNameStatement` objects + """ + def __init__(self, tag, axisOrder, names, location=None): + Statement.__init__(self, location) + self.tag = tag + self.axisOrder = axisOrder + self.names = names + self.location = location + + def build(self, builder): + builder.addDesignAxis(self, self.location) + + def asFea(self, indent=""): + indent += SHIFT + res = f"DesignAxis {self.tag} {self.axisOrder} {{ \n" + res += ("\n" + indent).join([s.asFea(indent=indent) for s in + self.names]) + "\n" + res += "};" + return res + + +class ElidedFallbackName(Statement): + """STAT table ElidedFallbackName + + Args: + names: a list of :class:`STATNameStatement` objects + """ + def __init__(self, names, location=None): + Statement.__init__(self, location) + self.names = names + self.location = location + + def build(self, builder): + builder.setElidedFallbackName(self.names, self.location) + + def asFea(self, indent=""): + indent += SHIFT + res = "ElidedFallbackName { \n" + res += ("\n" + indent).join([s.asFea(indent=indent) for s in + self.names]) + "\n" + res += "};" + return res + + +class ElidedFallbackNameID(Statement): + """STAT table ElidedFallbackNameID + + Args: + value: an int pointing to an existing name table name ID + """ + def __init__(self, value, location=None): + Statement.__init__(self, location) + self.value = value + self.location = location + + def build(self, builder): + builder.setElidedFallbackName(self.value, self.location) + + def asFea(self, indent=""): + return f"ElidedFallbackNameID {self.value};" + + +class STATAxisValueRecord(Statement): + """A STAT table Axis Value Record + + Args: + names (list): a list of :class:`STATNameStatement` objects + locations (list): a list of :class:`AxisValueLocation` objects + flags (int): an int + """ + def __init__(self, names, locations, flags, location=None): + Statement.__init__(self, location) + self.names = names + self.locations = locations + self.flags = flags + + def build(self, builder): + builder.addAxisValueRecord(self, self.location) + + def asFea(self, indent=""): + res = "AxisValue {\n" + for location in self.locations: + res += f"location {location.tag} " + res += f"{' '.join(str(i) for i in location.values)};\n" + + for nameRecord in self.names: + res += nameRecord.asFea() + res += "\n" + + if self.flags: + flags = ["OlderSiblingFontAttribute", "ElidableAxisValueName"] + flagStrings = [] + curr = 1 + for i in range(len(flags)): + if self.flags & curr != 0: + flagStrings.append(flags[i]) + curr = curr << 1 + res += f"flag {' '.join(flagStrings)};\n" + res += "};" + return res + + +class AxisValueLocation(NamedTuple): + """ + A STAT table Axis Value Location + + Args: + tag (str): a 4 letter axis tag + values (list): a list of ints and/or floats + """ + tag: str + values: list diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index 30046bda8..e269a4d4f 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -98,6 +98,7 @@ class Builder(object): "hhea", "name", "vhea", + "STAT", ] ) @@ -159,6 +160,8 @@ class Builder(object): self.hhea_ = {} # for table 'vhea' self.vhea_ = {} + # for table 'STAT' + self.stat_ = {} def build(self, tables=None, debug=False): if self.parseTree is None: @@ -188,6 +191,8 @@ class Builder(object): self.build_name() if "OS/2" in tables: self.build_OS_2() + if "STAT" in tables: + self.build_STAT() for tag in ("GPOS", "GSUB"): if tag not in tables: continue @@ -510,6 +515,176 @@ class Builder(object): if version >= 5: checkattr(table, ("usLowerOpticalPointSize", "usUpperOpticalPointSize")) + def setElidedFallbackName(self, value, location): + # ElidedFallbackName is a convenience method for setting + # ElidedFallbackNameID so only one can be allowed + for token in ("ElidedFallbackName", "ElidedFallbackNameID"): + if token in self.stat_: + raise FeatureLibError( + f"{token} is already set.", + location, + ) + if isinstance(value, int): + self.stat_["ElidedFallbackNameID"] = value + elif isinstance(value, list): + self.stat_["ElidedFallbackName"] = value + else: + raise AssertionError(value) + + def addDesignAxis(self, designAxis, location): + if "DesignAxes" not in self.stat_: + self.stat_["DesignAxes"] = [] + if designAxis.tag in (r.tag for r in self.stat_["DesignAxes"]): + raise FeatureLibError( + 'DesignAxis already defined for tag "%s".' % designAxis.tag, + location, + ) + if designAxis.axisOrder in (r.axisOrder for r in self.stat_["DesignAxes"]): + raise FeatureLibError( + f"DesignAxis already defined for axis number {designAxis.axisOrder}.", + location, + ) + self.stat_["DesignAxes"].append(designAxis) + + def addAxisValueRecord(self, axisValueRecord, location): + if "AxisValueRecords" not in self.stat_: + self.stat_["AxisValueRecords"] = [] + # Check for duplicate AxisValueRecords + for record_ in self.stat_["AxisValueRecords"]: + if (sorted([n.asFea() for n in record_.names]) == + sorted([n.asFea() for n in axisValueRecord.names]) and + sorted(record_.locations) == sorted(axisValueRecord.locations) + and record_.flags == axisValueRecord.flags): + raise FeatureLibError( + "An AxisValueRecord with these values is already defined.", + location, + ) + self.stat_["AxisValueRecords"].append(axisValueRecord) + + def build_STAT(self): + if not self.stat_: + return + self.font["STAT"] = newTable("STAT") + table = self.font["STAT"].table = otTables.STAT() + table.Version = 0x00010001 + nameTable = self.font.get("name") + if not nameTable: # this only happens for unit tests + nameTable = self.font["name"] = newTable("name") + nameTable.names = [] + if "ElidedFallbackNameID" in self.stat_: + nameID = self.stat_["ElidedFallbackNameID"] + name = nameTable.getDebugName(nameID) + if not name: + raise FeatureLibError('ElidedFallbackNameID %d points ' + 'to a nameID that does not exist in the ' + '"name" table' % nameID, None) + table.ElidedFallbackNameID = nameID + if "ElidedFallbackName" in self.stat_: + nameRecords = self.stat_["ElidedFallbackName"] + nameID = self.get_user_name_id(nameTable) + for nameRecord in nameRecords: + nameTable.setName(nameRecord.string, nameID, + nameRecord.platformID, nameRecord.platEncID, + nameRecord.langID) + table.ElidedFallbackNameID = nameID + + axisRecords = [] + axisValueRecords = [] + designAxisOrder = {} + for record in self.stat_["DesignAxes"]: + axis = otTables.AxisRecord() + axis.AxisTag = record.tag + nameID = self.get_user_name_id(nameTable) + for nameRecord in record.names: + nameTable.setName(nameRecord.string, nameID, + nameRecord.platformID, nameRecord.platEncID, + nameRecord.langID) + + axis.AxisNameID = nameID + axis.AxisOrdering = record.axisOrder + axisRecords.append(axis) + designAxisOrder[record.tag] = record.axisOrder + + if "AxisValueRecords" in self.stat_: + for record in self.stat_["AxisValueRecords"]: + if len(record.locations) == 1: + location = record.locations[0] + tag = location.tag + values = location.values + axisOrder = designAxisOrder[tag] + axisValueRecord = otTables.AxisValue() + axisValueRecord.AxisIndex = axisOrder + axisValueRecord.Flags = record.flags + + nameID = self.get_user_name_id(nameTable) + for nameRecord in record.names: + nameTable.setName(nameRecord.string, nameID, + nameRecord.platformID, + nameRecord.platEncID, + nameRecord.langID) + + axisValueRecord.ValueNameID = nameID + + if len(values) == 1: + axisValueRecord.Format = 1 + axisValueRecord.Value = values[0] + if len(values) == 2: + axisValueRecord.Format = 3 + axisValueRecord.Value = values[0] + axisValueRecord.LinkedValue = values[1] + if len(values) == 3: + axisValueRecord.Format = 2 + nominal, minVal, maxVal = values + axisValueRecord.NominalValue = nominal + axisValueRecord.RangeMinValue = minVal + axisValueRecord.RangeMaxValue = maxVal + axisValueRecords.append(axisValueRecord) + + if len(record.locations) > 1: + # Multiple locations = Format 4 + table.Version = 0x00010002 + axisValue = otTables.AxisValue() + axisValue.Format = 4 + + nameID = self.get_user_name_id(nameTable) + for nameRecord in record.names: + nameTable.setName(nameRecord.string, nameID, + nameRecord.platformID, + nameRecord.platEncID, + nameRecord.langID) + + axisValue.ValueNameID = nameID + axisValue.Flags = record.flags + + axisValueRecords_fmt4 = [] + for location in record.locations: + tag = location.tag + values = location.values + axisOrder = designAxisOrder[tag] + axisValueRecord = otTables.AxisValueRecord() + axisValueRecord.AxisIndex = axisOrder + axisValueRecord.Value = values[0] + axisValueRecords_fmt4.append(axisValueRecord) + axisValue.AxisCount = len(axisValueRecords_fmt4) + axisValue.AxisValueRecord = axisValueRecords_fmt4 + axisValueRecords.append(axisValue) + + if axisRecords: + # Store AxisRecords + axisRecordArray = otTables.AxisRecordArray() + axisRecordArray.Axis = axisRecords + # XXX these should not be hard-coded but computed automatically + table.DesignAxisRecordSize = 8 + table.DesignAxisRecord = axisRecordArray + table.DesignAxisCount = len(axisRecords) + + if axisValueRecords: + # Store AxisValueRecords + axisValueArray = otTables.AxisValueArray() + axisValueArray.AxisValue = axisValueRecords + table.AxisValueArray = axisValueArray + table.AxisValueCount = len(axisValueRecords) + def build_codepages_(self, pages): pages2bits = { 1252: 0, diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index 23a496181..ff2330f84 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -1003,6 +1003,7 @@ class Parser(object): "name": self.parse_table_name_, "BASE": self.parse_table_BASE_, "OS/2": self.parse_table_OS_2_, + "STAT": self.parse_table_STAT_, }.get(name) if handler: handler(table) @@ -1162,6 +1163,37 @@ class Parser(object): unescaped = self.unescape_string_(string, encoding) return platformID, platEncID, langID, unescaped + def parse_stat_name_(self): + platEncID = None + langID = None + if self.next_token_type_ in Lexer.NUMBERS: + platformID = self.expect_any_number_() + location = self.cur_token_location_ + if platformID not in (1, 3): + raise FeatureLibError("Expected platform id 1 or 3", location) + if self.next_token_type_ in Lexer.NUMBERS: + platEncID = self.expect_any_number_() + langID = self.expect_any_number_() + else: + platformID = 3 + location = self.cur_token_location_ + + if platformID == 1: # Macintosh + platEncID = platEncID or 0 # Roman + langID = langID or 0 # English + else: # 3, Windows + platEncID = platEncID or 1 # Unicode + langID = langID or 0x0409 # English + + string = self.expect_string_() + # self.expect_symbol_(";") + + encoding = getEncoding(platformID, platEncID, langID) + if encoding is None: + raise FeatureLibError("Unsupported encoding", location) + unescaped = self.unescape_string_(string, encoding) + return platformID, platEncID, langID, unescaped + def parse_nameid_(self): assert self.cur_token_ == "nameid", self.cur_token_ location, nameID = self.cur_token_location_, self.expect_any_number_() @@ -1283,6 +1315,179 @@ class Parser(object): elif self.cur_token_ == ";": continue + def parse_STAT_ElidedFallbackName(self): + assert self.is_cur_keyword_("ElidedFallbackName") + self.expect_symbol_("{") + names = [] + while self.next_token_ != "}" or self.cur_comments_: + self.advance_lexer_() + if self.is_cur_keyword_("name"): + platformID, platEncID, langID, string = self.parse_stat_name_() + nameRecord = self.ast.STATNameStatement( + "stat", platformID, platEncID, langID, string, + location=self.cur_token_location_ + ) + names.append(nameRecord) + else: + if self.cur_token_ != ";": + raise FeatureLibError(f"Unexpected token {self.cur_token_} " + f"in ElidedFallbackName", + self.cur_token_location_) + self.expect_symbol_("}") + if not names: + raise FeatureLibError('Expected "name"', + self.cur_token_location_) + return names + + def parse_STAT_design_axis(self): + assert self.is_cur_keyword_('DesignAxis') + names = [] + axisTag = self.expect_tag_() + if (axisTag not in ('ital', 'opsz', 'slnt', 'wdth', 'wght') + and not axisTag.isupper()): + log.warning( + f'Unregistered axis tag {axisTag} should be uppercase.' + ) + axisOrder = self.expect_number_() + self.expect_symbol_('{') + while self.next_token_ != "}" or self.cur_comments_: + self.advance_lexer_() + if self.cur_token_type_ is Lexer.COMMENT: + continue + elif self.is_cur_keyword_("name"): + location = self.cur_token_location_ + platformID, platEncID, langID, string = self.parse_stat_name_() + name = self.ast.STATNameStatement( + "stat", platformID, platEncID, langID, string, location=location + ) + names.append(name) + elif self.cur_token_ == ";": + continue + else: + raise FeatureLibError(f'Expected "name", got {self.cur_token_}', + self.cur_token_location_) + + self.expect_symbol_("}") + return self.ast.STATDesignAxis(axisTag, axisOrder, names, self.cur_token_location_) + + def parse_STAT_axis_value_(self): + assert self.is_cur_keyword_("AxisValue") + self.expect_symbol_("{") + locations = [] + names = [] + flags = 0 + while self.next_token_ != "}" or self.cur_comments_: + self.advance_lexer_(comments=True) + if self.cur_token_type_ is Lexer.COMMENT: + continue + elif self.is_cur_keyword_("name"): + location = self.cur_token_location_ + platformID, platEncID, langID, string = self.parse_stat_name_() + name = self.ast.STATNameStatement( + "stat", platformID, platEncID, langID, string, location=location + ) + names.append(name) + elif self.is_cur_keyword_("location"): + location = self.parse_STAT_location() + locations.append(location) + elif self.is_cur_keyword_("flag"): + flags = self.expect_stat_flags() + elif self.cur_token_ == ";": + continue + else: + raise FeatureLibError(f"Unexpected token {self.cur_token_} " + f"in AxisValue", self.cur_token_location_) + self.expect_symbol_("}") + if not names: + raise FeatureLibError('Expected "Axis Name"', + self.cur_token_location_) + if not locations: + raise FeatureLibError('Expected "Axis location"', + self.cur_token_location_) + if len(locations) > 1: + for location in locations: + if len(location.values) > 1: + raise FeatureLibError("Only one value is allowed in a " + "Format 4 Axis Value Record, but " + f"{len(location.values)} were found.", + self.cur_token_location_) + format4_tags = [] + for location in locations: + tag = location.tag + if tag in format4_tags: + raise FeatureLibError(f"Axis tag {tag} already " + "defined.", + self.cur_token_location_) + format4_tags.append(tag) + + return self.ast.STATAxisValueRecord(names, locations, flags, self.cur_token_location_) + + def parse_STAT_location(self): + values = [] + tag = self.expect_tag_() + if len(tag.strip()) != 4: + raise FeatureLibError(f"Axis tag {self.cur_token_} must be 4 " + "characters", self.cur_token_location_) + + while self.next_token_ != ";": + if self.next_token_type_ is Lexer.FLOAT: + value = self.expect_float_() + values.append(value) + elif self.next_token_type_ is Lexer.NUMBER: + value = self.expect_number_() + values.append(value) + else: + raise FeatureLibError(f'Unexpected value "{self.next_token_}". ' + 'Expected integer or float.', + self.next_token_location_) + if len(values) == 3: + nominal, min_val, max_val = values + if nominal < min_val or nominal > max_val: + raise FeatureLibError(f'Default value {nominal} is outside ' + f'of specified range ' + f'{min_val}-{max_val}.', + self.next_token_location_) + return self.ast.AxisValueLocation(tag, values) + + def parse_table_STAT_(self, table): + statements = table.statements + design_axes = [] + while self.next_token_ != "}" or self.cur_comments_: + self.advance_lexer_(comments=True) + if self.cur_token_type_ is Lexer.COMMENT: + statements.append( + self.ast.Comment(self.cur_token_, location=self.cur_token_location_) + ) + elif self.cur_token_type_ is Lexer.NAME: + if self.is_cur_keyword_("ElidedFallbackName"): + names = self.parse_STAT_ElidedFallbackName() + statements.append(self.ast.ElidedFallbackName(names)) + elif self.is_cur_keyword_("ElidedFallbackNameID"): + value = self.expect_number_() + statements.append(self.ast.ElidedFallbackNameID(value)) + self.expect_symbol_(";") + elif self.is_cur_keyword_("DesignAxis"): + designAxis = self.parse_STAT_design_axis() + design_axes.append(designAxis.tag) + statements.append(designAxis) + self.expect_symbol_(";") + elif self.is_cur_keyword_("AxisValue"): + axisValueRecord = self.parse_STAT_axis_value_() + for location in axisValueRecord.locations: + if location.tag not in design_axes: + # Tag must be defined in a DesignAxis before it + # can be referenced + raise FeatureLibError("DesignAxis not defined for " + f"{location.tag}.", + self.cur_token_location_) + statements.append(axisValueRecord) + self.expect_symbol_(";") + else: + raise FeatureLibError(f"Unexpected token {self.cur_token_}", + self.cur_token_location_) + elif self.cur_token_ == ";": + continue + def parse_base_tag_list_(self): # Parses BASE table entries. (See `section 9.a `_) assert self.cur_token_ in ( @@ -1853,7 +2058,36 @@ class Parser(object): return self.expect_number_() / 10 else: raise FeatureLibError( - "Expected an integer or floating-point number", self.cur_token_location_ + "Expected an integer or floating-point number", + self.cur_token_location_ + ) + + def expect_stat_flags(self): + value = 0 + flags = { + "OlderSiblingFontAttribute": 1, + "ElidableAxisValueName": 2, + } + while self.next_token_ != ";": + if self.next_token_ in flags: + name = self.expect_name_() + value = value | flags[name] + else: + raise FeatureLibError( + f"Unexpected STAT flag {self.cur_token_}", + self.cur_token_location_ + ) + return value + + def expect_stat_values_(self): + if self.next_token_type_ == Lexer.FLOAT: + return self.expect_float_() + elif self.next_token_type_ is Lexer.NUMBER: + return self.expect_number_() + else: + raise FeatureLibError( + "Expected an integer or floating-point number", + self.cur_token_location_ ) def expect_string_(self): diff --git a/Lib/fontTools/ttLib/tables/otConverters.py b/Lib/fontTools/ttLib/tables/otConverters.py index 96d461a38..28c8cc876 100644 --- a/Lib/fontTools/ttLib/tables/otConverters.py +++ b/Lib/fontTools/ttLib/tables/otConverters.py @@ -338,6 +338,18 @@ class NameID(UShort): log.warning("name id %d missing from name table" % value) xmlWriter.newline() +class STATFlags(UShort): + def xmlWrite(self, xmlWriter, font, value, name, attrs): + xmlWriter.simpletag(name, attrs + [("value", value)]) + flags = [] + if value & 0x01: + flags.append("OlderSiblingFontAttribute") + if value & 0x02: + flags.append("ElidableAxisValueName") + if flags: + xmlWriter.write(" ") + xmlWriter.comment(" ".join(flags)) + xmlWriter.newline() class FloatValue(SimpleValue): @staticmethod @@ -1745,7 +1757,6 @@ converterMapping = { "int8": Int8, "int16": Short, "uint8": UInt8, - "uint8": UInt8, "uint16": UShort, "uint24": UInt24, "uint32": ULong, @@ -1770,6 +1781,7 @@ converterMapping = { "LookupFlag": LookupFlag, "ExtendMode": ExtendMode, "CompositeMode": CompositeMode, + "STATFlags": STATFlags, # AAT "CIDGlyphMap": CIDGlyphMap, diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py index 28b40c478..f1c2c5665 100755 --- a/Lib/fontTools/ttLib/tables/otData.py +++ b/Lib/fontTools/ttLib/tables/otData.py @@ -872,7 +872,7 @@ otData = [ ('AxisValueFormat1', [ ('uint16', 'Format', None, None, 'Format, = 1'), ('uint16', 'AxisIndex', None, None, 'Index into the axis record array identifying the axis of design variation to which the axis value record applies.'), - ('uint16', 'Flags', None, None, 'Flags.'), + ('STATFlags', 'Flags', None, None, 'Flags.'), ('NameID', 'ValueNameID', None, None, ''), ('Fixed', 'Value', None, None, ''), ]), @@ -880,7 +880,7 @@ otData = [ ('AxisValueFormat2', [ ('uint16', 'Format', None, None, 'Format, = 2'), ('uint16', 'AxisIndex', None, None, 'Index into the axis record array identifying the axis of design variation to which the axis value record applies.'), - ('uint16', 'Flags', None, None, 'Flags.'), + ('STATFlags', 'Flags', None, None, 'Flags.'), ('NameID', 'ValueNameID', None, None, ''), ('Fixed', 'NominalValue', None, None, ''), ('Fixed', 'RangeMinValue', None, None, ''), @@ -890,7 +890,7 @@ otData = [ ('AxisValueFormat3', [ ('uint16', 'Format', None, None, 'Format, = 3'), ('uint16', 'AxisIndex', None, None, 'Index into the axis record array identifying the axis of design variation to which the axis value record applies.'), - ('uint16', 'Flags', None, None, 'Flags.'), + ('STATFlags', 'Flags', None, None, 'Flags.'), ('NameID', 'ValueNameID', None, None, ''), ('Fixed', 'Value', None, None, ''), ('Fixed', 'LinkedValue', None, None, ''), @@ -899,7 +899,7 @@ otData = [ ('AxisValueFormat4', [ ('uint16', 'Format', None, None, 'Format, = 4'), ('uint16', 'AxisCount', None, None, 'The total number of axes contributing to this axis-values combination.'), - ('uint16', 'Flags', None, None, 'Flags.'), + ('STATFlags', 'Flags', None, None, 'Flags.'), ('NameID', 'ValueNameID', None, None, ''), ('struct', 'AxisValueRecord', 'AxisCount', 0, 'Array of AxisValue records that provide the combination of axis values, one for each contributing axis. '), ]), diff --git a/Tests/feaLib/STAT2.fea b/Tests/feaLib/STAT2.fea new file mode 100644 index 000000000..2595a9a4a --- /dev/null +++ b/Tests/feaLib/STAT2.fea @@ -0,0 +1,4 @@ +table STAT { + ElidedFallbackName { name "Roman"; }; + DesignAxis zonk 0 { name "Zonkey"; };' +} STAT; diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py index 279e8ca87..6c1a664ae 100644 --- a/Tests/feaLib/builder_test.py +++ b/Tests/feaLib/builder_test.py @@ -9,6 +9,7 @@ from fontTools.feaLib import ast from fontTools.feaLib.lexer import Lexer import difflib import os +import re import shutil import sys import tempfile @@ -73,7 +74,7 @@ class BuilderTest(unittest.TestCase): LigatureSubtable AlternateSubtable MultipleSubstSubtable SingleSubstSubtable aalt_chain_contextual_subst AlternateChained MultipleLookupsPerGlyph MultipleLookupsPerGlyph2 GSUB_6_formats - GSUB_5_formats delete_glyph + GSUB_5_formats delete_glyph STAT_test """.split() def __init__(self, methodName): @@ -118,7 +119,7 @@ class BuilderTest(unittest.TestCase): def expect_ttx(self, font, expected_ttx, replace=None): path = self.temp_path(suffix=".ttx") font.saveXML(path, tables=['head', 'name', 'BASE', 'GDEF', 'GSUB', - 'GPOS', 'OS/2', 'hhea', 'vhea']) + 'GPOS', 'OS/2', 'STAT', 'hhea', 'vhea']) actual = self.read_ttx(path) expected = self.read_ttx(expected_ttx) if replace: @@ -463,6 +464,200 @@ class BuilderTest(unittest.TestCase): "} test;" ) + def test_STAT_elidedfallbackname_already_defined(self): + self.assertRaisesRegex( + FeatureLibError, + 'ElidedFallbackName is already set.', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' ElidedFallbackNameID 256;' + '} STAT;') + + def test_STAT_elidedfallbackname_set_twice(self): + self.assertRaisesRegex( + FeatureLibError, + 'ElidedFallbackName is already set.', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' ElidedFallbackName { name "Italic"; };' + '} STAT;') + + def test_STAT_elidedfallbacknameID_already_defined(self): + self.assertRaisesRegex( + FeatureLibError, + 'ElidedFallbackNameID is already set.', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackNameID 256;' + ' ElidedFallbackName { name "Roman"; };' + '} STAT;') + + def test_STAT_elidedfallbacknameID_not_in_name_table(self): + self.assertRaisesRegex( + FeatureLibError, + 'ElidedFallbackNameID 256 points to a nameID that does not ' + 'exist in the "name" table', + self.build, + 'table name {' + ' nameid 257 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackNameID 256;' + '} STAT;') + + def test_STAT_design_axis_name(self): + self.assertRaisesRegex( + FeatureLibError, + 'Expected "name"', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' DesignAxis opsz 0 { badtag "Optical Size"; };' + '} STAT;') + + def test_STAT_duplicate_design_axis_name(self): + self.assertRaisesRegex( + FeatureLibError, + 'DesignAxis already defined for tag "opsz".', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' DesignAxis opsz 0 { name "Optical Size"; };' + ' DesignAxis opsz 1 { name "Optical Size"; };' + '} STAT;') + + def test_STAT_design_axis_duplicate_order(self): + self.assertRaisesRegex( + FeatureLibError, + "DesignAxis already defined for axis number 0.", + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' DesignAxis opsz 0 { name "Optical Size"; };' + ' DesignAxis wdth 0 { name "Width"; };' + ' AxisValue {' + ' location opsz 8;' + ' location wdth 400;' + ' name "Caption";' + ' };' + '} STAT;') + + def test_STAT_undefined_tag(self): + self.assertRaisesRegex( + FeatureLibError, + 'DesignAxis not defined for wdth.', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' DesignAxis opsz 0 { name "Optical Size"; };' + ' AxisValue { ' + ' location wdth 125; ' + ' name "Wide"; ' + ' };' + '} STAT;') + + def test_STAT_axis_value_format4(self): + self.assertRaisesRegex( + FeatureLibError, + 'Axis tag wdth already defined.', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' DesignAxis opsz 0 { name "Optical Size"; };' + ' DesignAxis wdth 1 { name "Width"; };' + ' DesignAxis wght 2 { name "Weight"; };' + ' AxisValue { ' + ' location opsz 8; ' + ' location wdth 125; ' + ' location wdth 125; ' + ' location wght 500; ' + ' name "Caption Medium Wide"; ' + ' };' + '} STAT;') + + def test_STAT_duplicate_axis_value_record(self): + # Test for Duplicate AxisValueRecords even when the definition order + # is different. + self.assertRaisesRegex( + FeatureLibError, + 'An AxisValueRecord with these values is already defined.', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' DesignAxis opsz 0 { name "Optical Size"; };' + ' DesignAxis wdth 1 { name "Width"; };' + ' AxisValue {' + ' location opsz 8;' + ' location wdth 400;' + ' name "Caption";' + ' };' + ' AxisValue {' + ' location wdth 400;' + ' location opsz 8;' + ' name "Caption";' + ' };' + '} STAT;') + + def test_STAT_axis_value_missing_location(self): + self.assertRaisesRegex( + FeatureLibError, + 'Expected "Axis location"', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; ' + '};' + ' DesignAxis opsz 0 { name "Optical Size"; };' + ' AxisValue { ' + ' name "Wide"; ' + ' };' + '} STAT;') + + def test_STAT_invalid_location_tag(self): + self.assertRaisesRegex( + FeatureLibError, + 'Tags can not be longer than 4 characters', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; ' + ' name 3 1 0x0411 "ćƒ­ćƒ¼ćƒžćƒ³"; }; ' + ' DesignAxis width 0 { name "Width"; };' + '} STAT;') + def test_extensions(self): class ast_BaseClass(ast.MarkClass): def asFea(self, indent=""): diff --git a/Tests/feaLib/data/STAT_bad.fea b/Tests/feaLib/data/STAT_bad.fea new file mode 100644 index 000000000..8c87aabcc --- /dev/null +++ b/Tests/feaLib/data/STAT_bad.fea @@ -0,0 +1,95 @@ +table name { + nameid 25 "TestFont"; +} name; + + +table STAT { + + ElidedFallbackName { name "Roman"; }; + + DesignAxis opsz 0 { badtag "Optical Size"; }; + DesignAxis wdth 1 { name "Width"; }; + DesignAxis wght 2 { name "Weight"; }; + DesignAxis ital 3 { name "Italic"; }; + + AxisValue { + location opsz 8 5 9; + location wdth 300 350 450; + name "Caption"; + }; + + AxisValue { + location opsz 11 9 12; + name "Text"; + flag OlderSiblingFontAttribute ElidableAxisValueName ; + }; + + AxisValue { + location opsz 16.7 12 24; + name "Subhead"; + }; + + AxisValue { + location opsz 72 24 72; + name "Display"; + }; + + AxisValue { + location wdth 80 80 89; + name "Condensed"; + }; + + AxisValue { + location wdth 90 90 96; + name "Semicondensed"; + }; + + AxisValue { + location wdth 100 97 101; + name "Normal"; + flag ElidableAxisValueName; + }; + + AxisValue { + location wdth 125 102 125; + name "Extended"; + }; + + AxisValue { + location wght 300 300 349; + name "Light"; + }; + + AxisValue { + location wght 400 350 449; + name "Regular"; + flag ElidableAxisValueName; + }; + + AxisValue { + location wght 500 450 549; + name "Medium"; + }; + + AxisValue { + location wght 600 550 649; + name "Semibold"; + }; + + AxisValue { + location wght 700 650 749; + name "Bold"; + }; + + AxisValue { + location wght 900 750 900; + name "Black"; + }; + + AxisValue { + location ital 0; + name "Roman"; + flag ElidableAxisValueName; + }; + +} STAT; diff --git a/Tests/feaLib/data/STAT_test.fea b/Tests/feaLib/data/STAT_test.fea new file mode 100644 index 000000000..010363764 --- /dev/null +++ b/Tests/feaLib/data/STAT_test.fea @@ -0,0 +1,109 @@ +table name { + nameid 25 "TestFont"; +} name; + + +table STAT { + + ElidedFallbackName { + name "Roman"; + name 3 1 1041 "ćƒ­ćƒ¼ćƒžćƒ³"; + }; + + DesignAxis opsz 0 { + name "Optical Size"; + }; + + DesignAxis wdth 1 { + name "Width"; + }; + + DesignAxis wght 2 { + name "Weight"; + }; + + DesignAxis ital 3 { + name "Italic"; + }; # here comment + + AxisValue { + location opsz 8; # comment here + location wdth 400; # another comment + name "Caption"; # more comments + }; + + AxisValue { + location opsz 11 9 12; + name "Text"; + flag OlderSiblingFontAttribute ElidableAxisValueName; + }; + + AxisValue { + location opsz 16.7 12 24; + name "Subhead"; + }; + + AxisValue { + location opsz 72 24 72; + name "Display"; + }; + + AxisValue { + location wdth 80 80 89; + name "Condensed"; + }; + + AxisValue { + location wdth 90 90 96; + name "Semicondensed"; + }; + + AxisValue { + location wdth 100 97 101; + name "Normal"; + flag ElidableAxisValueName; + }; + + AxisValue { + location wdth 125 102 125; + name "Extended"; + }; + + AxisValue { + location wght 300 300 349; + name "Light"; + }; + + AxisValue { + location wght 400 350 449; + name "Regular"; + flag ElidableAxisValueName; + }; + + AxisValue { + location wght 500 450 549; + name "Medium"; + }; + + AxisValue { + location wght 600 550 649; + name "Semibold"; + }; + + AxisValue { + location wght 700 650 749; + name "Bold"; + }; + + AxisValue { + location wght 900 750 900; + name "Black"; + }; + + AxisValue { + location ital 0; + name "Roman"; + flag ElidableAxisValueName; # flag comment + }; + +} STAT; diff --git a/Tests/feaLib/data/STAT_test.ttx b/Tests/feaLib/data/STAT_test.ttx new file mode 100644 index 000000000..caef1b0ad --- /dev/null +++ b/Tests/feaLib/data/STAT_test.ttx @@ -0,0 +1,228 @@ + + + + + + TestFont + + + Roman + + + ćƒ­ćƒ¼ćƒžćƒ³ + + + Optical Size + + + Width + + + Weight + + + Italic + + + Caption + + + Text + + + Subhead + + + Display + + + Condensed + + + Semicondensed + + + Normal + + + Extended + + + Light + + + Regular + + + Medium + + + Semibold + + + Bold + + + Black + + + Roman + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py index db505950c..8b21a4ec5 100644 --- a/Tests/feaLib/parser_test.py +++ b/Tests/feaLib/parser_test.py @@ -1280,6 +1280,76 @@ class ParserTest(unittest.TestCase): '"dflt" is not a valid script tag; use "DFLT" instead', self.parse, "feature test {script dflt;} test;") + def test_stat_design_axis(self): # STAT DesignAxis + doc = self.parse('table STAT { DesignAxis opsz 0 ' + '{name "Optical Size";}; } STAT;') + da = doc.statements[0].statements[0] + self.assertIsInstance(da, ast.STATDesignAxis) + self.assertEqual(da.tag, 'opsz') + self.assertEqual(da.axisOrder, 0) + self.assertEqual(da.names[0].string, 'Optical Size') + + def test_stat_axis_value_format1(self): # STAT AxisValue + doc = self.parse('table STAT { DesignAxis opsz 0 ' + '{name "Optical Size";}; ' + 'AxisValue {location opsz 8; name "Caption";}; } ' + 'STAT;') + avr = doc.statements[0].statements[1] + self.assertIsInstance(avr, ast.STATAxisValueRecord) + self.assertEqual(avr.locations[0].tag, 'opsz') + self.assertEqual(avr.locations[0].values[0], 8) + self.assertEqual(avr.names[0].string, 'Caption') + + def test_stat_axis_value_format2(self): # STAT AxisValue + doc = self.parse('table STAT { DesignAxis opsz 0 ' + '{name "Optical Size";}; ' + 'AxisValue {location opsz 8 6 10; name "Caption";}; } ' + 'STAT;') + avr = doc.statements[0].statements[1] + self.assertIsInstance(avr, ast.STATAxisValueRecord) + self.assertEqual(avr.locations[0].tag, 'opsz') + self.assertEqual(avr.locations[0].values, [8, 6, 10]) + self.assertEqual(avr.names[0].string, 'Caption') + + def test_stat_axis_value_format2_bad_range(self): # STAT AxisValue + self.assertRaisesRegex( + FeatureLibError, + 'Default value 5 is outside of specified range 6-10.', + self.parse, 'table STAT { DesignAxis opsz 0 ' + '{name "Optical Size";}; ' + 'AxisValue {location opsz 5 6 10; name "Caption";}; } ' + 'STAT;') + + def test_stat_axis_value_format4(self): # STAT AxisValue + self.assertRaisesRegex( + FeatureLibError, + 'Only one value is allowed in a Format 4 Axis Value Record, but 3 were found.', + self.parse, 'table STAT { ' + 'DesignAxis opsz 0 {name "Optical Size";}; ' + 'DesignAxis wdth 0 {name "Width";}; ' + 'AxisValue {' + 'location opsz 8 6 10; ' + 'location wdth 400; ' + 'name "Caption";}; } ' + 'STAT;') + + def test_stat_elidedfallbackname(self): # STAT ElidedFallbackName + doc = self.parse('table STAT { ElidedFallbackName {name "Roman"; ' + 'name 3 1 0x0411 "ćƒ­ćƒ¼ćƒžćƒ³"; }; ' + '} STAT;') + nameRecord = doc.statements[0].statements[0] + self.assertIsInstance(nameRecord, ast.ElidedFallbackName) + self.assertEqual(nameRecord.names[0].string, 'Roman') + self.assertEqual(nameRecord.names[1].string, 'ćƒ­ćƒ¼ćƒžćƒ³') + + def test_stat_elidedfallbacknameid(self): # STAT ElidedFallbackNameID + doc = self.parse('table name { nameid 278 "Roman"; } name; ' + 'table STAT { ElidedFallbackNameID 278; ' + '} STAT;') + nameRecord = doc.statements[0].statements[0] + self.assertIsInstance(nameRecord, ast.NameRecord) + self.assertEqual(nameRecord.string, 'Roman') + def test_sub_single_format_a(self): # GSUB LookupType 1 doc = self.parse("feature smcp {substitute a by a.sc;} smcp;") sub = doc.statements[0].statements[0] diff --git a/Tests/fontBuilder/data/test_var.ttf.ttx b/Tests/fontBuilder/data/test_var.ttf.ttx index 781bb6460..c3585bed5 100644 --- a/Tests/fontBuilder/data/test_var.ttf.ttx +++ b/Tests/fontBuilder/data/test_var.ttf.ttx @@ -393,7 +393,7 @@ - + @@ -405,7 +405,7 @@ - + @@ -417,7 +417,7 @@ - + @@ -429,7 +429,7 @@ - + diff --git a/Tests/otlLib/builder_test.py b/Tests/otlLib/builder_test.py index 3ea5a7459..01d6895cd 100644 --- a/Tests/otlLib/builder_test.py +++ b/Tests/otlLib/builder_test.py @@ -1138,7 +1138,7 @@ buildStatTable_test_data = [ ' ', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -1193,7 +1193,7 @@ buildStatTable_test_data = [ ' ', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -1211,7 +1211,7 @@ buildStatTable_test_data = [ ' ', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -1246,7 +1246,7 @@ buildStatTable_test_data = [ ' ', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -1291,7 +1291,7 @@ buildStatTable_test_data = [ ' ', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -1354,7 +1354,7 @@ buildStatTable_test_data = [ ' ', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', diff --git a/Tests/ttLib/tables/S_T_A_T_test.py b/Tests/ttLib/tables/S_T_A_T_test.py index d8c1b7dca..5366e8a26 100644 --- a/Tests/ttLib/tables/S_T_A_T_test.py +++ b/Tests/ttLib/tables/S_T_A_T_test.py @@ -147,7 +147,7 @@ STAT_XML_AXIS_VALUE_FORMAT3 = [ '', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -191,7 +191,7 @@ STAT_XML_VERSION_1_1 = [ '', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx index 5e1107ce3..af840387e 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx @@ -531,7 +531,7 @@ - + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx index add86a67b..63d23240e 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx @@ -519,14 +519,14 @@ - + - + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx index 1405904af..6cf837dcb 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx @@ -525,7 +525,7 @@ - + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx index e48936a85..f8f94ba7b 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx @@ -531,7 +531,7 @@ - + From 29ff42d15fb8bf33e4e18259e27bb764682e153c Mon Sep 17 00:00:00 2001 From: Kamile Demir Date: Fri, 19 Feb 2021 17:17:28 -0500 Subject: [PATCH 124/167] Reusing otlLib buildStatTable() in feaLib --- Lib/fontTools/feaLib/ast.py | 28 ++- Lib/fontTools/feaLib/builder.py | 174 +++++--------- Lib/fontTools/feaLib/parser.py | 10 +- Lib/fontTools/otlLib/builder.py | 17 +- Tests/feaLib/builder_test.py | 5 +- Tests/feaLib/data/STAT_bad.fea | 3 +- Tests/feaLib/data/STAT_test.ttx | 88 +++---- .../data/STAT_test_elidedFallbackNameID.fea | 84 +++++++ .../data/STAT_test_elidedFallbackNameID.ttx | 225 ++++++++++++++++++ Tests/feaLib/parser_test.py | 6 +- 10 files changed, 453 insertions(+), 187 deletions(-) create mode 100644 Tests/feaLib/data/STAT_test_elidedFallbackNameID.fea create mode 100644 Tests/feaLib/data/STAT_test_elidedFallbackNameID.ttx diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 949d318aa..53b100ec7 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -29,7 +29,7 @@ __all__ = [ "Anchor", "AnchorDefinition", "AttachStatement", - "AxisValueLocation", + "AxisValueLocationStatement", "BaseAxis", "CVParametersNameStatement", "ChainContextPosStatement", @@ -66,8 +66,8 @@ __all__ = [ "SingleSubstStatement", "SizeParameters", "Statement", - "STATAxisValueRecord", - "STATDesignAxis", + "STATAxisValueStatement", + "STATDesignAxisStatement", "STATNameStatement", "SubtableStatement", "TableBlock", @@ -1902,7 +1902,7 @@ class VheaField(Statement): return "{} {};".format(keywords[self.key], self.value) -class STATDesignAxis(Statement): +class STATDesignAxisStatement(Statement): """A STAT table Design Axis Args: @@ -1970,12 +1970,12 @@ class ElidedFallbackNameID(Statement): return f"ElidedFallbackNameID {self.value};" -class STATAxisValueRecord(Statement): +class STATAxisValueStatement(Statement): """A STAT table Axis Value Record Args: names (list): a list of :class:`STATNameStatement` objects - locations (list): a list of :class:`AxisValueLocation` objects + locations (list): a list of :class:`AxisValueLocationStatement` objects flags (int): an int """ def __init__(self, names, locations, flags, location=None): @@ -1990,8 +1990,7 @@ class STATAxisValueRecord(Statement): def asFea(self, indent=""): res = "AxisValue {\n" for location in self.locations: - res += f"location {location.tag} " - res += f"{' '.join(str(i) for i in location.values)};\n" + res += location.asFea() for nameRecord in self.names: res += nameRecord.asFea() @@ -2010,7 +2009,7 @@ class STATAxisValueRecord(Statement): return res -class AxisValueLocation(NamedTuple): +class AxisValueLocationStatement(Statement): """ A STAT table Axis Value Location @@ -2018,5 +2017,12 @@ class AxisValueLocation(NamedTuple): tag (str): a 4 letter axis tag values (list): a list of ints and/or floats """ - tag: str - values: list + def __init__(self, tag, values, location=None): + Statement.__init__(self, location) + self.tag = tag + self.values = values + + def asFea(self, res=""): + res += f"location {self.tag} " + res += f"{' '.join(str(i) for i in self.values)};\n" + return res diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index e269a4d4f..ff03b79ab 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -536,7 +536,7 @@ class Builder(object): self.stat_["DesignAxes"] = [] if designAxis.tag in (r.tag for r in self.stat_["DesignAxes"]): raise FeatureLibError( - 'DesignAxis already defined for tag "%s".' % designAxis.tag, + f'DesignAxis already defined for tag "{designAxis.tag}".', location, ) if designAxis.axisOrder in (r.axisOrder for r in self.stat_["DesignAxes"]): @@ -551,10 +551,11 @@ class Builder(object): self.stat_["AxisValueRecords"] = [] # Check for duplicate AxisValueRecords for record_ in self.stat_["AxisValueRecords"]: - if (sorted([n.asFea() for n in record_.names]) == - sorted([n.asFea() for n in axisValueRecord.names]) and - sorted(record_.locations) == sorted(axisValueRecord.locations) - and record_.flags == axisValueRecord.flags): + if ({n.asFea() for n in record_.names} == + {n.asFea() for n in axisValueRecord.names} and + {n.asFea() for n in record_.locations} == + {n.asFea() for n in axisValueRecord.locations} + and record_.flags == axisValueRecord.flags): raise FeatureLibError( "An AxisValueRecord with these values is already defined.", location, @@ -564,126 +565,65 @@ class Builder(object): def build_STAT(self): if not self.stat_: return - self.font["STAT"] = newTable("STAT") - table = self.font["STAT"].table = otTables.STAT() - table.Version = 0x00010001 + + axes = self.stat_.get("DesignAxes") + if not axes: + raise FeatureLibError('DesignAxes not defined', None) + axisValueRecords = self.stat_.get("AxisValueRecords") + axisValues = {} + format4_locations = [] + for tag in axes: + axisValues[tag.tag] = [] + if axisValueRecords is not None: + for avr in axisValueRecords: + valuesDict = {} + if avr.flags > 0: + valuesDict['flags'] = avr.flags + if len(avr.locations) == 1: + location = avr.locations[0] + values = location.values + if len(values) == 1: #format1 + valuesDict.update({'value': values[0],'name': avr.names}) + if len(values) == 2: #format3 + valuesDict.update({ 'value': values[0], + 'linkedValue': values[1], + 'name': avr.names}) + if len(values) == 3: #format2 + nominal, minVal, maxVal = values + valuesDict.update({ 'nominalValue': nominal, + 'rangeMinValue': minVal, + 'rangeMaxValue': maxVal, + 'name': avr.names}) + axisValues[location.tag].append(valuesDict) + else: + valuesDict.update({"location": {i.tag: i.values[0] + for i in avr.locations}, + "name": avr.names}) + format4_locations.append(valuesDict) + + designAxes = [{"ordering": a.axisOrder, + "tag": a.tag, + "name": a.names, + 'values': axisValues[a.tag]} for a in axes] + nameTable = self.font.get("name") if not nameTable: # this only happens for unit tests nameTable = self.font["name"] = newTable("name") nameTable.names = [] + if "ElidedFallbackNameID" in self.stat_: - nameID = self.stat_["ElidedFallbackNameID"] - name = nameTable.getDebugName(nameID) + nameID = self.stat_["ElidedFallbackNameID"] + name = nameTable.getDebugName(nameID) if not name: - raise FeatureLibError('ElidedFallbackNameID %d points ' + raise FeatureLibError(f'ElidedFallbackNameID {nameID} points ' 'to a nameID that does not exist in the ' - '"name" table' % nameID, None) - table.ElidedFallbackNameID = nameID - if "ElidedFallbackName" in self.stat_: - nameRecords = self.stat_["ElidedFallbackName"] - nameID = self.get_user_name_id(nameTable) - for nameRecord in nameRecords: - nameTable.setName(nameRecord.string, nameID, - nameRecord.platformID, nameRecord.platEncID, - nameRecord.langID) - table.ElidedFallbackNameID = nameID + '"name" table', None) + elif "ElidedFallbackName" in self.stat_: + nameID = self.stat_["ElidedFallbackName"] + + otl.buildStatTable(self.font, designAxes, locations=format4_locations, + elidedFallbackName=nameID) - axisRecords = [] - axisValueRecords = [] - designAxisOrder = {} - for record in self.stat_["DesignAxes"]: - axis = otTables.AxisRecord() - axis.AxisTag = record.tag - nameID = self.get_user_name_id(nameTable) - for nameRecord in record.names: - nameTable.setName(nameRecord.string, nameID, - nameRecord.platformID, nameRecord.platEncID, - nameRecord.langID) - - axis.AxisNameID = nameID - axis.AxisOrdering = record.axisOrder - axisRecords.append(axis) - designAxisOrder[record.tag] = record.axisOrder - - if "AxisValueRecords" in self.stat_: - for record in self.stat_["AxisValueRecords"]: - if len(record.locations) == 1: - location = record.locations[0] - tag = location.tag - values = location.values - axisOrder = designAxisOrder[tag] - axisValueRecord = otTables.AxisValue() - axisValueRecord.AxisIndex = axisOrder - axisValueRecord.Flags = record.flags - - nameID = self.get_user_name_id(nameTable) - for nameRecord in record.names: - nameTable.setName(nameRecord.string, nameID, - nameRecord.platformID, - nameRecord.platEncID, - nameRecord.langID) - - axisValueRecord.ValueNameID = nameID - - if len(values) == 1: - axisValueRecord.Format = 1 - axisValueRecord.Value = values[0] - if len(values) == 2: - axisValueRecord.Format = 3 - axisValueRecord.Value = values[0] - axisValueRecord.LinkedValue = values[1] - if len(values) == 3: - axisValueRecord.Format = 2 - nominal, minVal, maxVal = values - axisValueRecord.NominalValue = nominal - axisValueRecord.RangeMinValue = minVal - axisValueRecord.RangeMaxValue = maxVal - axisValueRecords.append(axisValueRecord) - - if len(record.locations) > 1: - # Multiple locations = Format 4 - table.Version = 0x00010002 - axisValue = otTables.AxisValue() - axisValue.Format = 4 - - nameID = self.get_user_name_id(nameTable) - for nameRecord in record.names: - nameTable.setName(nameRecord.string, nameID, - nameRecord.platformID, - nameRecord.platEncID, - nameRecord.langID) - - axisValue.ValueNameID = nameID - axisValue.Flags = record.flags - - axisValueRecords_fmt4 = [] - for location in record.locations: - tag = location.tag - values = location.values - axisOrder = designAxisOrder[tag] - axisValueRecord = otTables.AxisValueRecord() - axisValueRecord.AxisIndex = axisOrder - axisValueRecord.Value = values[0] - axisValueRecords_fmt4.append(axisValueRecord) - axisValue.AxisCount = len(axisValueRecords_fmt4) - axisValue.AxisValueRecord = axisValueRecords_fmt4 - axisValueRecords.append(axisValue) - - if axisRecords: - # Store AxisRecords - axisRecordArray = otTables.AxisRecordArray() - axisRecordArray.Axis = axisRecords - # XXX these should not be hard-coded but computed automatically - table.DesignAxisRecordSize = 8 - table.DesignAxisRecord = axisRecordArray - table.DesignAxisCount = len(axisRecords) - - if axisValueRecords: - # Store AxisValueRecords - axisValueArray = otTables.AxisValueArray() - axisValueArray.AxisValue = axisValueRecords - table.AxisValueArray = axisValueArray - table.AxisValueCount = len(axisValueRecords) def build_codepages_(self, pages): pages2bits = { diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index ff2330f84..f638c68c4 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -1186,8 +1186,6 @@ class Parser(object): langID = langID or 0x0409 # English string = self.expect_string_() - # self.expect_symbol_(";") - encoding = getEncoding(platformID, platEncID, langID) if encoding is None: raise FeatureLibError("Unsupported encoding", location) @@ -1368,7 +1366,7 @@ class Parser(object): self.cur_token_location_) self.expect_symbol_("}") - return self.ast.STATDesignAxis(axisTag, axisOrder, names, self.cur_token_location_) + return self.ast.STATDesignAxisStatement(axisTag, axisOrder, names, self.cur_token_location_) def parse_STAT_axis_value_(self): assert self.is_cur_keyword_("AxisValue") @@ -1420,7 +1418,7 @@ class Parser(object): self.cur_token_location_) format4_tags.append(tag) - return self.ast.STATAxisValueRecord(names, locations, flags, self.cur_token_location_) + return self.ast.STATAxisValueStatement(names, locations, flags, self.cur_token_location_) def parse_STAT_location(self): values = [] @@ -1447,7 +1445,7 @@ class Parser(object): f'of specified range ' f'{min_val}-{max_val}.', self.next_token_location_) - return self.ast.AxisValueLocation(tag, values) + return self.ast.AxisValueLocationStatement(tag, values) def parse_table_STAT_(self, table): statements = table.statements @@ -1989,7 +1987,7 @@ class Parser(object): raise FeatureLibError("Expected a tag", self.cur_token_location_) if len(self.cur_token_) > 4: raise FeatureLibError( - "Tags can not be longer than 4 characters", self.cur_token_location_ + "Tags cannot be longer than 4 characters", self.cur_token_location_ ) return (self.cur_token_ + " ")[:4] diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index 029aa3fc5..ab27463a6 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -9,6 +9,7 @@ from fontTools.ttLib.tables.otBase import ( CountReference, ) from fontTools.ttLib.tables import otBase +from fontTools.feaLib.ast import STATNameStatement from fontTools.otlLib.error import OpenTypeLibError import logging import copy @@ -2687,8 +2688,8 @@ def buildStatTable(ttFont, axes, locations=None, elidedFallbackName=2): ] The optional 'elidedFallbackName' argument can be a name ID (int), - a string, or a dictionary containing multilingual names. It - translates to the ElidedFallbackNameID field. + a string, a dictionary containing multilingual names, or a list of + STATNameStatements. 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 @@ -2797,6 +2798,16 @@ def _addName(nameTable, value, minNameID=0): names = dict(en=value) elif isinstance(value, dict): names = value + elif isinstance(value, list): + nameID = nameTable._findUnusedNameID() + for nameRecord in value: + if isinstance(nameRecord, STATNameStatement): + nameTable.setName(nameRecord.string, + nameID,nameRecord.platformID, + nameRecord.platEncID,nameRecord.langID) + else: + raise TypeError("value must be a list of STATNameStatements") + return nameID else: - raise TypeError("value must be int, str or dict") + raise TypeError("value must be int, str, dict or list") return nameTable.addMultilingualName(names, minNameID=minNameID) diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py index 6c1a664ae..2f6319e62 100644 --- a/Tests/feaLib/builder_test.py +++ b/Tests/feaLib/builder_test.py @@ -74,7 +74,7 @@ class BuilderTest(unittest.TestCase): LigatureSubtable AlternateSubtable MultipleSubstSubtable SingleSubstSubtable aalt_chain_contextual_subst AlternateChained MultipleLookupsPerGlyph MultipleLookupsPerGlyph2 GSUB_6_formats - GSUB_5_formats delete_glyph STAT_test + GSUB_5_formats delete_glyph STAT_test STAT_test_elidedFallbackNameID """.split() def __init__(self, methodName): @@ -514,6 +514,7 @@ class BuilderTest(unittest.TestCase): '} name;' 'table STAT {' ' ElidedFallbackNameID 256;' + ' DesignAxis opsz 1 { name "Optical Size"; };' '} STAT;') def test_STAT_design_axis_name(self): @@ -647,7 +648,7 @@ class BuilderTest(unittest.TestCase): def test_STAT_invalid_location_tag(self): self.assertRaisesRegex( FeatureLibError, - 'Tags can not be longer than 4 characters', + 'Tags cannot be longer than 4 characters', self.build, 'table name {' ' nameid 256 "Roman"; ' diff --git a/Tests/feaLib/data/STAT_bad.fea b/Tests/feaLib/data/STAT_bad.fea index 8c87aabcc..8ec887f0e 100644 --- a/Tests/feaLib/data/STAT_bad.fea +++ b/Tests/feaLib/data/STAT_bad.fea @@ -1,3 +1,4 @@ +# bad fea file: Testing DesignAxis tag with incorrect label table name { nameid 25 "TestFont"; } name; @@ -7,7 +8,7 @@ table STAT { ElidedFallbackName { name "Roman"; }; - DesignAxis opsz 0 { badtag "Optical Size"; }; + DesignAxis opsz 0 { badtag "Optical Size"; }; #'badtag' instead of 'name' is incorrect DesignAxis wdth 1 { name "Width"; }; DesignAxis wght 2 { name "Weight"; }; DesignAxis ital 3 { name "Italic"; }; diff --git a/Tests/feaLib/data/STAT_test.ttx b/Tests/feaLib/data/STAT_test.ttx index caef1b0ad..d1b2b6970 100644 --- a/Tests/feaLib/data/STAT_test.ttx +++ b/Tests/feaLib/data/STAT_test.ttx @@ -1,5 +1,5 @@ - + @@ -15,59 +15,59 @@ Optical Size - Width - - - Weight - - - Italic - - - Caption - - Text - + Subhead - + Display - + + Width + + Condensed - + Semicondensed - + Normal - + Extended - + + Weight + + Light - + Regular - + Medium - + Semibold - + Bold - + Black - + + Italic + + Roman + + Caption + @@ -82,17 +82,17 @@ - + - + - + @@ -101,7 +101,7 @@ - + @@ -114,7 +114,7 @@ - + @@ -122,7 +122,7 @@ - + @@ -130,7 +130,7 @@ - + @@ -138,7 +138,7 @@ - + @@ -146,7 +146,7 @@ - + @@ -154,7 +154,7 @@ - + @@ -162,7 +162,7 @@ - + @@ -170,7 +170,7 @@ - + @@ -178,7 +178,7 @@ - + @@ -186,7 +186,7 @@ - + @@ -194,7 +194,7 @@ - + @@ -202,7 +202,7 @@ - + @@ -210,7 +210,7 @@ - + @@ -218,7 +218,7 @@ - + diff --git a/Tests/feaLib/data/STAT_test_elidedFallbackNameID.fea b/Tests/feaLib/data/STAT_test_elidedFallbackNameID.fea new file mode 100644 index 000000000..5a1418037 --- /dev/null +++ b/Tests/feaLib/data/STAT_test_elidedFallbackNameID.fea @@ -0,0 +1,84 @@ +table name { + nameid 25 "TestFont"; + nameid 256 "Roman"; +} name; +table STAT { + ElidedFallbackNameID 256; + DesignAxis opsz 0 { + name "Optical Size"; + }; + DesignAxis wdth 1 { + name "Width"; + }; + DesignAxis wght 2 { + name "Weight"; + }; + DesignAxis ital 3 { + name "Italic"; + }; # here comment + AxisValue { + location opsz 8; # comment here + location wdth 400; # another comment + name "Caption"; # more comments + }; + AxisValue { + location opsz 11 9 12; + name "Text"; + flag OlderSiblingFontAttribute ElidableAxisValueName; + }; + AxisValue { + location opsz 16.7 12 24; + name "Subhead"; + }; + AxisValue { + location opsz 72 24 72; + name "Display"; + }; + AxisValue { + location wdth 80 80 89; + name "Condensed"; + }; + AxisValue { + location wdth 90 90 96; + name "Semicondensed"; + }; + AxisValue { + location wdth 100 97 101; + name "Normal"; + flag ElidableAxisValueName; + }; + AxisValue { + location wdth 125 102 125; + name "Extended"; + }; + AxisValue { + location wght 300 300 349; + name "Light"; + }; + AxisValue { + location wght 400 350 449; + name "Regular"; + flag ElidableAxisValueName; + }; + AxisValue { + location wght 500 450 549; + name "Medium"; + }; + AxisValue { + location wght 600 550 649; + name "Semibold"; + }; + AxisValue { + location wght 700 650 749; + name "Bold"; + }; + AxisValue { + location wght 900 750 900; + name "Black"; + }; + AxisValue { + location ital 0; + name "Roman"; + flag ElidableAxisValueName; # flag comment + }; +} STAT; \ No newline at end of file diff --git a/Tests/feaLib/data/STAT_test_elidedFallbackNameID.ttx b/Tests/feaLib/data/STAT_test_elidedFallbackNameID.ttx new file mode 100644 index 000000000..32802e0fe --- /dev/null +++ b/Tests/feaLib/data/STAT_test_elidedFallbackNameID.ttx @@ -0,0 +1,225 @@ + + + + + + TestFont + + + Roman + + + Optical Size + + + Text + + + Subhead + + + Display + + + Width + + + Condensed + + + Semicondensed + + + Normal + + + Extended + + + Weight + + + Light + + + Regular + + + Medium + + + Semibold + + + Bold + + + Black + + + Italic + + + Roman + + + Caption + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py index 8b21a4ec5..de2bc3ca8 100644 --- a/Tests/feaLib/parser_test.py +++ b/Tests/feaLib/parser_test.py @@ -1284,7 +1284,7 @@ class ParserTest(unittest.TestCase): doc = self.parse('table STAT { DesignAxis opsz 0 ' '{name "Optical Size";}; } STAT;') da = doc.statements[0].statements[0] - self.assertIsInstance(da, ast.STATDesignAxis) + self.assertIsInstance(da, ast.STATDesignAxisStatement) self.assertEqual(da.tag, 'opsz') self.assertEqual(da.axisOrder, 0) self.assertEqual(da.names[0].string, 'Optical Size') @@ -1295,7 +1295,7 @@ class ParserTest(unittest.TestCase): 'AxisValue {location opsz 8; name "Caption";}; } ' 'STAT;') avr = doc.statements[0].statements[1] - self.assertIsInstance(avr, ast.STATAxisValueRecord) + self.assertIsInstance(avr, ast.STATAxisValueStatement) self.assertEqual(avr.locations[0].tag, 'opsz') self.assertEqual(avr.locations[0].values[0], 8) self.assertEqual(avr.names[0].string, 'Caption') @@ -1306,7 +1306,7 @@ class ParserTest(unittest.TestCase): 'AxisValue {location opsz 8 6 10; name "Caption";}; } ' 'STAT;') avr = doc.statements[0].statements[1] - self.assertIsInstance(avr, ast.STATAxisValueRecord) + self.assertIsInstance(avr, ast.STATAxisValueStatement) self.assertEqual(avr.locations[0].tag, 'opsz') self.assertEqual(avr.locations[0].values, [8, 6, 10]) self.assertEqual(avr.names[0].string, 'Caption') From 9aeb48286db6e1a5c0dd2d2ac0c7751d28d93bfc Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 25 Feb 2021 16:59:47 +0000 Subject: [PATCH 125/167] black --- Lib/fontTools/feaLib/ast.py | 20 +++--- Lib/fontTools/feaLib/builder.py | 100 +++++++++++++++++---------- Lib/fontTools/feaLib/parser.py | 118 ++++++++++++++++++-------------- Lib/fontTools/otlLib/builder.py | 23 ++++--- 4 files changed, 156 insertions(+), 105 deletions(-) diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 53b100ec7..5bf5bbcfa 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -84,7 +84,6 @@ def deviceToString(device): return "" % ", ".join("%d %d" % t for t in device) - fea_keywords = set( [ "anchor", @@ -260,7 +259,7 @@ class GlyphClass(Expression): def add_range(self, start, end, glyphs): """Add a range (e.g. ``A-Z``) to the class. ``start`` and ``end`` - are either :class:`GlyphName` objects or strings representing the + are either :class:`GlyphName` objects or strings representing the start and end glyphs in the class, and ``glyphs`` is the full list of :class:`GlyphName` objects in the range.""" if self.curr < len(self.glyphs): @@ -555,7 +554,7 @@ class MarkClass(object): class MarkClassDefinition(Statement): - """A single ``markClass`` statement. The ``markClass`` should be a + """A single ``markClass`` statement. The ``markClass`` should be a :class:`MarkClass` object, the ``anchor`` an :class:`Anchor` object, and the ``glyphs`` parameter should be a `glyph-containing object`_ . @@ -857,7 +856,7 @@ class IgnorePosStatement(Statement): """An ``ignore pos`` statement, containing `one or more` contexts to ignore. ``chainContexts`` should be a list of ``(prefix, glyphs, suffix)`` tuples, - with each of ``prefix``, ``glyphs`` and ``suffix`` being + with each of ``prefix``, ``glyphs`` and ``suffix`` being `glyph-containing objects`_ .""" def __init__(self, chainContexts, location=None): @@ -1173,7 +1172,7 @@ class MarkLigPosStatement(Statement): # ... add definitions to mark classes... glyph = GlyphName("lam_meem_jeem") - marks = [ + marks = [ [ (Anchor(625,1800), m1) ], # Attachments on 1st component (lam) [ (Anchor(376,-378), m2) ], # Attachments on 2nd component (meem) [ ] # No attachments on the jeem @@ -1910,6 +1909,7 @@ class STATDesignAxisStatement(Statement): axisOrder (int): an int names (list): a list of :class:`STATNameStatement` objects """ + def __init__(self, tag, axisOrder, names, location=None): Statement.__init__(self, location) self.tag = tag @@ -1923,8 +1923,7 @@ class STATDesignAxisStatement(Statement): def asFea(self, indent=""): indent += SHIFT res = f"DesignAxis {self.tag} {self.axisOrder} {{ \n" - res += ("\n" + indent).join([s.asFea(indent=indent) for s in - self.names]) + "\n" + res += ("\n" + indent).join([s.asFea(indent=indent) for s in self.names]) + "\n" res += "};" return res @@ -1935,6 +1934,7 @@ class ElidedFallbackName(Statement): Args: names: a list of :class:`STATNameStatement` objects """ + def __init__(self, names, location=None): Statement.__init__(self, location) self.names = names @@ -1946,8 +1946,7 @@ class ElidedFallbackName(Statement): def asFea(self, indent=""): indent += SHIFT res = "ElidedFallbackName { \n" - res += ("\n" + indent).join([s.asFea(indent=indent) for s in - self.names]) + "\n" + res += ("\n" + indent).join([s.asFea(indent=indent) for s in self.names]) + "\n" res += "};" return res @@ -1958,6 +1957,7 @@ class ElidedFallbackNameID(Statement): Args: value: an int pointing to an existing name table name ID """ + def __init__(self, value, location=None): Statement.__init__(self, location) self.value = value @@ -1978,6 +1978,7 @@ class STATAxisValueStatement(Statement): locations (list): a list of :class:`AxisValueLocationStatement` objects flags (int): an int """ + def __init__(self, names, locations, flags, location=None): Statement.__init__(self, location) self.names = names @@ -2017,6 +2018,7 @@ class AxisValueLocationStatement(Statement): tag (str): a 4 letter axis tag values (list): a list of ints and/or floats """ + def __init__(self, tag, values, location=None): Statement.__init__(self, location) self.tag = tag diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index ff03b79ab..ae81c9ff9 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -551,11 +551,13 @@ class Builder(object): self.stat_["AxisValueRecords"] = [] # Check for duplicate AxisValueRecords for record_ in self.stat_["AxisValueRecords"]: - if ({n.asFea() for n in record_.names} == - {n.asFea() for n in axisValueRecord.names} and - {n.asFea() for n in record_.locations} == - {n.asFea() for n in axisValueRecord.locations} - and record_.flags == axisValueRecord.flags): + if ( + {n.asFea() for n in record_.names} + == {n.asFea() for n in axisValueRecord.names} + and {n.asFea() for n in record_.locations} + == {n.asFea() for n in axisValueRecord.locations} + and record_.flags == axisValueRecord.flags + ): raise FeatureLibError( "An AxisValueRecord with these values is already defined.", location, @@ -568,7 +570,7 @@ class Builder(object): axes = self.stat_.get("DesignAxes") if not axes: - raise FeatureLibError('DesignAxes not defined', None) + raise FeatureLibError("DesignAxes not defined", None) axisValueRecords = self.stat_.get("AxisValueRecords") axisValues = {} format4_locations = [] @@ -578,52 +580,74 @@ class Builder(object): for avr in axisValueRecords: valuesDict = {} if avr.flags > 0: - valuesDict['flags'] = avr.flags + valuesDict["flags"] = avr.flags if len(avr.locations) == 1: location = avr.locations[0] values = location.values - if len(values) == 1: #format1 - valuesDict.update({'value': values[0],'name': avr.names}) - if len(values) == 2: #format3 - valuesDict.update({ 'value': values[0], - 'linkedValue': values[1], - 'name': avr.names}) - if len(values) == 3: #format2 + if len(values) == 1: # format1 + valuesDict.update({"value": values[0], "name": avr.names}) + if len(values) == 2: # format3 + valuesDict.update( + { + "value": values[0], + "linkedValue": values[1], + "name": avr.names, + } + ) + if len(values) == 3: # format2 nominal, minVal, maxVal = values - valuesDict.update({ 'nominalValue': nominal, - 'rangeMinValue': minVal, - 'rangeMaxValue': maxVal, - 'name': avr.names}) + valuesDict.update( + { + "nominalValue": nominal, + "rangeMinValue": minVal, + "rangeMaxValue": maxVal, + "name": avr.names, + } + ) axisValues[location.tag].append(valuesDict) else: - valuesDict.update({"location": {i.tag: i.values[0] - for i in avr.locations}, - "name": avr.names}) + valuesDict.update( + { + "location": {i.tag: i.values[0] for i in avr.locations}, + "name": avr.names, + } + ) format4_locations.append(valuesDict) - designAxes = [{"ordering": a.axisOrder, - "tag": a.tag, - "name": a.names, - 'values': axisValues[a.tag]} for a in axes] - + designAxes = [ + { + "ordering": a.axisOrder, + "tag": a.tag, + "name": a.names, + "values": axisValues[a.tag], + } + for a in axes + ] + nameTable = self.font.get("name") if not nameTable: # this only happens for unit tests nameTable = self.font["name"] = newTable("name") nameTable.names = [] if "ElidedFallbackNameID" in self.stat_: - nameID = self.stat_["ElidedFallbackNameID"] - name = nameTable.getDebugName(nameID) + nameID = self.stat_["ElidedFallbackNameID"] + name = nameTable.getDebugName(nameID) if not name: - raise FeatureLibError(f'ElidedFallbackNameID {nameID} points ' - 'to a nameID that does not exist in the ' - '"name" table', None) + raise FeatureLibError( + f"ElidedFallbackNameID {nameID} points " + "to a nameID that does not exist in the " + '"name" table', + None, + ) elif "ElidedFallbackName" in self.stat_: - nameID = self.stat_["ElidedFallbackName"] - - otl.buildStatTable(self.font, designAxes, locations=format4_locations, - elidedFallbackName=nameID) + nameID = self.stat_["ElidedFallbackName"] + otl.buildStatTable( + self.font, + designAxes, + locations=format4_locations, + elidedFallbackName=nameID, + ) def build_codepages_(self, pages): pages2bits = { @@ -833,8 +857,10 @@ class Builder(object): str(ix) ]._replace(feature=key) except KeyError: - warnings.warn("feaLib.Builder subclass needs upgrading to " - "stash debug information. See fonttools#2065.") + warnings.warn( + "feaLib.Builder subclass needs upgrading to " + "stash debug information. See fonttools#2065." + ) feature_key = (feature_tag, lookup_indices) feature_index = feature_indices.get(feature_key) diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index f638c68c4..c248c3409 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -1322,32 +1322,36 @@ class Parser(object): if self.is_cur_keyword_("name"): platformID, platEncID, langID, string = self.parse_stat_name_() nameRecord = self.ast.STATNameStatement( - "stat", platformID, platEncID, langID, string, - location=self.cur_token_location_ + "stat", + platformID, + platEncID, + langID, + string, + location=self.cur_token_location_, ) names.append(nameRecord) else: if self.cur_token_ != ";": - raise FeatureLibError(f"Unexpected token {self.cur_token_} " - f"in ElidedFallbackName", - self.cur_token_location_) + raise FeatureLibError( + f"Unexpected token {self.cur_token_} " f"in ElidedFallbackName", + self.cur_token_location_, + ) self.expect_symbol_("}") if not names: - raise FeatureLibError('Expected "name"', - self.cur_token_location_) + raise FeatureLibError('Expected "name"', self.cur_token_location_) return names def parse_STAT_design_axis(self): - assert self.is_cur_keyword_('DesignAxis') + assert self.is_cur_keyword_("DesignAxis") names = [] axisTag = self.expect_tag_() - if (axisTag not in ('ital', 'opsz', 'slnt', 'wdth', 'wght') - and not axisTag.isupper()): - log.warning( - f'Unregistered axis tag {axisTag} should be uppercase.' - ) + if ( + axisTag not in ("ital", "opsz", "slnt", "wdth", "wght") + and not axisTag.isupper() + ): + log.warning(f"Unregistered axis tag {axisTag} should be uppercase.") axisOrder = self.expect_number_() - self.expect_symbol_('{') + self.expect_symbol_("{") while self.next_token_ != "}" or self.cur_comments_: self.advance_lexer_() if self.cur_token_type_ is Lexer.COMMENT: @@ -1362,11 +1366,14 @@ class Parser(object): elif self.cur_token_ == ";": continue else: - raise FeatureLibError(f'Expected "name", got {self.cur_token_}', - self.cur_token_location_) + raise FeatureLibError( + f'Expected "name", got {self.cur_token_}', self.cur_token_location_ + ) self.expect_symbol_("}") - return self.ast.STATDesignAxisStatement(axisTag, axisOrder, names, self.cur_token_location_) + return self.ast.STATDesignAxisStatement( + axisTag, axisOrder, names, self.cur_token_location_ + ) def parse_STAT_axis_value_(self): assert self.is_cur_keyword_("AxisValue") @@ -1393,39 +1400,45 @@ class Parser(object): elif self.cur_token_ == ";": continue else: - raise FeatureLibError(f"Unexpected token {self.cur_token_} " - f"in AxisValue", self.cur_token_location_) + raise FeatureLibError( + f"Unexpected token {self.cur_token_} " f"in AxisValue", + self.cur_token_location_, + ) self.expect_symbol_("}") if not names: - raise FeatureLibError('Expected "Axis Name"', - self.cur_token_location_) + raise FeatureLibError('Expected "Axis Name"', self.cur_token_location_) if not locations: - raise FeatureLibError('Expected "Axis location"', - self.cur_token_location_) + raise FeatureLibError('Expected "Axis location"', self.cur_token_location_) if len(locations) > 1: for location in locations: if len(location.values) > 1: - raise FeatureLibError("Only one value is allowed in a " - "Format 4 Axis Value Record, but " - f"{len(location.values)} were found.", - self.cur_token_location_) + raise FeatureLibError( + "Only one value is allowed in a " + "Format 4 Axis Value Record, but " + f"{len(location.values)} were found.", + self.cur_token_location_, + ) format4_tags = [] for location in locations: tag = location.tag if tag in format4_tags: - raise FeatureLibError(f"Axis tag {tag} already " - "defined.", - self.cur_token_location_) + raise FeatureLibError( + f"Axis tag {tag} already " "defined.", self.cur_token_location_ + ) format4_tags.append(tag) - return self.ast.STATAxisValueStatement(names, locations, flags, self.cur_token_location_) + return self.ast.STATAxisValueStatement( + names, locations, flags, self.cur_token_location_ + ) def parse_STAT_location(self): values = [] tag = self.expect_tag_() if len(tag.strip()) != 4: - raise FeatureLibError(f"Axis tag {self.cur_token_} must be 4 " - "characters", self.cur_token_location_) + raise FeatureLibError( + f"Axis tag {self.cur_token_} must be 4 " "characters", + self.cur_token_location_, + ) while self.next_token_ != ";": if self.next_token_type_ is Lexer.FLOAT: @@ -1435,16 +1448,20 @@ class Parser(object): value = self.expect_number_() values.append(value) else: - raise FeatureLibError(f'Unexpected value "{self.next_token_}". ' - 'Expected integer or float.', - self.next_token_location_) + raise FeatureLibError( + f'Unexpected value "{self.next_token_}". ' + "Expected integer or float.", + self.next_token_location_, + ) if len(values) == 3: nominal, min_val, max_val = values if nominal < min_val or nominal > max_val: - raise FeatureLibError(f'Default value {nominal} is outside ' - f'of specified range ' - f'{min_val}-{max_val}.', - self.next_token_location_) + raise FeatureLibError( + f"Default value {nominal} is outside " + f"of specified range " + f"{min_val}-{max_val}.", + self.next_token_location_, + ) return self.ast.AxisValueLocationStatement(tag, values) def parse_table_STAT_(self, table): @@ -1475,14 +1492,16 @@ class Parser(object): if location.tag not in design_axes: # Tag must be defined in a DesignAxis before it # can be referenced - raise FeatureLibError("DesignAxis not defined for " - f"{location.tag}.", - self.cur_token_location_) + raise FeatureLibError( + "DesignAxis not defined for " f"{location.tag}.", + self.cur_token_location_, + ) statements.append(axisValueRecord) self.expect_symbol_(";") else: - raise FeatureLibError(f"Unexpected token {self.cur_token_}", - self.cur_token_location_) + raise FeatureLibError( + f"Unexpected token {self.cur_token_}", self.cur_token_location_ + ) elif self.cur_token_ == ";": continue @@ -2056,8 +2075,7 @@ class Parser(object): return self.expect_number_() / 10 else: raise FeatureLibError( - "Expected an integer or floating-point number", - self.cur_token_location_ + "Expected an integer or floating-point number", self.cur_token_location_ ) def expect_stat_flags(self): @@ -2072,8 +2090,7 @@ class Parser(object): value = value | flags[name] else: raise FeatureLibError( - f"Unexpected STAT flag {self.cur_token_}", - self.cur_token_location_ + f"Unexpected STAT flag {self.cur_token_}", self.cur_token_location_ ) return value @@ -2084,8 +2101,7 @@ class Parser(object): return self.expect_number_() else: raise FeatureLibError( - "Expected an integer or floating-point number", - self.cur_token_location_ + "Expected an integer or floating-point number", self.cur_token_location_ ) def expect_string_(self): diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index ab27463a6..ca9e936d6 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -95,9 +95,10 @@ def buildLookup(subtables, flags=0, markFilterSet=None): subtables = [st for st in subtables if st is not None] if not subtables: return None - assert all(t.LookupType == subtables[0].LookupType for t in subtables), ( - "all subtables must have the same LookupType; got %s" - % repr([t.LookupType for t in subtables]) + assert all( + t.LookupType == subtables[0].LookupType for t in subtables + ), "all subtables must have the same LookupType; got %s" % repr( + [t.LookupType for t in subtables] ) self = ot.Lookup() self.LookupType = subtables[0].LookupType @@ -2576,7 +2577,9 @@ class ClassDefBuilder(object): self.classes_.add(glyphs) for glyph in glyphs: if glyph in self.glyphs_: - raise OpenTypeLibError(f"Glyph {glyph} is already present in class.", None) + raise OpenTypeLibError( + f"Glyph {glyph} is already present in class.", None + ) self.glyphs_[glyph] = glyphs def classes(self): @@ -2688,7 +2691,7 @@ def buildStatTable(ttFont, axes, locations=None, elidedFallbackName=2): ] The optional 'elidedFallbackName' argument can be a name ID (int), - a string, a dictionary containing multilingual names, or a list of + a string, a dictionary containing multilingual names, or a list of STATNameStatements. It translates to the ElidedFallbackNameID field. The 'ttFont' argument must be a TTFont instance that already has a @@ -2802,9 +2805,13 @@ def _addName(nameTable, value, minNameID=0): nameID = nameTable._findUnusedNameID() for nameRecord in value: if isinstance(nameRecord, STATNameStatement): - nameTable.setName(nameRecord.string, - nameID,nameRecord.platformID, - nameRecord.platEncID,nameRecord.langID) + nameTable.setName( + nameRecord.string, + nameID, + nameRecord.platformID, + nameRecord.platEncID, + nameRecord.langID, + ) else: raise TypeError("value must be a list of STATNameStatements") return nameID From 1a3478da0ebc7f11fb86594c04e557ac4ff3c697 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Fri, 26 Feb 2021 10:03:32 +0000 Subject: [PATCH 126/167] Fixes from review --- Lib/fontTools/misc/bezierTools.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 06e00422a..a073b3f6a 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -1066,14 +1066,15 @@ def _curve_curve_intersections_t( ) ) - unique_key = lambda ts: int(ts[0] / precision) + unique_key = lambda ts: (int(ts[0] / precision), int(ts[1] / precision)) seen = set() unique_values = [] for ts in found: - if unique_key(ts) in seen: + key = unique_key(ts) + if key in seen: continue - seen.add(unique_key(ts)) + seen.add(key) unique_values.append(ts) return unique_values From 60c1ee01077ea02e47ab5da1861b6b0eab8f3bc7 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Fri, 26 Feb 2021 10:03:37 +0000 Subject: [PATCH 127/167] Black whole module --- Lib/fontTools/misc/bezierTools.py | 224 ++++++++++++++++++------------ 1 file changed, 132 insertions(+), 92 deletions(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index a073b3f6a..2ddd3816c 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -54,23 +54,31 @@ def calcCubicArcLength(pt1, pt2, pt3, pt4, tolerance=0.005): Returns: Arc length value. """ - return calcCubicArcLengthC(complex(*pt1), complex(*pt2), complex(*pt3), complex(*pt4), tolerance) + return calcCubicArcLengthC( + complex(*pt1), complex(*pt2), complex(*pt3), complex(*pt4), tolerance + ) def _split_cubic_into_two(p0, p1, p2, p3): - mid = (p0 + 3 * (p1 + p2) + p3) * .125 - deriv3 = (p3 + p2 - p1 - p0) * .125 - return ((p0, (p0 + p1) * .5, mid - deriv3, mid), - (mid, mid + deriv3, (p2 + p3) * .5, p3)) + mid = (p0 + 3 * (p1 + p2) + p3) * 0.125 + deriv3 = (p3 + p2 - p1 - p0) * 0.125 + return ( + (p0, (p0 + p1) * 0.5, mid - deriv3, mid), + (mid, mid + deriv3, (p2 + p3) * 0.5, p3), + ) + def _calcCubicArcLengthCRecurse(mult, p0, p1, p2, p3): - arch = abs(p0-p3) - box = abs(p0-p1) + abs(p1-p2) + abs(p2-p3) - if arch * mult >= box: - return (arch + box) * .5 - else: - one,two = _split_cubic_into_two(p0,p1,p2,p3) - return _calcCubicArcLengthCRecurse(mult, *one) + _calcCubicArcLengthCRecurse(mult, *two) + arch = abs(p0 - p3) + box = abs(p0 - p1) + abs(p1 - p2) + abs(p2 - p3) + if arch * mult >= box: + return (arch + box) * 0.5 + else: + one, two = _split_cubic_into_two(p0, p1, p2, p3) + return _calcCubicArcLengthCRecurse(mult, *one) + _calcCubicArcLengthCRecurse( + mult, *two + ) + def calcCubicArcLengthC(pt1, pt2, pt3, pt4, tolerance=0.005): """Calculates the arc length for a cubic Bezier segment. @@ -82,7 +90,7 @@ def calcCubicArcLengthC(pt1, pt2, pt3, pt4, tolerance=0.005): Returns: Arc length value. """ - mult = 1. + 1.5 * tolerance # The 1.5 is a empirical hack; no math + mult = 1.0 + 1.5 * tolerance # The 1.5 is a empirical hack; no math return _calcCubicArcLengthCRecurse(mult, pt1, pt2, pt3, pt4) @@ -97,7 +105,7 @@ def _dot(v1, v2): def _intSecAtan(x): # In : sympy.integrate(sp.sec(sp.atan(x))) # Out: x*sqrt(x**2 + 1)/2 + asinh(x)/2 - return x * math.sqrt(x**2 + 1)/2 + math.asinh(x)/2 + return x * math.sqrt(x ** 2 + 1) / 2 + math.asinh(x) / 2 def calcQuadraticArcLength(pt1, pt2, pt3): @@ -153,16 +161,16 @@ def calcQuadraticArcLengthC(pt1, pt2, pt3): d = d1 - d0 n = d * 1j scale = abs(n) - if scale == 0.: - return abs(pt3-pt1) - origDist = _dot(n,d0) + if scale == 0.0: + return abs(pt3 - pt1) + origDist = _dot(n, d0) if abs(origDist) < epsilon: - if _dot(d0,d1) >= 0: - return abs(pt3-pt1) + if _dot(d0, d1) >= 0: + return abs(pt3 - pt1) a, b = abs(d0), abs(d1) - return (a*a + b*b) / (a+b) - x0 = _dot(d,d0) / origDist - x1 = _dot(d,d1) / origDist + return (a * a + b * b) / (a + b) + x0 = _dot(d, d0) / origDist + x1 = _dot(d, d1) / origDist Len = abs(2 * (_intSecAtan(x1) - _intSecAtan(x0)) * origDist / (scale * (x1 - x0))) return Len @@ -202,13 +210,17 @@ def approximateQuadraticArcLengthC(pt1, pt2, pt3): # to be integrated with the best-matching fifth-degree polynomial # approximation of it. # - #https://en.wikipedia.org/wiki/Gaussian_quadrature#Gauss.E2.80.93Legendre_quadrature + # https://en.wikipedia.org/wiki/Gaussian_quadrature#Gauss.E2.80.93Legendre_quadrature # abs(BezierCurveC[2].diff(t).subs({t:T})) for T in sorted(.5, .5Ā±sqrt(3/5)/2), # weighted 5/18, 8/18, 5/18 respectively. - v0 = abs(-0.492943519233745*pt1 + 0.430331482911935*pt2 + 0.0626120363218102*pt3) - v1 = abs(pt3-pt1)*0.4444444444444444 - v2 = abs(-0.0626120363218102*pt1 - 0.430331482911935*pt2 + 0.492943519233745*pt3) + v0 = abs( + -0.492943519233745 * pt1 + 0.430331482911935 * pt2 + 0.0626120363218102 * pt3 + ) + v1 = abs(pt3 - pt1) * 0.4444444444444444 + v2 = abs( + -0.0626120363218102 * pt1 - 0.430331482911935 * pt2 + 0.492943519233745 * pt3 + ) return v0 + v1 + v2 @@ -232,14 +244,18 @@ def calcQuadraticBounds(pt1, pt2, pt3): (0.0, 0.0, 100, 100) """ (ax, ay), (bx, by), (cx, cy) = calcQuadraticParameters(pt1, pt2, pt3) - ax2 = ax*2.0 - ay2 = ay*2.0 + ax2 = ax * 2.0 + ay2 = ay * 2.0 roots = [] if ax2 != 0: - roots.append(-bx/ax2) + roots.append(-bx / ax2) if ay2 != 0: - roots.append(-by/ay2) - points = [(ax*t*t + bx*t + cx, ay*t*t + by*t + cy) for t in roots if 0 <= t < 1] + [pt1, pt3] + roots.append(-by / ay2) + points = [ + (ax * t * t + bx * t + cx, ay * t * t + by * t + cy) + for t in roots + if 0 <= t < 1 + ] + [pt1, pt3] return calcBounds(points) @@ -268,7 +284,9 @@ def approximateCubicArcLength(pt1, pt2, pt3, pt4): >>> approximateCubicArcLength((0, 0), (50, 0), (100, -50), (-50, 0)) # cusp 154.80848416537057 """ - return approximateCubicArcLengthC(complex(*pt1), complex(*pt2), complex(*pt3), complex(*pt4)) + return approximateCubicArcLengthC( + complex(*pt1), complex(*pt2), complex(*pt3), complex(*pt4) + ) def approximateCubicArcLengthC(pt1, pt2, pt3, pt4): @@ -288,11 +306,21 @@ def approximateCubicArcLengthC(pt1, pt2, pt3, pt4): # abs(BezierCurveC[3].diff(t).subs({t:T})) for T in sorted(0, .5Ā±(3/7)**.5/2, .5, 1), # weighted 1/20, 49/180, 32/90, 49/180, 1/20 respectively. - v0 = abs(pt2-pt1)*.15 - v1 = abs(-0.558983582205757*pt1 + 0.325650248872424*pt2 + 0.208983582205757*pt3 + 0.024349751127576*pt4) - v2 = abs(pt4-pt1+pt3-pt2)*0.26666666666666666 - v3 = abs(-0.024349751127576*pt1 - 0.208983582205757*pt2 - 0.325650248872424*pt3 + 0.558983582205757*pt4) - v4 = abs(pt4-pt3)*.15 + v0 = abs(pt2 - pt1) * 0.15 + v1 = abs( + -0.558983582205757 * pt1 + + 0.325650248872424 * pt2 + + 0.208983582205757 * pt3 + + 0.024349751127576 * pt4 + ) + v2 = abs(pt4 - pt1 + pt3 - pt2) * 0.26666666666666666 + v3 = abs( + -0.024349751127576 * pt1 + - 0.208983582205757 * pt2 + - 0.325650248872424 * pt3 + + 0.558983582205757 * pt4 + ) + v4 = abs(pt4 - pt3) * 0.15 return v0 + v1 + v2 + v3 + v4 @@ -325,7 +353,13 @@ def calcCubicBounds(pt1, pt2, pt3, pt4): yRoots = [t for t in solveQuadratic(ay3, by2, cy) if 0 <= t < 1] roots = xRoots + yRoots - points = [(ax*t*t*t + bx*t*t + cx * t + dx, ay*t*t*t + by*t*t + cy * t + dy) for t in roots] + [pt1, pt4] + points = [ + ( + ax * t * t * t + bx * t * t + cx * t + dx, + ay * t * t * t + by * t * t + cy * t + dy, + ) + for t in roots + ] + [pt1, pt4] return calcBounds(points) @@ -368,8 +402,8 @@ def splitLine(pt1, pt2, where, isHorizontal): pt1x, pt1y = pt1 pt2x, pt2y = pt2 - ax = (pt2x - pt1x) - ay = (pt2y - pt1y) + ax = pt2x - pt1x + ay = pt2y - pt1y bx = pt1x by = pt1y @@ -422,8 +456,9 @@ def splitQuadratic(pt1, pt2, pt3, where, isHorizontal): ((50, 50), (75, 50), (100, 0)) """ a, b, c = calcQuadraticParameters(pt1, pt2, pt3) - solutions = solveQuadratic(a[isHorizontal], b[isHorizontal], - c[isHorizontal] - where) + solutions = solveQuadratic( + a[isHorizontal], b[isHorizontal], c[isHorizontal] - where + ) solutions = sorted([t for t in solutions if 0 <= t < 1]) if not solutions: return [(pt1, pt2, pt3)] @@ -458,8 +493,9 @@ def splitCubic(pt1, pt2, pt3, pt4, where, isHorizontal): ((92.5259, 25), (95.202, 17.5085), (97.7062, 9.17517), (100, 1.77636e-15)) """ a, b, c, d = calcCubicParameters(pt1, pt2, pt3, pt4) - solutions = solveCubic(a[isHorizontal], b[isHorizontal], c[isHorizontal], - d[isHorizontal] - where) + solutions = solveCubic( + a[isHorizontal], b[isHorizontal], c[isHorizontal], d[isHorizontal] - where + ) solutions = sorted([t for t in solutions if 0 <= t < 1]) if not solutions: return [(pt1, pt2, pt3, pt4)] @@ -524,17 +560,17 @@ def _splitQuadraticAtT(a, b, c, *ts): cx, cy = c for i in range(len(ts) - 1): t1 = ts[i] - t2 = ts[i+1] - delta = (t2 - t1) + t2 = ts[i + 1] + delta = t2 - t1 # calc new a, b and c - delta_2 = delta*delta + delta_2 = delta * delta a1x = ax * delta_2 a1y = ay * delta_2 - b1x = (2*ax*t1 + bx) * delta - b1y = (2*ay*t1 + by) * delta - t1_2 = t1*t1 - c1x = ax*t1_2 + bx*t1 + cx - c1y = ay*t1_2 + by*t1 + cy + b1x = (2 * ax * t1 + bx) * delta + b1y = (2 * ay * t1 + by) * delta + t1_2 = t1 * t1 + c1x = ax * t1_2 + bx * t1 + cx + c1y = ay * t1_2 + by * t1 + cy pt1, pt2, pt3 = calcQuadraticPoints((a1x, a1y), (b1x, b1y), (c1x, c1y)) segments.append((pt1, pt2, pt3)) @@ -552,24 +588,26 @@ def _splitCubicAtT(a, b, c, d, *ts): dx, dy = d for i in range(len(ts) - 1): t1 = ts[i] - t2 = ts[i+1] - delta = (t2 - t1) + t2 = ts[i + 1] + delta = t2 - t1 - delta_2 = delta*delta - delta_3 = delta*delta_2 - t1_2 = t1*t1 - t1_3 = t1*t1_2 + delta_2 = delta * delta + delta_3 = delta * delta_2 + t1_2 = t1 * t1 + t1_3 = t1 * t1_2 # calc new a, b, c and d a1x = ax * delta_3 a1y = ay * delta_3 - b1x = (3*ax*t1 + bx) * delta_2 - b1y = (3*ay*t1 + by) * delta_2 - c1x = (2*bx*t1 + cx + 3*ax*t1_2) * delta - c1y = (2*by*t1 + cy + 3*ay*t1_2) * delta - d1x = ax*t1_3 + bx*t1_2 + cx*t1 + dx - d1y = ay*t1_3 + by*t1_2 + cy*t1 + dy - pt1, pt2, pt3, pt4 = calcCubicPoints((a1x, a1y), (b1x, b1y), (c1x, c1y), (d1x, d1y)) + b1x = (3 * ax * t1 + bx) * delta_2 + b1y = (3 * ay * t1 + by) * delta_2 + c1x = (2 * bx * t1 + cx + 3 * ax * t1_2) * delta + c1y = (2 * by * t1 + cy + 3 * ay * t1_2) * delta + d1x = ax * t1_3 + bx * t1_2 + cx * t1 + dx + d1y = ay * t1_3 + by * t1_2 + cy * t1 + dy + pt1, pt2, pt3, pt4 = calcCubicPoints( + (a1x, a1y), (b1x, b1y), (c1x, c1y), (d1x, d1y) + ) segments.append((pt1, pt2, pt3, pt4)) return segments @@ -581,8 +619,7 @@ def _splitCubicAtT(a, b, c, d, *ts): from math import sqrt, acos, cos, pi -def solveQuadratic(a, b, c, - sqrt=sqrt): +def solveQuadratic(a, b, c, sqrt=sqrt): """Solve a quadratic equation. Solves *a*x*x + b*x + c = 0* where a, b and c are real. @@ -602,13 +639,13 @@ def solveQuadratic(a, b, c, roots = [] else: # We have a linear equation with 1 root. - roots = [-c/b] + roots = [-c / b] else: # We have a true quadratic equation. Apply the quadratic formula to find two roots. - DD = b*b - 4.0*a*c + DD = b * b - 4.0 * a * c if DD >= 0.0: rDD = sqrt(DD) - roots = [(-b+rDD)/2.0/a, (-b-rDD)/2.0/a] + roots = [(-b + rDD) / 2.0 / a, (-b - rDD) / 2.0 / a] else: # complex roots, ignore roots = [] @@ -658,52 +695,52 @@ def solveCubic(a, b, c, d): # returns unreliable results, so we fall back to quad. return solveQuadratic(b, c, d) a = float(a) - a1 = b/a - a2 = c/a - a3 = d/a + a1 = b / a + a2 = c / a + a3 = d / a - Q = (a1*a1 - 3.0*a2)/9.0 - R = (2.0*a1*a1*a1 - 9.0*a1*a2 + 27.0*a3)/54.0 + Q = (a1 * a1 - 3.0 * a2) / 9.0 + R = (2.0 * a1 * a1 * a1 - 9.0 * a1 * a2 + 27.0 * a3) / 54.0 - R2 = R*R - Q3 = Q*Q*Q + R2 = R * R + Q3 = Q * Q * Q R2 = 0 if R2 < epsilon else R2 Q3 = 0 if abs(Q3) < epsilon else Q3 R2_Q3 = R2 - Q3 - if R2 == 0. and Q3 == 0.: - x = round(-a1/3.0, epsilonDigits) + if R2 == 0.0 and Q3 == 0.0: + x = round(-a1 / 3.0, epsilonDigits) return [x, x, x] - elif R2_Q3 <= epsilon * .5: + elif R2_Q3 <= epsilon * 0.5: # The epsilon * .5 above ensures that Q3 is not zero. - theta = acos(max(min(R/sqrt(Q3), 1.0), -1.0)) - rQ2 = -2.0*sqrt(Q) - a1_3 = a1/3.0 - x0 = rQ2*cos(theta/3.0) - a1_3 - x1 = rQ2*cos((theta+2.0*pi)/3.0) - a1_3 - x2 = rQ2*cos((theta+4.0*pi)/3.0) - a1_3 + theta = acos(max(min(R / sqrt(Q3), 1.0), -1.0)) + rQ2 = -2.0 * sqrt(Q) + a1_3 = a1 / 3.0 + x0 = rQ2 * cos(theta / 3.0) - a1_3 + x1 = rQ2 * cos((theta + 2.0 * pi) / 3.0) - a1_3 + x2 = rQ2 * cos((theta + 4.0 * pi) / 3.0) - a1_3 x0, x1, x2 = sorted([x0, x1, x2]) # Merge roots that are close-enough if x1 - x0 < epsilon and x2 - x1 < epsilon: - x0 = x1 = x2 = round((x0 + x1 + x2) / 3., epsilonDigits) + x0 = x1 = x2 = round((x0 + x1 + x2) / 3.0, epsilonDigits) elif x1 - x0 < epsilon: - x0 = x1 = round((x0 + x1) / 2., epsilonDigits) + x0 = x1 = round((x0 + x1) / 2.0, epsilonDigits) x2 = round(x2, epsilonDigits) elif x2 - x1 < epsilon: x0 = round(x0, epsilonDigits) - x1 = x2 = round((x1 + x2) / 2., epsilonDigits) + x1 = x2 = round((x1 + x2) / 2.0, epsilonDigits) else: x0 = round(x0, epsilonDigits) x1 = round(x1, epsilonDigits) x2 = round(x2, epsilonDigits) return [x0, x1, x2] else: - x = pow(sqrt(R2_Q3)+abs(R), 1/3.0) - x = x + Q/x + x = pow(sqrt(R2_Q3) + abs(R), 1 / 3.0) + x = x + Q / x if R >= 0.0: x = -x - x = round(x - a1/3.0, epsilonDigits) + x = round(x - a1 / 3.0, epsilonDigits) return [x] @@ -711,6 +748,7 @@ def solveCubic(a, b, c, d): # Conversion routines for points to parameters and vice versa # + def calcQuadraticParameters(pt1, pt2, pt3): x2, y2 = pt2 x3, y3 = pt3 @@ -1176,7 +1214,9 @@ def printSegments(segments): for segment in segments: print(_segmentrepr(segment)) + if __name__ == "__main__": import sys import doctest + sys.exit(doctest.testmod().failed) From b95607513c2afb2d817559c5036270f956ee386d Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Fri, 2 Oct 2020 13:05:08 +0100 Subject: [PATCH 128/167] WIP instancer: update static font nametable --- Lib/fontTools/varLib/instancer.py | 146 ++++++++++++++++++++++++++++++ Tests/varLib/instancer_test.py | 37 ++++++++ 2 files changed, 183 insertions(+) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index fba178429..6ed8c366f 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -127,6 +127,15 @@ class OverlapMode(IntEnum): KEEP_AND_SET_FLAGS = 1 REMOVE = 2 +class NameID(IntEnum): + FAMILY_NAME = 1 + SUBFAMILY_NAME = 2 + UNIQUE_FONT_IDENTIFIER = 3 + FULL_FONT_NAME = 4 + POSTSCRIPT_NAME = 6 + TYPOGRAPHIC_FAMILY_NAME = 16 + TYPOGRAPHIC_SUBFAMILY_NAME = 17 + def instantiateTupleVariationStore( variations, axisLimits, origCoords=None, endPts=None @@ -1187,6 +1196,7 @@ def instantiateVariableFont( inplace=False, optimize=True, overlap=OverlapMode.KEEP_AND_SET_FLAGS, + update_nametable=False ): """Instantiate variable font, either fully or partially. @@ -1272,6 +1282,10 @@ def instantiateVariableFont( log.info("Removing overlaps from glyf table") removeOverlaps(varfont) + if update_nametable: + log.info("Updating nametable") + updateNameTable(varfont, axisLimits) + varLib.set_default_weight_width_slant( varfont, location={ @@ -1284,6 +1298,131 @@ def instantiateVariableFont( return varfont +def updateNameTable(varfont, axisLimits): + nametable = varfont["name"] + if "STAT" not in varfont: + raise ValueError("Cannot update name table since there is no STAT table.") + stat = varfont['STAT'] + + axisRecords = stat.table.DesignAxisRecord.Axis + axisValues = stat.table.AxisValueArray.AxisValue + + axisOrder = {a.AxisOrdering: a.AxisTag for a in axisRecords} + keptAxisValues = [] + for axisValue in axisValues: + # TODO Format 4 + if axisValue.Format == 4: + continue + + axisTag = axisOrder[axisValue.AxisIndex] + if axisTag in axisLimits: + pinnedAxis = isinstance(axisLimits[axisTag], (float, int)) + else: + pinnedAxis = False + + # Ignore axisValue if it has ELIDABLE_AXIS_VALUE_NAME flag enabled. + # Enabling this flag will hide the axisValue in application font menus. + if axisValue.Flags == 2: + continue + + if axisValue.Format in (1, 3): + # Add axisValue if it's used to link to another variable font + if axisTag not in axisLimits and axisValue.Value == 1.0: + keptAxisValues.append(axisValue) + + # Add axisValue if its value is in the axisLimits and the user has + # pinned the axis + elif pinnedAxis and axisValue.Value == axisLimits[axisTag]: + keptAxisValues.append(axisValue) + + if axisValue.Format == 2: + if pinnedAxis and axisLimits[axisTag] >= axisValue.RangeMinValue \ + and axisLimits[axisTag] <= axisValue.RangeMaxValue: + keptAxisValues.append(axisValue) + + _updateNameRecords(varfont, nametable, keptAxisValues) + + +def _updateNameRecords(varfont, nametable, axisValues): + # Update nametable based on the axisValues + # using the R/I/B/BI and WWS models. + engNameRecords = any([r for r in nametable.names if r.langID == 0x409]) + if not engNameRecords: + # TODO (Marc F) improve error msg + raise ValueError("No English namerecords") + + ribbiAxisValues = _ribbiAxisValues(nametable, axisValues) + nonRibbiAxisValues = [av for av in axisValues if av not in ribbiAxisValues] + + nametblLangs = set((r.platformID, r.platEncID, r.langID) for r in nametable.names) + for lang in nametblLangs: + _updateStyleRecords( + nametable, + ribbiAxisValues, + nonRibbiAxisValues, + lang, + ) + + +def _ribbiAxisValues(nametable, axisValues): + ribbiStyles = frozenset(["Regular", "Italic", "Bold", "Bold Italic"]) + res = [] + for axisValue in axisValues: + name = nametable.getName(axisValue.ValueNameID, 3, 1, 0x409).toUnicode() + if name in ribbiStyles: + res.append(axisValue) + return res + + +def _updateStyleRecords( + nametable, + ribbiAxisValues, + nonRibbiAxisValues, + lang=(3, 1, 0x409) +): +# wwsAxes = frozenset(["wght", "wdth", "ital"]) + currentFamilyName = nametable.getName(NameID.TYPOGRAPHIC_FAMILY_NAME, *lang) or \ + nametable.getName(NameID.FAMILY_NAME, *lang) + if not currentFamilyName: + return + currentFamilyName = currentFamilyName.toUnicode() + + currentStyleName = nametable.getName(NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *lang) or \ + nametable.getName(NameID.SUBFAMILY_NAME, *lang) + currentStyleName = currentStyleName.toUnicode() + + ribbiName = " ".join([nametable.getName(a.ValueNameID, *lang).toUnicode() for a in ribbiAxisValues]) + nonRibbiName = " ".join([nametable.getName(a.ValueNameID, *lang).toUnicode() for a in nonRibbiAxisValues]) + + nameIDs = { + NameID.FAMILY_NAME: currentFamilyName, + NameID.SUBFAMILY_NAME: ribbiName or "Regular" + } + if nonRibbiAxisValues: + nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {nonRibbiName}" + nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName + nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = f"{nonRibbiName} {ribbiName}".strip() +# # Include WWS name records if there are nonWwsParticles +# if nonWwsParticles: +# nameIDs[21] = f"{currentFamilyName} {' '.join(nonWwsParticles)}" +# nameIDs[22] = " ".join(wwsParticles) +# # Enable fsSelection bit 8 (WWS) +# varfont['OS/2'].fsSelection |= (1 << 8) +# + newFamilyName = nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or \ + nameIDs.get(NameID.FAMILY_NAME) + newStyleName = nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or \ + nameIDs.get(NameID.SUBFAMILY_NAME) + + nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}" + nameIDs[NameID.POSTSCRIPT_NAME] = f"{newFamilyName.replace(' ', '')}-{newStyleName.replace(' ', '')}" + # Update uniqueID + # TODO + # versionRecord = nametable.getName(5, 3, 1, 0x409) + for nameID, string in nameIDs.items(): + nametable.setName(string, nameID, *lang) + + def splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange): location, axisRanges = {}, {} for axisTag, value in axisLimits.items(): @@ -1377,6 +1516,12 @@ def parseArgs(args): help="Merge overlapping contours and components (only applicable " "when generating a full instance). Requires skia-pathops", ) + parser.add_argument( + "--update-nametable", + action="store_true", + help="Update the instantiated font's nametable using the STAT " + "table Axis Values" + ) loggingGroup = parser.add_mutually_exclusive_group(required=False) loggingGroup.add_argument( "-v", "--verbose", action="store_true", help="Run more verbosely." @@ -1428,6 +1573,7 @@ def main(args=None): inplace=True, optimize=options.optimize, overlap=options.overlap, + update_nametable=options.update_nametable, ) outfile = ( diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 5e999dc83..75156a497 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1916,6 +1916,43 @@ def test_normalizeAxisLimits_missing_from_fvar(varfont): instancer.normalizeAxisLimits(varfont, {"ZZZZ": 1000}) +def _get_name_records(varfont): + nametable = varfont["name"] + return { + (r.nameID, r.platformID, r.platEncID, r.langID): r.toUnicode() + for r in nametable.names + } + + +def test_updateNameTable(varfont): + instancer.updateNameTable(varfont, {"wght": 400, "wdth": 100}) + names = _get_name_records(varfont) + assert names[(1, 3, 1, 0x409)] == "Test Variable Font" + assert names[(2, 3, 1, 0x0409)] == "Regular" + assert names[(6, 3, 1, 0x409)] == "TestVariableFont-Regular" + assert (16, 3, 1, 0x409) not in names + assert (17, 3, 1, 0x409) not in names + + instancer.updateNameTable(varfont, {"wght": 900, "wdth": 100}) + names = _get_name_records(varfont) + assert names[(1, 3, 1, 0x409)] == "Test Variable Font Black" + assert names[(2, 3, 1, 0x409)] == "Regular" + assert names[(6, 3, 1, 0x409)] == "TestVariableFont-Black" + assert names[(16, 3, 1, 0x409)] == "Test Variable Font" + assert names[(17, 3, 1, 0x409)] == "Black" + + instancer.updateNameTable(varfont, {"wght": 100, "wdth": 100}) + names = _get_name_records(varfont) + assert names[(1, 3, 1, 0x409)] == "Test Variable Font Thin" + assert names[(2, 3, 1, 0x409)] == "Regular" + assert names[(6, 3, 1, 0x409)] == "TestVariableFont-Thin" + assert names[(16, 3, 1, 0x409)] == "Test Variable Font" + assert names[(17, 3, 1, 0x409)] == "Thin" + + # TODO (Marc F) this doesn't work because our test font is using Format 4 for wdth axis + instancer.updateNameTable(varfont, {"wdth": 79, "wdth": 400}) + + def test_sanityCheckVariableTables(varfont): font = ttLib.TTFont() with pytest.raises(ValueError, match="Missing required table fvar"): From b502471a08395d567c1f1fd22ff10c160ac1a93c Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Wed, 7 Oct 2020 22:59:20 +0100 Subject: [PATCH 129/167] wip instancer: support format 4 axisvalues --- Lib/fontTools/varLib/instancer.py | 83 ++++++++++++++++++++----------- Tests/varLib/instancer_test.py | 48 ++++++++++++++++-- 2 files changed, 100 insertions(+), 31 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 6ed8c366f..004dad5e1 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1298,49 +1298,74 @@ def instantiateVariableFont( return varfont -def updateNameTable(varfont, axisLimits): - nametable = varfont["name"] - if "STAT" not in varfont: - raise ValueError("Cannot update name table since there is no STAT table.") - stat = varfont['STAT'] +def axisValuesFromAxisLimits(stat, axisLimits): axisRecords = stat.table.DesignAxisRecord.Axis axisValues = stat.table.AxisValueArray.AxisValue - axisOrder = {a.AxisOrdering: a.AxisTag for a in axisRecords} - keptAxisValues = [] - for axisValue in axisValues: - # TODO Format 4 - if axisValue.Format == 4: - continue + format4 = [a for a in axisValues if a.Format == 4] + nonformat4 = [a for a in axisValues if a not in format4] + axisValues = format4 + nonformat4 - axisTag = axisOrder[axisValue.AxisIndex] - if axisTag in axisLimits: - pinnedAxis = isinstance(axisLimits[axisTag], (float, int)) - else: - pinnedAxis = False + axisOrder = {a.AxisOrdering: a.AxisTag for a in axisRecords} + pinnedAxes = set(k for k, v in axisLimits.items() if isinstance(v, (float, int))) + + results, seen_axes = [], set() + for axisValue in axisValues: # Ignore axisValue if it has ELIDABLE_AXIS_VALUE_NAME flag enabled. # Enabling this flag will hide the axisValue in application font menus. + # TODO this is too greedy! we need to retain wght axisValues if axisValue.Flags == 2: continue - if axisValue.Format in (1, 3): + if axisValue.Format == 4: + axisIndexes = set(r.AxisIndex for r in axisValue.AxisValueRecord) + if seen_axes - axisIndexes != seen_axes: + continue + # TODO fix dup appends + for rec in axisValue.AxisValueRecord: + axisTag = axisOrder[rec.AxisIndex] + if axisTag not in pinnedAxes: + continue + if rec.Value == axisLimits[axisTag]: + seen_axes.add(rec.AxisIndex) + results.append((rec.AxisIndex, axisValue)) + + elif axisValue.Format in (1, 3): + axisTag = axisOrder[axisValue.AxisIndex] # Add axisValue if it's used to link to another variable font if axisTag not in axisLimits and axisValue.Value == 1.0: - keptAxisValues.append(axisValue) + seen_axes.add(rec.AxisIndex) + results.append((axisValue.AxisIndex, axisValue)) + if axisTag not in pinnedAxes: + continue # Add axisValue if its value is in the axisLimits and the user has # pinned the axis - elif pinnedAxis and axisValue.Value == axisLimits[axisTag]: - keptAxisValues.append(axisValue) + elif axisValue.Value == axisLimits[axisTag]: + seen_axes.add(rec.AxisIndex) + results.append((axisValue.AxisIndex,axisValue)) - if axisValue.Format == 2: - if pinnedAxis and axisLimits[axisTag] >= axisValue.RangeMinValue \ + elif axisValue.Format == 2: + axisTag = axisOrder[axisValue.AxisIndex] + if axisTag not in pinnedAxes: + continue + if axisLimits[axisTag] >= axisValue.RangeMinValue \ and axisLimits[axisTag] <= axisValue.RangeMaxValue: - keptAxisValues.append(axisValue) + seen_axes.add(axisValue.AxisIndex) + results.append((axisValue.AxisIndex, axisValue)) + return [v for k, v in sorted(results)] - _updateNameRecords(varfont, nametable, keptAxisValues) + +def updateNameTable(varfont, axisLimits): + if "STAT" not in varfont: + raise ValueError("Cannot update name table since there is no STAT table.") + stat = varfont['STAT'] + nametable = varfont["name"] + + selectedAxisValues = axisValuesFromAxisLimits(stat, axisLimits) + _updateNameRecords(varfont, nametable, selectedAxisValues) def _updateNameRecords(varfont, nametable, axisValues): @@ -1383,12 +1408,14 @@ def _updateStyleRecords( # wwsAxes = frozenset(["wght", "wdth", "ital"]) currentFamilyName = nametable.getName(NameID.TYPOGRAPHIC_FAMILY_NAME, *lang) or \ nametable.getName(NameID.FAMILY_NAME, *lang) - if not currentFamilyName: - return - currentFamilyName = currentFamilyName.toUnicode() currentStyleName = nametable.getName(NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *lang) or \ nametable.getName(NameID.SUBFAMILY_NAME, *lang) + # TODO cleanup + if not currentFamilyName or not currentStyleName: + print(f"Cannot update {lang} since it's missing a familyName nameID 1 or subFamilyName nameID 2 entry") + return + currentFamilyName = currentFamilyName.toUnicode() currentStyleName = currentStyleName.toUnicode() ribbiName = " ".join([nametable.getName(a.ValueNameID, *lang).toUnicode() for a in ribbiAxisValues]) @@ -1396,7 +1423,7 @@ def _updateStyleRecords( nameIDs = { NameID.FAMILY_NAME: currentFamilyName, - NameID.SUBFAMILY_NAME: ribbiName or "Regular" + NameID.SUBFAMILY_NAME: ribbiName or nametable.getName(NameID.SUBFAMILY_NAME, *lang).toUnicode() } if nonRibbiAxisValues: nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {nonRibbiName}" diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 75156a497..c6ae14666 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1924,7 +1924,8 @@ def _get_name_records(varfont): } -def test_updateNameTable(varfont): +def test_updateNameTable_with_registered_axes(varfont): + # Regular instancer.updateNameTable(varfont, {"wght": 400, "wdth": 100}) names = _get_name_records(varfont) assert names[(1, 3, 1, 0x409)] == "Test Variable Font" @@ -1933,6 +1934,7 @@ def test_updateNameTable(varfont): assert (16, 3, 1, 0x409) not in names assert (17, 3, 1, 0x409) not in names + # Black instancer.updateNameTable(varfont, {"wght": 900, "wdth": 100}) names = _get_name_records(varfont) assert names[(1, 3, 1, 0x409)] == "Test Variable Font Black" @@ -1941,6 +1943,7 @@ def test_updateNameTable(varfont): assert names[(16, 3, 1, 0x409)] == "Test Variable Font" assert names[(17, 3, 1, 0x409)] == "Black" + # Thin instancer.updateNameTable(varfont, {"wght": 100, "wdth": 100}) names = _get_name_records(varfont) assert names[(1, 3, 1, 0x409)] == "Test Variable Font Thin" @@ -1949,8 +1952,47 @@ def test_updateNameTable(varfont): assert names[(16, 3, 1, 0x409)] == "Test Variable Font" assert names[(17, 3, 1, 0x409)] == "Thin" - # TODO (Marc F) this doesn't work because our test font is using Format 4 for wdth axis - instancer.updateNameTable(varfont, {"wdth": 79, "wdth": 400}) + # Thin Condensed + instancer.updateNameTable(varfont, {"wdth": 79, "wght": 100}) + names = _get_name_records(varfont) + assert names[(1, 3, 1, 0x409)] == "Test Variable Font Thin Condensed" + assert names[(2, 3, 1, 0x409)] == "Regular" + assert names[(6, 3, 1, 0x409)] == "TestVariableFont-ThinCondensed" + assert names[(16, 3, 1, 0x409)] == "Test Variable Font" + assert names[(17, 3, 1, 0x409)] == "Thin Condensed" + + +def test_updateNameTable_with_multilingual_names(varfont): + name = varfont["name"] + name.setName("Test Variable Font", 1, 3, 1, 0x405) + name.setName("Normal", 2, 3, 1, 0x405) + name.setName("Normal", 261, 3, 1, 0x405) # nameID 261=Regular STAT entry + name.setName("Negreta",266, 3, 1, 0x405) # nameID 266=Black STAT entry + name.setName("ZhuÅ”těnĆ©", 279, 3, 1, 0x405) # nameID 279=Condensed STAT entry + + # Regular | Normal + instancer.updateNameTable(varfont, {"wdth": 100, "wght": 400}) + names = _get_name_records(varfont) + assert names[(1, 3, 1, 0x405)] == "Test Variable Font" + assert names[(2, 3, 1, 0x405)] == "Normal" + assert (16, 3, 1, 0x405) not in names + assert (17, 3, 1, 0x405) not in names + + # Black | Negreta + instancer.updateNameTable(varfont, {"wdth": 100, "wght": 900}) + names = _get_name_records(varfont) + assert names[(1, 3, 1, 0x405)] == "Test Variable Font Negreta" + assert names[(2, 3, 1, 0x405)] == "Normal" + assert names[(16, 3, 1, 0x405)] == "Test Variable Font" + assert names[(17, 3, 1, 0x405)] == "Negreta" + + # Black Condensed | Negreta ZhuÅ”těnĆ© + instancer.updateNameTable(varfont, {"wdth": 79, "wght": 900}) + names = _get_name_records(varfont) + assert names[(1, 3, 1, 0x405)] == "Test Variable Font Negreta ZhuÅ”těnĆ©" + assert names[(2, 3, 1, 0x405)] == "Normal" + assert names[(16, 3, 1, 0x405)] == "Test Variable Font" + assert names[(17, 3, 1, 0x405)] == "Negreta ZhuÅ”těnĆ©" def test_sanityCheckVariableTables(varfont): From 4cd0fb80f6a67cddd4b7250006c67a8edf573aae Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Thu, 8 Oct 2020 17:41:12 +0100 Subject: [PATCH 130/167] Fix typos --- Lib/fontTools/varLib/instancer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 004dad5e1..31ecc3d71 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1336,7 +1336,7 @@ def axisValuesFromAxisLimits(stat, axisLimits): axisTag = axisOrder[axisValue.AxisIndex] # Add axisValue if it's used to link to another variable font if axisTag not in axisLimits and axisValue.Value == 1.0: - seen_axes.add(rec.AxisIndex) + seen_axes.add(axisValue.AxisIndex) results.append((axisValue.AxisIndex, axisValue)) if axisTag not in pinnedAxes: @@ -1344,7 +1344,7 @@ def axisValuesFromAxisLimits(stat, axisLimits): # Add axisValue if its value is in the axisLimits and the user has # pinned the axis elif axisValue.Value == axisLimits[axisTag]: - seen_axes.add(rec.AxisIndex) + seen_axes.add(axisValue.AxisIndex) results.append((axisValue.AxisIndex,axisValue)) elif axisValue.Format == 2: @@ -1426,7 +1426,7 @@ def _updateStyleRecords( NameID.SUBFAMILY_NAME: ribbiName or nametable.getName(NameID.SUBFAMILY_NAME, *lang).toUnicode() } if nonRibbiAxisValues: - nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {nonRibbiName}" + nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {nonRibbiName}".strip() nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = f"{nonRibbiName} {ribbiName}".strip() # # Include WWS name records if there are nonWwsParticles From 78f6c2ae7538bbe18764aa1660083ba91f6eb8b2 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Fri, 9 Oct 2020 11:05:32 +0100 Subject: [PATCH 131/167] instancer: add test for partial instance name --- Tests/varLib/instancer_test.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index c6ae14666..d0aa44f3f 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1995,6 +1995,15 @@ def test_updateNameTable_with_multilingual_names(varfont): assert names[(17, 3, 1, 0x405)] == "Negreta ZhuÅ”těnĆ©" +def test_updateNametable_partial(varfont): + instancer.updateNameTable(varfont, {"wdth": 79, "wght": (400, 900)}) + names = _get_name_records(varfont) + assert names[(1, 3, 1, 0x409)] == "Test Variable Font Condensed" + assert names[(2, 3, 1, 0x409)] == "Regular" + assert names[(16, 3, 1, 0x409)] == "Test Variable Font" + assert names[(17, 3, 1, 0x409)] == "Condensed" #? maybe Condensed Regular? + + def test_sanityCheckVariableTables(varfont): font = ttLib.TTFont() with pytest.raises(ValueError, match="Missing required table fvar"): From 2fd934051bda1005be306dad9861f1654b148185 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Fri, 9 Oct 2020 13:00:23 +0100 Subject: [PATCH 132/167] Refactor axisValuesFromAxisLimits --- Lib/fontTools/varLib/instancer.py | 105 ++++++++++++++---------------- 1 file changed, 50 insertions(+), 55 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 31ecc3d71..d8c400d76 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1298,64 +1298,59 @@ def instantiateVariableFont( return varfont +def axisValueIsSelected(axisValue, seeker): + if axisValue.Format == 4: + res = [] + for rec in axisValue.AxisValueRecord: + axisIndex = rec.AxisIndex + if axisIndex not in seeker: + return False + if rec.Value == seeker[axisIndex]: + res.append(True) + else: + res.append(False) + return True if all(res) else False + + axisIndex = axisValue.AxisIndex + if axisIndex not in seeker: + return False + + if axisValue.Format in (1, 3): + # Add axisValue if it's used to link to another variable font + if axisIndex not in seeker and axisValue.Value == 1.0: + return True + + elif axisValue.Value == seeker[axisIndex]: + return True + + if axisValue.Format == 2: + return True if all([ + seeker[axisIndex] >= axisValue.RangeMinValue, + seeker[axisIndex] <= axisValue.RangeMaxValue + ]) else False + return False + + def axisValuesFromAxisLimits(stat, axisLimits): - - axisRecords = stat.table.DesignAxisRecord.Axis axisValues = stat.table.AxisValueArray.AxisValue + axisRecords = stat.table.DesignAxisRecord.Axis + axisOrder = {a.AxisTag: a.AxisOrdering for a in axisRecords} + # Only check pinnedAxes for matching AxisValues + AxisValuesToFind = { + axisOrder[k]: v for k, v in axisLimits.items() \ + if isinstance(v, (float, int)) + } - format4 = [a for a in axisValues if a.Format == 4] - nonformat4 = [a for a in axisValues if a not in format4] - axisValues = format4 + nonformat4 - - axisOrder = {a.AxisOrdering: a.AxisTag for a in axisRecords} - pinnedAxes = set(k for k, v in axisLimits.items() if isinstance(v, (float, int))) - - results, seen_axes = [], set() - for axisValue in axisValues: - - # Ignore axisValue if it has ELIDABLE_AXIS_VALUE_NAME flag enabled. - # Enabling this flag will hide the axisValue in application font menus. - # TODO this is too greedy! we need to retain wght axisValues - if axisValue.Flags == 2: - continue - - if axisValue.Format == 4: - axisIndexes = set(r.AxisIndex for r in axisValue.AxisValueRecord) - if seen_axes - axisIndexes != seen_axes: - continue - # TODO fix dup appends - for rec in axisValue.AxisValueRecord: - axisTag = axisOrder[rec.AxisIndex] - if axisTag not in pinnedAxes: - continue - if rec.Value == axisLimits[axisTag]: - seen_axes.add(rec.AxisIndex) - results.append((rec.AxisIndex, axisValue)) - - elif axisValue.Format in (1, 3): - axisTag = axisOrder[axisValue.AxisIndex] - # Add axisValue if it's used to link to another variable font - if axisTag not in axisLimits and axisValue.Value == 1.0: - seen_axes.add(axisValue.AxisIndex) - results.append((axisValue.AxisIndex, axisValue)) - - if axisTag not in pinnedAxes: - continue - # Add axisValue if its value is in the axisLimits and the user has - # pinned the axis - elif axisValue.Value == axisLimits[axisTag]: - seen_axes.add(axisValue.AxisIndex) - results.append((axisValue.AxisIndex,axisValue)) - - elif axisValue.Format == 2: - axisTag = axisOrder[axisValue.AxisIndex] - if axisTag not in pinnedAxes: - continue - if axisLimits[axisTag] >= axisValue.RangeMinValue \ - and axisLimits[axisTag] <= axisValue.RangeMaxValue: - seen_axes.add(axisValue.AxisIndex) - results.append((axisValue.AxisIndex, axisValue)) - return [v for k, v in sorted(results)] + axisValues = [a for a in axisValues if axisValueIsSelected(a, AxisValuesToFind)] + axisValuesMissing = set(AxisValuesToFind) - set(a.AxisIndex for a in axisValues) + if axisValuesMissing: + # TODO better error msg + missing = [i for i in axisValuesMissing] + raise ValueError(f"Cannot find AxisValues for {missing}") + # filter out Elidable axisValues + axisValues = [a for a in axisValues if a.Flags != 2] + # TODO sort and remove duplicates so format 4 axisValues are dominant + return axisValues def updateNameTable(varfont, axisLimits): From f89c01d2d7ed2b9063b57e1c832622689af9f1f8 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Fri, 9 Oct 2020 14:14:55 +0100 Subject: [PATCH 133/167] instancer: only updateNames if axisValue with specified coord exists --- Lib/fontTools/varLib/instancer.py | 26 ++++++++++++++++++++------ Tests/varLib/instancer_test.py | 15 ++++++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index d8c400d76..efe30127c 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1331,25 +1331,39 @@ def axisValueIsSelected(axisValue, seeker): return False +def axisValueIndexes(axisValue): + if axisValue.Format == 4: + return [r.AxisIndex for r in axisValue.AxisValueRecord] + return [axisValue.AxisIndex] + + +def axisValuesIndexes(axisValues): + res = [] + for axisValue in axisValues: + res += axisValueIndexes(axisValue) + return res + + def axisValuesFromAxisLimits(stat, axisLimits): axisValues = stat.table.AxisValueArray.AxisValue axisRecords = stat.table.DesignAxisRecord.Axis axisOrder = {a.AxisTag: a.AxisOrdering for a in axisRecords} + axisTag = {a.AxisOrdering: a.AxisTag for a in axisRecords} # Only check pinnedAxes for matching AxisValues - AxisValuesToFind = { + axisValuesToFind = { axisOrder[k]: v for k, v in axisLimits.items() \ if isinstance(v, (float, int)) } - axisValues = [a for a in axisValues if axisValueIsSelected(a, AxisValuesToFind)] - axisValuesMissing = set(AxisValuesToFind) - set(a.AxisIndex for a in axisValues) + axisValues = [a for a in axisValues if axisValueIsSelected(a, axisValuesToFind)] + axisValuesMissing = set(axisValuesToFind) - set(axisValuesIndexes(axisValues)) if axisValuesMissing: # TODO better error msg - missing = [i for i in axisValuesMissing] - raise ValueError(f"Cannot find AxisValues for {missing}") + missing = [f"{axisTag[i]}={axisValuesToFind[i]}" for i in axisValuesMissing] + raise ValueError(f"Cannot find AxisValue for {', '.join(missing)}") # filter out Elidable axisValues axisValues = [a for a in axisValues if a.Flags != 2] - # TODO sort and remove duplicates so format 4 axisValues are dominant + # TODO sort and remove duplicates so format 4 axisValues are dominant return axisValues diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index d0aa44f3f..ec3f85cfa 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1926,7 +1926,7 @@ def _get_name_records(varfont): def test_updateNameTable_with_registered_axes(varfont): # Regular - instancer.updateNameTable(varfont, {"wght": 400, "wdth": 100}) + instancer.updateNameTable(varfont, {"wght": 400}) names = _get_name_records(varfont) assert names[(1, 3, 1, 0x409)] == "Test Variable Font" assert names[(2, 3, 1, 0x0409)] == "Regular" @@ -1935,7 +1935,7 @@ def test_updateNameTable_with_registered_axes(varfont): assert (17, 3, 1, 0x409) not in names # Black - instancer.updateNameTable(varfont, {"wght": 900, "wdth": 100}) + instancer.updateNameTable(varfont, {"wght": 900}) names = _get_name_records(varfont) assert names[(1, 3, 1, 0x409)] == "Test Variable Font Black" assert names[(2, 3, 1, 0x409)] == "Regular" @@ -1944,7 +1944,7 @@ def test_updateNameTable_with_registered_axes(varfont): assert names[(17, 3, 1, 0x409)] == "Black" # Thin - instancer.updateNameTable(varfont, {"wght": 100, "wdth": 100}) + instancer.updateNameTable(varfont, {"wght": 100}) names = _get_name_records(varfont) assert names[(1, 3, 1, 0x409)] == "Test Variable Font Thin" assert names[(2, 3, 1, 0x409)] == "Regular" @@ -1971,7 +1971,7 @@ def test_updateNameTable_with_multilingual_names(varfont): name.setName("ZhuÅ”těnĆ©", 279, 3, 1, 0x405) # nameID 279=Condensed STAT entry # Regular | Normal - instancer.updateNameTable(varfont, {"wdth": 100, "wght": 400}) + instancer.updateNameTable(varfont, {"wght": 400}) names = _get_name_records(varfont) assert names[(1, 3, 1, 0x405)] == "Test Variable Font" assert names[(2, 3, 1, 0x405)] == "Normal" @@ -1979,7 +1979,7 @@ def test_updateNameTable_with_multilingual_names(varfont): assert (17, 3, 1, 0x405) not in names # Black | Negreta - instancer.updateNameTable(varfont, {"wdth": 100, "wght": 900}) + instancer.updateNameTable(varfont, {"wght": 900}) names = _get_name_records(varfont) assert names[(1, 3, 1, 0x405)] == "Test Variable Font Negreta" assert names[(2, 3, 1, 0x405)] == "Normal" @@ -2004,6 +2004,11 @@ def test_updateNametable_partial(varfont): assert names[(17, 3, 1, 0x409)] == "Condensed" #? maybe Condensed Regular? +def test_updateNameTable_missing_axisValues(varfont): + with pytest.raises(ValueError, match="Cannot find AxisValue for wght=200"): + instancer.updateNameTable(varfont, {"wght": 200}) + + def test_sanityCheckVariableTables(varfont): font = ttLib.TTFont() with pytest.raises(ValueError, match="Missing required table fvar"): From b4b1ce35794c9e76c6329a8c418f4332009fdee0 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Mon, 12 Oct 2020 10:26:58 +0100 Subject: [PATCH 134/167] instancer: include attribute axisValues --- Lib/fontTools/varLib/instancer.py | 17 +++++++++-------- Tests/varLib/instancer_test.py | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index efe30127c..ec0f57dd3 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1312,22 +1312,24 @@ def axisValueIsSelected(axisValue, seeker): return True if all(res) else False axisIndex = axisValue.AxisIndex - if axisIndex not in seeker: - return False if axisValue.Format in (1, 3): - # Add axisValue if it's used to link to another variable font - if axisIndex not in seeker and axisValue.Value == 1.0: + # Add axisValue if it's an attribute of a font. Font family + if axisIndex not in seeker and axisValue.Value in [0.0, 1.0]: return True - elif axisValue.Value == seeker[axisIndex]: + elif axisIndex in seeker and axisValue.Value == seeker[axisIndex]: return True if axisValue.Format == 2: return True if all([ - seeker[axisIndex] >= axisValue.RangeMinValue, - seeker[axisIndex] <= axisValue.RangeMaxValue + axisIndex in seeker and seeker[axisIndex] >= axisValue.RangeMinValue, + axisIndex in seeker and seeker[axisIndex] <= axisValue.RangeMaxValue ]) else False + + if axisIndex not in seeker: + return False + return False @@ -1358,7 +1360,6 @@ def axisValuesFromAxisLimits(stat, axisLimits): axisValues = [a for a in axisValues if axisValueIsSelected(a, axisValuesToFind)] axisValuesMissing = set(axisValuesToFind) - set(axisValuesIndexes(axisValues)) if axisValuesMissing: - # TODO better error msg missing = [f"{axisTag[i]}={axisValuesToFind[i]}" for i in axisValuesMissing] raise ValueError(f"Cannot find AxisValue for {', '.join(missing)}") # filter out Elidable axisValues diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index ec3f85cfa..f0725e1a1 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -2009,6 +2009,29 @@ def test_updateNameTable_missing_axisValues(varfont): instancer.updateNameTable(varfont, {"wght": 200}) +def test_updateNameTable_vf_with_italic_attribute(varfont): + font_link_axisValue = varfont["STAT"].table.AxisValueArray.AxisValue[4] + font_link_axisValue.Flags = 0 + font_link_axisValue.ValueNameID = 294 # Roman --> Italic + + # Italic + instancer.updateNameTable(varfont, {"wght": 400}) + names = _get_name_records(varfont) + assert names[(1, 3, 1, 0x409)] == "Test Variable Font" + assert names[(2, 3, 1, 0x409)] == "Italic" + assert (16, 3, 1, 0x405) not in names + assert (17, 3, 1, 0x405) not in names + + # Black Condensed Italic + instancer.updateNameTable(varfont, {"wdth": 79, "wght": 900}) + names = _get_name_records(varfont) + assert names[(1, 3, 1, 0x409)] == "Test Variable Font Black Condensed" + assert names[(2, 3, 1, 0x409)] == "Italic" + assert names[(6, 3, 1, 0x409)] == "TestVariableFont-BlackCondensedItalic" + assert names[(16, 3, 1, 0x409)] == "Test Variable Font" + assert names[(17, 3, 1, 0x409)] == "Black Condensed Italic" + + def test_sanityCheckVariableTables(varfont): font = ttLib.TTFont() with pytest.raises(ValueError, match="Missing required table fvar"): From 0b639c2979078aaeb43ebe3cc8a6b1ff77b56018 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Mon, 12 Oct 2020 10:28:48 +0100 Subject: [PATCH 135/167] instancer: use bit mask for axisValue flags --- Lib/fontTools/varLib/instancer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index ec0f57dd3..042309d2a 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -136,6 +136,8 @@ class NameID(IntEnum): TYPOGRAPHIC_FAMILY_NAME = 16 TYPOGRAPHIC_SUBFAMILY_NAME = 17 +ELIDABLE_AXIS_VALUE_NAME = 2 + def instantiateTupleVariationStore( variations, axisLimits, origCoords=None, endPts=None @@ -1363,7 +1365,7 @@ def axisValuesFromAxisLimits(stat, axisLimits): missing = [f"{axisTag[i]}={axisValuesToFind[i]}" for i in axisValuesMissing] raise ValueError(f"Cannot find AxisValue for {', '.join(missing)}") # filter out Elidable axisValues - axisValues = [a for a in axisValues if a.Flags != 2] + axisValues = [a for a in axisValues if a.Flags & ELIDABLE_AXIS_VALUE_NAME != 2] # TODO sort and remove duplicates so format 4 axisValues are dominant return axisValues From 69c86679824cf60bdeccb3bb71b743dc4bb87168 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Mon, 12 Oct 2020 12:37:11 +0100 Subject: [PATCH 136/167] instancer: sort axisValues so format 4 are dominant for constructing names --- Lib/fontTools/varLib/instancer.py | 25 +++++++++++++++++++++++-- Tests/varLib/instancer_test.py | 27 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 042309d2a..d88cb38c9 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1366,8 +1366,29 @@ def axisValuesFromAxisLimits(stat, axisLimits): raise ValueError(f"Cannot find AxisValue for {', '.join(missing)}") # filter out Elidable axisValues axisValues = [a for a in axisValues if a.Flags & ELIDABLE_AXIS_VALUE_NAME != 2] - # TODO sort and remove duplicates so format 4 axisValues are dominant - return axisValues + return sortedAxisValues(axisValues) + + +def sortedAxisValues(axisValues): + # Sort and remove duplicates so format 4 axisValues are dominant + results, seenAxes = [], set() + format4 = sorted( + [a for a in axisValues if a.Format == 4], + key=lambda k: len(k.AxisValueRecord), reverse=True + ) + nonFormat4 = [a for a in axisValues if a not in format4] + + for axisValue in format4: + axes = set([r.AxisIndex for r in axisValue.AxisValueRecord]) + if seenAxes - axes == seenAxes: + seenAxes |= axes + results.append((tuple(axes), axisValue)) + + for axisValue in nonFormat4: + axisIndex = axisValue.AxisIndex + if axisIndex not in seenAxes: + results.append(((axisIndex,), axisValue)) + return [v for k, v in sorted(results)] def updateNameTable(varfont, axisLimits): diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index f0725e1a1..d148a8c45 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -2032,6 +2032,33 @@ def test_updateNameTable_vf_with_italic_attribute(varfont): assert names[(17, 3, 1, 0x409)] == "Black Condensed Italic" +def test_updateNameTable_format4_axisValues(varfont): + # format 4 axisValues should dominate the other axisValues + stat = varfont["STAT"].table + + axisValue = otTables.AxisValue() + axisValue.Format = 4 + axisValue.Flags = 0 + varfont["name"].setName("Dominant Value", 297, 3, 1, 0x409) + axisValue.ValueNameID = 297 + axisValue.AxisValueRecord = [] + for tag, value in (("wght", 900), ("wdth", 79)): + rec = otTables.AxisValueRecord() + rec.AxisIndex = next( + i for i, a in enumerate(stat.DesignAxisRecord.Axis) if a.AxisTag == tag + ) + rec.Value = value + axisValue.AxisValueRecord.append(rec) + stat.AxisValueArray.AxisValue.append(axisValue) + + instancer.updateNameTable(varfont, {"wdth": 79, "wght": 900}) + names = _get_name_records(varfont) + assert names[(1, 3, 1, 0x409)] == "Test Variable Font Dominant Value" + assert names[(2, 3, 1, 0x409)] == "Regular" + assert names[(16, 3, 1, 0x409)] == "Test Variable Font" + assert names[(17, 3, 1, 0x409)] == "Dominant Value" + + def test_sanityCheckVariableTables(varfont): font = ttLib.TTFont() with pytest.raises(ValueError, match="Missing required table fvar"): From ce1d8a9955de307ac03036fe6e2d6222044dddaa Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Mon, 12 Oct 2020 15:54:28 +0100 Subject: [PATCH 137/167] instancer: Add axisDefaults to axisLimits --- Lib/fontTools/varLib/instancer.py | 10 +++++++--- Tests/varLib/data/PartialInstancerTest-VF.ttx | 12 ++++++++++++ Tests/varLib/instancer_test.py | 6 +++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index d88cb38c9..4943a5463 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1329,9 +1329,6 @@ def axisValueIsSelected(axisValue, seeker): axisIndex in seeker and seeker[axisIndex] <= axisValue.RangeMaxValue ]) else False - if axisIndex not in seeker: - return False - return False @@ -1395,8 +1392,15 @@ def updateNameTable(varfont, axisLimits): if "STAT" not in varfont: raise ValueError("Cannot update name table since there is no STAT table.") stat = varfont['STAT'] + fvar = varfont['fvar'] nametable = varfont["name"] + # add default axis values if they are missing from axisLimits + fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} + for k, v in fvarDefaults.items(): + if k not in axisLimits: + axisLimits[k] = v + selectedAxisValues = axisValuesFromAxisLimits(stat, axisLimits) _updateNameRecords(varfont, nametable, selectedAxisValues) diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/data/PartialInstancerTest-VF.ttx index 92540e03e..268b5068b 100644 --- a/Tests/varLib/data/PartialInstancerTest-VF.ttx +++ b/Tests/varLib/data/PartialInstancerTest-VF.ttx @@ -479,6 +479,9 @@ TestVariableFont-XCdBd + + Normal + @@ -764,6 +767,15 @@ + + + + + + + + + diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index d148a8c45..a794f79d2 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1209,8 +1209,8 @@ class InstantiateSTATTest(object): @pytest.mark.parametrize( "location, expected", [ - ({"wght": 400}, ["Regular", "Condensed", "Upright"]), - ({"wdth": 100}, ["Thin", "Regular", "Black", "Upright"]), + ({"wght": 400}, ["Regular", "Condensed", "Upright", "Normal"]), + ({"wdth": 100}, ["Thin", "Regular", "Black", "Upright", "Normal"]), ], ) def test_pin_and_drop_axis(self, varfont, location, expected): @@ -1344,7 +1344,7 @@ class InstantiateSTATTest(object): def test_pruningUnusedNames(varfont): varNameIDs = instancer.getVariationNameIDs(varfont) - assert varNameIDs == set(range(256, 296 + 1)) + assert varNameIDs == set(range(256, 297 + 1)) fvar = varfont["fvar"] stat = varfont["STAT"].table From b3284750720ea6cc1721d01f491d7924fe6fd083 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Mon, 12 Oct 2020 18:14:27 +0100 Subject: [PATCH 138/167] wip instancer: update uniqueID --- Lib/fontTools/varLib/instancer.py | 46 +++++++++++++++++++++++++------ Tests/varLib/instancer_test.py | 8 ++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 4943a5463..e5078fefb 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1369,6 +1369,7 @@ def axisValuesFromAxisLimits(stat, axisLimits): def sortedAxisValues(axisValues): # Sort and remove duplicates so format 4 axisValues are dominant results, seenAxes = [], set() + # ensure format4 axes with the most AxisValueRecords are first format4 = sorted( [a for a in axisValues if a.Format == 4], key=lambda k: len(k.AxisValueRecord), reverse=True @@ -1392,14 +1393,15 @@ def updateNameTable(varfont, axisLimits): if "STAT" not in varfont: raise ValueError("Cannot update name table since there is no STAT table.") stat = varfont['STAT'] - fvar = varfont['fvar'] nametable = varfont["name"] # add default axis values if they are missing from axisLimits - fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} - for k, v in fvarDefaults.items(): - if k not in axisLimits: - axisLimits[k] = v + if 'fvar' in varfont: + fvar = varfont['fvar'] + fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} + for k, v in fvarDefaults.items(): + if k not in axisLimits: + axisLimits[k] = v selectedAxisValues = axisValuesFromAxisLimits(stat, axisLimits) _updateNameRecords(varfont, nametable, selectedAxisValues) @@ -1419,6 +1421,7 @@ def _updateNameRecords(varfont, nametable, axisValues): nametblLangs = set((r.platformID, r.platEncID, r.langID) for r in nametable.names) for lang in nametblLangs: _updateStyleRecords( + varfont, nametable, ribbiAxisValues, nonRibbiAxisValues, @@ -1437,6 +1440,7 @@ def _ribbiAxisValues(nametable, axisValues): def _updateStyleRecords( + varfont, nametable, ribbiAxisValues, nonRibbiAxisValues, @@ -1480,13 +1484,39 @@ def _updateStyleRecords( nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}" nameIDs[NameID.POSTSCRIPT_NAME] = f"{newFamilyName.replace(' ', '')}-{newStyleName.replace(' ', '')}" - # Update uniqueID - # TODO - # versionRecord = nametable.getName(5, 3, 1, 0x409) + nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = _uniqueIdRecord(varfont, lang, nameIDs) + for nameID, string in nameIDs.items(): + if not string: + continue nametable.setName(string, nameID, *lang) +def _uniqueIdRecord(varfont, lang, nameIDs): + name = varfont['name'] + record = name.getName(NameID.UNIQUE_FONT_IDENTIFIER, *lang) + if not record: + return None + + def isSubString(string1, string2): + if string2 in string1: + return True + return False + + # Check if full name and postscript name are a substring + for nameID in (4, 6): + nameRecord = name.getName(nameID, *lang) + if not nameRecord: + continue + if isSubString(record.toUnicode(), nameRecord.toUnicode()): + return record.toUnicode().replace( + nameRecord.toUnicode(), + nameIDs[nameRecord.nameID] + ) + # TODO (M Foley) Construct new uniqueID if full name or postscript names are not subsets + return None + + def splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange): location, axisRanges = {}, {} for axisTag, value in axisLimits.items(): diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index a794f79d2..854a4b4fb 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1930,6 +1930,7 @@ def test_updateNameTable_with_registered_axes(varfont): names = _get_name_records(varfont) assert names[(1, 3, 1, 0x409)] == "Test Variable Font" assert names[(2, 3, 1, 0x0409)] == "Regular" + assert names[(3, 3, 1, 0x0409)] == "2.001;GOOG;TestVariableFont-Regular" assert names[(6, 3, 1, 0x409)] == "TestVariableFont-Regular" assert (16, 3, 1, 0x409) not in names assert (17, 3, 1, 0x409) not in names @@ -1939,6 +1940,7 @@ def test_updateNameTable_with_registered_axes(varfont): names = _get_name_records(varfont) assert names[(1, 3, 1, 0x409)] == "Test Variable Font Black" assert names[(2, 3, 1, 0x409)] == "Regular" + assert names[(3, 3, 1, 0x0409)] == "2.001;GOOG;TestVariableFont-Black" assert names[(6, 3, 1, 0x409)] == "TestVariableFont-Black" assert names[(16, 3, 1, 0x409)] == "Test Variable Font" assert names[(17, 3, 1, 0x409)] == "Black" @@ -1948,6 +1950,7 @@ def test_updateNameTable_with_registered_axes(varfont): names = _get_name_records(varfont) assert names[(1, 3, 1, 0x409)] == "Test Variable Font Thin" assert names[(2, 3, 1, 0x409)] == "Regular" + assert names[(3, 3, 1, 0x0409)] == "2.001;GOOG;TestVariableFont-Thin" assert names[(6, 3, 1, 0x409)] == "TestVariableFont-Thin" assert names[(16, 3, 1, 0x409)] == "Test Variable Font" assert names[(17, 3, 1, 0x409)] == "Thin" @@ -1957,6 +1960,7 @@ def test_updateNameTable_with_registered_axes(varfont): names = _get_name_records(varfont) assert names[(1, 3, 1, 0x409)] == "Test Variable Font Thin Condensed" assert names[(2, 3, 1, 0x409)] == "Regular" + assert names[(3, 3, 1, 0x0409)] == "2.001;GOOG;TestVariableFont-ThinCondensed" assert names[(6, 3, 1, 0x409)] == "TestVariableFont-ThinCondensed" assert names[(16, 3, 1, 0x409)] == "Test Variable Font" assert names[(17, 3, 1, 0x409)] == "Thin Condensed" @@ -1975,6 +1979,7 @@ def test_updateNameTable_with_multilingual_names(varfont): names = _get_name_records(varfont) assert names[(1, 3, 1, 0x405)] == "Test Variable Font" assert names[(2, 3, 1, 0x405)] == "Normal" + assert (3, 3, 1, 0x405) not in names assert (16, 3, 1, 0x405) not in names assert (17, 3, 1, 0x405) not in names @@ -1983,6 +1988,7 @@ def test_updateNameTable_with_multilingual_names(varfont): names = _get_name_records(varfont) assert names[(1, 3, 1, 0x405)] == "Test Variable Font Negreta" assert names[(2, 3, 1, 0x405)] == "Normal" + assert (3, 3, 1, 0x405) not in names assert names[(16, 3, 1, 0x405)] == "Test Variable Font" assert names[(17, 3, 1, 0x405)] == "Negreta" @@ -1991,6 +1997,7 @@ def test_updateNameTable_with_multilingual_names(varfont): names = _get_name_records(varfont) assert names[(1, 3, 1, 0x405)] == "Test Variable Font Negreta ZhuÅ”těnĆ©" assert names[(2, 3, 1, 0x405)] == "Normal" + assert (3, 3, 1, 0x405) not in names assert names[(16, 3, 1, 0x405)] == "Test Variable Font" assert names[(17, 3, 1, 0x405)] == "Negreta ZhuÅ”těnĆ©" @@ -2000,6 +2007,7 @@ def test_updateNametable_partial(varfont): names = _get_name_records(varfont) assert names[(1, 3, 1, 0x409)] == "Test Variable Font Condensed" assert names[(2, 3, 1, 0x409)] == "Regular" + assert (3, 3, 1, 0x405) not in names assert names[(16, 3, 1, 0x409)] == "Test Variable Font" assert names[(17, 3, 1, 0x409)] == "Condensed" #? maybe Condensed Regular? From 9a72311d197a10e38e8ae2001041fae53071b8e6 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Wed, 14 Oct 2020 11:56:18 +0100 Subject: [PATCH 139/167] instancer: refactor updateNameTable --- Lib/fontTools/varLib/instancer.py | 514 +++++++++++++++++------------- Tests/varLib/instancer_test.py | 2 +- 2 files changed, 290 insertions(+), 226 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index e5078fefb..c72ff48f7 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -127,6 +127,7 @@ class OverlapMode(IntEnum): KEEP_AND_SET_FLAGS = 1 REMOVE = 2 + class NameID(IntEnum): FAMILY_NAME = 1 SUBFAMILY_NAME = 2 @@ -136,6 +137,7 @@ class NameID(IntEnum): TYPOGRAPHIC_FAMILY_NAME = 16 TYPOGRAPHIC_SUBFAMILY_NAME = 17 + ELIDABLE_AXIS_VALUE_NAME = 2 @@ -1110,6 +1112,280 @@ def pruningUnusedNames(varfont): del varfont["ltag"] +def updateNameTable(varfont, axisLimits): + """Update an instatiated variable font's name table using the STAT + table's Axis Value Tables. + + To establish which Axis Value Tables are needed, we first remove all + tables whose Value's are not in the axisLimits dictionary. We then + remove all tables which have Flag 2 enabled (ELIDABLE_AXIS_VALUE_NAME). + Finally, we remove duplicates and ensure Format 4 tables preside over + the other formats. + + The updated nametable will conform to the R/I/B/BI naming model. + """ + if "STAT" not in varfont: + raise ValueError("Cannot update name table since there is no STAT table.") + stat = varfont["STAT"] + fvar = varfont["fvar"] + + # The updated name table must reflect the 'zero origin' of the font. + # If a user is instantiating a partial font, we will populate the + # unpinned axes with their default values. + fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} + axisCoords = axisLimits + for axisTag, val in fvarDefaults.items(): + if axisTag not in axisCoords: + axisCoords[axisTag] = val + elif isinstance(axisCoords[axisTag], tuple): + axisCoords[axisTag] = val + + axisValueTables = _axisValueTablesFromAxisCoords(stat, axisCoords) + _updateNameRecords(varfont, axisValueTables) + + +def _axisValueTablesFromAxisCoords(stat, axisCoords): + axisValueTables = stat.table.AxisValueArray.AxisValue + axisRecords = stat.table.DesignAxisRecord.Axis + axisRecordIndex = {a.AxisTag: a.AxisOrdering for a in axisRecords} + axisRecordTag = {a.AxisOrdering: a.AxisTag for a in axisRecords} + + axisValuesToFind = { + axisRecordIndex[axisTag]: val for axisTag, val in axisCoords.items() + } + axisValueTables = [ + v for v in axisValueTables if _axisValueInAxisCoords(v, axisValuesToFind) + ] + axisValueTablesMissing = set(axisValuesToFind) - axisValueRecordsIndexes( + axisValueTables + ) + if axisValueTablesMissing: + missing = ", ".join( + f"{axisRecordTag[i]}={axisValuesToFind[i]}" for i in axisValueTablesMissing + ) + raise ValueError(f"Cannot find Axis Value Tables {missing}") + # remove axis Value Tables which have Elidable_AXIS_VALUE_NAME flag set + axisValueTables = [ + v for v in axisValueTables if v.Flags & ELIDABLE_AXIS_VALUE_NAME != 2 + ] + return _sortedAxisValues(axisValueTables) + + +def _axisValueInAxisCoords(axisValueTable, axisCoords): + if axisValueTable.Format == 4: + res = [] + for rec in axisValueTable.AxisValueRecord: + axisIndex = rec.AxisIndex + if axisIndex not in axisCoords: + return False + if rec.Value == axisCoords[axisIndex]: + res.append(True) + else: + res.append(False) + return True if all(res) else False + + axisIndex = axisValueTable.AxisIndex + + if axisValueTable.Format in (1, 3): + # A variable font can have additional axes that are not implemented as + # dynamic-variation axes in the fvar table, but that are + # relevant for the font or the family of which it is a member. This + # condition will include them. + # A common scenario is a family which consists of two variable fonts, + # one for Roman styles, the other for Italic styles. Both fonts have a + # weight axis. In order to establish a relationship between the fonts, + # an Italic Axis Record is created for both fonts. In the Roman font, + # an Axis Value Table is added to the Italic Axis Record which has the + # name "Roman" and its Value is set to 0.0, it also includes link + # Value of 1. In the Italic font, an Axis Value Table is also added + # to the Italic Axis Record which has the name "Italic", its Value set + # to 1.0. + if axisIndex not in axisCoords and axisValueTable.Value in (0.0, 1.0): + return True + + elif axisIndex in axisCoords and axisValueTable.Value == axisCoords[axisIndex]: + return True + + if axisValueTable.Format == 2: + return ( + True + if all( + [ + axisIndex in axisCoords + and axisCoords[axisIndex] >= axisValueTable.RangeMinValue, + axisIndex in axisCoords + and axisCoords[axisIndex] <= axisValueTable.RangeMaxValue, + ] + ) + else False + ) + return False + + +def axisValueRecordsIndexes(axisValueTables): + res = set() + for val in axisValueTables: + res |= axisValueRecordIndexes(val) + return res + + +def axisValueRecordIndexes(axisValueTable): + if axisValueTable.Format == 4: + return set(r.AxisIndex for r in axisValueTable.AxisValueRecord) + return set([axisValueTable.AxisIndex]) + + +def _sortedAxisValues(axisValueTables): + # Sort and remove duplicates ensuring that format 4 axis Value Tables + # are dominant + results = [] + seenAxes = set() + # sort format 4 axes so the tables with the most AxisValueRecords + # are first + format4 = sorted( + [v for v in axisValueTables if v.Format == 4], + key=lambda v: len(v.AxisValueRecord), + reverse=True, + ) + nonFormat4 = [v for v in axisValueTables if v not in format4] + + for val in format4: + axisIndexes = axisValueRecordIndexes(val) + if bool(seenAxes & axisIndexes) == False: + seenAxes |= axisIndexes + results.append((tuple(axisIndexes), val)) + + for val in nonFormat4: + axisIndex = val.AxisIndex + if axisIndex not in seenAxes: + seenAxes.add(axisIndex) + results.append(((axisIndex,), val)) + return [axisValueTable for _, axisValueTable in sorted(results)] + + +def _updateNameRecords(varfont, axisValueTables): + # Update nametable based on the axisValues using the R/I/B/BI model. + nametable = varfont["name"] + + ribbiAxisValues = _ribbiAxisValueTables(nametable, axisValueTables) + nonRibbiAxisValues = [v for v in axisValueTables if v not in ribbiAxisValues] + + nameTablePlatEncLangs = set( + (r.platformID, r.platEncID, r.langID) for r in nametable.names + ) + for platEncLang in nameTablePlatEncLangs: + _updateStyleRecords( + varfont, + nametable, + ribbiAxisValues, + nonRibbiAxisValues, + platEncLang, + ) + + +def _ribbiAxisValueTables(nametable, axisValueTables): + engNameRecords = any([r for r in nametable.names if r.langID == 0x409]) + if not engNameRecords: + raise ValueError( + f"Canot determine if there are RIBBI Axis Value Tables " + "since there are no name table Records which have " + "platformID=3, platEncID=1, langID=0x409" + ) + return [ + v + for v in axisValueTables + if nametable.getName(v.ValueNameID, 3, 1, 0x409).toUnicode() + in ("Regular", "Italic", "Bold", "Bold Italic") + ] + + +def _updateStyleRecords( + varfont, nametable, ribbiAxisValues, nonRibbiAxisValues, platEncLang=(3, 1, 0x409) +): + currentFamilyName = nametable.getName( + NameID.TYPOGRAPHIC_FAMILY_NAME, *platEncLang + ) or nametable.getName(NameID.FAMILY_NAME, *platEncLang) + + currentStyleName = nametable.getName( + NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platEncLang + ) or nametable.getName(NameID.SUBFAMILY_NAME, *platEncLang) + + if not currentFamilyName or not currentStyleName: + # Since no family name or style name entries were found, we cannot + # update this set of name Records. + return + + currentFamilyName = currentFamilyName.toUnicode() + currentStyleName = currentStyleName.toUnicode() + + ribbiName = " ".join( + nametable.getName(a.ValueNameID, *platEncLang).toUnicode() + for a in ribbiAxisValues + ) + nonRibbiName = " ".join( + nametable.getName(a.ValueNameID, *platEncLang).toUnicode() + for a in nonRibbiAxisValues + ) + + nameIDs = { + NameID.FAMILY_NAME: currentFamilyName, + # TODO (M Foley) what about Elidable fallback name instead? + NameID.SUBFAMILY_NAME: ribbiName + or nametable.getName(NameID.SUBFAMILY_NAME, *platEncLang).toUnicode(), + } + if nonRibbiAxisValues: + nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {nonRibbiName}".strip() + nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName + nameIDs[ + NameID.TYPOGRAPHIC_SUBFAMILY_NAME + ] = f"{nonRibbiName} {ribbiName}".strip() + + newFamilyName = nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs.get( + NameID.FAMILY_NAME + ) + newStyleName = nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or nameIDs.get( + NameID.SUBFAMILY_NAME + ) + + nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}" + # TODO (M Foley) implement Adobe PS naming for VFs + nameIDs[ + NameID.POSTSCRIPT_NAME + ] = f"{newFamilyName.replace(' ', '')}-{newStyleName.replace(' ', '')}" + nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = _updateUniqueIdNameRecord( + varfont, nameIDs, platEncLang + ) + + for nameID, string in nameIDs.items(): + if not string: + continue + nametable.setName(string, nameID, *platEncLang) + + +def _updateUniqueIdNameRecord(varfont, nameIDs, platEncLang): + name = varfont["name"] + record = name.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platEncLang) + if not record: + return None + + def isSubString(string1, string2): + if string2 in string1: + return True + return False + + # Check if full name and postscript name are a substring + for nameID in (4, 6): + nameRecord = name.getName(nameID, *platEncLang) + if not nameRecord: + continue + if isSubString(record.toUnicode(), nameRecord.toUnicode()): + return record.toUnicode().replace( + nameRecord.toUnicode(), nameIDs[nameRecord.nameID] + ) + # TODO (M Foley) Construct new uniqueID if full name or postscript names are not subsets + return None + + def setMacOverlapFlags(glyfTable): flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND flagOverlapSimple = _g_l_y_f.flagOverlapSimple @@ -1198,7 +1474,7 @@ def instantiateVariableFont( inplace=False, optimize=True, overlap=OverlapMode.KEEP_AND_SET_FLAGS, - update_nametable=False + updateFontNames=False, ): """Instantiate variable font, either fully or partially. @@ -1231,6 +1507,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 nametable 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 coordinate, an Error will be + raised. """ # 'overlap' used to be bool and is now enum; for backward compat keep accepting bool overlap = OverlapMode(int(overlap)) @@ -1246,6 +1527,10 @@ def instantiateVariableFont( if not inplace: varfont = deepcopy(varfont) + if updateFontNames: + log.info("Updating nametable") + updateNameTable(varfont, axisLimits) + if "gvar" in varfont: instantiateGvar(varfont, normalizedLimits, optimize=optimize) @@ -1284,10 +1569,6 @@ def instantiateVariableFont( log.info("Removing overlaps from glyf table") removeOverlaps(varfont) - if update_nametable: - log.info("Updating nametable") - updateNameTable(varfont, axisLimits) - varLib.set_default_weight_width_slant( varfont, location={ @@ -1300,223 +1581,6 @@ def instantiateVariableFont( return varfont -def axisValueIsSelected(axisValue, seeker): - if axisValue.Format == 4: - res = [] - for rec in axisValue.AxisValueRecord: - axisIndex = rec.AxisIndex - if axisIndex not in seeker: - return False - if rec.Value == seeker[axisIndex]: - res.append(True) - else: - res.append(False) - return True if all(res) else False - - axisIndex = axisValue.AxisIndex - - if axisValue.Format in (1, 3): - # Add axisValue if it's an attribute of a font. Font family - if axisIndex not in seeker and axisValue.Value in [0.0, 1.0]: - return True - - elif axisIndex in seeker and axisValue.Value == seeker[axisIndex]: - return True - - if axisValue.Format == 2: - return True if all([ - axisIndex in seeker and seeker[axisIndex] >= axisValue.RangeMinValue, - axisIndex in seeker and seeker[axisIndex] <= axisValue.RangeMaxValue - ]) else False - - return False - - -def axisValueIndexes(axisValue): - if axisValue.Format == 4: - return [r.AxisIndex for r in axisValue.AxisValueRecord] - return [axisValue.AxisIndex] - - -def axisValuesIndexes(axisValues): - res = [] - for axisValue in axisValues: - res += axisValueIndexes(axisValue) - return res - - -def axisValuesFromAxisLimits(stat, axisLimits): - axisValues = stat.table.AxisValueArray.AxisValue - axisRecords = stat.table.DesignAxisRecord.Axis - axisOrder = {a.AxisTag: a.AxisOrdering for a in axisRecords} - axisTag = {a.AxisOrdering: a.AxisTag for a in axisRecords} - # Only check pinnedAxes for matching AxisValues - axisValuesToFind = { - axisOrder[k]: v for k, v in axisLimits.items() \ - if isinstance(v, (float, int)) - } - - axisValues = [a for a in axisValues if axisValueIsSelected(a, axisValuesToFind)] - axisValuesMissing = set(axisValuesToFind) - set(axisValuesIndexes(axisValues)) - if axisValuesMissing: - missing = [f"{axisTag[i]}={axisValuesToFind[i]}" for i in axisValuesMissing] - raise ValueError(f"Cannot find AxisValue for {', '.join(missing)}") - # filter out Elidable axisValues - axisValues = [a for a in axisValues if a.Flags & ELIDABLE_AXIS_VALUE_NAME != 2] - return sortedAxisValues(axisValues) - - -def sortedAxisValues(axisValues): - # Sort and remove duplicates so format 4 axisValues are dominant - results, seenAxes = [], set() - # ensure format4 axes with the most AxisValueRecords are first - format4 = sorted( - [a for a in axisValues if a.Format == 4], - key=lambda k: len(k.AxisValueRecord), reverse=True - ) - nonFormat4 = [a for a in axisValues if a not in format4] - - for axisValue in format4: - axes = set([r.AxisIndex for r in axisValue.AxisValueRecord]) - if seenAxes - axes == seenAxes: - seenAxes |= axes - results.append((tuple(axes), axisValue)) - - for axisValue in nonFormat4: - axisIndex = axisValue.AxisIndex - if axisIndex not in seenAxes: - results.append(((axisIndex,), axisValue)) - return [v for k, v in sorted(results)] - - -def updateNameTable(varfont, axisLimits): - if "STAT" not in varfont: - raise ValueError("Cannot update name table since there is no STAT table.") - stat = varfont['STAT'] - nametable = varfont["name"] - - # add default axis values if they are missing from axisLimits - if 'fvar' in varfont: - fvar = varfont['fvar'] - fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} - for k, v in fvarDefaults.items(): - if k not in axisLimits: - axisLimits[k] = v - - selectedAxisValues = axisValuesFromAxisLimits(stat, axisLimits) - _updateNameRecords(varfont, nametable, selectedAxisValues) - - -def _updateNameRecords(varfont, nametable, axisValues): - # Update nametable based on the axisValues - # using the R/I/B/BI and WWS models. - engNameRecords = any([r for r in nametable.names if r.langID == 0x409]) - if not engNameRecords: - # TODO (Marc F) improve error msg - raise ValueError("No English namerecords") - - ribbiAxisValues = _ribbiAxisValues(nametable, axisValues) - nonRibbiAxisValues = [av for av in axisValues if av not in ribbiAxisValues] - - nametblLangs = set((r.platformID, r.platEncID, r.langID) for r in nametable.names) - for lang in nametblLangs: - _updateStyleRecords( - varfont, - nametable, - ribbiAxisValues, - nonRibbiAxisValues, - lang, - ) - - -def _ribbiAxisValues(nametable, axisValues): - ribbiStyles = frozenset(["Regular", "Italic", "Bold", "Bold Italic"]) - res = [] - for axisValue in axisValues: - name = nametable.getName(axisValue.ValueNameID, 3, 1, 0x409).toUnicode() - if name in ribbiStyles: - res.append(axisValue) - return res - - -def _updateStyleRecords( - varfont, - nametable, - ribbiAxisValues, - nonRibbiAxisValues, - lang=(3, 1, 0x409) -): -# wwsAxes = frozenset(["wght", "wdth", "ital"]) - currentFamilyName = nametable.getName(NameID.TYPOGRAPHIC_FAMILY_NAME, *lang) or \ - nametable.getName(NameID.FAMILY_NAME, *lang) - - currentStyleName = nametable.getName(NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *lang) or \ - nametable.getName(NameID.SUBFAMILY_NAME, *lang) - # TODO cleanup - if not currentFamilyName or not currentStyleName: - print(f"Cannot update {lang} since it's missing a familyName nameID 1 or subFamilyName nameID 2 entry") - return - currentFamilyName = currentFamilyName.toUnicode() - currentStyleName = currentStyleName.toUnicode() - - ribbiName = " ".join([nametable.getName(a.ValueNameID, *lang).toUnicode() for a in ribbiAxisValues]) - nonRibbiName = " ".join([nametable.getName(a.ValueNameID, *lang).toUnicode() for a in nonRibbiAxisValues]) - - nameIDs = { - NameID.FAMILY_NAME: currentFamilyName, - NameID.SUBFAMILY_NAME: ribbiName or nametable.getName(NameID.SUBFAMILY_NAME, *lang).toUnicode() - } - if nonRibbiAxisValues: - nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {nonRibbiName}".strip() - nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName - nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = f"{nonRibbiName} {ribbiName}".strip() -# # Include WWS name records if there are nonWwsParticles -# if nonWwsParticles: -# nameIDs[21] = f"{currentFamilyName} {' '.join(nonWwsParticles)}" -# nameIDs[22] = " ".join(wwsParticles) -# # Enable fsSelection bit 8 (WWS) -# varfont['OS/2'].fsSelection |= (1 << 8) -# - newFamilyName = nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or \ - nameIDs.get(NameID.FAMILY_NAME) - newStyleName = nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or \ - nameIDs.get(NameID.SUBFAMILY_NAME) - - nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}" - nameIDs[NameID.POSTSCRIPT_NAME] = f"{newFamilyName.replace(' ', '')}-{newStyleName.replace(' ', '')}" - nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = _uniqueIdRecord(varfont, lang, nameIDs) - - for nameID, string in nameIDs.items(): - if not string: - continue - nametable.setName(string, nameID, *lang) - - -def _uniqueIdRecord(varfont, lang, nameIDs): - name = varfont['name'] - record = name.getName(NameID.UNIQUE_FONT_IDENTIFIER, *lang) - if not record: - return None - - def isSubString(string1, string2): - if string2 in string1: - return True - return False - - # Check if full name and postscript name are a substring - for nameID in (4, 6): - nameRecord = name.getName(nameID, *lang) - if not nameRecord: - continue - if isSubString(record.toUnicode(), nameRecord.toUnicode()): - return record.toUnicode().replace( - nameRecord.toUnicode(), - nameIDs[nameRecord.nameID] - ) - # TODO (M Foley) Construct new uniqueID if full name or postscript names are not subsets - return None - - def splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange): location, axisRanges = {}, {} for axisTag, value in axisLimits.items(): @@ -1613,8 +1677,8 @@ def parseArgs(args): parser.add_argument( "--update-nametable", action="store_true", - help="Update the instantiated font's nametable using the STAT " - "table Axis Values" + help="Update the instantiated font's nametable. Input font must have " + "a STAT table with Axis Value Tables", ) loggingGroup = parser.add_mutually_exclusive_group(required=False) loggingGroup.add_argument( @@ -1667,7 +1731,7 @@ def main(args=None): inplace=True, optimize=options.optimize, overlap=options.overlap, - update_nametable=options.update_nametable, + updateFontNames=options.update_nametable, ) outfile = ( diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 854a4b4fb..0ae43d7fc 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -2013,7 +2013,7 @@ def test_updateNametable_partial(varfont): def test_updateNameTable_missing_axisValues(varfont): - with pytest.raises(ValueError, match="Cannot find AxisValue for wght=200"): + with pytest.raises(ValueError, match="Cannot find Axis Value Tables wght=200"): instancer.updateNameTable(varfont, {"wght": 200}) From 0bcbbfdbb5e3bcc2d73b2f5deda227cfd702d05a Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Fri, 16 Oct 2020 10:47:40 +0100 Subject: [PATCH 140/167] instancer: reuse existing instantiateSTAT func --- Lib/fontTools/varLib/instancer.py | 138 +++++++++--------------------- Tests/varLib/instancer_test.py | 10 +++ 2 files changed, 52 insertions(+), 96 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index c72ff48f7..6aece3abb 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1021,6 +1021,11 @@ def instantiateSTAT(varfont, axisLimits): ): return # STAT table empty, nothing to do + log.info("Instantiating STAT table") + _instantiateSTAT(stat, axisLimits) + + +def _instantiateSTAT(stat, axisLimits): location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange) def isAxisValueOutsideLimits(axisTag, axisValue): @@ -1032,8 +1037,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 @@ -1113,11 +1116,11 @@ def pruningUnusedNames(varfont): def updateNameTable(varfont, axisLimits): - """Update an instatiated variable font's name table using the STAT - table's Axis Value Tables. + """Update an instatiated variable font's name table using the Axis + Value Tables from the STAT table. To establish which Axis Value Tables are needed, we first remove all - tables whose Value's are not in the axisLimits dictionary. We then + tables whose Value's are not in the axisLimits. We then remove all tables which have Flag 2 enabled (ELIDABLE_AXIS_VALUE_NAME). Finally, we remove duplicates and ensure Format 4 tables preside over the other formats. @@ -1129,9 +1132,9 @@ def updateNameTable(varfont, axisLimits): stat = varfont["STAT"] fvar = varfont["fvar"] - # The updated name table must reflect the 'zero origin' of the font. + # The updated name table must reflect the new 'zero origin' of the font. # If a user is instantiating a partial font, we will populate the - # unpinned axes with their default values. + # unpinned axes with their default axis values. fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} axisCoords = axisLimits for axisTag, val in fvarDefaults.items(): @@ -1140,104 +1143,46 @@ def updateNameTable(varfont, axisLimits): elif isinstance(axisCoords[axisTag], tuple): axisCoords[axisTag] = val - axisValueTables = _axisValueTablesFromAxisCoords(stat, axisCoords) - _updateNameRecords(varfont, axisValueTables) + stat_new = deepcopy(stat).table + _instantiateSTAT(stat_new, axisCoords) + checkMissingAxisValues(stat_new, axisCoords) - -def _axisValueTablesFromAxisCoords(stat, axisCoords): - axisValueTables = stat.table.AxisValueArray.AxisValue - axisRecords = stat.table.DesignAxisRecord.Axis - axisRecordIndex = {a.AxisTag: a.AxisOrdering for a in axisRecords} - axisRecordTag = {a.AxisOrdering: a.AxisTag for a in axisRecords} - - axisValuesToFind = { - axisRecordIndex[axisTag]: val for axisTag, val in axisCoords.items() - } - axisValueTables = [ - v for v in axisValueTables if _axisValueInAxisCoords(v, axisValuesToFind) - ] - axisValueTablesMissing = set(axisValuesToFind) - axisValueRecordsIndexes( - axisValueTables - ) - if axisValueTablesMissing: - missing = ", ".join( - f"{axisRecordTag[i]}={axisValuesToFind[i]}" for i in axisValueTablesMissing - ) - raise ValueError(f"Cannot find Axis Value Tables {missing}") + axisValueTables = stat_new.AxisValueArray.AxisValue # remove axis Value Tables which have Elidable_AXIS_VALUE_NAME flag set + # Axis Value which have this flag enabled won't be visible in + # application font menus. axisValueTables = [ v for v in axisValueTables if v.Flags & ELIDABLE_AXIS_VALUE_NAME != 2 ] - return _sortedAxisValues(axisValueTables) + stat_new.AxisValueArray.AxisValue = axisValueTables + axisValueTables = _sortedAxisValues(stat_new, axisCoords) + _updateNameRecords(varfont, axisValueTables) -def _axisValueInAxisCoords(axisValueTable, axisCoords): - if axisValueTable.Format == 4: - res = [] - for rec in axisValueTable.AxisValueRecord: - axisIndex = rec.AxisIndex - if axisIndex not in axisCoords: - return False - if rec.Value == axisCoords[axisIndex]: - res.append(True) - else: - res.append(False) - return True if all(res) else False - - axisIndex = axisValueTable.AxisIndex - - if axisValueTable.Format in (1, 3): - # A variable font can have additional axes that are not implemented as - # dynamic-variation axes in the fvar table, but that are - # relevant for the font or the family of which it is a member. This - # condition will include them. - # A common scenario is a family which consists of two variable fonts, - # one for Roman styles, the other for Italic styles. Both fonts have a - # weight axis. In order to establish a relationship between the fonts, - # an Italic Axis Record is created for both fonts. In the Roman font, - # an Axis Value Table is added to the Italic Axis Record which has the - # name "Roman" and its Value is set to 0.0, it also includes link - # Value of 1. In the Italic font, an Axis Value Table is also added - # to the Italic Axis Record which has the name "Italic", its Value set - # to 1.0. - if axisIndex not in axisCoords and axisValueTable.Value in (0.0, 1.0): - return True - - elif axisIndex in axisCoords and axisValueTable.Value == axisCoords[axisIndex]: - return True - - if axisValueTable.Format == 2: - return ( - True - if all( - [ - axisIndex in axisCoords - and axisCoords[axisIndex] >= axisValueTable.RangeMinValue, - axisIndex in axisCoords - and axisCoords[axisIndex] <= axisValueTable.RangeMaxValue, - ] - ) - else False - ) - return False - - -def axisValueRecordsIndexes(axisValueTables): - res = set() +def checkMissingAxisValues(stat, axisCoords): + seen = set() + axisValueTables = stat.AxisValueArray.AxisValue + designAxes = stat.DesignAxisRecord.Axis for val in axisValueTables: - res |= axisValueRecordIndexes(val) - return res + if val.Format == 4: + for rec in val.AxisValueRecord: + axisTag = designAxes[rec.AxisIndex].AxisTag + seen.add(axisTag) + else: + axisTag = designAxes[val.AxisIndex].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 Value Tables {missing}") -def axisValueRecordIndexes(axisValueTable): - if axisValueTable.Format == 4: - return set(r.AxisIndex for r in axisValueTable.AxisValueRecord) - return set([axisValueTable.AxisIndex]) - - -def _sortedAxisValues(axisValueTables): +def _sortedAxisValues(stat, axisCoords): # Sort and remove duplicates ensuring that format 4 axis Value Tables # are dominant + axisValueTables = stat.AxisValueArray.AxisValue + designAxes = stat.DesignAxisRecord.Axis results = [] seenAxes = set() # sort format 4 axes so the tables with the most AxisValueRecords @@ -1250,7 +1195,7 @@ def _sortedAxisValues(axisValueTables): nonFormat4 = [v for v in axisValueTables if v not in format4] for val in format4: - axisIndexes = axisValueRecordIndexes(val) + axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord) if bool(seenAxes & axisIndexes) == False: seenAxes |= axisIndexes results.append((tuple(axisIndexes), val)) @@ -1260,6 +1205,7 @@ def _sortedAxisValues(axisValueTables): if axisIndex not in seenAxes: seenAxes.add(axisIndex) results.append(((axisIndex,), val)) + return [axisValueTable for _, axisValueTable in sorted(results)] @@ -1311,7 +1257,7 @@ def _updateStyleRecords( ) or nametable.getName(NameID.SUBFAMILY_NAME, *platEncLang) if not currentFamilyName or not currentStyleName: - # Since no family name or style name entries were found, we cannot + # Since no family name or style name records were found, we cannot # update this set of name Records. return @@ -1510,7 +1456,7 @@ def instantiateVariableFont( updateFontNames (bool): if True, update the instantiated font's nametable 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 coordinate, an Error will be + an Axis Value table is missing for a given axis coordinate, an Error will be raised. """ # 'overlap' used to be bool and is now enum; for backward compat keep accepting bool diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 0ae43d7fc..a0ba3b418 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1966,6 +1966,10 @@ def test_updateNameTable_with_registered_axes(varfont): assert names[(17, 3, 1, 0x409)] == "Thin Condensed" +def test_updatetNameTable_axis_order(varfont): + pass + + def test_updateNameTable_with_multilingual_names(varfont): name = varfont["name"] name.setName("Test Variable Font", 1, 3, 1, 0x405) @@ -2017,6 +2021,12 @@ def test_updateNameTable_missing_axisValues(varfont): instancer.updateNameTable(varfont, {"wght": 200}) +def test_updateNameTable_missing_stat(varfont): + del varfont["STAT"] + with pytest.raises(ValueError, match="Cannot update name table since there is no STAT table."): + instancer.updateNameTable(varfont, {"wght": 400}) + + def test_updateNameTable_vf_with_italic_attribute(varfont): font_link_axisValue = varfont["STAT"].table.AxisValueArray.AxisValue[4] font_link_axisValue.Flags = 0 From 11f0ade44c5edbcfcac6c22a813dda9da8153744 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Fri, 16 Oct 2020 13:12:36 +0100 Subject: [PATCH 141/167] cleanup _updateUniqueIdNameRecord --- Lib/fontTools/varLib/instancer.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 6aece3abb..25d9f1120 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1309,9 +1309,9 @@ def _updateStyleRecords( def _updateUniqueIdNameRecord(varfont, nameIDs, platEncLang): - name = varfont["name"] - record = name.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platEncLang) - if not record: + nametable = varfont["name"] + currentRecord = nametable.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platEncLang) + if not currentRecord: return None def isSubString(string1, string2): @@ -1319,13 +1319,13 @@ def _updateUniqueIdNameRecord(varfont, nameIDs, platEncLang): return True return False - # Check if full name and postscript name are a substring + # Check if full name and postscript name are a substring of currentRecord for nameID in (4, 6): - nameRecord = name.getName(nameID, *platEncLang) + nameRecord = nametable.getName(nameID, *platEncLang) if not nameRecord: continue - if isSubString(record.toUnicode(), nameRecord.toUnicode()): - return record.toUnicode().replace( + if isSubString(currentRecord.toUnicode(), nameRecord.toUnicode()): + return currentRecord.toUnicode().replace( nameRecord.toUnicode(), nameIDs[nameRecord.nameID] ) # TODO (M Foley) Construct new uniqueID if full name or postscript names are not subsets From bef1d08c0b40698aea940241c978e894848fbd7c Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Mon, 19 Oct 2020 12:51:59 +0100 Subject: [PATCH 142/167] instancer: updateNameTableStyleRecords use strings as input instead of axis values --- Lib/fontTools/varLib/instancer.py | 32 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 25d9f1120..f8dfb27f0 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1216,15 +1216,26 @@ def _updateNameRecords(varfont, axisValueTables): ribbiAxisValues = _ribbiAxisValueTables(nametable, axisValueTables) nonRibbiAxisValues = [v for v in axisValueTables if v not in ribbiAxisValues] + getName = nametable.getName nameTablePlatEncLangs = set( (r.platformID, r.platEncID, r.langID) for r in nametable.names ) for platEncLang in nameTablePlatEncLangs: - _updateStyleRecords( + + subFamilyName = [ + getName(a.ValueNameID, *platEncLang) for a in ribbiAxisValues if a + ] + subFamilyName = " ".join([r.toUnicode() for r in subFamilyName if r]) + typoSubFamilyName = [ + getName(a.ValueNameID, *platEncLang) for a in nonRibbiAxisValues if a + ] + typoSubFamilyName = " ".join([r.toUnicode() for r in typoSubFamilyName if r]) + + updateNameTableStyleRecords( varfont, nametable, - ribbiAxisValues, - nonRibbiAxisValues, + subFamilyName, + typoSubFamilyName, platEncLang, ) @@ -1245,8 +1256,8 @@ def _ribbiAxisValueTables(nametable, axisValueTables): ] -def _updateStyleRecords( - varfont, nametable, ribbiAxisValues, nonRibbiAxisValues, platEncLang=(3, 1, 0x409) +def updateNameTableStyleRecords( + varfont, nametable, ribbiName, nonRibbiName, platEncLang=(3, 1, 0x409) ): currentFamilyName = nametable.getName( NameID.TYPOGRAPHIC_FAMILY_NAME, *platEncLang @@ -1264,22 +1275,13 @@ def _updateStyleRecords( currentFamilyName = currentFamilyName.toUnicode() currentStyleName = currentStyleName.toUnicode() - ribbiName = " ".join( - nametable.getName(a.ValueNameID, *platEncLang).toUnicode() - for a in ribbiAxisValues - ) - nonRibbiName = " ".join( - nametable.getName(a.ValueNameID, *platEncLang).toUnicode() - for a in nonRibbiAxisValues - ) - nameIDs = { NameID.FAMILY_NAME: currentFamilyName, # TODO (M Foley) what about Elidable fallback name instead? NameID.SUBFAMILY_NAME: ribbiName or nametable.getName(NameID.SUBFAMILY_NAME, *platEncLang).toUnicode(), } - if nonRibbiAxisValues: + if nonRibbiName: nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {nonRibbiName}".strip() nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName nameIDs[ From 29e4ff987ce2c191caa9184a882b72f87c9d23d9 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Mon, 19 Oct 2020 16:32:30 +0100 Subject: [PATCH 143/167] instancer: implement Cosimo feedback --- Lib/fontTools/varLib/instancer.py | 159 +++++++++++++++++++----------- Tests/varLib/instancer_test.py | 54 +++++++++- 2 files changed, 151 insertions(+), 62 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index f8dfb27f0..7dccdcb6c 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -133,6 +133,7 @@ class NameID(IntEnum): 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 @@ -1117,13 +1118,7 @@ def pruningUnusedNames(varfont): def updateNameTable(varfont, axisLimits): """Update an instatiated variable font's name table using the Axis - Value Tables from the STAT table. - - To establish which Axis Value Tables are needed, we first remove all - tables whose Value's are not in the axisLimits. We then - remove all tables which have Flag 2 enabled (ELIDABLE_AXIS_VALUE_NAME). - Finally, we remove duplicates and ensure Format 4 tables preside over - the other formats. + Values from the STAT table. The updated nametable will conform to the R/I/B/BI naming model. """ @@ -1133,23 +1128,24 @@ def updateNameTable(varfont, axisLimits): fvar = varfont["fvar"] # The updated name table must reflect the new 'zero origin' of the font. - # If a user is instantiating a partial font, we will populate the - # unpinned axes with their default axis values. + # 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} - axisCoords = axisLimits + axisCoords = deepcopy(axisLimits) for axisTag, val in fvarDefaults.items(): - if axisTag not in axisCoords: - axisCoords[axisTag] = val - elif isinstance(axisCoords[axisTag], tuple): + if axisTag not in axisCoords or isinstance(axisCoords[axisTag], tuple): axisCoords[axisTag] = val + # To get the required Axis Values for the zero origin, we can simply + # duplicate the STAT table and instantiate it using the axis coords we + # created in the previous step. stat_new = deepcopy(stat).table _instantiateSTAT(stat_new, axisCoords) checkMissingAxisValues(stat_new, axisCoords) axisValueTables = stat_new.AxisValueArray.AxisValue - # remove axis Value Tables which have Elidable_AXIS_VALUE_NAME flag set - # Axis Value which have this flag enabled won't be visible in + # Remove axis Values which have Elidable_AXIS_VALUE_NAME flag set + # Axis Values which have this flag enabled won't be visible in # application font menus. axisValueTables = [ v for v in axisValueTables if v.Flags & ELIDABLE_AXIS_VALUE_NAME != 2 @@ -1174,18 +1170,18 @@ def checkMissingAxisValues(stat, axisCoords): missingAxes = set(axisCoords) - seen if missingAxes: - missing = ", ".join(f"{i}={axisCoords[i]}" for i in missingAxes) - raise ValueError(f"Cannot find Axis Value Tables {missing}") + missing = ", ".join(f"'{i}={axisCoords[i]}'" for i in missingAxes) + raise ValueError(f"Cannot find Axis Value Tables [{missing}]") def _sortedAxisValues(stat, axisCoords): - # Sort and remove duplicates ensuring that format 4 axis Value Tables + # Sort and remove duplicates ensuring that format 4 Axis Values # are dominant axisValueTables = stat.AxisValueArray.AxisValue designAxes = stat.DesignAxisRecord.Axis results = [] seenAxes = set() - # sort format 4 axes so the tables with the most AxisValueRecords + # Sort format 4 axes so the tables with the most AxisValueRecords # are first format4 = sorted( [v for v in axisValueTables if v.Format == 4], @@ -1196,15 +1192,16 @@ def _sortedAxisValues(stat, axisCoords): for val in format4: axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord) - if bool(seenAxes & axisIndexes) == False: + minIndex = min(axisIndexes) + if not seenAxes & axisIndexes: seenAxes |= axisIndexes - results.append((tuple(axisIndexes), val)) + results.append((minIndex, val)) for val in nonFormat4: axisIndex = val.AxisIndex if axisIndex not in seenAxes: seenAxes.add(axisIndex) - results.append(((axisIndex,), val)) + results.append((axisIndex, val)) return [axisValueTable for _, axisValueTable in sorted(results)] @@ -1212,9 +1209,13 @@ def _sortedAxisValues(stat, axisCoords): def _updateNameRecords(varfont, axisValueTables): # Update nametable based on the axisValues using the R/I/B/BI model. nametable = varfont["name"] + stat = varfont["STAT"].table - ribbiAxisValues = _ribbiAxisValueTables(nametable, axisValueTables) - nonRibbiAxisValues = [v for v in axisValueTables if v not in ribbiAxisValues] + axisValueNameIDs = [a.ValueNameID for a in axisValueTables] + ribbiNameIDs = [n for n in axisValueNameIDs if nameIdIsRibbi(nametable, n)] + nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs] + elidedNameID = stat.ElidedFallbackNameID + elidedNameIsRibbi = nameIdIsRibbi(nametable, elidedNameID) getName = nametable.getName nameTablePlatEncLangs = set( @@ -1222,43 +1223,73 @@ def _updateNameRecords(varfont, axisValueTables): ) for platEncLang in nameTablePlatEncLangs: - subFamilyName = [ - getName(a.ValueNameID, *platEncLang) for a in ribbiAxisValues if a - ] - subFamilyName = " ".join([r.toUnicode() for r in subFamilyName if r]) - typoSubFamilyName = [ - getName(a.ValueNameID, *platEncLang) for a in nonRibbiAxisValues if a - ] - typoSubFamilyName = " ".join([r.toUnicode() for r in typoSubFamilyName if r]) + if not all(getName(i, *platEncLang) 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 - updateNameTableStyleRecords( + subFamilyName = " ".join( + getName(n, *platEncLang).toUnicode() for n in ribbiNameIDs + ) + typoSubFamilyName = " ".join( + getName(n, *platEncLang).toUnicode() for n in axisValueNameIDs + ) + + # 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, *platEncLang).toUnicode() + else: + typoSubFamilyName = getName(elidedNameID, *platEncLang).toUnicode() + + familyNameSuffix = " ".join( + getName(n, *platEncLang).toUnicode() for n in nonRibbiNameIDs + ) + + _updateNameTableStyleRecords( varfont, - nametable, + familyNameSuffix, subFamilyName, typoSubFamilyName, - platEncLang, + *platEncLang, ) -def _ribbiAxisValueTables(nametable, axisValueTables): - engNameRecords = any([r for r in nametable.names if r.langID == 0x409]) +def nameIdIsRibbi(nametable, nameID): + engNameRecords = any( + r + for r in nametable.names + if (r.platformID, r.platEncID, r.langID) == (3, 1, 0x409) + ) if not engNameRecords: raise ValueError( f"Canot determine if there are RIBBI Axis Value Tables " "since there are no name table Records which have " "platformID=3, platEncID=1, langID=0x409" ) - return [ - v - for v in axisValueTables - if nametable.getName(v.ValueNameID, 3, 1, 0x409).toUnicode() + return ( + True + if nametable.getName(nameID, 3, 1, 0x409).toUnicode() in ("Regular", "Italic", "Bold", "Bold Italic") - ] + else False + ) -def updateNameTableStyleRecords( - varfont, nametable, ribbiName, nonRibbiName, platEncLang=(3, 1, 0x409) +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"] + platEncLang = (platformID, platEncID, langID) + currentFamilyName = nametable.getName( NameID.TYPOGRAPHIC_FAMILY_NAME, *platEncLang ) or nametable.getName(NameID.FAMILY_NAME, *platEncLang) @@ -1267,26 +1298,25 @@ def updateNameTableStyleRecords( NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platEncLang ) or nametable.getName(NameID.SUBFAMILY_NAME, *platEncLang) - if not currentFamilyName or not currentStyleName: - # Since no family name or style name records were found, we cannot - # update this set of name Records. - return - currentFamilyName = currentFamilyName.toUnicode() currentStyleName = currentStyleName.toUnicode() nameIDs = { NameID.FAMILY_NAME: currentFamilyName, - # TODO (M Foley) what about Elidable fallback name instead? - NameID.SUBFAMILY_NAME: ribbiName - or nametable.getName(NameID.SUBFAMILY_NAME, *platEncLang).toUnicode(), + NameID.SUBFAMILY_NAME: subFamilyName, } - if nonRibbiName: - nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {nonRibbiName}".strip() + if typoSubFamilyName: + nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip() nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName - nameIDs[ + nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = f"{typoSubFamilyName}" + # Remove previous Typographic Family and SubFamily names since they're + # no longer required + else: + for nameID in ( + NameID.TYPOGRAPHIC_FAMILY_NAME, NameID.TYPOGRAPHIC_SUBFAMILY_NAME - ] = f"{nonRibbiName} {ribbiName}".strip() + ): + nametable.removeNames(nameID=nameID) newFamilyName = nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs.get( NameID.FAMILY_NAME @@ -1330,8 +1360,21 @@ def _updateUniqueIdNameRecord(varfont, nameIDs, platEncLang): return currentRecord.toUnicode().replace( nameRecord.toUnicode(), nameIDs[nameRecord.nameID] ) - # TODO (M Foley) Construct new uniqueID if full name or postscript names are not subsets - return None + # Create a new string since we couldn't find any substrings. + fontVersion = _fontVersion(varfont, platEncLang) + vendor = varfont["OS/2"].achVendID.strip() + psName = nameIDs[NameID.POSTSCRIPT_NAME] + return f"{fontVersion};{vendor};{psName}" + + +def _fontVersion(font, platEncLang=(3, 1, 0x409)): + nameRecord = font["name"].getName(NameID.VERSION_STRING, *platEncLang) + 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() def setMacOverlapFlags(glyfTable): diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index a0ba3b418..332924c46 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -7,6 +7,7 @@ from fontTools.ttLib.tables import _f_v_a_r, _g_l_y_f from fontTools.ttLib.tables import otTables from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools import varLib +from fontTools.otlLib.builder import buildStatTable from fontTools.varLib import instancer from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib import builder @@ -1967,7 +1968,34 @@ def test_updateNameTable_with_registered_axes(varfont): def test_updatetNameTable_axis_order(varfont): - pass + axes = [ + dict( + tag="wght", + name="Weight", + values=[ + dict(value=400, name='Regular'), + ], + ), + dict( + tag="wdth", + name="Width", + values=[ + dict(value=75, name="Condensed"), + ] + ) + ] + buildStatTable(varfont, axes) + instancer.updateNameTable(varfont, {"wdth": 75, "wght": 400}) + names = _get_name_records(varfont) + assert names[(17, 3, 1, 0x409)] == "Regular Condensed" + + # Swap the axes so the names get swapped + axes[0], axes[1] = axes[1], axes[0] + + buildStatTable(varfont, axes) + instancer.updateNameTable(varfont, {"wdth": 75, "wght": 400}) + names = _get_name_records(varfont) + assert names[(17, 3, 1, 0x409)] == "Condensed Regular" def test_updateNameTable_with_multilingual_names(varfont): @@ -2013,11 +2041,11 @@ def test_updateNametable_partial(varfont): assert names[(2, 3, 1, 0x409)] == "Regular" assert (3, 3, 1, 0x405) not in names assert names[(16, 3, 1, 0x409)] == "Test Variable Font" - assert names[(17, 3, 1, 0x409)] == "Condensed" #? maybe Condensed Regular? + assert names[(17, 3, 1, 0x409)] == "Condensed" def test_updateNameTable_missing_axisValues(varfont): - with pytest.raises(ValueError, match="Cannot find Axis Value Tables wght=200"): + with pytest.raises(ValueError, match="Cannot find Axis Value Tables \['wght=200'\]"): instancer.updateNameTable(varfont, {"wght": 200}) @@ -2029,7 +2057,8 @@ def test_updateNameTable_missing_stat(varfont): def test_updateNameTable_vf_with_italic_attribute(varfont): font_link_axisValue = varfont["STAT"].table.AxisValueArray.AxisValue[4] - font_link_axisValue.Flags = 0 + # Unset ELIDABLE_AXIS_VALUE_NAME flag + font_link_axisValue.Flags &= ~instancer.ELIDABLE_AXIS_VALUE_NAME font_link_axisValue.ValueNameID = 294 # Roman --> Italic # Italic @@ -2077,6 +2106,23 @@ def test_updateNameTable_format4_axisValues(varfont): assert names[(17, 3, 1, 0x409)] == "Dominant Value" +def test_updateNameTable_elided_axisValues(varfont): + stat = varfont["STAT"].table + # set ELIDABLE_AXIS_VALUE_NAME flag for all axisValues + for axisValue in stat.AxisValueArray.AxisValue: + axisValue.Flags |= instancer.ELIDABLE_AXIS_VALUE_NAME + + stat.ElidedFallbackNameID = 266 # Regular --> Black + instancer.updateNameTable(varfont, {"wght": 400}) + names = _get_name_records(varfont) + # Since all axis values are elided, the elided fallback name + # must be used to construct the style names. Since we + # changed it to Black, we need both a typoSubFamilyName and + # the subFamilyName set so it conforms to the RIBBI model. + assert names[(2, 3, 1, 0x409)] == "Regular" + assert names[(17, 3, 1, 0x409)] == "Black" + + def test_sanityCheckVariableTables(varfont): font = ttLib.TTFont() with pytest.raises(ValueError, match="Missing required table fvar"): From 5eac886e5a34e86569988db2f2f5e4e38ff123cf Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Tue, 27 Oct 2020 21:32:08 +0000 Subject: [PATCH 144/167] Only create typographic subfamily name if there are nonRibbi tokens --- Lib/fontTools/varLib/instancer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 7dccdcb6c..aefce744c 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1124,7 +1124,9 @@ def updateNameTable(varfont, axisLimits): """ if "STAT" not in varfont: raise ValueError("Cannot update name table since there is no STAT table.") - stat = varfont["STAT"] + 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 must reflect the new 'zero origin' of the font. @@ -1139,7 +1141,7 @@ def updateNameTable(varfont, axisLimits): # To get the required Axis Values for the zero origin, we can simply # duplicate the STAT table and instantiate it using the axis coords we # created in the previous step. - stat_new = deepcopy(stat).table + stat_new = deepcopy(stat) _instantiateSTAT(stat_new, axisCoords) checkMissingAxisValues(stat_new, axisCoords) @@ -1232,7 +1234,7 @@ def _updateNameRecords(varfont, axisValueTables): getName(n, *platEncLang).toUnicode() for n in ribbiNameIDs ) typoSubFamilyName = " ".join( - getName(n, *platEncLang).toUnicode() for n in axisValueNameIDs + getName(n, *platEncLang).toUnicode() for n in axisValueNameIDs if nonRibbiNameIDs ) # If neither subFamilyName and typographic SubFamilyName exist, From de38c9ce967160eca357f52d7650d22630e972f6 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Thu, 29 Oct 2020 10:32:39 +0000 Subject: [PATCH 145/167] Tidy up variable name and run through black --- Lib/fontTools/varLib/instancer.py | 75 +++++++++++++++---------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index aefce744c..3840f9f59 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1146,7 +1146,7 @@ def updateNameTable(varfont, axisLimits): checkMissingAxisValues(stat_new, axisCoords) axisValueTables = stat_new.AxisValueArray.AxisValue - # Remove axis Values which have Elidable_AXIS_VALUE_NAME flag set + # Remove axis Values which have Elidable_AXIS_VALUE_NAME flag set. # Axis Values which have this flag enabled won't be visible in # application font menus. axisValueTables = [ @@ -1159,9 +1159,9 @@ def updateNameTable(varfont, axisLimits): def checkMissingAxisValues(stat, axisCoords): seen = set() - axisValueTables = stat.AxisValueArray.AxisValue + axisValues = stat.AxisValueArray.AxisValue designAxes = stat.DesignAxisRecord.Axis - for val in axisValueTables: + for val in axisValues: if val.Format == 4: for rec in val.AxisValueRecord: axisTag = designAxes[rec.AxisIndex].AxisTag @@ -1179,18 +1179,18 @@ def checkMissingAxisValues(stat, axisCoords): def _sortedAxisValues(stat, axisCoords): # Sort and remove duplicates ensuring that format 4 Axis Values # are dominant - axisValueTables = stat.AxisValueArray.AxisValue + axisValues = stat.AxisValueArray.AxisValue designAxes = stat.DesignAxisRecord.Axis results = [] seenAxes = set() # Sort format 4 axes so the tables with the most AxisValueRecords # are first format4 = sorted( - [v for v in axisValueTables if v.Format == 4], + [v for v in axisValues if v.Format == 4], key=lambda v: len(v.AxisValueRecord), reverse=True, ) - nonFormat4 = [v for v in axisValueTables if v not in format4] + nonFormat4 = [v for v in axisValues if v not in format4] for val in format4: axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord) @@ -1205,48 +1205,47 @@ def _sortedAxisValues(stat, axisCoords): seenAxes.add(axisIndex) results.append((axisIndex, val)) - return [axisValueTable for _, axisValueTable in sorted(results)] + return [axisValue for _, axisValue in sorted(results)] -def _updateNameRecords(varfont, axisValueTables): +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 axisValueTables] - ribbiNameIDs = [n for n in axisValueNameIDs if nameIdIsRibbi(nametable, n)] + axisValueNameIDs = [a.ValueNameID for a in axisValues] + ribbiNameIDs = [n for n in axisValueNameIDs if nameIDIsRibbi(nametable, n)] nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs] elidedNameID = stat.ElidedFallbackNameID - elidedNameIsRibbi = nameIdIsRibbi(nametable, elidedNameID) + elidedNameIsRibbi = nameIDIsRibbi(nametable, elidedNameID) getName = nametable.getName - nameTablePlatEncLangs = set( - (r.platformID, r.platEncID, r.langID) for r in nametable.names - ) - for platEncLang in nameTablePlatEncLangs: - - if not all(getName(i, *platEncLang) for i in (1,2, elidedNameID)): + 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, *platEncLang).toUnicode() for n in ribbiNameIDs + getName(n, *platform).toUnicode() for n in ribbiNameIDs ) typoSubFamilyName = " ".join( - getName(n, *platEncLang).toUnicode() for n in axisValueNameIDs if nonRibbiNameIDs + getName(n, *platform).toUnicode() + for n in axisValueNameIDs + if nonRibbiNameIDs ) # 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, *platEncLang).toUnicode() + subFamilyName = getName(elidedNameID, *platform).toUnicode() else: - typoSubFamilyName = getName(elidedNameID, *platEncLang).toUnicode() + typoSubFamilyName = getName(elidedNameID, *platform).toUnicode() familyNameSuffix = " ".join( - getName(n, *platEncLang).toUnicode() for n in nonRibbiNameIDs + getName(n, *platform).toUnicode() for n in nonRibbiNameIDs ) _updateNameTableStyleRecords( @@ -1254,11 +1253,11 @@ def _updateNameRecords(varfont, axisValueTables): familyNameSuffix, subFamilyName, typoSubFamilyName, - *platEncLang, + *platform, ) -def nameIdIsRibbi(nametable, nameID): +def nameIDIsRibbi(nametable, nameID): engNameRecords = any( r for r in nametable.names @@ -1290,15 +1289,15 @@ def _updateNameTableStyleRecords( # TODO (Marc F) It may be nice to make this part a standalone # font renamer in the future. nametable = varfont["name"] - platEncLang = (platformID, platEncID, langID) + platform = (platformID, platEncID, langID) currentFamilyName = nametable.getName( - NameID.TYPOGRAPHIC_FAMILY_NAME, *platEncLang - ) or nametable.getName(NameID.FAMILY_NAME, *platEncLang) + NameID.TYPOGRAPHIC_FAMILY_NAME, *platform + ) or nametable.getName(NameID.FAMILY_NAME, *platform) currentStyleName = nametable.getName( - NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platEncLang - ) or nametable.getName(NameID.SUBFAMILY_NAME, *platEncLang) + NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platform + ) or nametable.getName(NameID.SUBFAMILY_NAME, *platform) currentFamilyName = currentFamilyName.toUnicode() currentStyleName = currentStyleName.toUnicode() @@ -1316,7 +1315,7 @@ def _updateNameTableStyleRecords( else: for nameID in ( NameID.TYPOGRAPHIC_FAMILY_NAME, - NameID.TYPOGRAPHIC_SUBFAMILY_NAME + NameID.TYPOGRAPHIC_SUBFAMILY_NAME, ): nametable.removeNames(nameID=nameID) @@ -1333,18 +1332,18 @@ def _updateNameTableStyleRecords( NameID.POSTSCRIPT_NAME ] = f"{newFamilyName.replace(' ', '')}-{newStyleName.replace(' ', '')}" nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = _updateUniqueIdNameRecord( - varfont, nameIDs, platEncLang + varfont, nameIDs, platform ) for nameID, string in nameIDs.items(): if not string: continue - nametable.setName(string, nameID, *platEncLang) + nametable.setName(string, nameID, *platform) -def _updateUniqueIdNameRecord(varfont, nameIDs, platEncLang): +def _updateUniqueIdNameRecord(varfont, nameIDs, platform): nametable = varfont["name"] - currentRecord = nametable.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platEncLang) + currentRecord = nametable.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platform) if not currentRecord: return None @@ -1355,7 +1354,7 @@ def _updateUniqueIdNameRecord(varfont, nameIDs, platEncLang): # Check if full name and postscript name are a substring of currentRecord for nameID in (4, 6): - nameRecord = nametable.getName(nameID, *platEncLang) + nameRecord = nametable.getName(nameID, *platform) if not nameRecord: continue if isSubString(currentRecord.toUnicode(), nameRecord.toUnicode()): @@ -1363,14 +1362,14 @@ def _updateUniqueIdNameRecord(varfont, nameIDs, platEncLang): nameRecord.toUnicode(), nameIDs[nameRecord.nameID] ) # Create a new string since we couldn't find any substrings. - fontVersion = _fontVersion(varfont, platEncLang) + fontVersion = _fontVersion(varfont, platform) vendor = varfont["OS/2"].achVendID.strip() psName = nameIDs[NameID.POSTSCRIPT_NAME] return f"{fontVersion};{vendor};{psName}" -def _fontVersion(font, platEncLang=(3, 1, 0x409)): - nameRecord = font["name"].getName(NameID.VERSION_STRING, *platEncLang) +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" From daf6427b0b18793b3d3534012f1c618df8f4445e Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Tue, 5 Jan 2021 14:21:04 +0000 Subject: [PATCH 146/167] Implement Adobe ps naming for instantiated instances --- Lib/fontTools/varLib/instancer.py | 36 ++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 3840f9f59..986d8bcc3 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -137,6 +137,7 @@ class NameID(IntEnum): POSTSCRIPT_NAME = 6 TYPOGRAPHIC_FAMILY_NAME = 16 TYPOGRAPHIC_SUBFAMILY_NAME = 17 + VARIATIONS_POSTSCRIPT_NAME_PREFIX = 25 ELIDABLE_AXIS_VALUE_NAME = 2 @@ -1173,7 +1174,7 @@ def checkMissingAxisValues(stat, axisCoords): missingAxes = set(axisCoords) - seen if missingAxes: missing = ", ".join(f"'{i}={axisCoords[i]}'" for i in missingAxes) - raise ValueError(f"Cannot find Axis Value Tables [{missing}]") + raise ValueError(f"Cannot find Axis Values [{missing}]") def _sortedAxisValues(stat, axisCoords): @@ -1327,10 +1328,9 @@ def _updateNameTableStyleRecords( ) nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}" - # TODO (M Foley) implement Adobe PS naming for VFs - nameIDs[ - NameID.POSTSCRIPT_NAME - ] = f"{newFamilyName.replace(' ', '')}-{newStyleName.replace(' ', '')}" + nameIDs[NameID.POSTSCRIPT_NAME] = _updatePSNameRecord( + varfont, newFamilyName, newStyleName, platform + ) nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = _updateUniqueIdNameRecord( varfont, nameIDs, platform ) @@ -1341,6 +1341,32 @@ def _updateNameTableStyleRecords( nametable.setName(string, nameID, *platform) +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 = familyPrefix.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) From 336e7827e7debca37a3595acfdb72bf5aa9f8cd5 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Tue, 5 Jan 2021 14:34:25 +0000 Subject: [PATCH 147/167] Implement Cosimo feedback --- Lib/fontTools/varLib/instancer.py | 182 ++++++++++++++++++++++-------- Tests/varLib/instancer_test.py | 6 +- 2 files changed, 137 insertions(+), 51 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 986d8bcc3..581f52ee6 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1024,10 +1024,12 @@ def instantiateSTAT(varfont, axisLimits): return # STAT table empty, nothing to do log.info("Instantiating STAT table") - _instantiateSTAT(stat, axisLimits) + newAxisValueTables = axisValuesFromAxisLimits(stat, axisLimits) + stat.AxisValueArray.AxisValue = newAxisValueTables + stat.AxisValueCount = len(stat.AxisValueArray.AxisValue) -def _instantiateSTAT(stat, axisLimits): +def axisValuesFromAxisLimits(stat, axisLimits): location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange) def isAxisValueOutsideLimits(axisTag, axisValue): @@ -1068,9 +1070,7 @@ def _instantiateSTAT(stat, axisLimits): else: log.warn("Unknown AxisValue table format (%s); ignored", axisValueFormat) newAxisValueTables.append(axisValueTable) - - stat.AxisValueArray.AxisValue = newAxisValueTables - stat.AxisValueCount = len(stat.AxisValueArray.AxisValue) + return newAxisValueTables def getVariationNameIDs(varfont): @@ -1121,8 +1121,97 @@ def updateNameTable(varfont, axisLimits): """Update an instatiated variable font's name table using the Axis Values from the STAT table. - The updated nametable will conform to the R/I/B/BI naming model. + The updated name table will conform to the R/I/B/BI naming model. """ + # This task can be split into two parts: + + # Task 1: Collecting and sorting the relevant AxisValues: + # 1. First check the variable font has a STAT table and it contains + # AxisValues. + # 2. Create a dictionary which contains the pinned axes from the + # axisLimits dict and for the unpinned axes, we'll use the fvar + # default coordinates e.g + # axisLimits = {"wght": 500, "wdth": AxisRange(75, 100), our dict will + # be {"wght": 500, "wdth": 100} if the width axis has a default of 100. + # 3. Create a new list of AxisValues whose Values match the dict we just + # created. + # 4. Remove any AxisValues from the list which have the + # Elidable_AXIS_VALUE_NAME flag set. + # 5. Remove and sort AxisValues in the list so format 4 AxisValues take + # precedence. According to the MS Spec "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 + + # Task 2: Updating a name table's style and family names from a list of + # AxisValues: + # 1. Sort AxisValues into two groups. For the first group, the names must be + # any of the following ["Regular", "Italic", "Bold", "Bold Italic"]. + # This group of names is often referred to as "RIBBI" names. For the + # other group, names must be non-RIBBI e.g "Medium Italic", "Condensed" + # etc. + # 2. Repeat the next steps for each name table record platform: + # a. Create new subFamily name and Typographic subFamily name from the + # above groups. + # b. Update nameIDs 1, 2, 3, 4, 6, 16, 17 using the new name created + # in the last step. + # + # Step by step example: + # A variable font which has a width and weight axes. + # AxisValues in font (represented as simplified dicts): + # axisValues = [ + # {"name": "Light", "axis": "wght", "value": 300}, + # {"name": "Regular", "axis": "wght", "value": 400}, + # {"name": "Medium", "axis": "wght", "value": 500}, + # {"name": "Bold", "axis": "wght", "value": 600}, + # {"name": "Condensed", "axis": "wdth", "value": 75}, + # {"name": "Normal", "axis": "wdth", "value": 100, "flags": 0x2}, + # ] + # # Let's instantiate a partial font which has a pinned wght axis and an + # unpinned width axis. + # >>> axisLimits = {"wght": 500, "width": AxisRange(75, 100)} + # >>> updateNameTable(varfont, axisLimits) + # + # AxisValues remaining after task 1.3: + # axisValues = [ + # {"name": "Medium", "axis": "wght", "value": 500}, + # {"name": "Normal", "axis": "wdth", "value": 100, "flags": 0x2} + # ] + # + # AxisValues remaining after completing all 1.x tasks: + # axisValues = [{"name": "Medium", "axis": "wght", "value": 500}] + # The Normal AxisValue is removed because it has the + # Elidable_AXIS_VALUE_NAME flag set. + # + # # AxisValues after separating into two groups in task 2.1: + # ribbiAxisValues = [] + # nonRibbiAxisValues = [{"name": "Medium", "axis": "wght", "value": 500}] + # + # # Names created from AxisValues in task 2.2a for Win US English platform: + # subFamilyName = "" + # typoSubFamilyName = "Medium" + # + # NameRecords updated in task 2.2b for Win US English platform: + # NameID 1 familyName: "Open Sans" --> "Open Sans Medium" + # NameID 2 subFamilyName: "Regular" --> "Regular" + # NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \ + # "3.000;GOOG;OpenSans-Medium" + # NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Medium" + # NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Medium" + # NameID 16 Typographic Family name: None --> "Open Sans" + # NameID 17 Typographic Subfamily name: None --> "Medium" + # + # Notes on name table record updates: + # - Typographic names have been added since Medium is a non-Ribbi name. + # - Neither the before or after name records include the Width AxisValue + # names because the "Normal" AxisValue has the + # Elidable_AXIS_VALUE_NAME flag set. + # If we instantiate the same font but pin the wdth axis to 75, + # the "Condensed" AxisValue will be included. + # - For info regarding how RIBBI and non-RIBBI can be constructed see: + # https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids + if "STAT" not in varfont: raise ValueError("Cannot update name table since there is no STAT table.") stat = varfont["STAT"].table @@ -1134,42 +1223,44 @@ def updateNameTable(varfont, axisLimits): # 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} - axisCoords = deepcopy(axisLimits) + defaultAxisCoords = deepcopy(axisLimits) for axisTag, val in fvarDefaults.items(): - if axisTag not in axisCoords or isinstance(axisCoords[axisTag], tuple): - axisCoords[axisTag] = val + if axisTag not in defaultAxisCoords or isinstance( + defaultAxisCoords[axisTag], AxisRange + ): + defaultAxisCoords[axisTag] = val - # To get the required Axis Values for the zero origin, we can simply - # duplicate the STAT table and instantiate it using the axis coords we - # created in the previous step. - stat_new = deepcopy(stat) - _instantiateSTAT(stat_new, axisCoords) - checkMissingAxisValues(stat_new, axisCoords) + axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords) + checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords) - axisValueTables = stat_new.AxisValueArray.AxisValue - # Remove axis Values which have Elidable_AXIS_VALUE_NAME flag set. + # Remove axis Values which have ELIDABLE_AXIS_VALUE_NAME flag set. # Axis Values which have this flag enabled won't be visible in # application font menus. axisValueTables = [ - v for v in axisValueTables if v.Flags & ELIDABLE_AXIS_VALUE_NAME != 2 + v for v in axisValueTables if not v.Flags & ELIDABLE_AXIS_VALUE_NAME ] - stat_new.AxisValueArray.AxisValue = axisValueTables - axisValueTables = _sortedAxisValues(stat_new, axisCoords) + axisValueTables = _sortAxisValues(axisValueTables) _updateNameRecords(varfont, axisValueTables) -def checkMissingAxisValues(stat, axisCoords): +def checkAxisValuesExist(stat, axisValues, axisCoords): seen = set() - axisValues = stat.AxisValueArray.AxisValue designAxes = stat.DesignAxisRecord.Axis - for val in axisValues: - if val.Format == 4: - for rec in val.AxisValueRecord: - axisTag = designAxes[rec.AxisIndex].AxisTag + 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) - else: - axisTag = designAxes[val.AxisIndex].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: @@ -1177,11 +1268,9 @@ def checkMissingAxisValues(stat, axisCoords): raise ValueError(f"Cannot find Axis Values [{missing}]") -def _sortedAxisValues(stat, axisCoords): +def _sortAxisValues(axisValues): # Sort and remove duplicates ensuring that format 4 Axis Values # are dominant - axisValues = stat.AxisValueArray.AxisValue - designAxes = stat.DesignAxisRecord.Axis results = [] seenAxes = set() # Sort format 4 axes so the tables with the most AxisValueRecords @@ -1320,11 +1409,11 @@ def _updateNameTableStyleRecords( ): nametable.removeNames(nameID=nameID) - newFamilyName = nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs.get( - NameID.FAMILY_NAME + newFamilyName = ( + nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs[NameID.FAMILY_NAME] ) - newStyleName = nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or nameIDs.get( - NameID.SUBFAMILY_NAME + newStyleName = ( + nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or nameIDs[NameID.SUBFAMILY_NAME] ) nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}" @@ -1373,23 +1462,20 @@ def _updateUniqueIdNameRecord(varfont, nameIDs, platform): if not currentRecord: return None - def isSubString(string1, string2): - if string2 in string1: - return True - return False - # Check if full name and postscript name are a substring of currentRecord - for nameID in (4, 6): + for nameID in (NameID.FULL_FONT_NAME, NameID.POSTSCRIPT_NAME): nameRecord = nametable.getName(nameID, *platform) if not nameRecord: continue - if isSubString(currentRecord.toUnicode(), nameRecord.toUnicode()): + if currentRecord.toUnicode() in nameRecord.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) - vendor = varfont["OS/2"].achVendID.strip() + 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}" @@ -1546,7 +1632,7 @@ def instantiateVariableFont( varfont = deepcopy(varfont) if updateFontNames: - log.info("Updating nametable") + log.info("Updating name table") updateNameTable(varfont, axisLimits) if "gvar" in varfont: @@ -1693,9 +1779,9 @@ def parseArgs(args): "when generating a full instance). Requires skia-pathops", ) parser.add_argument( - "--update-nametable", + "--update-name-table", action="store_true", - help="Update the instantiated font's nametable. Input font must have " + 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) @@ -1749,7 +1835,7 @@ def main(args=None): inplace=True, optimize=options.optimize, overlap=options.overlap, - updateFontNames=options.update_nametable, + updateFontNames=options.update_name_table, ) outfile = ( diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 332924c46..6b23bfde2 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -2000,6 +2000,7 @@ def test_updatetNameTable_axis_order(varfont): def test_updateNameTable_with_multilingual_names(varfont): name = varfont["name"] + # langID 0x405 is the Czech Windows langID name.setName("Test Variable Font", 1, 3, 1, 0x405) name.setName("Normal", 2, 3, 1, 0x405) name.setName("Normal", 261, 3, 1, 0x405) # nameID 261=Regular STAT entry @@ -2035,17 +2036,16 @@ def test_updateNameTable_with_multilingual_names(varfont): def test_updateNametable_partial(varfont): - instancer.updateNameTable(varfont, {"wdth": 79, "wght": (400, 900)}) + instancer.updateNameTable(varfont, {"wdth": 79, "wght": instancer.AxisRange(400, 900)}) names = _get_name_records(varfont) assert names[(1, 3, 1, 0x409)] == "Test Variable Font Condensed" assert names[(2, 3, 1, 0x409)] == "Regular" - assert (3, 3, 1, 0x405) not in names assert names[(16, 3, 1, 0x409)] == "Test Variable Font" assert names[(17, 3, 1, 0x409)] == "Condensed" def test_updateNameTable_missing_axisValues(varfont): - with pytest.raises(ValueError, match="Cannot find Axis Value Tables \['wght=200'\]"): + with pytest.raises(ValueError, match="Cannot find Axis Values \['wght=200'\]"): instancer.updateNameTable(varfont, {"wght": 200}) From 0280eb36cc0551c2e61254fcc5942dfd28e7edaa Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Mon, 1 Feb 2021 15:37:16 +0000 Subject: [PATCH 148/167] Parametrize updateNameTable tests --- Tests/varLib/instancer_test.py | 306 ++++++++++++++++++++------------- 1 file changed, 189 insertions(+), 117 deletions(-) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 6b23bfde2..e4fc81bc9 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -145,7 +145,7 @@ class InstantiateGvarTest(object): assert "gvar" not in varfont def test_composite_glyph_not_in_gvar(self, varfont): - """ The 'minus' glyph is a composite glyph, which references 'hyphen' as a + """The 'minus' glyph is a composite glyph, which references 'hyphen' as a component, but has no tuple variations in gvar table, so the component offset and the phantom points do not change; however the sidebearings and bounding box do change as a result of the parent glyph 'hyphen' changing. @@ -1917,54 +1917,106 @@ def test_normalizeAxisLimits_missing_from_fvar(varfont): instancer.normalizeAxisLimits(varfont, {"ZZZZ": 1000}) -def _get_name_records(varfont): +def _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x409]): nametable = varfont["name"] - return { + font_names = { (r.nameID, r.platformID, r.platEncID, r.langID): r.toUnicode() for r in nametable.names } + for k in expected: + if k[-1] not in platforms: + continue + assert font_names[k] == expected[k] + if isNonRIBBI: + font_nameids = set(i[0] for i in font_names) + assert 16 in font_nameids + assert 17 in font_nameids -def test_updateNameTable_with_registered_axes(varfont): - # Regular - instancer.updateNameTable(varfont, {"wght": 400}) - names = _get_name_records(varfont) - assert names[(1, 3, 1, 0x409)] == "Test Variable Font" - assert names[(2, 3, 1, 0x0409)] == "Regular" - assert names[(3, 3, 1, 0x0409)] == "2.001;GOOG;TestVariableFont-Regular" - assert names[(6, 3, 1, 0x409)] == "TestVariableFont-Regular" - assert (16, 3, 1, 0x409) not in names - assert (17, 3, 1, 0x409) not in names - - # Black - instancer.updateNameTable(varfont, {"wght": 900}) - names = _get_name_records(varfont) - assert names[(1, 3, 1, 0x409)] == "Test Variable Font Black" - assert names[(2, 3, 1, 0x409)] == "Regular" - assert names[(3, 3, 1, 0x0409)] == "2.001;GOOG;TestVariableFont-Black" - assert names[(6, 3, 1, 0x409)] == "TestVariableFont-Black" - assert names[(16, 3, 1, 0x409)] == "Test Variable Font" - assert names[(17, 3, 1, 0x409)] == "Black" - - # Thin - instancer.updateNameTable(varfont, {"wght": 100}) - names = _get_name_records(varfont) - assert names[(1, 3, 1, 0x409)] == "Test Variable Font Thin" - assert names[(2, 3, 1, 0x409)] == "Regular" - assert names[(3, 3, 1, 0x0409)] == "2.001;GOOG;TestVariableFont-Thin" - assert names[(6, 3, 1, 0x409)] == "TestVariableFont-Thin" - assert names[(16, 3, 1, 0x409)] == "Test Variable Font" - assert names[(17, 3, 1, 0x409)] == "Thin" - - # Thin Condensed - instancer.updateNameTable(varfont, {"wdth": 79, "wght": 100}) - names = _get_name_records(varfont) - assert names[(1, 3, 1, 0x409)] == "Test Variable Font Thin Condensed" - assert names[(2, 3, 1, 0x409)] == "Regular" - assert names[(3, 3, 1, 0x0409)] == "2.001;GOOG;TestVariableFont-ThinCondensed" - assert names[(6, 3, 1, 0x409)] == "TestVariableFont-ThinCondensed" - assert names[(16, 3, 1, 0x409)] == "Test Variable Font" - assert names[(17, 3, 1, 0x409)] == "Thin Condensed" +@pytest.mark.parametrize( + "limits, expected, isNonRIBBI", + [ + # Regular + ( + {"wght": 400}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Regular", + (6, 3, 1, 0x409): "TestVariableFont-Regular", + }, + False, + ), + # Regular Normal (width axis Normal isn't included since it is elided) + ( + {"wght": 400, "wdth": 100}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Regular", + (6, 3, 1, 0x409): "TestVariableFont-Regular", + }, + False, + ), + # Black + ( + {"wght": 900}, + { + (1, 3, 1, 0x409): "Test Variable Font Black", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Black", + (6, 3, 1, 0x409): "TestVariableFont-Black", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Black", + }, + True, + ), + # Thin + ( + {"wght": 100}, + { + (1, 3, 1, 0x409): "Test Variable Font Thin", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Thin", + (6, 3, 1, 0x409): "TestVariableFont-Thin", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Thin", + }, + True, + ), + # Thin Condensed + ( + {"wght": 100, "wdth": 79}, + { + (1, 3, 1, 0x409): "Test Variable Font Thin Condensed", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-ThinCondensed", + (6, 3, 1, 0x409): "TestVariableFont-ThinCondensed", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Thin Condensed", + }, + True, + ), + # Condensed with unpinned weights + ( + {"wdth": 79, "wght": instancer.AxisRange(400, 900)}, + { + (1, 3, 1, 0x409): "Test Variable Font Condensed", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Condensed", + (6, 3, 1, 0x409): "TestVariableFont-Condensed", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Condensed", + }, + True, + ), + ], +) +def test_updateNameTable_with_registered_axes_ribbi( + varfont, limits, expected, isNonRIBBI +): + instancer.updateNameTable(varfont, limits) + _test_name_records(varfont, expected, isNonRIBBI) def test_updatetNameTable_axis_order(varfont): @@ -1973,7 +2025,7 @@ def test_updatetNameTable_axis_order(varfont): tag="wght", name="Weight", values=[ - dict(value=400, name='Regular'), + dict(value=400, name="Regular"), ], ), dict( @@ -1981,67 +2033,69 @@ def test_updatetNameTable_axis_order(varfont): name="Width", values=[ dict(value=75, name="Condensed"), - ] - ) + ], + ), ] + nametable = varfont["name"] buildStatTable(varfont, axes) instancer.updateNameTable(varfont, {"wdth": 75, "wght": 400}) - names = _get_name_records(varfont) - assert names[(17, 3, 1, 0x409)] == "Regular Condensed" + assert nametable.getName(17, 3, 1, 0x409).toUnicode() == "Regular Condensed" # Swap the axes so the names get swapped axes[0], axes[1] = axes[1], axes[0] buildStatTable(varfont, axes) instancer.updateNameTable(varfont, {"wdth": 75, "wght": 400}) - names = _get_name_records(varfont) - assert names[(17, 3, 1, 0x409)] == "Condensed Regular" + assert nametable.getName(17, 3, 1, 0x409).toUnicode() == "Condensed Regular" -def test_updateNameTable_with_multilingual_names(varfont): +@pytest.mark.parametrize( + "limits, expected, isNonRIBBI", + [ + # Regular | Normal + ( + {"wght": 400}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Normal", + }, + False, + ), + # Black | Negreta + ( + {"wght": 900}, + { + (1, 3, 1, 0x409): "Test Variable Font Negreta", + (2, 3, 1, 0x409): "Normal", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Negreta", + }, + True, + ), + # Black Condensed | Negreta ZhuÅ”těnĆ© + ( + {"wght": 900, "wdth": 79}, + { + (1, 3, 1, 0x409): "Test Variable Font Negreta ZhuÅ”těnĆ©", + (2, 3, 1, 0x409): "Normal", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Negreta ZhuÅ”těnĆ©", + }, + True, + ), + ], +) +def test_updateNameTable_with_multilingual_names(varfont, limits, expected, isNonRIBBI): name = varfont["name"] # langID 0x405 is the Czech Windows langID name.setName("Test Variable Font", 1, 3, 1, 0x405) name.setName("Normal", 2, 3, 1, 0x405) - name.setName("Normal", 261, 3, 1, 0x405) # nameID 261=Regular STAT entry - name.setName("Negreta",266, 3, 1, 0x405) # nameID 266=Black STAT entry - name.setName("ZhuÅ”těnĆ©", 279, 3, 1, 0x405) # nameID 279=Condensed STAT entry + name.setName("Normal", 261, 3, 1, 0x405) # nameID 261=Regular STAT entry + name.setName("Negreta", 266, 3, 1, 0x405) # nameID 266=Black STAT entry + name.setName("ZhuÅ”těnĆ©", 279, 3, 1, 0x405) # nameID 279=Condensed STAT entry - # Regular | Normal - instancer.updateNameTable(varfont, {"wght": 400}) - names = _get_name_records(varfont) - assert names[(1, 3, 1, 0x405)] == "Test Variable Font" - assert names[(2, 3, 1, 0x405)] == "Normal" - assert (3, 3, 1, 0x405) not in names - assert (16, 3, 1, 0x405) not in names - assert (17, 3, 1, 0x405) not in names - - # Black | Negreta - instancer.updateNameTable(varfont, {"wght": 900}) - names = _get_name_records(varfont) - assert names[(1, 3, 1, 0x405)] == "Test Variable Font Negreta" - assert names[(2, 3, 1, 0x405)] == "Normal" - assert (3, 3, 1, 0x405) not in names - assert names[(16, 3, 1, 0x405)] == "Test Variable Font" - assert names[(17, 3, 1, 0x405)] == "Negreta" - - # Black Condensed | Negreta ZhuÅ”těnĆ© - instancer.updateNameTable(varfont, {"wdth": 79, "wght": 900}) - names = _get_name_records(varfont) - assert names[(1, 3, 1, 0x405)] == "Test Variable Font Negreta ZhuÅ”těnĆ©" - assert names[(2, 3, 1, 0x405)] == "Normal" - assert (3, 3, 1, 0x405) not in names - assert names[(16, 3, 1, 0x405)] == "Test Variable Font" - assert names[(17, 3, 1, 0x405)] == "Negreta ZhuÅ”těnĆ©" - - -def test_updateNametable_partial(varfont): - instancer.updateNameTable(varfont, {"wdth": 79, "wght": instancer.AxisRange(400, 900)}) - names = _get_name_records(varfont) - assert names[(1, 3, 1, 0x409)] == "Test Variable Font Condensed" - assert names[(2, 3, 1, 0x409)] == "Regular" - assert names[(16, 3, 1, 0x409)] == "Test Variable Font" - assert names[(17, 3, 1, 0x409)] == "Condensed" + instancer.updateNameTable(varfont, limits) + names = _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x405]) def test_updateNameTable_missing_axisValues(varfont): @@ -2051,32 +2105,49 @@ def test_updateNameTable_missing_axisValues(varfont): def test_updateNameTable_missing_stat(varfont): del varfont["STAT"] - with pytest.raises(ValueError, match="Cannot update name table since there is no STAT table."): + with pytest.raises( + ValueError, match="Cannot update name table since there is no STAT table." + ): instancer.updateNameTable(varfont, {"wght": 400}) -def test_updateNameTable_vf_with_italic_attribute(varfont): +@pytest.mark.parametrize( + "limits, expected, isNonRIBBI", + [ + # Regular | Normal + ( + {"wght": 400}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Italic", + (6, 3, 1, 0x409): "TestVariableFont-Italic", + }, + False, + ), + # Black Condensed Italic + ( + {"wght": 900, "wdth": 79}, + { + (1, 3, 1, 0x409): "Test Variable Font Black Condensed", + (2, 3, 1, 0x409): "Italic", + (6, 3, 1, 0x409): "TestVariableFont-BlackCondensedItalic", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Black Condensed Italic", + }, + True, + ), + ], +) +def test_updateNameTable_vf_with_italic_attribute( + varfont, limits, expected, isNonRIBBI +): font_link_axisValue = varfont["STAT"].table.AxisValueArray.AxisValue[4] # Unset ELIDABLE_AXIS_VALUE_NAME flag font_link_axisValue.Flags &= ~instancer.ELIDABLE_AXIS_VALUE_NAME - font_link_axisValue.ValueNameID = 294 # Roman --> Italic + font_link_axisValue.ValueNameID = 294 # Roman --> Italic - # Italic - instancer.updateNameTable(varfont, {"wght": 400}) - names = _get_name_records(varfont) - assert names[(1, 3, 1, 0x409)] == "Test Variable Font" - assert names[(2, 3, 1, 0x409)] == "Italic" - assert (16, 3, 1, 0x405) not in names - assert (17, 3, 1, 0x405) not in names - - # Black Condensed Italic - instancer.updateNameTable(varfont, {"wdth": 79, "wght": 900}) - names = _get_name_records(varfont) - assert names[(1, 3, 1, 0x409)] == "Test Variable Font Black Condensed" - assert names[(2, 3, 1, 0x409)] == "Italic" - assert names[(6, 3, 1, 0x409)] == "TestVariableFont-BlackCondensedItalic" - assert names[(16, 3, 1, 0x409)] == "Test Variable Font" - assert names[(17, 3, 1, 0x409)] == "Black Condensed Italic" + instancer.updateNameTable(varfont, limits) + names = _test_name_records(varfont, expected, isNonRIBBI) def test_updateNameTable_format4_axisValues(varfont): @@ -2099,11 +2170,13 @@ def test_updateNameTable_format4_axisValues(varfont): stat.AxisValueArray.AxisValue.append(axisValue) instancer.updateNameTable(varfont, {"wdth": 79, "wght": 900}) - names = _get_name_records(varfont) - assert names[(1, 3, 1, 0x409)] == "Test Variable Font Dominant Value" - assert names[(2, 3, 1, 0x409)] == "Regular" - assert names[(16, 3, 1, 0x409)] == "Test Variable Font" - assert names[(17, 3, 1, 0x409)] == "Dominant Value" + expected = { + (1, 3, 1, 0x409): "Test Variable Font Dominant Value", + (2, 3, 1, 0x409): "Regular", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Dominant Value", + } + _test_name_records(varfont, expected, isNonRIBBI=True) def test_updateNameTable_elided_axisValues(varfont): @@ -2112,15 +2185,14 @@ def test_updateNameTable_elided_axisValues(varfont): for axisValue in stat.AxisValueArray.AxisValue: axisValue.Flags |= instancer.ELIDABLE_AXIS_VALUE_NAME - stat.ElidedFallbackNameID = 266 # Regular --> Black + stat.ElidedFallbackNameID = 266 # Regular --> Black instancer.updateNameTable(varfont, {"wght": 400}) - names = _get_name_records(varfont) # Since all axis values are elided, the elided fallback name # must be used to construct the style names. Since we # changed it to Black, we need both a typoSubFamilyName and # the subFamilyName set so it conforms to the RIBBI model. - assert names[(2, 3, 1, 0x409)] == "Regular" - assert names[(17, 3, 1, 0x409)] == "Black" + expected = {(2, 3, 1, 0x409): "Regular", (17, 3, 1, 0x409): "Black"} + _test_name_records(varfont, expected, isNonRIBBI=True) def test_sanityCheckVariableTables(varfont): From 2be13d50acf3f7c89dc06bbe26f79b7da4f61106 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 15 Feb 2021 12:22:48 +0000 Subject: [PATCH 149/167] make instancer into a package dir and move all names-related funcs to submodule move instancer tests to Tests/varLib/instancer directory create instancer/__main__.py to make package executable --- .../{instancer.py => instancer/__init__.py} | 443 +----------------- Lib/fontTools/varLib/instancer/__main__.py | 5 + Lib/fontTools/varLib/instancer/names.py | 441 +++++++++++++++++ Tests/varLib/instancer/conftest.py | 13 + .../data/PartialInstancerTest-VF.ttx | 0 .../data/PartialInstancerTest2-VF.ttx | 0 .../data/PartialInstancerTest3-VF.ttx | 0 ...tialInstancerTest2-VF-instance-100,100.ttx | 0 ...ialInstancerTest2-VF-instance-100,62.5.ttx | 0 ...tialInstancerTest2-VF-instance-400,100.ttx | 0 ...ialInstancerTest2-VF-instance-400,62.5.ttx | 0 ...tialInstancerTest2-VF-instance-900,100.ttx | 0 ...ialInstancerTest2-VF-instance-900,62.5.ttx | 0 ...Test3-VF-instance-400-no-overlap-flags.ttx | 0 ...ancerTest3-VF-instance-400-no-overlaps.ttx | 0 ...ancerTest3-VF-instance-700-no-overlaps.ttx | 0 .../varLib/{ => instancer}/instancer_test.py | 312 +----------- Tests/varLib/instancer/names_test.py | 307 ++++++++++++ 18 files changed, 771 insertions(+), 750 deletions(-) rename Lib/fontTools/varLib/{instancer.py => instancer/__init__.py} (76%) create mode 100644 Lib/fontTools/varLib/instancer/__main__.py create mode 100644 Lib/fontTools/varLib/instancer/names.py create mode 100644 Tests/varLib/instancer/conftest.py rename Tests/varLib/{ => instancer}/data/PartialInstancerTest-VF.ttx (100%) rename Tests/varLib/{ => instancer}/data/PartialInstancerTest2-VF.ttx (100%) rename Tests/varLib/{ => instancer}/data/PartialInstancerTest3-VF.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx (100%) rename Tests/varLib/{ => instancer}/instancer_test.py (86%) create mode 100644 Tests/varLib/instancer/names_test.py diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer/__init__.py similarity index 76% rename from Lib/fontTools/varLib/instancer.py rename to Lib/fontTools/varLib/instancer/__init__.py index 581f52ee6..aa64cc798 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 @@ -128,21 +129,6 @@ class OverlapMode(IntEnum): REMOVE = 2 -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 instantiateTupleVariationStore( variations, axisLimits, origCoords=None, endPts=None ): @@ -1073,423 +1059,6 @@ def axisValuesFromAxisLimits(stat, axisLimits): return newAxisValueTables -def getVariationNameIDs(varfont): - used = [] - if "fvar" in varfont: - fvar = varfont["fvar"] - for axis in fvar.axes: - used.append(axis.axisNameID) - for instance in fvar.instances: - used.append(instance.subfamilyNameID) - if instance.postscriptNameID != 0xFFFF: - used.append(instance.postscriptNameID) - if "STAT" in varfont: - stat = varfont["STAT"].table - for axis in stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else (): - used.append(axis.AxisNameID) - for value in stat.AxisValueArray.AxisValue if stat.AxisValueArray else (): - used.append(value.ValueNameID) - # nameIDs <= 255 are reserved by OT spec so we don't touch them - return {nameID for nameID in used if nameID > 255} - - -@contextmanager -def pruningUnusedNames(varfont): - origNameIDs = getVariationNameIDs(varfont) - - yield - - log.info("Pruning name table") - exclude = origNameIDs - getVariationNameIDs(varfont) - varfont["name"].names[:] = [ - record for record in varfont["name"].names if record.nameID not in exclude - ] - if "ltag" in varfont: - # Drop the whole 'ltag' table if all the language-dependent Unicode name - # records that reference it have been dropped. - # TODO: Only prune unused ltag tags, renumerating langIDs accordingly. - # Note ltag can also be used by feat or morx tables, so check those too. - if not any( - record - for record in varfont["name"].names - if record.platformID == 0 and record.langID != 0xFFFF - ): - del varfont["ltag"] - - -def updateNameTable(varfont, axisLimits): - """Update an instatiated variable font's name table using the Axis - Values from the STAT table. - - The updated name table will conform to the R/I/B/BI naming model. - """ - # This task can be split into two parts: - - # Task 1: Collecting and sorting the relevant AxisValues: - # 1. First check the variable font has a STAT table and it contains - # AxisValues. - # 2. Create a dictionary which contains the pinned axes from the - # axisLimits dict and for the unpinned axes, we'll use the fvar - # default coordinates e.g - # axisLimits = {"wght": 500, "wdth": AxisRange(75, 100), our dict will - # be {"wght": 500, "wdth": 100} if the width axis has a default of 100. - # 3. Create a new list of AxisValues whose Values match the dict we just - # created. - # 4. Remove any AxisValues from the list which have the - # Elidable_AXIS_VALUE_NAME flag set. - # 5. Remove and sort AxisValues in the list so format 4 AxisValues take - # precedence. According to the MS Spec "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 - - # Task 2: Updating a name table's style and family names from a list of - # AxisValues: - # 1. Sort AxisValues into two groups. For the first group, the names must be - # any of the following ["Regular", "Italic", "Bold", "Bold Italic"]. - # This group of names is often referred to as "RIBBI" names. For the - # other group, names must be non-RIBBI e.g "Medium Italic", "Condensed" - # etc. - # 2. Repeat the next steps for each name table record platform: - # a. Create new subFamily name and Typographic subFamily name from the - # above groups. - # b. Update nameIDs 1, 2, 3, 4, 6, 16, 17 using the new name created - # in the last step. - # - # Step by step example: - # A variable font which has a width and weight axes. - # AxisValues in font (represented as simplified dicts): - # axisValues = [ - # {"name": "Light", "axis": "wght", "value": 300}, - # {"name": "Regular", "axis": "wght", "value": 400}, - # {"name": "Medium", "axis": "wght", "value": 500}, - # {"name": "Bold", "axis": "wght", "value": 600}, - # {"name": "Condensed", "axis": "wdth", "value": 75}, - # {"name": "Normal", "axis": "wdth", "value": 100, "flags": 0x2}, - # ] - # # Let's instantiate a partial font which has a pinned wght axis and an - # unpinned width axis. - # >>> axisLimits = {"wght": 500, "width": AxisRange(75, 100)} - # >>> updateNameTable(varfont, axisLimits) - # - # AxisValues remaining after task 1.3: - # axisValues = [ - # {"name": "Medium", "axis": "wght", "value": 500}, - # {"name": "Normal", "axis": "wdth", "value": 100, "flags": 0x2} - # ] - # - # AxisValues remaining after completing all 1.x tasks: - # axisValues = [{"name": "Medium", "axis": "wght", "value": 500}] - # The Normal AxisValue is removed because it has the - # Elidable_AXIS_VALUE_NAME flag set. - # - # # AxisValues after separating into two groups in task 2.1: - # ribbiAxisValues = [] - # nonRibbiAxisValues = [{"name": "Medium", "axis": "wght", "value": 500}] - # - # # Names created from AxisValues in task 2.2a for Win US English platform: - # subFamilyName = "" - # typoSubFamilyName = "Medium" - # - # NameRecords updated in task 2.2b for Win US English platform: - # NameID 1 familyName: "Open Sans" --> "Open Sans Medium" - # NameID 2 subFamilyName: "Regular" --> "Regular" - # NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \ - # "3.000;GOOG;OpenSans-Medium" - # NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Medium" - # NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Medium" - # NameID 16 Typographic Family name: None --> "Open Sans" - # NameID 17 Typographic Subfamily name: None --> "Medium" - # - # Notes on name table record updates: - # - Typographic names have been added since Medium is a non-Ribbi name. - # - Neither the before or after name records include the Width AxisValue - # names because the "Normal" AxisValue has the - # Elidable_AXIS_VALUE_NAME flag set. - # If we instantiate the same font but pin the wdth axis to 75, - # the "Condensed" AxisValue will be included. - # - For info regarding how RIBBI and non-RIBBI can be constructed see: - # https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids - - 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 must 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) - - # Remove axis Values which have ELIDABLE_AXIS_VALUE_NAME flag set. - # Axis Values which have this flag enabled won't be visible 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 and remove duplicates ensuring that format 4 Axis Values - # are dominant - 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, - ) - nonFormat4 = [v for v in axisValues if v not in format4] - - 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 nonFormat4: - 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 nameIDIsRibbi(nametable, n)] - nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs] - elidedNameID = stat.ElidedFallbackNameID - elidedNameIsRibbi = nameIDIsRibbi(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 - ) - typoSubFamilyName = " ".join( - getName(n, *platform).toUnicode() - for n in axisValueNameIDs - if nonRibbiNameIDs - ) - - # 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 nameIDIsRibbi(nametable, nameID): - engNameRecords = any( - r - for r in nametable.names - if (r.platformID, r.platEncID, r.langID) == (3, 1, 0x409) - ) - if not engNameRecords: - raise ValueError( - f"Canot determine if there are RIBBI Axis Value Tables " - "since there are no name table Records which have " - "platformID=3, platEncID=1, langID=0x409" - ) - return ( - True - if nametable.getName(nameID, 3, 1, 0x409).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) - - currentFamilyName = currentFamilyName.toUnicode() - currentStyleName = currentStyleName.toUnicode() - - nameIDs = { - NameID.FAMILY_NAME: currentFamilyName, - NameID.SUBFAMILY_NAME: subFamilyName, - } - if typoSubFamilyName: - nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip() - nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName - nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = f"{typoSubFamilyName}" - # Remove previous Typographic Family and SubFamily names since they're - # no longer required - else: - 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 - ) - nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = _updateUniqueIdNameRecord( - varfont, nameIDs, platform - ) - - for nameID, string in nameIDs.items(): - if not string: - continue - nametable.setName(string, nameID, *platform) - - -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 = familyPrefix.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 currentRecord.toUnicode() in nameRecord.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() - - def setMacOverlapFlags(glyfTable): flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND flagOverlapSimple = _g_l_y_f.flagOverlapSimple @@ -1633,7 +1202,7 @@ def instantiateVariableFont( if updateFontNames: log.info("Updating name table") - updateNameTable(varfont, axisLimits) + names.updateNameTable(varfont, axisLimits) if "gvar" in varfont: instantiateGvar(varfont, normalizedLimits, optimize=optimize) @@ -1657,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) @@ -1851,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 000000000..64ffff2b9 --- /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 000000000..7cd7515c5 --- /dev/null +++ b/Lib/fontTools/varLib/instancer/names.py @@ -0,0 +1,441 @@ +"""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 an instatiated variable font's name table using the Axis + Values from the STAT table. + + The updated name table will conform to the R/I/B/BI naming model. + """ + # This task can be split into two parts: + + # Task 1: Collecting and sorting the relevant AxisValues: + # 1. First check the variable font has a STAT table and it contains + # AxisValues. + # 2. Create a dictionary which contains the pinned axes from the + # axisLimits dict and for the unpinned axes, we'll use the fvar + # default coordinates e.g + # axisLimits = {"wght": 500, "wdth": AxisRange(75, 100), our dict will + # be {"wght": 500, "wdth": 100} if the width axis has a default of 100. + # 3. Create a new list of AxisValues whose Values match the dict we just + # created. + # 4. Remove any AxisValues from the list which have the + # Elidable_AXIS_VALUE_NAME flag set. + # 5. Remove and sort AxisValues in the list so format 4 AxisValues take + # precedence. According to the MS Spec "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 + + # Task 2: Updating a name table's style and family names from a list of + # AxisValues: + # 1. Sort AxisValues into two groups. For the first group, the names must be + # any of the following ["Regular", "Italic", "Bold", "Bold Italic"]. + # This group of names is often referred to as "RIBBI" names. For the + # other group, names must be non-RIBBI e.g "Medium Italic", "Condensed" + # etc. + # 2. Repeat the next steps for each name table record platform: + # a. Create new subFamily name and Typographic subFamily name from the + # above groups. + # b. Update nameIDs 1, 2, 3, 4, 6, 16, 17 using the new name created + # in the last step. + # + # Step by step example: + # A variable font which has a width and weight axes. + # AxisValues in font (represented as simplified dicts): + # axisValues = [ + # {"name": "Light", "axis": "wght", "value": 300}, + # {"name": "Regular", "axis": "wght", "value": 400}, + # {"name": "Medium", "axis": "wght", "value": 500}, + # {"name": "Bold", "axis": "wght", "value": 600}, + # {"name": "Condensed", "axis": "wdth", "value": 75}, + # {"name": "Normal", "axis": "wdth", "value": 100, "flags": 0x2}, + # ] + # # Let's instantiate a partial font which has a pinned wght axis and an + # unpinned width axis. + # >>> axisLimits = {"wght": 500, "width": AxisRange(75, 100)} + # >>> updateNameTable(varfont, axisLimits) + # + # AxisValues remaining after task 1.3: + # axisValues = [ + # {"name": "Medium", "axis": "wght", "value": 500}, + # {"name": "Normal", "axis": "wdth", "value": 100, "flags": 0x2} + # ] + # + # AxisValues remaining after completing all 1.x tasks: + # axisValues = [{"name": "Medium", "axis": "wght", "value": 500}] + # The Normal AxisValue is removed because it has the + # Elidable_AXIS_VALUE_NAME flag set. + # + # # AxisValues after separating into two groups in task 2.1: + # ribbiAxisValues = [] + # nonRibbiAxisValues = [{"name": "Medium", "axis": "wght", "value": 500}] + # + # # Names created from AxisValues in task 2.2a for Win US English platform: + # subFamilyName = "" + # typoSubFamilyName = "Medium" + # + # NameRecords updated in task 2.2b for Win US English platform: + # NameID 1 familyName: "Open Sans" --> "Open Sans Medium" + # NameID 2 subFamilyName: "Regular" --> "Regular" + # NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \ + # "3.000;GOOG;OpenSans-Medium" + # NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Medium" + # NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Medium" + # NameID 16 Typographic Family name: None --> "Open Sans" + # NameID 17 Typographic Subfamily name: None --> "Medium" + # + # Notes on name table record updates: + # - Typographic names have been added since Medium is a non-Ribbi name. + # - Neither the before or after name records include the Width AxisValue + # names because the "Normal" AxisValue has the + # Elidable_AXIS_VALUE_NAME flag set. + # If we instantiate the same font but pin the wdth axis to 75, + # the "Condensed" AxisValue will be included. + # - For info regarding how RIBBI and non-RIBBI can be constructed see: + # 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 must 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) + + # Remove axis Values which have ELIDABLE_AXIS_VALUE_NAME flag set. + # Axis Values which have this flag enabled won't be visible 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 and remove duplicates ensuring that format 4 Axis Values + # are dominant + 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, + ) + nonFormat4 = [v for v in axisValues if v not in format4] + + 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 nonFormat4: + 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 nameIDIsRibbi(nametable, n)] + nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs] + elidedNameID = stat.ElidedFallbackNameID + elidedNameIsRibbi = nameIDIsRibbi(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 + ) + typoSubFamilyName = " ".join( + getName(n, *platform).toUnicode() + for n in axisValueNameIDs + if nonRibbiNameIDs + ) + + # 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 nameIDIsRibbi(nametable, nameID): + engNameRecords = any( + r + for r in nametable.names + if (r.platformID, r.platEncID, r.langID) == (3, 1, 0x409) + ) + if not engNameRecords: + raise ValueError( + f"Canot determine if there are RIBBI Axis Value Tables " + "since there are no name table Records which have " + "platformID=3, platEncID=1, langID=0x409" + ) + return ( + True + if nametable.getName(nameID, 3, 1, 0x409).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) + + currentFamilyName = currentFamilyName.toUnicode() + currentStyleName = currentStyleName.toUnicode() + + nameIDs = { + NameID.FAMILY_NAME: currentFamilyName, + NameID.SUBFAMILY_NAME: subFamilyName, + } + if typoSubFamilyName: + nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip() + nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName + nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = f"{typoSubFamilyName}" + # Remove previous Typographic Family and SubFamily names since they're + # no longer required + else: + 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 + ) + nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = _updateUniqueIdNameRecord( + varfont, nameIDs, platform + ) + + for nameID, string in nameIDs.items(): + if not string: + continue + nametable.setName(string, nameID, *platform) + + +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 currentRecord.toUnicode() in nameRecord.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/Tests/varLib/instancer/conftest.py b/Tests/varLib/instancer/conftest.py new file mode 100644 index 000000000..0ac8091df --- /dev/null +++ b/Tests/varLib/instancer/conftest.py @@ -0,0 +1,13 @@ +import os +from fontTools import ttLib +import pytest + + +TESTDATA = os.path.join(os.path.dirname(__file__), "data") + + +@pytest.fixture +def varfont(): + f = ttLib.TTFont() + f.importXML(os.path.join(TESTDATA, "PartialInstancerTest-VF.ttx")) + return f diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx similarity index 100% rename from Tests/varLib/data/PartialInstancerTest-VF.ttx rename to Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx diff --git a/Tests/varLib/data/PartialInstancerTest2-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest2-VF.ttx similarity index 100% rename from Tests/varLib/data/PartialInstancerTest2-VF.ttx rename to Tests/varLib/instancer/data/PartialInstancerTest2-VF.ttx diff --git a/Tests/varLib/data/PartialInstancerTest3-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest3-VF.ttx similarity index 100% rename from Tests/varLib/data/PartialInstancerTest3-VF.ttx rename to Tests/varLib/instancer/data/PartialInstancerTest3-VF.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer/instancer_test.py similarity index 86% rename from Tests/varLib/instancer_test.py rename to Tests/varLib/instancer/instancer_test.py index e4fc81bc9..c3e0729be 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer/instancer_test.py @@ -7,7 +7,6 @@ from fontTools.ttLib.tables import _f_v_a_r, _g_l_y_f from fontTools.ttLib.tables import otTables from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools import varLib -from fontTools.otlLib.builder import buildStatTable from fontTools.varLib import instancer from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib import builder @@ -21,16 +20,11 @@ import re import pytest +# see Tests/varLib/instancer/conftest.py for "varfont" fixture definition + TESTDATA = os.path.join(os.path.dirname(__file__), "data") -@pytest.fixture -def varfont(): - f = ttLib.TTFont() - f.importXML(os.path.join(TESTDATA, "PartialInstancerTest-VF.ttx")) - return f - - @pytest.fixture(params=[True, False], ids=["optimize", "no-optimize"]) def optimize(request): return request.param @@ -1342,30 +1336,6 @@ class InstantiateSTATTest(object): assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue -def test_pruningUnusedNames(varfont): - varNameIDs = instancer.getVariationNameIDs(varfont) - - assert varNameIDs == set(range(256, 297 + 1)) - - fvar = varfont["fvar"] - stat = varfont["STAT"].table - - with instancer.pruningUnusedNames(varfont): - del fvar.axes[0] # Weight (nameID=256) - del fvar.instances[0] # Thin (nameID=258) - del stat.DesignAxisRecord.Axis[0] # Weight (nameID=256) - del stat.AxisValueArray.AxisValue[0] # Thin (nameID=258) - - assert not any(n for n in varfont["name"].names if n.nameID in {256, 258}) - - with instancer.pruningUnusedNames(varfont): - del varfont["fvar"] - del varfont["STAT"] - - assert not any(n for n in varfont["name"].names if n.nameID in varNameIDs) - assert "ltag" not in varfont - - def test_setMacOverlapFlags(): flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND flagOverlapSimple = _g_l_y_f.flagOverlapSimple @@ -1917,284 +1887,6 @@ def test_normalizeAxisLimits_missing_from_fvar(varfont): instancer.normalizeAxisLimits(varfont, {"ZZZZ": 1000}) -def _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x409]): - nametable = varfont["name"] - font_names = { - (r.nameID, r.platformID, r.platEncID, r.langID): r.toUnicode() - for r in nametable.names - } - for k in expected: - if k[-1] not in platforms: - continue - assert font_names[k] == expected[k] - if isNonRIBBI: - font_nameids = set(i[0] for i in font_names) - assert 16 in font_nameids - assert 17 in font_nameids - - -@pytest.mark.parametrize( - "limits, expected, isNonRIBBI", - [ - # Regular - ( - {"wght": 400}, - { - (1, 3, 1, 0x409): "Test Variable Font", - (2, 3, 1, 0x409): "Regular", - (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Regular", - (6, 3, 1, 0x409): "TestVariableFont-Regular", - }, - False, - ), - # Regular Normal (width axis Normal isn't included since it is elided) - ( - {"wght": 400, "wdth": 100}, - { - (1, 3, 1, 0x409): "Test Variable Font", - (2, 3, 1, 0x409): "Regular", - (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Regular", - (6, 3, 1, 0x409): "TestVariableFont-Regular", - }, - False, - ), - # Black - ( - {"wght": 900}, - { - (1, 3, 1, 0x409): "Test Variable Font Black", - (2, 3, 1, 0x409): "Regular", - (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Black", - (6, 3, 1, 0x409): "TestVariableFont-Black", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Black", - }, - True, - ), - # Thin - ( - {"wght": 100}, - { - (1, 3, 1, 0x409): "Test Variable Font Thin", - (2, 3, 1, 0x409): "Regular", - (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Thin", - (6, 3, 1, 0x409): "TestVariableFont-Thin", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Thin", - }, - True, - ), - # Thin Condensed - ( - {"wght": 100, "wdth": 79}, - { - (1, 3, 1, 0x409): "Test Variable Font Thin Condensed", - (2, 3, 1, 0x409): "Regular", - (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-ThinCondensed", - (6, 3, 1, 0x409): "TestVariableFont-ThinCondensed", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Thin Condensed", - }, - True, - ), - # Condensed with unpinned weights - ( - {"wdth": 79, "wght": instancer.AxisRange(400, 900)}, - { - (1, 3, 1, 0x409): "Test Variable Font Condensed", - (2, 3, 1, 0x409): "Regular", - (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Condensed", - (6, 3, 1, 0x409): "TestVariableFont-Condensed", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Condensed", - }, - True, - ), - ], -) -def test_updateNameTable_with_registered_axes_ribbi( - varfont, limits, expected, isNonRIBBI -): - instancer.updateNameTable(varfont, limits) - _test_name_records(varfont, expected, isNonRIBBI) - - -def test_updatetNameTable_axis_order(varfont): - axes = [ - dict( - tag="wght", - name="Weight", - values=[ - dict(value=400, name="Regular"), - ], - ), - dict( - tag="wdth", - name="Width", - values=[ - dict(value=75, name="Condensed"), - ], - ), - ] - nametable = varfont["name"] - buildStatTable(varfont, axes) - instancer.updateNameTable(varfont, {"wdth": 75, "wght": 400}) - assert nametable.getName(17, 3, 1, 0x409).toUnicode() == "Regular Condensed" - - # Swap the axes so the names get swapped - axes[0], axes[1] = axes[1], axes[0] - - buildStatTable(varfont, axes) - instancer.updateNameTable(varfont, {"wdth": 75, "wght": 400}) - assert nametable.getName(17, 3, 1, 0x409).toUnicode() == "Condensed Regular" - - -@pytest.mark.parametrize( - "limits, expected, isNonRIBBI", - [ - # Regular | Normal - ( - {"wght": 400}, - { - (1, 3, 1, 0x409): "Test Variable Font", - (2, 3, 1, 0x409): "Normal", - }, - False, - ), - # Black | Negreta - ( - {"wght": 900}, - { - (1, 3, 1, 0x409): "Test Variable Font Negreta", - (2, 3, 1, 0x409): "Normal", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Negreta", - }, - True, - ), - # Black Condensed | Negreta ZhuÅ”těnĆ© - ( - {"wght": 900, "wdth": 79}, - { - (1, 3, 1, 0x409): "Test Variable Font Negreta ZhuÅ”těnĆ©", - (2, 3, 1, 0x409): "Normal", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Negreta ZhuÅ”těnĆ©", - }, - True, - ), - ], -) -def test_updateNameTable_with_multilingual_names(varfont, limits, expected, isNonRIBBI): - name = varfont["name"] - # langID 0x405 is the Czech Windows langID - name.setName("Test Variable Font", 1, 3, 1, 0x405) - name.setName("Normal", 2, 3, 1, 0x405) - name.setName("Normal", 261, 3, 1, 0x405) # nameID 261=Regular STAT entry - name.setName("Negreta", 266, 3, 1, 0x405) # nameID 266=Black STAT entry - name.setName("ZhuÅ”těnĆ©", 279, 3, 1, 0x405) # nameID 279=Condensed STAT entry - - instancer.updateNameTable(varfont, limits) - names = _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x405]) - - -def test_updateNameTable_missing_axisValues(varfont): - with pytest.raises(ValueError, match="Cannot find Axis Values \['wght=200'\]"): - instancer.updateNameTable(varfont, {"wght": 200}) - - -def test_updateNameTable_missing_stat(varfont): - del varfont["STAT"] - with pytest.raises( - ValueError, match="Cannot update name table since there is no STAT table." - ): - instancer.updateNameTable(varfont, {"wght": 400}) - - -@pytest.mark.parametrize( - "limits, expected, isNonRIBBI", - [ - # Regular | Normal - ( - {"wght": 400}, - { - (1, 3, 1, 0x409): "Test Variable Font", - (2, 3, 1, 0x409): "Italic", - (6, 3, 1, 0x409): "TestVariableFont-Italic", - }, - False, - ), - # Black Condensed Italic - ( - {"wght": 900, "wdth": 79}, - { - (1, 3, 1, 0x409): "Test Variable Font Black Condensed", - (2, 3, 1, 0x409): "Italic", - (6, 3, 1, 0x409): "TestVariableFont-BlackCondensedItalic", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Black Condensed Italic", - }, - True, - ), - ], -) -def test_updateNameTable_vf_with_italic_attribute( - varfont, limits, expected, isNonRIBBI -): - font_link_axisValue = varfont["STAT"].table.AxisValueArray.AxisValue[4] - # Unset ELIDABLE_AXIS_VALUE_NAME flag - font_link_axisValue.Flags &= ~instancer.ELIDABLE_AXIS_VALUE_NAME - font_link_axisValue.ValueNameID = 294 # Roman --> Italic - - instancer.updateNameTable(varfont, limits) - names = _test_name_records(varfont, expected, isNonRIBBI) - - -def test_updateNameTable_format4_axisValues(varfont): - # format 4 axisValues should dominate the other axisValues - stat = varfont["STAT"].table - - axisValue = otTables.AxisValue() - axisValue.Format = 4 - axisValue.Flags = 0 - varfont["name"].setName("Dominant Value", 297, 3, 1, 0x409) - axisValue.ValueNameID = 297 - axisValue.AxisValueRecord = [] - for tag, value in (("wght", 900), ("wdth", 79)): - rec = otTables.AxisValueRecord() - rec.AxisIndex = next( - i for i, a in enumerate(stat.DesignAxisRecord.Axis) if a.AxisTag == tag - ) - rec.Value = value - axisValue.AxisValueRecord.append(rec) - stat.AxisValueArray.AxisValue.append(axisValue) - - instancer.updateNameTable(varfont, {"wdth": 79, "wght": 900}) - expected = { - (1, 3, 1, 0x409): "Test Variable Font Dominant Value", - (2, 3, 1, 0x409): "Regular", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Dominant Value", - } - _test_name_records(varfont, expected, isNonRIBBI=True) - - -def test_updateNameTable_elided_axisValues(varfont): - stat = varfont["STAT"].table - # set ELIDABLE_AXIS_VALUE_NAME flag for all axisValues - for axisValue in stat.AxisValueArray.AxisValue: - axisValue.Flags |= instancer.ELIDABLE_AXIS_VALUE_NAME - - stat.ElidedFallbackNameID = 266 # Regular --> Black - instancer.updateNameTable(varfont, {"wght": 400}) - # Since all axis values are elided, the elided fallback name - # must be used to construct the style names. Since we - # changed it to Black, we need both a typoSubFamilyName and - # the subFamilyName set so it conforms to the RIBBI model. - expected = {(2, 3, 1, 0x409): "Regular", (17, 3, 1, 0x409): "Black"} - _test_name_records(varfont, expected, isNonRIBBI=True) - - def test_sanityCheckVariableTables(varfont): font = ttLib.TTFont() with pytest.raises(ValueError, match="Missing required table fvar"): diff --git a/Tests/varLib/instancer/names_test.py b/Tests/varLib/instancer/names_test.py new file mode 100644 index 000000000..27446e373 --- /dev/null +++ b/Tests/varLib/instancer/names_test.py @@ -0,0 +1,307 @@ +from fontTools.ttLib.tables import otTables +from fontTools.otlLib.builder import buildStatTable +from fontTools.varLib import instancer + +import pytest + + +def test_pruningUnusedNames(varfont): + varNameIDs = instancer.names.getVariationNameIDs(varfont) + + assert varNameIDs == set(range(256, 297 + 1)) + + fvar = varfont["fvar"] + stat = varfont["STAT"].table + + with instancer.names.pruningUnusedNames(varfont): + del fvar.axes[0] # Weight (nameID=256) + del fvar.instances[0] # Thin (nameID=258) + del stat.DesignAxisRecord.Axis[0] # Weight (nameID=256) + del stat.AxisValueArray.AxisValue[0] # Thin (nameID=258) + + assert not any(n for n in varfont["name"].names if n.nameID in {256, 258}) + + with instancer.names.pruningUnusedNames(varfont): + del varfont["fvar"] + del varfont["STAT"] + + assert not any(n for n in varfont["name"].names if n.nameID in varNameIDs) + assert "ltag" not in varfont + + +def _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x409]): + nametable = varfont["name"] + font_names = { + (r.nameID, r.platformID, r.platEncID, r.langID): r.toUnicode() + for r in nametable.names + } + for k in expected: + if k[-1] not in platforms: + continue + assert font_names[k] == expected[k] + if isNonRIBBI: + font_nameids = set(i[0] for i in font_names) + assert 16 in font_nameids + assert 17 in font_nameids + + +@pytest.mark.parametrize( + "limits, expected, isNonRIBBI", + [ + # Regular + ( + {"wght": 400}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Regular", + (6, 3, 1, 0x409): "TestVariableFont-Regular", + }, + False, + ), + # Regular Normal (width axis Normal isn't included since it is elided) + ( + {"wght": 400, "wdth": 100}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Regular", + (6, 3, 1, 0x409): "TestVariableFont-Regular", + }, + False, + ), + # Black + ( + {"wght": 900}, + { + (1, 3, 1, 0x409): "Test Variable Font Black", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Black", + (6, 3, 1, 0x409): "TestVariableFont-Black", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Black", + }, + True, + ), + # Thin + ( + {"wght": 100}, + { + (1, 3, 1, 0x409): "Test Variable Font Thin", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Thin", + (6, 3, 1, 0x409): "TestVariableFont-Thin", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Thin", + }, + True, + ), + # Thin Condensed + ( + {"wght": 100, "wdth": 79}, + { + (1, 3, 1, 0x409): "Test Variable Font Thin Condensed", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-ThinCondensed", + (6, 3, 1, 0x409): "TestVariableFont-ThinCondensed", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Thin Condensed", + }, + True, + ), + # Condensed with unpinned weights + ( + {"wdth": 79, "wght": instancer.AxisRange(400, 900)}, + { + (1, 3, 1, 0x409): "Test Variable Font Condensed", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Condensed", + (6, 3, 1, 0x409): "TestVariableFont-Condensed", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Condensed", + }, + True, + ), + ], +) +def test_updateNameTable_with_registered_axes_ribbi( + varfont, limits, expected, isNonRIBBI +): + instancer.names.updateNameTable(varfont, limits) + _test_name_records(varfont, expected, isNonRIBBI) + + +def test_updatetNameTable_axis_order(varfont): + axes = [ + dict( + tag="wght", + name="Weight", + values=[ + dict(value=400, name="Regular"), + ], + ), + dict( + tag="wdth", + name="Width", + values=[ + dict(value=75, name="Condensed"), + ], + ), + ] + nametable = varfont["name"] + buildStatTable(varfont, axes) + instancer.names.updateNameTable(varfont, {"wdth": 75, "wght": 400}) + assert nametable.getName(17, 3, 1, 0x409).toUnicode() == "Regular Condensed" + + # Swap the axes so the names get swapped + axes[0], axes[1] = axes[1], axes[0] + + buildStatTable(varfont, axes) + instancer.names.updateNameTable(varfont, {"wdth": 75, "wght": 400}) + assert nametable.getName(17, 3, 1, 0x409).toUnicode() == "Condensed Regular" + + +@pytest.mark.parametrize( + "limits, expected, isNonRIBBI", + [ + # Regular | Normal + ( + {"wght": 400}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Normal", + }, + False, + ), + # Black | Negreta + ( + {"wght": 900}, + { + (1, 3, 1, 0x409): "Test Variable Font Negreta", + (2, 3, 1, 0x409): "Normal", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Negreta", + }, + True, + ), + # Black Condensed | Negreta ZhuÅ”těnĆ© + ( + {"wght": 900, "wdth": 79}, + { + (1, 3, 1, 0x409): "Test Variable Font Negreta ZhuÅ”těnĆ©", + (2, 3, 1, 0x409): "Normal", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Negreta ZhuÅ”těnĆ©", + }, + True, + ), + ], +) +def test_updateNameTable_with_multilingual_names(varfont, limits, expected, isNonRIBBI): + name = varfont["name"] + # langID 0x405 is the Czech Windows langID + name.setName("Test Variable Font", 1, 3, 1, 0x405) + name.setName("Normal", 2, 3, 1, 0x405) + name.setName("Normal", 261, 3, 1, 0x405) # nameID 261=Regular STAT entry + name.setName("Negreta", 266, 3, 1, 0x405) # nameID 266=Black STAT entry + name.setName("ZhuÅ”těnĆ©", 279, 3, 1, 0x405) # nameID 279=Condensed STAT entry + + instancer.names.updateNameTable(varfont, limits) + _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x405]) + + +def test_updateNameTable_missing_axisValues(varfont): + with pytest.raises(ValueError, match="Cannot find Axis Values \['wght=200'\]"): + instancer.names.updateNameTable(varfont, {"wght": 200}) + + +def test_updateNameTable_missing_stat(varfont): + del varfont["STAT"] + with pytest.raises( + ValueError, match="Cannot update name table since there is no STAT table." + ): + instancer.names.updateNameTable(varfont, {"wght": 400}) + + +@pytest.mark.parametrize( + "limits, expected, isNonRIBBI", + [ + # Regular | Normal + ( + {"wght": 400}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Italic", + (6, 3, 1, 0x409): "TestVariableFont-Italic", + }, + False, + ), + # Black Condensed Italic + ( + {"wght": 900, "wdth": 79}, + { + (1, 3, 1, 0x409): "Test Variable Font Black Condensed", + (2, 3, 1, 0x409): "Italic", + (6, 3, 1, 0x409): "TestVariableFont-BlackCondensedItalic", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Black Condensed Italic", + }, + True, + ), + ], +) +def test_updateNameTable_vf_with_italic_attribute( + varfont, limits, expected, isNonRIBBI +): + font_link_axisValue = varfont["STAT"].table.AxisValueArray.AxisValue[4] + # Unset ELIDABLE_AXIS_VALUE_NAME flag + font_link_axisValue.Flags &= ~instancer.names.ELIDABLE_AXIS_VALUE_NAME + font_link_axisValue.ValueNameID = 294 # Roman --> Italic + + instancer.names.updateNameTable(varfont, limits) + _test_name_records(varfont, expected, isNonRIBBI) + + +def test_updateNameTable_format4_axisValues(varfont): + # format 4 axisValues should dominate the other axisValues + stat = varfont["STAT"].table + + axisValue = otTables.AxisValue() + axisValue.Format = 4 + axisValue.Flags = 0 + varfont["name"].setName("Dominant Value", 297, 3, 1, 0x409) + axisValue.ValueNameID = 297 + axisValue.AxisValueRecord = [] + for tag, value in (("wght", 900), ("wdth", 79)): + rec = otTables.AxisValueRecord() + rec.AxisIndex = next( + i for i, a in enumerate(stat.DesignAxisRecord.Axis) if a.AxisTag == tag + ) + rec.Value = value + axisValue.AxisValueRecord.append(rec) + stat.AxisValueArray.AxisValue.append(axisValue) + + instancer.names.updateNameTable(varfont, {"wdth": 79, "wght": 900}) + expected = { + (1, 3, 1, 0x409): "Test Variable Font Dominant Value", + (2, 3, 1, 0x409): "Regular", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Dominant Value", + } + _test_name_records(varfont, expected, isNonRIBBI=True) + + +def test_updateNameTable_elided_axisValues(varfont): + stat = varfont["STAT"].table + # set ELIDABLE_AXIS_VALUE_NAME flag for all axisValues + for axisValue in stat.AxisValueArray.AxisValue: + axisValue.Flags |= instancer.names.ELIDABLE_AXIS_VALUE_NAME + + stat.ElidedFallbackNameID = 266 # Regular --> Black + instancer.names.updateNameTable(varfont, {"wght": 400}) + # Since all axis values are elided, the elided fallback name + # must be used to construct the style names. Since we + # changed it to Black, we need both a typoSubFamilyName and + # the subFamilyName set so it conforms to the RIBBI model. + expected = {(2, 3, 1, 0x409): "Regular", (17, 3, 1, 0x409): "Black"} + _test_name_records(varfont, expected, isNonRIBBI=True) From fcc02826b47f4cd349d37af7825b8e1da63e0988 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Tue, 16 Feb 2021 09:36:15 +0000 Subject: [PATCH 150/167] Implement Cosimo feedback from previous pr --- Lib/fontTools/varLib/instancer/__init__.py | 6 +- Lib/fontTools/varLib/instancer/names.py | 156 +++++++-------------- 2 files changed, 55 insertions(+), 107 deletions(-) diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py index aa64cc798..4f00ed7be 100644 --- a/Lib/fontTools/varLib/instancer/__init__.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -1180,11 +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 nametable using + 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, an Error will be - raised. + 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)) diff --git a/Lib/fontTools/varLib/instancer/names.py b/Lib/fontTools/varLib/instancer/names.py index 7cd7515c5..ed15fe2d0 100644 --- a/Lib/fontTools/varLib/instancer/names.py +++ b/Lib/fontTools/varLib/instancer/names.py @@ -68,99 +68,38 @@ def pruningUnusedNames(varfont): def updateNameTable(varfont, axisLimits): - """Update an instatiated variable font's name table using the Axis - Values from the STAT table. + """Update an instatiated variable font's name table using the + AxisValues from the STAT table. The updated name table will conform to the R/I/B/BI naming model. + R/I/B/BI is an acronym for (Regular, Italic, Bold, Bold Italic) font + styles. + + This task can be split into two parts: + + Task 1: Collect and sort the relevant AxisValues into a new list which + only includes AxisValues whose coordinates match the new default + axis locations. We also skip any AxisValues which are elided. + + Task 2: Update the name table's style and family names records using the + AxisValues found in step 1. The MS spec provides further info for applying + the R/I/B/BI model to each name record: + https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids + + 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" """ - # This task can be split into two parts: - - # Task 1: Collecting and sorting the relevant AxisValues: - # 1. First check the variable font has a STAT table and it contains - # AxisValues. - # 2. Create a dictionary which contains the pinned axes from the - # axisLimits dict and for the unpinned axes, we'll use the fvar - # default coordinates e.g - # axisLimits = {"wght": 500, "wdth": AxisRange(75, 100), our dict will - # be {"wght": 500, "wdth": 100} if the width axis has a default of 100. - # 3. Create a new list of AxisValues whose Values match the dict we just - # created. - # 4. Remove any AxisValues from the list which have the - # Elidable_AXIS_VALUE_NAME flag set. - # 5. Remove and sort AxisValues in the list so format 4 AxisValues take - # precedence. According to the MS Spec "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 - - # Task 2: Updating a name table's style and family names from a list of - # AxisValues: - # 1. Sort AxisValues into two groups. For the first group, the names must be - # any of the following ["Regular", "Italic", "Bold", "Bold Italic"]. - # This group of names is often referred to as "RIBBI" names. For the - # other group, names must be non-RIBBI e.g "Medium Italic", "Condensed" - # etc. - # 2. Repeat the next steps for each name table record platform: - # a. Create new subFamily name and Typographic subFamily name from the - # above groups. - # b. Update nameIDs 1, 2, 3, 4, 6, 16, 17 using the new name created - # in the last step. - # - # Step by step example: - # A variable font which has a width and weight axes. - # AxisValues in font (represented as simplified dicts): - # axisValues = [ - # {"name": "Light", "axis": "wght", "value": 300}, - # {"name": "Regular", "axis": "wght", "value": 400}, - # {"name": "Medium", "axis": "wght", "value": 500}, - # {"name": "Bold", "axis": "wght", "value": 600}, - # {"name": "Condensed", "axis": "wdth", "value": 75}, - # {"name": "Normal", "axis": "wdth", "value": 100, "flags": 0x2}, - # ] - # # Let's instantiate a partial font which has a pinned wght axis and an - # unpinned width axis. - # >>> axisLimits = {"wght": 500, "width": AxisRange(75, 100)} - # >>> updateNameTable(varfont, axisLimits) - # - # AxisValues remaining after task 1.3: - # axisValues = [ - # {"name": "Medium", "axis": "wght", "value": 500}, - # {"name": "Normal", "axis": "wdth", "value": 100, "flags": 0x2} - # ] - # - # AxisValues remaining after completing all 1.x tasks: - # axisValues = [{"name": "Medium", "axis": "wght", "value": 500}] - # The Normal AxisValue is removed because it has the - # Elidable_AXIS_VALUE_NAME flag set. - # - # # AxisValues after separating into two groups in task 2.1: - # ribbiAxisValues = [] - # nonRibbiAxisValues = [{"name": "Medium", "axis": "wght", "value": 500}] - # - # # Names created from AxisValues in task 2.2a for Win US English platform: - # subFamilyName = "" - # typoSubFamilyName = "Medium" - # - # NameRecords updated in task 2.2b for Win US English platform: - # NameID 1 familyName: "Open Sans" --> "Open Sans Medium" - # NameID 2 subFamilyName: "Regular" --> "Regular" - # NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \ - # "3.000;GOOG;OpenSans-Medium" - # NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Medium" - # NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Medium" - # NameID 16 Typographic Family name: None --> "Open Sans" - # NameID 17 Typographic Subfamily name: None --> "Medium" - # - # Notes on name table record updates: - # - Typographic names have been added since Medium is a non-Ribbi name. - # - Neither the before or after name records include the Width AxisValue - # names because the "Normal" AxisValue has the - # Elidable_AXIS_VALUE_NAME flag set. - # If we instantiate the same font but pin the wdth axis to 75, - # the "Condensed" AxisValue will be included. - # - For info regarding how RIBBI and non-RIBBI can be constructed see: - # https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids from . import AxisRange, axisValuesFromAxisLimits if "STAT" not in varfont: @@ -170,7 +109,7 @@ def updateNameTable(varfont, axisLimits): raise ValueError("Cannot update name table since there are no STAT Axis Values") fvar = varfont["fvar"] - # The updated name table must reflect the new 'zero origin' of the font. + # 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} @@ -184,8 +123,8 @@ def updateNameTable(varfont, axisLimits): axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords) checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords) - # Remove axis Values which have ELIDABLE_AXIS_VALUE_NAME flag set. - # Axis Values which have this flag enabled won't be visible in + # Ignore axis Values which have ELIDABLE_AXIS_VALUE_NAME flag set. + # AxisValues which have this flag enabled won't be visible in # application font menus. axisValueTables = [ v for v in axisValueTables if not v.Flags & ELIDABLE_AXIS_VALUE_NAME @@ -220,8 +159,12 @@ def checkAxisValuesExist(stat, axisValues, axisCoords): def _sortAxisValues(axisValues): - # Sort and remove duplicates ensuring that format 4 Axis Values - # are dominant + # Sort and remove duplicates and ensure that format 4 AxisValues + # are dominant. We need format 4 AxisValues to be dominant because 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 @@ -255,10 +198,10 @@ def _updateNameRecords(varfont, axisValues): stat = varfont["STAT"].table axisValueNameIDs = [a.ValueNameID for a in axisValues] - ribbiNameIDs = [n for n in axisValueNameIDs if nameIDIsRibbi(nametable, n)] + 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 = nameIDIsRibbi(nametable, elidedNameID) + elidedNameIsRibbi = _isRibbi(nametable, elidedNameID) getName = nametable.getName platforms = set((r.platformID, r.platEncID, r.langID) for r in nametable.names) @@ -271,11 +214,13 @@ def _updateNameRecords(varfont, axisValues): subFamilyName = " ".join( getName(n, *platform).toUnicode() for n in ribbiNameIDs ) - typoSubFamilyName = " ".join( - getName(n, *platform).toUnicode() - for n in axisValueNameIDs - if nonRibbiNameIDs - ) + 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 @@ -298,7 +243,7 @@ def _updateNameRecords(varfont, axisValues): ) -def nameIDIsRibbi(nametable, nameID): +def _isRibbi(nametable, nameID): engNameRecords = any( r for r in nametable.names @@ -306,7 +251,7 @@ def nameIDIsRibbi(nametable, nameID): ) if not engNameRecords: raise ValueError( - f"Canot determine if there are RIBBI Axis Value Tables " + f"Cannot determine if there are RIBBI Axis Value Tables " "since there are no name table Records which have " "platformID=3, platEncID=1, langID=0x409" ) @@ -340,6 +285,9 @@ def _updateNameTableStyleRecords( NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platform ) or nametable.getName(NameID.SUBFAMILY_NAME, *platform) + if not all([currentFamilyName, currentStyleName]): + raise ValueError("Name table must have NameIDs 1 and 2") + currentFamilyName = currentFamilyName.toUnicode() currentStyleName = currentStyleName.toUnicode() From fcfcb78cc0d5ff946c5ab1adf88e357cbe78a909 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 18 Feb 2021 17:35:10 +0000 Subject: [PATCH 151/167] make docstring shorter; fix _isRibbi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit return True only if a corresponding english record is found and matches R/I/B/BI, else return False. We don't really care if there are any otherĀ unrelated english records, we care about this specific one, whether it's RIBBI or not minor --- Lib/fontTools/varLib/instancer/names.py | 74 ++++++++++--------------- 1 file changed, 30 insertions(+), 44 deletions(-) diff --git a/Lib/fontTools/varLib/instancer/names.py b/Lib/fontTools/varLib/instancer/names.py index ed15fe2d0..42f9ed4c7 100644 --- a/Lib/fontTools/varLib/instancer/names.py +++ b/Lib/fontTools/varLib/instancer/names.py @@ -68,23 +68,18 @@ def pruningUnusedNames(varfont): def updateNameTable(varfont, axisLimits): - """Update an instatiated variable font's name table using the - AxisValues from the STAT table. + """Update instatiated variable font's name table using STAT AxisValues. - The updated name table will conform to the R/I/B/BI naming model. - R/I/B/BI is an acronym for (Regular, Italic, Bold, Bold Italic) font - styles. + Raises ValueError if the STAT table is missing or an Axis Value table is + missing for requested axis locations. - This task can be split into two parts: - - Task 1: Collect and sort the relevant AxisValues into a new list which - only includes AxisValues whose coordinates match the new default - axis locations. We also skip any AxisValues which are elided. - - Task 2: Update the name table's style and family names records using the - AxisValues found in step 1. The MS spec provides further info for applying - the R/I/B/BI model to each name record: - https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids + 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") @@ -99,6 +94,10 @@ def updateNameTable(varfont, axisLimits): 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 @@ -123,9 +122,7 @@ def updateNameTable(varfont, axisLimits): axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords) checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords) - # Ignore axis Values which have ELIDABLE_AXIS_VALUE_NAME flag set. - # AxisValues which have this flag enabled won't be visible in - # application font menus. + # 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 ] @@ -159,22 +156,20 @@ def checkAxisValuesExist(stat, axisValues, axisCoords): def _sortAxisValues(axisValues): - # Sort and remove duplicates and ensure that format 4 AxisValues - # are dominant. We need format 4 AxisValues to be dominant because the - # MS Spec states, "if a format 1, format 2 or format 3 table has a + # 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 + # 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, ) - nonFormat4 = [v for v in axisValues if v not in format4] for val in format4: axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord) @@ -183,7 +178,9 @@ def _sortAxisValues(axisValues): seenAxes |= axisIndexes results.append((minIndex, val)) - for val in nonFormat4: + for val in axisValues: + if val in format4: + continue axisIndex = val.AxisIndex if axisIndex not in seenAxes: seenAxes.add(axisIndex) @@ -216,8 +213,7 @@ def _updateNameRecords(varfont, axisValues): ) if nonRibbiNameIDs: typoSubFamilyName = " ".join( - getName(n, *platform).toUnicode() - for n in axisValueNameIDs + getName(n, *platform).toUnicode() for n in axisValueNameIDs ) else: typoSubFamilyName = None @@ -244,21 +240,11 @@ def _updateNameRecords(varfont, axisValues): def _isRibbi(nametable, nameID): - engNameRecords = any( - r - for r in nametable.names - if (r.platformID, r.platEncID, r.langID) == (3, 1, 0x409) - ) - if not engNameRecords: - raise ValueError( - f"Cannot determine if there are RIBBI Axis Value Tables " - "since there are no name table Records which have " - "platformID=3, platEncID=1, langID=0x409" - ) + englishRecord = nametable.getName(nameID, 3, 1, 0x409) return ( True - if nametable.getName(nameID, 3, 1, 0x409).toUnicode() - in ("Regular", "Italic", "Bold", "Bold Italic") + if englishRecord is not None + and englishRecord.toUnicode() in ("Regular", "Italic", "Bold", "Bold Italic") else False ) @@ -286,7 +272,7 @@ def _updateNameTableStyleRecords( ) or nametable.getName(NameID.SUBFAMILY_NAME, *platform) if not all([currentFamilyName, currentStyleName]): - raise ValueError("Name table must have NameIDs 1 and 2") + raise ValueError(f"Missing required NameIDs 1 and 2 for platform {platform}") currentFamilyName = currentFamilyName.toUnicode() currentStyleName = currentStyleName.toUnicode() @@ -298,10 +284,10 @@ def _updateNameTableStyleRecords( if typoSubFamilyName: nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip() nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName - nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = f"{typoSubFamilyName}" - # Remove previous Typographic Family and SubFamily names since they're - # no longer required + 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, From 0c92d33bc0bb07904c1210f321ba4c3e030e7846 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 19 Feb 2021 11:02:26 +0000 Subject: [PATCH 152/167] fix sub-string check in update uniqueID --- Lib/fontTools/varLib/instancer/names.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer/names.py b/Lib/fontTools/varLib/instancer/names.py index 42f9ed4c7..96898eb0b 100644 --- a/Lib/fontTools/varLib/instancer/names.py +++ b/Lib/fontTools/varLib/instancer/names.py @@ -352,10 +352,11 @@ def _updateUniqueIdNameRecord(varfont, nameIDs, platform): nameRecord = nametable.getName(nameID, *platform) if not nameRecord: continue - if currentRecord.toUnicode() in nameRecord.toUnicode(): + 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 From d1a8e1ee760d59f8a1e637a1a93654ab0dbe5867 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 19 Feb 2021 11:03:41 +0000 Subject: [PATCH 153/167] fall back to 'Regular' when no subFamilyName assert we have some 'string' instead of silently continuing --- Lib/fontTools/varLib/instancer/names.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/varLib/instancer/names.py b/Lib/fontTools/varLib/instancer/names.py index 96898eb0b..4129aca89 100644 --- a/Lib/fontTools/varLib/instancer/names.py +++ b/Lib/fontTools/varLib/instancer/names.py @@ -279,7 +279,7 @@ def _updateNameTableStyleRecords( nameIDs = { NameID.FAMILY_NAME: currentFamilyName, - NameID.SUBFAMILY_NAME: subFamilyName, + NameID.SUBFAMILY_NAME: subFamilyName or "Regular", } if typoSubFamilyName: nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip() @@ -305,13 +305,13 @@ def _updateNameTableStyleRecords( nameIDs[NameID.POSTSCRIPT_NAME] = _updatePSNameRecord( varfont, newFamilyName, newStyleName, platform ) - nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = _updateUniqueIdNameRecord( - varfont, nameIDs, platform - ) + + uniqueID = _updateUniqueIdNameRecord(varfont, nameIDs, platform) + if uniqueID: + nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = uniqueID for nameID, string in nameIDs.items(): - if not string: - continue + assert string, nameID nametable.setName(string, nameID, *platform) From a7913ef50eb17903f57528decce15f10bdebf7d0 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Mon, 22 Feb 2021 11:31:48 +0000 Subject: [PATCH 154/167] Add test for Regular fallback --- Tests/varLib/instancer/names_test.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Tests/varLib/instancer/names_test.py b/Tests/varLib/instancer/names_test.py index 27446e373..ae8e4caaf 100644 --- a/Tests/varLib/instancer/names_test.py +++ b/Tests/varLib/instancer/names_test.py @@ -305,3 +305,14 @@ def test_updateNameTable_elided_axisValues(varfont): # the subFamilyName set so it conforms to the RIBBI model. expected = {(2, 3, 1, 0x409): "Regular", (17, 3, 1, 0x409): "Black"} _test_name_records(varfont, expected, isNonRIBBI=True) + + +def test_updateNameTable_existing_subfamily_name_is_not_regular(varfont): + # Check the subFamily name will be set to Regular when we update a name + # table to a non-RIBBI style and the current subFamily name is a RIBBI + # style which isn't Regular. + varfont["name"].setName("Bold", 2, 3, 1, 0x409) # subFamily Regular --> Bold + + instancer.names.updateNameTable(varfont, {"wght": 100}) + expected = {(2, 3, 1, 0x409): "Regular", (17, 3, 1, 0x409): "Thin"} + _test_name_records(varfont, expected, isNonRIBBI=True) From 52fec53d20810f616615e9bc3666a7f81597790b Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Fri, 26 Feb 2021 09:52:30 +0000 Subject: [PATCH 155/167] Drop nameID 25 if instantiating a static font --- Lib/fontTools/varLib/instancer/names.py | 3 +++ Tests/varLib/instancer/names_test.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer/names.py b/Lib/fontTools/varLib/instancer/names.py index 4129aca89..cfe12a94d 100644 --- a/Lib/fontTools/varLib/instancer/names.py +++ b/Lib/fontTools/varLib/instancer/names.py @@ -314,6 +314,9 @@ def _updateNameTableStyleRecords( 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 : diff --git a/Tests/varLib/instancer/names_test.py b/Tests/varLib/instancer/names_test.py index ae8e4caaf..9774458a9 100644 --- a/Tests/varLib/instancer/names_test.py +++ b/Tests/varLib/instancer/names_test.py @@ -39,11 +39,15 @@ def _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x409]): if k[-1] not in platforms: continue assert font_names[k] == expected[k] + + font_nameids = set(i[0] for i in font_names) if isNonRIBBI: - font_nameids = set(i[0] for i in font_names) assert 16 in font_nameids assert 17 in font_nameids + if "fvar" not in varfont: + assert 25 not in font_nameids + @pytest.mark.parametrize( "limits, expected, isNonRIBBI", From a775b6e19c9a37d053511c51354050ab114d2692 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Fri, 26 Feb 2021 13:13:59 +0000 Subject: [PATCH 156/167] Fix _aligment_transformation as suggested --- Lib/fontTools/misc/bezierTools.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 2ddd3816c..63bfb0903 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -978,10 +978,8 @@ def _alignment_transformation(segment): # intersections with the segment. start = segment[0] end = segment[-1] - m = Offset(-start[0], -start[1]) - endpt = m.transformPoint(end) - angle = math.atan2(endpt[1], endpt[0]) - return m.reverseTransform(Identity.rotate(-angle)) + angle = math.atan2(end[1] - start[1], end[0] - start[0]) + return Identity.rotate(-angle).translate(-start[0], -start[1]) def _curve_line_intersections_t(curve, line): From 6da59abba2838764ec9f4a9b84487cd037bcc5ef Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 26 Feb 2021 16:10:53 +0000 Subject: [PATCH 157/167] Update changelog [skip ci] --- NEWS.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/NEWS.rst b/NEWS.rst index 6e36c3977..755c1aa1d 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,14 @@ +- [feaLib] Indent anchor statements in ``asFea()`` to make them more legible and + diff-able (#2193). +- [pens] Turn ``AbstractPen`` and ``AbstractPointPen`` into abstract base classes + (#2164). +- [feaLib] Added support for parsing and building ``STAT`` table from AFDKO feature + files (#2039). +- [instancer] Added option to update name table of generated instance using ``STAT`` + table's axis values (#2189). +- [bezierTools] Added functions to compute bezier point-at-time, as well as line-line, + curve-line and curve-curve intersections (#2192). + 4.20.0 (released 2021-02-15) ---------------------------- From a337752766512dbec9b687bbaae81cfb45384cfa Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 26 Feb 2021 16:12:51 +0000 Subject: [PATCH 158/167] Release 4.21.0 --- Lib/fontTools/__init__.py | 2 +- NEWS.rst | 3 +++ setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index bdc337d91..74973dc10 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.20.1.dev0" +version = __version__ = "4.21.0" __all__ = ["version", "log", "configLogger"] diff --git a/NEWS.rst b/NEWS.rst index 755c1aa1d..c5468391d 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +4.21.0 (released 2021-02-26) +---------------------------- + - [feaLib] Indent anchor statements in ``asFea()`` to make them more legible and diff-able (#2193). - [pens] Turn ``AbstractPen`` and ``AbstractPointPen`` into abstract base classes diff --git a/setup.cfg b/setup.cfg index 006ac234b..d2445de1c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.20.1.dev0 +current_version = 4.21.0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 355e037f7..0d2b97f2c 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.20.1.dev0", + version="4.21.0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 4de2a4076be8671db78ae9eb041f3173fd12eab0 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 26 Feb 2021 16:12:51 +0000 Subject: [PATCH 159/167] =?UTF-8?q?Bump=20version:=204.21.0=20=E2=86=92=20?= =?UTF-8?q?4.21.1.dev0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Lib/fontTools/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 74973dc10..3bd29f453 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.21.0" +version = __version__ = "4.21.1.dev0" __all__ = ["version", "log", "configLogger"] diff --git a/setup.cfg b/setup.cfg index d2445de1c..3fa539eac 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.21.0 +current_version = 4.21.1.dev0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 0d2b97f2c..b82d310b2 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.21.0", + version="4.21.1.dev0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 2429a187fc44441490c09c4049e05b3e78fe07a2 Mon Sep 17 00:00:00 2001 From: justvanrossum Date: Fri, 26 Feb 2021 20:18:37 +0100 Subject: [PATCH 160/167] Revert ABC changes to Pens, fixes #2198 --- Lib/fontTools/pens/basePen.py | 31 +------------------------------ Lib/fontTools/pens/pointPen.py | 23 +---------------------- Tests/pens/basePen_test.py | 23 ----------------------- Tests/pens/pointPen_test.py | 17 ----------------- 4 files changed, 2 insertions(+), 92 deletions(-) diff --git a/Lib/fontTools/pens/basePen.py b/Lib/fontTools/pens/basePen.py index 7202a9e45..34f89f8d2 100644 --- a/Lib/fontTools/pens/basePen.py +++ b/Lib/fontTools/pens/basePen.py @@ -36,7 +36,6 @@ Coordinates are usually expressed as (x, y) tuples, but generally any sequence of length 2 will do. """ -import abc from typing import Any, Tuple from fontTools.misc.loggingTools import LogMixin @@ -45,42 +44,18 @@ __all__ = ["AbstractPen", "NullPen", "BasePen", "decomposeSuperBezierSegment", "decomposeQuadraticSegment"] -class AbstractPen(abc.ABC): - @classmethod - def __subclasshook__(cls, subclass: Any) -> bool: - if cls is not AbstractPen: - return NotImplemented - return ( - hasattr(subclass, "moveTo") - and callable(subclass.moveTo) - and hasattr(subclass, "lineTo") - and callable(subclass.lineTo) - and hasattr(subclass, "curveTo") - and callable(subclass.curveTo) - and hasattr(subclass, "qCurveTo") - and callable(subclass.qCurveTo) - and hasattr(subclass, "closePath") - and callable(subclass.closePath) - and hasattr(subclass, "endPath") - and callable(subclass.endPath) - and hasattr(subclass, "addComponent") - and callable(subclass.addComponent) - or NotImplemented - ) +class AbstractPen: - @abc.abstractmethod def moveTo(self, pt: Tuple[float, float]) -> None: """Begin a new sub path, set the current point to 'pt'. You must end each sub path with a call to pen.closePath() or pen.endPath(). """ raise NotImplementedError - @abc.abstractmethod def lineTo(self, pt: Tuple[float, float]) -> None: """Draw a straight line from the current point to 'pt'.""" raise NotImplementedError - @abc.abstractmethod def curveTo(self, *points: Tuple[float, float]) -> None: """Draw a cubic bezier with an arbitrary number of control points. @@ -102,7 +77,6 @@ class AbstractPen(abc.ABC): """ raise NotImplementedError - @abc.abstractmethod def qCurveTo(self, *points: Tuple[float, float]) -> None: """Draw a whole string of quadratic curve segments. @@ -120,21 +94,18 @@ class AbstractPen(abc.ABC): """ raise NotImplementedError - @abc.abstractmethod def closePath(self) -> None: """Close the current sub path. You must call either pen.closePath() or pen.endPath() after each sub path. """ pass - @abc.abstractmethod def endPath(self) -> None: """End the current sub path, but don't close it. You must call either pen.closePath() or pen.endPath() after each sub path. """ pass - @abc.abstractmethod def addComponent( self, glyphName: str, diff --git a/Lib/fontTools/pens/pointPen.py b/Lib/fontTools/pens/pointPen.py index cd9e30ef3..92846d315 100644 --- a/Lib/fontTools/pens/pointPen.py +++ b/Lib/fontTools/pens/pointPen.py @@ -12,7 +12,6 @@ This allows the caller to provide more data for each point. For instance, whether or not a point is smooth, and its name. """ -import abc import math from typing import Any, List, Optional, Tuple @@ -28,36 +27,17 @@ __all__ = [ ] -class AbstractPointPen(abc.ABC): +class AbstractPointPen: """Baseclass for all PointPens.""" - @classmethod - def __subclasshook__(cls, subclass: Any) -> bool: - if cls is not AbstractPointPen: - return NotImplemented - return ( - hasattr(subclass, "beginPath") - and callable(subclass.beginPath) - and hasattr(subclass, "endPath") - and callable(subclass.endPath) - and hasattr(subclass, "addPoint") - and callable(subclass.addPoint) - and hasattr(subclass, "addComponent") - and callable(subclass.addComponent) - or NotImplemented - ) - - @abc.abstractmethod def beginPath(self, identifier: Optional[str] = None, **kwargs: Any) -> None: """Start a new sub path.""" raise NotImplementedError - @abc.abstractmethod def endPath(self) -> None: """End the current sub path.""" raise NotImplementedError - @abc.abstractmethod def addPoint( self, pt: Tuple[float, float], @@ -70,7 +50,6 @@ class AbstractPointPen(abc.ABC): """Add a point to the current sub path.""" raise NotImplementedError - @abc.abstractmethod def addComponent( self, baseGlyphName: str, diff --git a/Tests/pens/basePen_test.py b/Tests/pens/basePen_test.py index 059945c77..db57e80e8 100644 --- a/Tests/pens/basePen_test.py +++ b/Tests/pens/basePen_test.py @@ -5,29 +5,6 @@ from fontTools.misc.loggingTools import CapturingLogHandler import unittest -def test_subclasshook(): - class NullPen: - def moveTo(self, pt): - pass - def lineTo(self, pt): - pass - def curveTo(self, *points): - pass - def qCurveTo(self, *points): - pass - def closePath(self): - pass - def endPath(self): - pass - def addComponent(self, glyphName, transformation): - pass - - assert issubclass(NullPen, AbstractPen) - assert isinstance(NullPen(), AbstractPen) - assert not issubclass(NullPen, AbstractPointPen) - assert not isinstance(NullPen(), AbstractPointPen) - - class _TestPen(BasePen): def __init__(self): BasePen.__init__(self, glyphSet={}) diff --git a/Tests/pens/pointPen_test.py b/Tests/pens/pointPen_test.py index 80098ee6a..07261d039 100644 --- a/Tests/pens/pointPen_test.py +++ b/Tests/pens/pointPen_test.py @@ -5,23 +5,6 @@ from fontTools.pens.pointPen import AbstractPointPen, PointToSegmentPen, \ SegmentToPointPen, GuessSmoothPointPen, ReverseContourPointPen -def test_subclasshook(): - class NullPen: - def beginPath(self, identifier, **kwargs) -> None: - pass - def endPath(self) -> None: - pass - def addPoint(self, pt, segmentType, smooth, name, identifier, **kwargs) -> None: - pass - def addComponent(self, baseGlyphName, transformation, identifier, **kwargs) -> None: - pass - - assert issubclass(NullPen, AbstractPointPen) - assert isinstance(NullPen(), AbstractPointPen) - assert not issubclass(NullPen, AbstractPen) - assert not isinstance(NullPen(), AbstractPen) - - class _TestSegmentPen(AbstractPen): def __init__(self): From a33df754f37dd45fda7699773024dc42b3c30b37 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 26 Feb 2021 19:41:05 +0000 Subject: [PATCH 161/167] Update changelog [skip ci] --- NEWS.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NEWS.rst b/NEWS.rst index c5468391d..6a1d3de84 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +- [pens] Reverted breaking change that turned ``AbstractPen`` and ``AbstractPointPen`` + into abstract base classes (#2164, #2198). + 4.21.0 (released 2021-02-26) ---------------------------- From cfd87d7ba74daeabfa21d00a5d7482c228bed66f Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 26 Feb 2021 19:41:14 +0000 Subject: [PATCH 162/167] Release 4.21.1 --- Lib/fontTools/__init__.py | 2 +- NEWS.rst | 3 +++ setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 3bd29f453..806a54e71 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.21.1.dev0" +version = __version__ = "4.21.1" __all__ = ["version", "log", "configLogger"] diff --git a/NEWS.rst b/NEWS.rst index 6a1d3de84..4b0563999 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +4.21.1 (released 2021-02-26) +---------------------------- + - [pens] Reverted breaking change that turned ``AbstractPen`` and ``AbstractPointPen`` into abstract base classes (#2164, #2198). diff --git a/setup.cfg b/setup.cfg index 3fa539eac..1676fed48 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.21.1.dev0 +current_version = 4.21.1 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index b82d310b2..ab2be293e 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.21.1.dev0", + version="4.21.1", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 825b5044fe2ef79a35b59f5ddce8fc0417fd9e03 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 26 Feb 2021 19:41:15 +0000 Subject: [PATCH 163/167] =?UTF-8?q?Bump=20version:=204.21.1=20=E2=86=92=20?= =?UTF-8?q?4.21.2.dev0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Lib/fontTools/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 806a54e71..e703895f5 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.21.1" +version = __version__ = "4.21.2.dev0" __all__ = ["version", "log", "configLogger"] diff --git a/setup.cfg b/setup.cfg index 1676fed48..358fa8421 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.21.1 +current_version = 4.21.2.dev0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index ab2be293e..fbf6117a5 100755 --- a/setup.py +++ b/setup.py @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.21.1", + version="4.21.2.dev0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 44b7560fe58261d25540f5108aef5bea5b8b1d97 Mon Sep 17 00:00:00 2001 From: justvanrossum Date: Sat, 27 Feb 2021 19:54:53 +0100 Subject: [PATCH 164/167] move Vector to its own submodule, and rewrite as a tuple subclass --- Lib/fontTools/misc/arrayTools.py | 112 +++---------------------- Lib/fontTools/misc/vector.py | 139 +++++++++++++++++++++++++++++++ Lib/fontTools/varLib/__init__.py | 2 +- Tests/misc/arrayTools_test.py | 13 +-- Tests/misc/vector_test.py | 66 +++++++++++++++ 5 files changed, 217 insertions(+), 115 deletions(-) create mode 100644 Lib/fontTools/misc/vector.py create mode 100644 Tests/misc/vector_test.py diff --git a/Lib/fontTools/misc/arrayTools.py b/Lib/fontTools/misc/arrayTools.py index 138ad8f36..4b5f08298 100644 --- a/Lib/fontTools/misc/arrayTools.py +++ b/Lib/fontTools/misc/arrayTools.py @@ -4,9 +4,10 @@ so on. from fontTools.misc.py23 import * from fontTools.misc.fixedTools import otRound -from numbers import Number +from fontTools.misc.vector import Vector as _Vector import math -import operator +import warnings + def calcBounds(array): """Calculate the bounding rectangle of a 2D points array. @@ -261,107 +262,14 @@ def intRect(rect): return (xMin, yMin, xMax, yMax) -class Vector(object): - """A math-like vector. +class Vector(_Vector): - Represents an n-dimensional numeric vector. ``Vector`` objects support - vector addition and subtraction, scalar multiplication and division, - negation, rounding, and comparison tests. - - Attributes: - values: Sequence of values stored in the vector. - """ - - def __init__(self, values, keep=False): - """Initialize a vector. If ``keep`` is true, values will be copied.""" - self.values = values if keep else list(values) - - def __getitem__(self, index): - return self.values[index] - - def __len__(self): - return len(self.values) - - def __repr__(self): - return "Vector(%s)" % self.values - - def _vectorOp(self, other, op): - if isinstance(other, Vector): - assert len(self.values) == len(other.values) - a = self.values - b = other.values - return [op(a[i], b[i]) for i in range(len(self.values))] - if isinstance(other, Number): - return [op(v, other) for v in self.values] - raise NotImplementedError - - def _scalarOp(self, other, op): - if isinstance(other, Number): - return [op(v, other) for v in self.values] - raise NotImplementedError - - def _unaryOp(self, op): - return [op(v) for v in self.values] - - def __add__(self, other): - return Vector(self._vectorOp(other, operator.add), keep=True) - def __iadd__(self, other): - self.values = self._vectorOp(other, operator.add) - return self - __radd__ = __add__ - - def __sub__(self, other): - return Vector(self._vectorOp(other, operator.sub), keep=True) - def __isub__(self, other): - self.values = self._vectorOp(other, operator.sub) - return self - def __rsub__(self, other): - return other + (-self) - - def __mul__(self, other): - return Vector(self._scalarOp(other, operator.mul), keep=True) - def __imul__(self, other): - self.values = self._scalarOp(other, operator.mul) - return self - __rmul__ = __mul__ - - def __truediv__(self, other): - return Vector(self._scalarOp(other, operator.truediv), keep=True) - def __itruediv__(self, other): - self.values = self._scalarOp(other, operator.truediv) - return self - - def __pos__(self): - return Vector(self._unaryOp(operator.pos), keep=True) - def __neg__(self): - return Vector(self._unaryOp(operator.neg), keep=True) - def __round__(self): - return Vector(self._unaryOp(round), keep=True) - def toInt(self): - """Synonym for ``round``.""" - return self.__round__() - - def __eq__(self, other): - if type(other) == Vector: - return self.values == other.values - else: - return self.values == other - def __ne__(self, other): - return not self.__eq__(other) - - def __bool__(self): - return any(self.values) - __nonzero__ = __bool__ - - def __abs__(self): - return math.sqrt(sum([x*x for x in self.values])) - def dot(self, other): - """Performs vector dot product, returning sum of - ``a[0] * b[0], a[1] * b[1], ...``""" - a = self.values - b = other.values if type(other) == Vector else b - assert len(a) == len(b) - return sum([a[i] * b[i] for i in range(len(a))]) + def __init__(self, *args, **kwargs): + warnings.warn( + "fontTools.misc.arrayTools.Vector has been deprecated, please use " + "fontTools.misc.vector.Vector instead.", + DeprecationWarning, + ) def pairwise(iterable, reverse=False): diff --git a/Lib/fontTools/misc/vector.py b/Lib/fontTools/misc/vector.py new file mode 100644 index 000000000..6d47a984b --- /dev/null +++ b/Lib/fontTools/misc/vector.py @@ -0,0 +1,139 @@ +from numbers import Number +import math +import operator +import warnings + + +class Vector(tuple): + + """A math-like vector. + + Represents an n-dimensional numeric vector. ``Vector`` objects support + vector addition and subtraction, scalar multiplication and division, + negation, rounding, and comparison tests. + """ + + def __new__(cls, values, keep=False): + """Initialize a vector..""" + if keep is not False: + warnings.warn( + "the 'keep' argument has been deprecated", + DeprecationWarning, + ) + if type(values) == Vector: + # No need to create a new object + return values + return super().__new__(cls, values) + + def __repr__(self): + return f"{self.__class__.__name__}({super().__repr__()})" + + def _vectorOp(self, other, op): + if isinstance(other, Vector): + assert len(self) == len(other) + return self.__class__(op(a, b) for a, b in zip(self, other)) + if isinstance(other, Number): + return self.__class__(op(v, other) for v in self) + raise NotImplementedError() + + def _scalarOp(self, other, op): + if isinstance(other, Number): + return self.__class__(op(v, other) for v in self) + raise NotImplementedError() + + def _unaryOp(self, op): + return self.__class__(op(v) for v in self) + + def __add__(self, other): + return self._vectorOp(other, operator.add) + + __radd__ = __add__ + + def __sub__(self, other): + return self._vectorOp(other, operator.sub) + + def __rsub__(self, other): + return self._vectorOp(other, _operator_rsub) + + def __mul__(self, other): + return self._scalarOp(other, operator.mul) + + __rmul__ = __mul__ + + def __truediv__(self, other): + return self._scalarOp(other, operator.truediv) + + def __rtruediv__(self, other): + return self._scalarOp(other, _operator_rtruediv) + + def __pos__(self): + return self._unaryOp(operator.pos) + + def __neg__(self): + return self._unaryOp(operator.neg) + + def __round__(self): + return self._unaryOp(round) + + def __eq__(self, other): + if isinstance(other, list): + # bw compat Vector([1, 2, 3]) == [1, 2, 3] + other = tuple(other) + return super().__eq__(other) + + def __ne__(self, other): + return not self.__eq__(other) + + def __bool__(self): + return any(self) + + __nonzero__ = __bool__ + + def __abs__(self): + return math.sqrt(sum(x * x for x in self)) + + def length(self): + """Return the length of the vector. Equivalent to abs(vector).""" + return abs(self) + + def normalized(self): + """Return the normalized vector of the vector.""" + return self / abs(self) + + def dot(self, other): + """Performs vector dot product, returning the sum of + ``a[0] * b[0], a[1] * b[1], ...``""" + assert len(self) == len(other) + return sum(a * b for a, b in zip(self, other)) + + # Deprecated methods/properties + + def toInt(self): + warnings.warn( + "the 'toInt' method has been deprecated, use round(vector) instead", + DeprecationWarning, + ) + return self.__round__() + + @property + def values(self): + warnings.warn( + "the 'values' attribute has been deprecated, use " + "the vector object itself instead", + DeprecationWarning, + ) + return list(self) + + @values.setter + def values(self, values): + raise AttributeError( + "can't set attribute, the 'values' attribute has been deprecated", + ) + + +def _operator_rsub(a, b): + return operator.sub(b, a) + + +def _operator_rtruediv(a, b): + return operator.truediv(b, a) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 605fda2a7..dd320b040 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -20,7 +20,7 @@ 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.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 diff --git a/Tests/misc/arrayTools_test.py b/Tests/misc/arrayTools_test.py index 73e0ab17e..127f153c8 100644 --- a/Tests/misc/arrayTools_test.py +++ b/Tests/misc/arrayTools_test.py @@ -1,7 +1,7 @@ from fontTools.misc.py23 import * from fontTools.misc.py23 import round3 from fontTools.misc.arrayTools import ( - Vector, calcBounds, calcIntBounds, updateBounds, pointInRect, pointsInRect, + calcBounds, calcIntBounds, updateBounds, pointInRect, pointsInRect, vectorLength, asInt16, normRect, scaleRect, offsetRect, insetRect, sectRect, unionRect, rectCenter, intRect) import math @@ -88,14 +88,3 @@ def test_rectCenter(): def test_intRect(): assert intRect((0.9, 2.9, 3.1, 4.1)) == (0, 2, 4, 5) - - -def test_Vector(): - v = Vector([100, 200]) - assert v == Vector([100, 200]) - assert v == [100, 200] - assert v + Vector([1, 2]) == [101, 202] - assert v - Vector([1, 2]) == [99, 198] - assert v * 2 == [200, 400] - assert v * 0.5 == [50, 100] - assert v / 2 == [50, 100] diff --git a/Tests/misc/vector_test.py b/Tests/misc/vector_test.py new file mode 100644 index 000000000..7448cef1d --- /dev/null +++ b/Tests/misc/vector_test.py @@ -0,0 +1,66 @@ +import math +import pytest +from fontTools.misc.arrayTools import Vector as ArrayVector +from fontTools.misc.vector import Vector + + +def test_Vector(): + v = Vector((100, 200)) + assert repr(v) == "Vector((100, 200))" + assert v == Vector((100, 200)) + assert v == Vector([100, 200]) + assert v == (100, 200) + assert (100, 200) == v + assert v == [100, 200] + assert [100, 200] == v + assert v is Vector(v) + assert v + 10 == (110, 210) + assert 10 + v == (110, 210) + assert v + Vector((1, 2)) == (101, 202) + assert v - Vector((1, 2)) == (99, 198) + assert v * 2 == (200, 400) + assert 2 * v == (200, 400) + assert v * 0.5 == (50, 100) + assert v / 2 == (50, 100) + assert 2 / v == (0.02, 0.01) + v = Vector((3, 4)) + assert abs(v) == 5 # length + assert v.length() == 5 + assert v.normalized() == Vector((0.6, 0.8)) + assert abs(Vector((1, 1, 1))) == math.sqrt(3) + assert bool(Vector((0, 0, 1))) + assert not bool(Vector((0, 0, 0))) + v1 = Vector((2, 3)) + v2 = Vector((3, 4)) + assert v1.dot(v2) == 18 + v = Vector((2, 4)) + assert round(v / 3) == (1, 1) + + +def test_deprecated(): + with pytest.warns( + DeprecationWarning, + match="fontTools.misc.arrayTools.Vector has been deprecated", + ): + ArrayVector((1, 2)) + with pytest.warns( + DeprecationWarning, + match="the 'keep' argument has been deprecated", + ): + Vector((1, 2), keep=True) + v = Vector((1, 2)) + with pytest.warns( + DeprecationWarning, + match="the 'toInt' method has been deprecated", + ): + v.toInt() + with pytest.warns( + DeprecationWarning, + match="the 'values' attribute has been deprecated", + ): + v.values + with pytest.raises( + AttributeError, + match="the 'values' attribute has been deprecated", + ): + v.values = [12, 23] From 9c9ab5ac3ab88ef9465923c81f9c314118a71858 Mon Sep 17 00:00:00 2001 From: justvanrossum Date: Sat, 27 Feb 2021 20:05:53 +0100 Subject: [PATCH 165/167] removed info-less doc string --- Lib/fontTools/misc/vector.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/fontTools/misc/vector.py b/Lib/fontTools/misc/vector.py index 6d47a984b..995385c04 100644 --- a/Lib/fontTools/misc/vector.py +++ b/Lib/fontTools/misc/vector.py @@ -14,7 +14,6 @@ class Vector(tuple): """ def __new__(cls, values, keep=False): - """Initialize a vector..""" if keep is not False: warnings.warn( "the 'keep' argument has been deprecated", From 516d0f7dfe111ebd74671ccd1cbc21c9e8d5909f Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Wed, 3 Mar 2021 12:57:52 +0000 Subject: [PATCH 166/167] Add definitions for varLib-specific terms --- Doc/source/varLib/index.rst | 93 +++++++++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 3 deletions(-) diff --git a/Doc/source/varLib/index.rst b/Doc/source/varLib/index.rst index 5394d6287..f22967f4d 100644 --- a/Doc/source/varLib/index.rst +++ b/Doc/source/varLib/index.rst @@ -1,6 +1,93 @@ -###### -varLib -###### +########################## +OpenType Variation Support +########################## + +The `fontTools.varLib` package contains a number of classes and routines +for handling, building and interpolating variable font data. These routines +rely on a common set of concepts, many of which are equivalent to concepts +in the OpenType Specification, but some of which are unique to `varLib`. + +Terminology +----------- + +axis + "A designer-determined variable in a font face design that can be used to derive multiple, variant designs within a family." (OpenType Specification) + An axis has a minimum value, a maximum value and a default value. + +designspace + The n-dimensional space formed by the font's axes. (OpenType Specification + calls this the "design-variation space") + +scalar + A value which is able to be varied at different points in the designspace: + for example, the horizontal advance width of the glyph "a" is a scalar. + However, see also *support scalar* below. + +default location + A point in the designspace whose coordinates are the default value of + all axes. + +location + A point in the designspace, specified as a set of coordinates on one or + more axes. In the context of ``varLib``, a location is a dictionary with + the keys being the axis tags and the values being the coordinates on the + respective axis. A ``varLib`` location dictionary may be "sparse", in the + sense that axes defined in the font may be omitted from the location's + coordinates, in which case the default value of the axis is assumed. + For example, given a font having a ``wght`` axis ranging from 200-1000 + with default 400, and a ``wdth`` axis ranging 100-300 with default 150, + the location ``{"wdth": 200}`` represents the point ``wght=400,wdth=200``. + +master + The value of a scalar at a given location. **Note that this is a + considerably more general concept than the usual type design sense of + the term "master".** + +normalized location + While the range of an axis is determined by its minimum and maximum values + as set by the designer, locations are specified internally to the font binary + in the range -1 to 1, with 0 being the default, -1 being the minimum and + 1 being the maximum. A normalized location is one which is scaled to the + range (-1,1) on all of its axes. Note that as the range from minimum to + default and from default to maximum on a given axis may differ (for + example, given ``wght min=200 default=500 max=1000``, the difference + between a normalized location -1 of a normalized location of 0 represents a + difference of 300 units while the difference between a normalized location + of 0 and a normalized location of 1 represents a difference of 700 units), + a location is scaled by a different factor depending on whether it is above + or below the axis' default value. + +support + While designers tend to think in terms of masters - that is, a precise + location having a particular value - OpenType Variations specifies the + variation of scalars in terms of deltas which are themselves composed of + the combined contributions of a set of triangular regions, each having + a contribution value of 0 at its minimum value, rising linearly to its + full contribution at the *peak* and falling linearly to zero from the + peak to the maximum value. The OpenType Specification calls these "regions", + while ``varLib`` calls them "supports" (a mathematical term used in real + analysis) and expresses them as a dictionary mapping each axis tag to a + tuple ``(min, peak, max)``. + +box + `varLib` uses the term "box" to denote the minimum and maximum "corners" of + a support, ignoring its peak value. + +delta + The term "delta" is used in OpenType Variations in two senses. In the + more general sense, a delta is the difference between a scalar at a + given location and its value at the default location. Additionally, inside + the font, variation data is stored as a mapping between supports and deltas. + The delta (in the first sense) is computed by summing the product of the + delta of each support by a factor representing the support's contribution + at this location (see "support scalar" below). + +support scalar + When interpolating a set of variation data, the support scalar represents + the scalar multiplier of the support's contribution at this location. For + example, the support scalar will be 1 at the support's peak location, and + 0 below its minimum or above its maximum. + .. toctree:: :maxdepth: 2 From a0d6ca3b7630d8c8005be91fb41ff9b38b6de406 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Wed, 3 Mar 2021 13:33:58 +0000 Subject: [PATCH 167/167] ReStructuredText nits --- Doc/source/varLib/index.rst | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Doc/source/varLib/index.rst b/Doc/source/varLib/index.rst index f22967f4d..7b2249674 100644 --- a/Doc/source/varLib/index.rst +++ b/Doc/source/varLib/index.rst @@ -1,17 +1,18 @@ -########################## -OpenType Variation Support -########################## +################################## +varLib: OpenType Variation Support +################################## -The `fontTools.varLib` package contains a number of classes and routines +The ``fontTools.varLib`` package contains a number of classes and routines for handling, building and interpolating variable font data. These routines rely on a common set of concepts, many of which are equivalent to concepts -in the OpenType Specification, but some of which are unique to `varLib`. +in the OpenType Specification, but some of which are unique to ``varLib``. Terminology ----------- axis - "A designer-determined variable in a font face design that can be used to derive multiple, variant designs within a family." (OpenType Specification) + "A designer-determined variable in a font face design that can be used to + derive multiple, variant designs within a family." (OpenType Specification) An axis has a minimum value, a maximum value and a default value. designspace @@ -70,7 +71,7 @@ support tuple ``(min, peak, max)``. box - `varLib` uses the term "box" to denote the minimum and maximum "corners" of + ``varLib`` uses the term "box" to denote the minimum and maximum "corners" of a support, ignoring its peak value. delta