From 696b0165ef5ff009f8166a47fd723ab60efa2ea3 Mon Sep 17 00:00:00 2001 From: Alex-Gilbert Date: Sun, 15 Feb 2026 21:54:09 -0600 Subject: [PATCH] feat: width table module with kerning-aware font metrics Co-Authored-By: Claude Opus 4.6 --- tests/test_width_table.py | 85 +++++++++++++++++++++++++++++ unredact/pipeline/width_table.py | 91 ++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 tests/test_width_table.py create mode 100644 unredact/pipeline/width_table.py diff --git a/tests/test_width_table.py b/tests/test_width_table.py new file mode 100644 index 0000000..7d2c3d4 --- /dev/null +++ b/tests/test_width_table.py @@ -0,0 +1,85 @@ +import numpy as np +import pytest +from PIL import ImageFont + +from unredact.pipeline.width_table import build_width_table, CHARSETS + + +def _get_test_font() -> ImageFont.FreeTypeFont: + """Get any available system font for testing.""" + import subprocess + result = subprocess.run( + ["fc-match", "--format=%{file}", "Liberation Serif"], + capture_output=True, text=True, + ) + path = result.stdout.strip() + return ImageFont.truetype(path, 40) + + +class TestCharsets: + def test_lowercase_has_26_chars(self): + assert len(CHARSETS["lowercase"]) == 26 + + def test_uppercase_has_26_chars(self): + assert len(CHARSETS["uppercase"]) == 26 + + def test_alpha_has_52_chars(self): + assert len(CHARSETS["alpha"]) == 52 + + def test_alphanumeric_has_62_chars(self): + assert len(CHARSETS["alphanumeric"]) == 62 + + +class TestBuildWidthTable: + def test_returns_correct_shape(self): + font = _get_test_font() + charset = "abc" + table = build_width_table(font, charset) + assert table.width_table.shape == (3, 3) + assert table.left_edge.shape == (3,) + assert table.right_edge.shape == (3,) + assert table.min_advance.shape == (3,) + assert table.max_advance.shape == (3,) + + def test_widths_are_positive(self): + font = _get_test_font() + charset = "abcdefghij" + table = build_width_table(font, charset) + assert np.all(table.width_table > 0) + + def test_min_max_bounds_correct(self): + font = _get_test_font() + charset = "abcdefghijklmnopqrstuvwxyz" + table = build_width_table(font, charset) + for i in range(len(charset)): + row = table.width_table[i] + assert table.min_advance[i] == pytest.approx(row.min()) + assert table.max_advance[i] == pytest.approx(row.max()) + + def test_kerning_captured(self): + """Different char pairs should produce different advance widths.""" + font = _get_test_font() + charset = "AVX" + table = build_width_table(font, charset) + assert table.width_table.shape == (3, 3) + # Advance of V after A should differ from advance of X after A (kerning) + assert table.width_table[0][1] != table.width_table[0][2] + + def test_left_edge_uses_context(self): + font = _get_test_font() + charset = "abcde" + table = build_width_table(font, charset, left_context="T") + table_no_ctx = build_width_table(font, charset, left_context="") + assert table.left_edge.shape == (5,) + # Left context should change at least some advance widths + assert not np.allclose(table.left_edge, table_no_ctx.left_edge) + + def test_right_edge_uses_context(self): + font = _get_test_font() + # Use uppercase chars that kern with lowercase context + charset = "AVT" + table = build_width_table(font, charset, right_context="o") + assert table.right_edge.shape == (3,) + # Right edge with context should differ from right edge without context + table_no_ctx = build_width_table(font, charset, right_context="") + assert not np.array_equal(table.right_edge, table_no_ctx.right_edge) diff --git a/unredact/pipeline/width_table.py b/unredact/pipeline/width_table.py new file mode 100644 index 0000000..33c6121 --- /dev/null +++ b/unredact/pipeline/width_table.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass + +import numpy as np +from PIL import ImageFont + +CHARSETS: dict[str, str] = { + "lowercase": "abcdefghijklmnopqrstuvwxyz", + "uppercase": "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "alpha": "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + "alphanumeric": "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + "printable": ( + "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "0123456789" + " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + ), +} + + +@dataclass +class WidthTable: + """Precomputed font metric table for a charset. + + width_table[i][j]: advance width of charset[j] when preceded by charset[i] + left_edge[j]: advance width of charset[j] when preceded by left_context + right_edge[i]: kerning correction for charset[i] followed by right_context + min_advance[i]: minimum advance of any char after charset[i] + max_advance[i]: maximum advance of any char after charset[i] + """ + + charset: str + width_table: np.ndarray # (N, N) float64 + left_edge: np.ndarray # (N,) float64 + right_edge: np.ndarray # (N,) float64 + min_advance: np.ndarray # (N,) float64 + max_advance: np.ndarray # (N,) float64 + left_min: float + left_max: float + + +def build_width_table( + font: ImageFont.FreeTypeFont, + charset: str, + left_context: str = "", + right_context: str = "", +) -> WidthTable: + """Build a kerning-aware width lookup table. + + Uses font.getlength() which returns advance width including kerning. + width_table[i][j] = getlength(charset[i] + charset[j]) - getlength(charset[i]) + This gives the advance of charset[j] when preceded by charset[i]. + """ + n = len(charset) + table = np.zeros((n, n), dtype=np.float64) + left_edge = np.zeros(n, dtype=np.float64) + right_edge = np.zeros(n, dtype=np.float64) + + single = np.array([font.getlength(c) for c in charset], dtype=np.float64) + + for i, prev in enumerate(charset): + base = font.getlength(prev) + for j, next_c in enumerate(charset): + table[i][j] = font.getlength(prev + next_c) - base + + if left_context: + base_left = font.getlength(left_context) + for j, c in enumerate(charset): + left_edge[j] = font.getlength(left_context + c) - base_left + else: + left_edge[:] = single + + if right_context: + right_len = font.getlength(right_context) + for i, c in enumerate(charset): + right_edge[i] = font.getlength(c + right_context) - single[i] - right_len + else: + right_edge[:] = 0.0 + + min_advance = table.min(axis=1) + max_advance = table.max(axis=1) + + return WidthTable( + charset=charset, + width_table=table, + left_edge=left_edge, + right_edge=right_edge, + min_advance=min_advance, + max_advance=max_advance, + left_min=float(left_edge.min()), + left_max=float(left_edge.max()), + )