| 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) |