mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-10-27 08:40:29 +00:00
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:
1 parent
c915935ee4
commit
6e77070538
3 files changed
+99
-85
No files matched your search
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user