mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-10-27 08:40:29 +00:00
feat: width table module with kerning-aware font metrics
Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
1 parent
fe70bcaa6c
commit
696b0165ef
2 files changed
+176
No files matched your search
@@ -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)
|
||||
@@ -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()),
|
||||
)
|
||||
Reference in new issue
Block a user