mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-10-27 08:40:29 +00:00
feat: add two-word binary search solver (phase 2 of word mode)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
1 parent
6c6a90e796
commit
d04116579b
2 files changed
+140
No files matched your search
@@ -86,3 +86,67 @@ class TestSolveWordDictionarySingleWord:
|
||||
target = font.getlength("dog")
|
||||
gen = solve_word_dictionary(font, target, tolerance=0.5, casing="lowercase")
|
||||
assert isinstance(gen, types.GeneratorType)
|
||||
|
||||
|
||||
class TestSolveWordDictionaryTwoWord:
|
||||
def test_finds_two_word_phrase(self):
|
||||
font = _get_test_font()
|
||||
target = font.getlength("large house")
|
||||
results = list(solve_word_dictionary(
|
||||
font, target, tolerance=1.0,
|
||||
casing="lowercase", known_start="large",
|
||||
))
|
||||
texts = [r.text for r in results]
|
||||
assert "large house" in texts
|
||||
|
||||
def test_two_word_capitalized(self):
|
||||
font = _get_test_font()
|
||||
target = font.getlength("Large House")
|
||||
results = list(solve_word_dictionary(
|
||||
font, target, tolerance=1.0,
|
||||
casing="capitalized", known_start="Large",
|
||||
))
|
||||
texts = [r.text for r in results]
|
||||
assert "Large House" in texts
|
||||
|
||||
def test_two_word_plural(self):
|
||||
font = _get_test_font()
|
||||
target = font.getlength("large houses")
|
||||
results = list(solve_word_dictionary(
|
||||
font, target, tolerance=1.0,
|
||||
casing="lowercase", ensure_plural=True, known_start="large",
|
||||
))
|
||||
texts = [r.text for r in results]
|
||||
assert "large houses" in texts
|
||||
|
||||
def test_two_word_known_start(self):
|
||||
font = _get_test_font()
|
||||
target = font.getlength("large house")
|
||||
results = list(solve_word_dictionary(
|
||||
font, target, tolerance=1.0,
|
||||
casing="lowercase", known_start="la",
|
||||
))
|
||||
texts = [r.text for r in results]
|
||||
assert "large house" in texts
|
||||
assert all(t.lower().startswith("la") for t in texts)
|
||||
|
||||
def test_two_word_known_end(self):
|
||||
font = _get_test_font()
|
||||
target = font.getlength("large house")
|
||||
results = list(solve_word_dictionary(
|
||||
font, target, tolerance=1.0,
|
||||
casing="lowercase", known_end="se",
|
||||
))
|
||||
texts = [r.text for r in results]
|
||||
assert "large house" in texts
|
||||
assert all(t.lower().endswith("se") for t in texts)
|
||||
|
||||
def test_no_duplicates_across_phases(self):
|
||||
font = _get_test_font()
|
||||
target = font.getlength("dog")
|
||||
results = list(solve_word_dictionary(
|
||||
font, target, tolerance=1.0,
|
||||
casing="lowercase",
|
||||
))
|
||||
texts = [r.text for r in results]
|
||||
assert len(texts) == len(set(texts))
|
||||
@@ -317,3 +317,79 @@ def solve_word_dictionary(
|
||||
error = abs(width - target_width)
|
||||
if error <= tolerance:
|
||||
yield SolveResult(text=display, width=float(width), error=float(error))
|
||||
|
||||
# Phase 2: two-word search (word1 + " " + noun)
|
||||
import bisect
|
||||
|
||||
from unredact.pipeline.word_filter import _get_adjectives
|
||||
|
||||
adjectives = _get_adjectives()
|
||||
|
||||
# Word1 pool: adjectives + nouns (deduplicated, sorted)
|
||||
word1_set = set(adjectives) | set(nouns)
|
||||
word1_list = sorted(word1_set)
|
||||
|
||||
# Word2 pool: nouns or plural nouns
|
||||
word2_list = list(plurals if ensure_plural else nouns)
|
||||
|
||||
# Pre-measure word2 widths and sort
|
||||
word2_measured: list[tuple[float, str, str]] = []
|
||||
for w2 in word2_list:
|
||||
display2 = _apply_casing(w2, casing)
|
||||
w = font.getlength(display2)
|
||||
word2_measured.append((w, display2, w2))
|
||||
|
||||
word2_measured.sort(key=lambda x: x[0])
|
||||
word2_widths = [x[0] for x in word2_measured]
|
||||
|
||||
KERNING_MARGIN = 3.0
|
||||
|
||||
for w1 in word1_list:
|
||||
if ks_lower and not w1.startswith(ks_lower):
|
||||
continue
|
||||
|
||||
display1 = _apply_casing(w1, casing)
|
||||
|
||||
# Measure word1 + space width (with left context kerning)
|
||||
w1_with_space = display1 + " "
|
||||
if left_context:
|
||||
w1_portion = (
|
||||
font.getlength(left_context + w1_with_space)
|
||||
- font.getlength(left_context)
|
||||
)
|
||||
else:
|
||||
w1_portion = font.getlength(w1_with_space)
|
||||
|
||||
remaining = target_width - w1_portion
|
||||
if remaining < 0:
|
||||
continue
|
||||
|
||||
# Binary search for word2 candidates
|
||||
lo = bisect.bisect_left(word2_widths, remaining - tolerance - KERNING_MARGIN)
|
||||
hi = bisect.bisect_right(word2_widths, remaining + tolerance + KERNING_MARGIN)
|
||||
|
||||
for i in range(lo, hi):
|
||||
_, display2, w2_raw = word2_measured[i]
|
||||
if ke_lower and not w2_raw.endswith(ke_lower):
|
||||
continue
|
||||
|
||||
phrase = display1 + " " + display2
|
||||
if phrase in seen:
|
||||
continue
|
||||
|
||||
# Verify exact combined width with full kerning context
|
||||
if left_context or right_context:
|
||||
full = left_context + phrase + right_context
|
||||
full_len = font.getlength(full)
|
||||
left_len = font.getlength(left_context) if left_context else 0.0
|
||||
right_len = font.getlength(right_context) if right_context else 0.0
|
||||
exact_width = full_len - left_len - right_len
|
||||
else:
|
||||
exact_width = font.getlength(phrase)
|
||||
|
||||
error = abs(exact_width - target_width)
|
||||
if error <= tolerance:
|
||||
seen.add(phrase)
|
||||
yield SolveResult(
|
||||
text=phrase, width=float(exact_width), error=float(error)
|
||||
)
|
||||
Reference in new issue
Block a user