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