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