xref: /aosp_15_r20/external/fonttools/Tests/otlLib/optimize_test.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1import contextlib
2import logging
3import os
4from pathlib import Path
5from subprocess import run
6from typing import List, Optional, Tuple
7
8import pytest
9from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
10from fontTools.fontBuilder import FontBuilder
11from fontTools.ttLib import TTFont
12from fontTools.ttLib.tables.otBase import OTTableWriter
13
14
15def test_main(tmpdir: Path):
16    """Check that calling the main function on an input TTF works."""
17    glyphs = ".notdef space A Aacute B D".split()
18    features = """
19    @A = [A Aacute];
20    @B = [B D];
21    feature kern {
22        pos @A @B -50;
23    } kern;
24    """
25    fb = FontBuilder(1000)
26    fb.setupGlyphOrder(glyphs)
27    addOpenTypeFeaturesFromString(fb.font, features)
28    input = tmpdir / "in.ttf"
29    fb.save(str(input))
30    output = tmpdir / "out.ttf"
31    run(
32        [
33            "fonttools",
34            "otlLib.optimize",
35            "--gpos-compression-level",
36            "5",
37            str(input),
38            "-o",
39            str(output),
40        ],
41        check=True,
42    )
43    assert output.exists()
44
45
46# Copy-pasted from https://stackoverflow.com/questions/2059482/python-temporarily-modify-the-current-processs-environment
47# TODO: remove when moving to the Config class
48@contextlib.contextmanager
49def set_env(**environ):
50    """
51    Temporarily set the process environment variables.
52
53    >>> with set_env(PLUGINS_DIR=u'test/plugins'):
54    ...   "PLUGINS_DIR" in os.environ
55    True
56
57    >>> "PLUGINS_DIR" in os.environ
58    False
59
60    :type environ: dict[str, unicode]
61    :param environ: Environment variables to set
62    """
63    old_environ = dict(os.environ)
64    os.environ.update(environ)
65    try:
66        yield
67    finally:
68        os.environ.clear()
69        os.environ.update(old_environ)
70
71
72def count_pairpos_subtables(font: TTFont) -> int:
73    subtables = 0
74    for lookup in font["GPOS"].table.LookupList.Lookup:
75        if lookup.LookupType == 2:
76            subtables += len(lookup.SubTable)
77        elif lookup.LookupType == 9:
78            for subtable in lookup.SubTable:
79                if subtable.ExtensionLookupType == 2:
80                    subtables += 1
81    return subtables
82
83
84def count_pairpos_bytes(font: TTFont) -> int:
85    bytes = 0
86    gpos = font["GPOS"]
87    for lookup in font["GPOS"].table.LookupList.Lookup:
88        if lookup.LookupType == 2:
89            w = OTTableWriter(tableTag=gpos.tableTag)
90            lookup.compile(w, font)
91            bytes += len(w.getAllData())
92        elif lookup.LookupType == 9:
93            if any(subtable.ExtensionLookupType == 2 for subtable in lookup.SubTable):
94                w = OTTableWriter(tableTag=gpos.tableTag)
95                lookup.compile(w, font)
96                bytes += len(w.getAllData())
97    return bytes
98
99
100def get_kerning_by_blocks(blocks: List[Tuple[int, int]]) -> Tuple[List[str], str]:
101    """Generate a highly compressible font by generating a bunch of rectangular
102    blocks on the diagonal that can easily be sliced into subtables.
103
104    Returns the list of glyphs and feature code of the font.
105    """
106    value = 0
107    glyphs: List[str] = []
108    rules = []
109    # Each block is like a script in a multi-script font
110    for script, (width, height) in enumerate(blocks):
111        glyphs.extend(f"g_{script}_{i}" for i in range(max(width, height)))
112        for l in range(height):
113            for r in range(width):
114                value += 1
115                rules.append((f"g_{script}_{l}", f"g_{script}_{r}", value))
116    classes = "\n".join([f"@{g} = [{g}];" for g in glyphs])
117    statements = "\n".join([f"pos @{l} @{r} {v};" for (l, r, v) in rules])
118    features = f"""
119        {classes}
120        feature kern {{
121            {statements}
122        }} kern;
123    """
124    return glyphs, features
125
126
127@pytest.mark.parametrize(
128    ("blocks", "level", "expected_subtables", "expected_bytes"),
129    [
130        # Level = 0 = no optimization leads to 650 bytes of GPOS
131        ([(15, 3), (2, 10)], None, 1, 602),
132        # Optimization level 1 recognizes the 2 blocks and splits into 2
133        # subtables = adds 1 subtable leading to a size reduction of
134        # (602-298)/602 = 50%
135        ([(15, 3), (2, 10)], 1, 2, 298),
136        # On a bigger block configuration, we see that mode=5 doesn't create
137        # as many subtables as it could, because of the stop criteria
138        ([(4, 4) for _ in range(20)], 5, 14, 2042),
139        # while level=9 creates as many subtables as there were blocks on the
140        # diagonal and yields a better saving
141        ([(4, 4) for _ in range(20)], 9, 20, 1886),
142        # On a fully occupied kerning matrix, even the strategy 9 doesn't
143        # split anything.
144        ([(10, 10)], 9, 1, 304),
145    ],
146)
147def test_optimization_mode(
148    caplog,
149    blocks: List[Tuple[int, int]],
150    level: Optional[int],
151    expected_subtables: int,
152    expected_bytes: int,
153):
154    """Check that the optimizations are off by default, and that increasing
155    the optimization level creates more subtables and a smaller byte size.
156    """
157    caplog.set_level(logging.DEBUG)
158
159    glyphs, features = get_kerning_by_blocks(blocks)
160    glyphs = [".notdef space"] + glyphs
161
162    fb = FontBuilder(1000)
163    if level is not None:
164        fb.font.cfg["fontTools.otlLib.optimize.gpos:COMPRESSION_LEVEL"] = level
165    fb.setupGlyphOrder(glyphs)
166    addOpenTypeFeaturesFromString(fb.font, features)
167    assert expected_subtables == count_pairpos_subtables(fb.font)
168    assert expected_bytes == count_pairpos_bytes(fb.font)
169