From b357713e5ea4cb684debff0b91e3cc90ee6ca12d Mon Sep 17 00:00:00 2001 From: Alex-Gilbert Date: Sun, 15 Feb 2026 22:15:07 -0600 Subject: [PATCH] feat: dictionary solver with in-memory word list store Add solve_dictionary() for linear-scan matching of dictionary entries against a target pixel width, and DictionaryStore for managing named word/name lists. Reuses SolveResult from the solver module. Co-Authored-By: Claude Opus 4.6 --- tests/test_dictionary.py | 67 +++++++++++++++++++++++++++++++++ unredact/pipeline/dictionary.py | 62 ++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 tests/test_dictionary.py create mode 100644 unredact/pipeline/dictionary.py diff --git a/tests/test_dictionary.py b/tests/test_dictionary.py new file mode 100644 index 0000000..03258c1 --- /dev/null +++ b/tests/test_dictionary.py @@ -0,0 +1,67 @@ +# tests/test_dictionary.py +import pytest +from PIL import ImageFont + +from unredact.pipeline.dictionary import solve_dictionary, DictionaryStore + + +def _get_test_font() -> ImageFont.FreeTypeFont: + 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 TestSolveDictionary: + def test_finds_matching_word(self): + font = _get_test_font() + entries = ["Smith", "Jones", "Brown"] + target = font.getlength("Smith") + results = solve_dictionary(font, entries, target, tolerance=0.5) + texts = [r.text for r in results] + assert "Smith" in texts + + def test_respects_tolerance(self): + font = _get_test_font() + entries = ["Smith", "Jones", "Brown"] + target = font.getlength("Smith") + results = solve_dictionary(font, entries, target, tolerance=0.0) + assert all(r.error == 0.0 for r in results) + + def test_with_context(self): + font = _get_test_font() + entries = ["Smith"] + target = font.getlength(" Smith ") - font.getlength(" ") - font.getlength(" ") + results = solve_dictionary( + font, entries, target, tolerance=1.0, + left_context=" ", right_context=" ", + ) + assert len(results) >= 0 # may or may not match due to kerning + + +class TestDictionaryStore: + def test_add_and_list(self): + store = DictionaryStore() + store.add("names", ["Alice", "Bob", "Charlie"]) + assert "names" in store.list() + + def test_get_entries(self): + store = DictionaryStore() + store.add("names", ["Alice", "Bob"]) + assert store.get_entries("names") == ["Alice", "Bob"] + + def test_remove(self): + store = DictionaryStore() + store.add("names", ["Alice"]) + store.remove("names") + assert "names" not in store.list() + + def test_all_entries(self): + store = DictionaryStore() + store.add("list1", ["Alice", "Bob"]) + store.add("list2", ["Charlie"]) + all_entries = store.all_entries() + assert set(all_entries) == {"Alice", "Bob", "Charlie"} diff --git a/unredact/pipeline/dictionary.py b/unredact/pipeline/dictionary.py new file mode 100644 index 0000000..576998e --- /dev/null +++ b/unredact/pipeline/dictionary.py @@ -0,0 +1,62 @@ +# unredact/pipeline/dictionary.py +from PIL import ImageFont + +from unredact.pipeline.solver import SolveResult + + +class DictionaryStore: + """In-memory store for word/name lists.""" + + def __init__(self): + self._dicts: dict[str, list[str]] = {} + + def add(self, name: str, entries: list[str]): + self._dicts[name] = entries + + def remove(self, name: str): + self._dicts.pop(name, None) + + def list(self) -> list[str]: + return list(self._dicts.keys()) + + def get_entries(self, name: str) -> list[str]: + return self._dicts.get(name, []) + + def all_entries(self) -> list[str]: + seen = set() + result = [] + for entries in self._dicts.values(): + for e in entries: + if e not in seen: + seen.add(e) + result.append(e) + return result + + +def solve_dictionary( + font: ImageFont.FreeTypeFont, + entries: list[str], + target_width: float, + tolerance: float = 0.0, + left_context: str = "", + right_context: str = "", +) -> list[SolveResult]: + """Check each dictionary entry against the target width.""" + results: list[SolveResult] = [] + + for entry in entries: + if left_context or right_context: + full = left_context + entry + 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 + width = full_len - left_len - right_len + else: + width = font.getlength(entry) + + error = abs(width - target_width) + if error <= tolerance: + results.append(SolveResult(text=entry, width=float(width), error=float(error))) + + results.sort(key=lambda r: (r.error, r.text)) + return results