aboutsummaryrefslogtreecommitdiff
path: root/Tests/otlLib/optimize_test.py
blob: 40cf389e3baf1298319bc3b49fb78fd153ef61cb (plain)
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
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)