diff options
Diffstat (limited to 'Tests/otlLib/optimize_test.py')
-rw-r--r-- | Tests/otlLib/optimize_test.py | 175 |
1 files changed, 175 insertions, 0 deletions
diff --git a/Tests/otlLib/optimize_test.py b/Tests/otlLib/optimize_test.py new file mode 100644 index 00000000..40cf389e --- /dev/null +++ b/Tests/otlLib/optimize_test.py @@ -0,0 +1,175 @@ +import logging +from pathlib import Path +from subprocess import run +import contextlib +import os +from typing import List, Optional, Tuple +from fontTools.ttLib import TTFont + +import pytest + +from fontTools.feaLib.builder import addOpenTypeFeaturesFromString +from fontTools.fontBuilder import FontBuilder + +from fontTools.ttLib.tables.otBase import OTTableWriter, ValueRecord + + +def test_main(tmpdir: Path): + """Check that calling the main function on an input TTF works.""" + glyphs = ".notdef space A Aacute B D".split() + features = """ + @A = [A Aacute]; + @B = [B D]; + feature kern { + pos @A @B -50; + } kern; + """ + fb = FontBuilder(1000) + fb.setupGlyphOrder(glyphs) + addOpenTypeFeaturesFromString(fb.font, features) + input = tmpdir / "in.ttf" + fb.save(str(input)) + output = tmpdir / "out.ttf" + run( + [ + "fonttools", + "otlLib.optimize", + "--gpos-compact-mode", + "5", + str(input), + "-o", + str(output), + ], + check=True, + ) + assert output.exists() + + +# Copy-pasted from https://stackoverflow.com/questions/2059482/python-temporarily-modify-the-current-processs-environment +# TODO: remove when moving to the Config class +@contextlib.contextmanager +def set_env(**environ): + """ + Temporarily set the process environment variables. + + >>> with set_env(PLUGINS_DIR=u'test/plugins'): + ... "PLUGINS_DIR" in os.environ + True + + >>> "PLUGINS_DIR" in os.environ + False + + :type environ: dict[str, unicode] + :param environ: Environment variables to set + """ + old_environ = dict(os.environ) + os.environ.update(environ) + try: + yield + finally: + os.environ.clear() + os.environ.update(old_environ) + + +def count_pairpos_subtables(font: TTFont) -> int: + subtables = 0 + for lookup in font["GPOS"].table.LookupList.Lookup: + if lookup.LookupType == 2: + subtables += len(lookup.SubTable) + elif lookup.LookupType == 9: + for subtable in lookup.SubTable: + if subtable.ExtensionLookupType == 2: + subtables += 1 + return subtables + + +def count_pairpos_bytes(font: TTFont) -> int: + bytes = 0 + gpos = font["GPOS"] + for lookup in font["GPOS"].table.LookupList.Lookup: + if lookup.LookupType == 2: + w = OTTableWriter(tableTag=gpos.tableTag) + lookup.compile(w, font) + bytes += len(w.getAllData()) + elif lookup.LookupType == 9: + if any(subtable.ExtensionLookupType == 2 for subtable in lookup.SubTable): + w = OTTableWriter(tableTag=gpos.tableTag) + lookup.compile(w, font) + bytes += len(w.getAllData()) + return bytes + + +def get_kerning_by_blocks(blocks: List[Tuple[int, int]]) -> Tuple[List[str], str]: + """Generate a highly compressible font by generating a bunch of rectangular + blocks on the diagonal that can easily be sliced into subtables. + + Returns the list of glyphs and feature code of the font. + """ + value = 0 + glyphs: List[str] = [] + rules = [] + # Each block is like a script in a multi-script font + for script, (width, height) in enumerate(blocks): + glyphs.extend(f"g_{script}_{i}" for i in range(max(width, height))) + for l in range(height): + for r in range(width): + value += 1 + rules.append((f"g_{script}_{l}", f"g_{script}_{r}", value)) + classes = "\n".join([f"@{g} = [{g}];" for g in glyphs]) + statements = "\n".join([f"pos @{l} @{r} {v};" for (l, r, v) in rules]) + features = f""" + {classes} + feature kern {{ + {statements} + }} kern; + """ + return glyphs, features + + +@pytest.mark.parametrize( + ("blocks", "mode", "expected_subtables", "expected_bytes"), + [ + # Mode = 0 = no optimization leads to 650 bytes of GPOS + ([(15, 3), (2, 10)], None, 1, 602), + # Optimization level 1 recognizes the 2 blocks and splits into 2 + # subtables = adds 1 subtable leading to a size reduction of + # (602-298)/602 = 50% + ([(15, 3), (2, 10)], 1, 2, 298), + # On a bigger block configuration, we see that mode=5 doesn't create + # as many subtables as it could, because of the stop criteria + ([(4, 4) for _ in range(20)], 5, 14, 2042), + # while level=9 creates as many subtables as there were blocks on the + # diagonal and yields a better saving + ([(4, 4) for _ in range(20)], 9, 20, 1886), + # On a fully occupied kerning matrix, even the strategy 9 doesn't + # split anything. + ([(10, 10)], 9, 1, 304) + ], +) +def test_optimization_mode( + caplog, + blocks: List[Tuple[int, int]], + mode: Optional[int], + expected_subtables: int, + expected_bytes: int, +): + """Check that the optimizations are off by default, and that increasing + the optimization level creates more subtables and a smaller byte size. + """ + caplog.set_level(logging.DEBUG) + + glyphs, features = get_kerning_by_blocks(blocks) + glyphs = [".notdef space"] + glyphs + + env = {} + if mode is not None: + # NOTE: activating this optimization via the environment variable is + # experimental and may not be supported once an alternative mechanism + # is in place. See: https://github.com/fonttools/fonttools/issues/2349 + env["FONTTOOLS_GPOS_COMPACT_MODE"] = str(mode) + with set_env(**env): + fb = FontBuilder(1000) + fb.setupGlyphOrder(glyphs) + addOpenTypeFeaturesFromString(fb.font, features) + assert expected_subtables == count_pairpos_subtables(fb.font) + assert expected_bytes == count_pairpos_bytes(fb.font) |