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