diff --git a/Lib/fontTools/otlLib/optimize/gpos.py b/Lib/fontTools/otlLib/optimize/gpos.py index 1f6644263..026744941 100644 --- a/Lib/fontTools/otlLib/optimize/gpos.py +++ b/Lib/fontTools/otlLib/optimize/gpos.py @@ -128,7 +128,9 @@ def _getClassRanges(glyphIDs: Iterable[int]): # Adapted from https://github.com/fonttools/fonttools/blob/f64f0b42f2d1163b2d85194e0979def539f5dca3/Lib/fontTools/ttLib/tables/otTables.py#L960-L989 def _classDef_bytes( - class_data: List[Tuple[List[Tuple[int, int]], int, int]], class_ids: List[int], coverage=False + class_data: List[Tuple[List[Tuple[int, int]], int, int]], + class_ids: List[int], + coverage=False, ): if not class_ids: return 0 @@ -152,6 +154,10 @@ def cluster_pairs_by_class2_coverage_custom_cost( pairs: Pairs, compression: int = 5, ) -> List[Pairs]: + if not pairs: + # The subtable was actually empty? + return [pairs] + # Sorted for reproducibility/determinism all_class1 = sorted(set(pair[0] for pair in pairs)) all_class2 = sorted(set(pair[1] for pair in pairs)) @@ -229,10 +235,13 @@ def cluster_pairs_by_class2_coverage_custom_cost( @property def cost(self): if self._cost is None: - # From: https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#pair-adjustment-positioning-format-2-class-pair-adjustment self._cost = ( - # uint16 posFormat Format identifier: format = 2 + # 2 bytes to store the offset to this subtable in the Lookup table above 2 + # Contents of the subtable + # From: https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#pair-adjustment-positioning-format-2-class-pair-adjustment + # uint16 posFormat Format identifier: format = 2 + + 2 # Offset16 coverageOffset Offset to Coverage table, from beginning of PairPos subtable. + 2 + self.coverage_bytes @@ -267,7 +276,9 @@ def cluster_pairs_by_class2_coverage_custom_cost( # uint16 glyphArray[glyphCount] Array of glyph IDs — in numerical order + sum(len(all_class1[i]) for i in self.indices) * 2 ) - ranges = sorted(chain.from_iterable(all_class1_data[i][0] for i in self.indices)) + ranges = sorted( + chain.from_iterable(all_class1_data[i][0] for i in self.indices) + ) merged_range_count = 0 last = None for (start, end) in ranges: diff --git a/Tests/otlLib/optimize_test.py b/Tests/otlLib/optimize_test.py index 4201aed09..db1cac5c3 100644 --- a/Tests/otlLib/optimize_test.py +++ b/Tests/otlLib/optimize_test.py @@ -1,9 +1,18 @@ +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.""" @@ -36,19 +45,128 @@ def test_main(tmpdir: Path): assert output.exists() -def test_off_by_default(tmpdir: Path): - """Check that calling the main function on an input TTF works.""" - glyphs = ".notdef space A B".split() - features = """ - feature kern { - pos A B -50; - } kern; +# 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): """ - 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", str(input), "-o", str(output)], check=True) - assert output.exists() + 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: + 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)