feat: width table module with kerning-aware font metrics

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-15 22:00:12 -06:00
1 parent fe70bcaa6c
commit 696b0165ef
2 files changed
+176

No files matched your search

+85
View File
@@ -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)
+91
View File
@@ -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()),
)