fix: known_start/end casing for multi-word name width calculation

When known_start was set with capitalized casing, the unknown portion was
forced to all-lowercase, breaking width measurements for multi-word names
(e.g., "hn doe" instead of "hn Doe"). Added _case_unknown_portion helper
that respects word boundaries. Also fixed effective_left/right kerning
context to use correctly cased characters from the display string.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-22 14:44:33 -06:00
1 parent c915935ee4
commit 6e77070538
3 files changed
+99 -85

No files matched your search

+37 -36
View File
@@ -14,13 +14,9 @@ def _mock_font(width_map: dict[str, float]) -> MagicMock:
class TestSolveFullNameDictionary:
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_basic_match(self, mock_variants, mock_lasts, mock_firsts):
mock_firsts.return_value = ["john", "jane"]
mock_lasts.return_value = ["doe", "smith"]
mock_variants.return_value = []
def test_basic_match(self, mock_variants):
mock_variants.return_value = ["john doe", "john smith", "jane doe", "jane smith"]
font = _mock_font({"John Doe": 50.0, "John Smith": 60.0, "Jane Doe": 48.0, "Jane Smith": 58.0})
@@ -30,13 +26,9 @@ class TestSolveFullNameDictionary:
assert "John Doe" in texts
assert "Jane Doe" not in texts # 48.0 is outside tolerance
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_uppercase_mode(self, mock_variants, mock_lasts, mock_firsts):
mock_firsts.return_value = ["john"]
mock_lasts.return_value = ["doe"]
mock_variants.return_value = []
def test_uppercase_mode(self, mock_variants):
mock_variants.return_value = ["john doe"]
font = _mock_font({"JOHN DOE": 55.0})
@@ -45,13 +37,9 @@ class TestSolveFullNameDictionary:
assert len(results) == 1
assert results[0].text == "JOHN DOE"
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_includes_associate_variants(self, mock_variants, mock_lasts, mock_firsts):
mock_firsts.return_value = ["john"]
mock_lasts.return_value = ["doe"]
mock_variants.return_value = ["j. doe", "johnny doe"]
def test_includes_associate_variants(self, mock_variants):
mock_variants.return_value = ["john doe", "j. doe", "johnny doe"]
font = _mock_font({
"John Doe": 50.0,
@@ -65,13 +53,9 @@ class TestSolveFullNameDictionary:
# J. Doe title-cases to "J. Doe" with width 35.0 -- outside tolerance
# Johnny Doe title-cases to "Johnny Doe" with width 60.0 -- outside tolerance
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_context_chars(self, mock_variants, mock_lasts, mock_firsts):
mock_firsts.return_value = ["john"]
mock_lasts.return_value = ["doe"]
mock_variants.return_value = []
def test_context_chars(self, mock_variants):
mock_variants.return_value = ["john doe"]
font = _mock_font({
"<John Doe>": 60.0,
@@ -83,13 +67,9 @@ class TestSolveFullNameDictionary:
assert len(results) == 1
assert results[0].text == "John Doe"
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_dedup_variants_and_cartesian(self, mock_variants, mock_lasts, mock_firsts):
mock_firsts.return_value = ["john"]
mock_lasts.return_value = ["doe"]
mock_variants.return_value = ["john doe"] # duplicate of cartesian result
def test_dedup(self, mock_variants):
mock_variants.return_value = ["john doe", "john doe"] # duplicate
font = _mock_font({"John Doe": 50.0})
@@ -97,16 +77,37 @@ class TestSolveFullNameDictionary:
texts = [r.text for r in results]
assert texts.count("John Doe") == 1 # no duplicates
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_sorted_by_error(self, mock_variants, mock_lasts, mock_firsts):
mock_firsts.return_value = ["john", "jane"]
mock_lasts.return_value = ["doe"]
mock_variants.return_value = []
def test_sorted_by_error(self, mock_variants):
mock_variants.return_value = ["john doe", "jane doe"]
font = _mock_font({"John Doe": 50.5, "Jane Doe": 50.0})
results = solve_full_name_dictionary(font, 50.0, 1.0)
assert len(results) == 2
assert results[0].error <= results[1].error
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_known_start_multiword_casing(self, mock_variants):
"""Unknown portion of multi-word name should preserve word-boundary casing.
Name: 'john doe', known_start='jo', unknown='hn Doe' (not 'hn doe').
The width measurement must use 'hn Doe' not 'hn doe'.
effective_left = last char of _apply_casing('jo') = 'o', so
font sees 'o' + 'hn Doe' = 'ohn Doe' (kerning context string).
"""
mock_variants.return_value = ["john doe"]
# Mock: the kerning-context string "ohn Doe" vs "ohn doe".
# font.getlength("o") defaults to 7.0 (1 char * 7.0).
# width = font.getlength("ohn Doe") - font.getlength("o") = 42.0 - 7.0 = 35.0
font = _mock_font({
"ohn Doe": 42.0, # correct cased unknown (width = 35.0)
"ohn doe": 38.0, # wrong (current bug: all-lowercase, width = 31.0)
})
results = solve_full_name_dictionary(
font, 35.0, 1.0, known_start="jo", casing="capitalized",
)
texts = [r.text for r in results]
assert "John Doe" in texts
+16
View File
@@ -108,6 +108,22 @@ class TestSolveNameDictionary:
assert len(results) == 2
assert results[0].error <= results[1].error
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
def test_known_start_capitalized_casing(self, mock_lasts, mock_firsts):
"""With known_start and capitalized casing, unknown part is mid-word lowercase."""
mock_firsts.return_value = ["john"]
mock_lasts.return_value = []
# known_start="j", unknown="ohn", should measure "ohn" (lowercase)
font = _mock_font({"ohn": 21.0})
results = solve_name_dictionary(
font, 21.0, 1.0, known_start="j", casing="capitalized",
)
texts = [r.text for r in results]
assert "John" in texts
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
def test_context_chars(self, mock_lasts, mock_firsts):
+46 -49
View File
@@ -71,6 +71,33 @@ def _apply_casing(text: str, casing: str) -> str:
return text
def _case_unknown_portion(unknown: str, known_start: str, casing: str) -> str:
"""Apply casing to the unknown portion of a name, respecting word boundaries.
When known_start is set and casing is 'capitalized', the unknown text
starts mid-word — its first fragment should be lowercase, but subsequent
words (after spaces) should be title-cased.
"""
if casing == "uppercase":
return unknown.upper()
if casing != "capitalized":
return unknown # lowercase — no change
if not known_start:
return unknown.title()
# If known_start ends with a space, unknown starts a new word
if known_start.endswith(" "):
return unknown.title()
# Unknown starts mid-word: first fragment lowercase, rest title-cased
parts = unknown.split(" ")
result = [parts[0].lower()]
for part in parts[1:]:
result.append(part.title() if part else "")
return " ".join(result)
def solve_full_name_dictionary(
font: ImageFont.FreeTypeFont,
target_width: float,
@@ -81,23 +108,17 @@ def solve_full_name_dictionary(
known_start: str = "",
known_end: str = "",
) -> list[SolveResult]:
"""Match associate first x last name combinations against target width.
"""Match per-person full name variants against target width.
Generates the Cartesian product of associate first and last names,
applies casing, and checks each combination's rendered width.
Also checks associate name variants (initials, nicknames).
Uses pre-built multi-word name variants from associates.json
(full names, initial+last, nickname+last) — each tied to a real
person rather than a Cartesian product of unrelated names.
If known_start/known_end are set, filters candidates and measures
only the unknown portion against the target width.
"""
from unredact.pipeline.word_filter import (
_get_associate_firsts,
_get_associate_lasts,
_get_associate_variants,
)
from unredact.pipeline.word_filter import _get_associate_variants
firsts = _get_associate_firsts()
lasts = _get_associate_lasts()
variants = _get_associate_variants()
results: list[SolveResult] = []
@@ -106,33 +127,28 @@ def solve_full_name_dictionary(
ks_lower = known_start.lower()
ke_lower = known_end.lower()
def _check(raw: str, display: str):
"""Check a candidate. raw is lowercase, display is cased."""
for variant in variants:
raw = variant.lower()
display = _apply_casing(variant, casing)
if display in seen:
return
continue
seen.add(display)
# Filter by known start/end
if ks_lower and not raw.startswith(ks_lower):
return
continue
if ke_lower and not raw.endswith(ke_lower):
return
continue
if known_start or known_end:
# Measure only the unknown portion
end_idx = len(raw) - len(known_end) if known_end else len(raw)
unknown_raw = raw[len(known_start):end_idx]
if not unknown_raw:
return
unknown_display = _apply_casing(unknown_raw, casing)
# If known_start exists and casing is capitalized, unknown part is mid-word
if known_start and casing == "capitalized":
# The unknown part follows known text, so it's lowercase
# (title() would wrongly capitalize first char of unknown)
unknown_display = unknown_raw.lower()
continue
unknown_display = _case_unknown_portion(unknown_raw, known_start, casing)
effective_left = known_start[-1] if known_start else left_context
effective_right = known_end[0] if known_end else right_context
effective_left = _apply_casing(known_start, casing)[-1] if known_start else left_context
effective_right = display[end_idx] if known_end else right_context
if effective_left or effective_right:
full = effective_left + unknown_display + effective_right
@@ -156,18 +172,6 @@ def solve_full_name_dictionary(
if error <= tolerance:
results.append(SolveResult(text=display, width=float(width), error=float(error)))
# Cartesian product of first x last names
for first in firsts:
for last in lasts:
raw = first + " " + last
display = _apply_casing(raw, casing)
_check(raw, display)
# Associate variants (J. Doe, Jeff Epstein, etc.)
for variant in variants:
display = _apply_casing(variant, casing)
_check(variant.lower(), display)
results.sort(key=lambda r: (r.error, r.text))
return results
@@ -236,19 +240,12 @@ def solve_name_dictionary(
if not unknown:
continue # entire name is known, nothing to measure
# Apply same casing to the unknown portion
if casing == "uppercase":
unknown_display = unknown.upper()
elif casing == "capitalized":
# Mid-word after known_start: lowercase. At word start: title case.
unknown_display = unknown.lower() if known_start else unknown.title()
else:
unknown_display = unknown
unknown_display = _case_unknown_portion(unknown, known_start, casing)
# Determine kerning context
# If known_start is set, its last char is left context for the unknown part
effective_left = known_start[-1] if known_start else left_context
effective_right = known_end[0] if known_end else right_context
effective_left = _apply_casing(known_start, casing)[-1] if known_start else left_context
effective_right = display[end_idx] if known_end else right_context
# Measure width of unknown portion with kerning context
if effective_left or effective_right: