Add tests that optimize block matrices

This commit is contained in:
Jany Belluz 2021-06-30 18:52:57 +01:00
parent ef67839fdb
commit 7860dd5fe8
2 changed files with 148 additions and 19 deletions

View File

@ -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:

View File

@ -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)