diff --git a/tests/conftest.py b/tests/conftest.py index 0a09df5..db3d655 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,3 +12,11 @@ def sample_pdf() -> Path: if not pdf.exists(): pytest.skip("Sample PDF not available") return pdf + + +@pytest.fixture +def sample_page_image(sample_pdf): + """Rasterize the first page of the sample PDF as a PIL Image.""" + from unredact.pipeline.rasterize import rasterize_pdf + pages = rasterize_pdf(sample_pdf) + return pages[0] diff --git a/tests/test_analyze_page.py b/tests/test_analyze_page.py index f7753ee..f17d518 100644 --- a/tests/test_analyze_page.py +++ b/tests/test_analyze_page.py @@ -15,6 +15,7 @@ from unredact.pipeline.analyze_page import ( RedactionAnalysis, PageAnalysis, analyze_page, + analyze_spot_redaction, ) @@ -348,3 +349,130 @@ async def test_analyze_page_uses_precomputed_ocr_lines(): m_ocr.assert_not_called() # The returned lines must be the exact same object (not a copy) assert result.lines is precomputed_lines + + +# --------------------------------------------------------------------------- +# test_analyze_spot_redaction +# --------------------------------------------------------------------------- + +def test_analyze_spot_redaction_returns_analysis(): + """analyze_spot_redaction should return a RedactionAnalysis for a box + that overlaps an OCR line, with correct font, text, and offsets.""" + line = _make_line_from_words(["Hello", "|||", "world"], y=100, h=20) + lines = [line] + + box = Redaction( + id="spot1", + x=100, + y=98, + w=30, + h=22, + ) + + font_match = _dummy_font_match() + page_image = _dummy_page_image() + + with ( + patch( + "unredact.pipeline.analyze_page.detect_font_masked", + return_value=font_match, + ), + patch( + "unredact.pipeline.analyze_page.align_text_to_page", + return_value=(3, -1), + ), + ): + result = analyze_spot_redaction(page_image, lines, box) + + assert result is not None + assert isinstance(result, RedactionAnalysis) + assert result.box is box + assert result.line is line + assert result.font is font_match + assert "Hello" in result.left_text + assert "world" in result.right_text + assert isinstance(result.offset_x, float) + assert isinstance(result.offset_y, float) + + +def test_analyze_spot_redaction_no_matching_line(): + """analyze_spot_redaction should return None if no OCR line overlaps.""" + # Line at y=100, box at y=500 — no overlap + line = _make_line_from_words(["Hello", "world"], y=100, h=20) + lines = [line] + + box = Redaction(id="spot2", x=50, y=500, w=100, h=20) + page_image = _dummy_page_image() + + result = analyze_spot_redaction(page_image, lines, box) + assert result is None + + +def test_analyze_spot_redaction_empty_lines(): + """analyze_spot_redaction should return None for empty OCR lines list.""" + box = Redaction(id="spot3", x=50, y=100, w=100, h=20) + page_image = _dummy_page_image() + + result = analyze_spot_redaction(page_image, [], box) + assert result is None + + +def test_analyze_spot_redaction_no_left_text_skips_alignment(): + """When the box covers the start of the line (no left text), + alignment should be skipped and offsets should be 0.0.""" + # Box starts at x=50 (same as line start), so no chars to the left + line = _make_line_from_words(["|||", "world"], start_x=50, y=100, h=20) + lines = [line] + + box = Redaction(id="spot4", x=50, y=98, w=30, h=22) + + font_match = _dummy_font_match() + page_image = _dummy_page_image() + + with patch( + "unredact.pipeline.analyze_page.detect_font_masked", + return_value=font_match, + ): + result = analyze_spot_redaction(page_image, lines, box) + + assert result is not None + assert result.left_text == "" + assert result.offset_x == 0.0 + assert result.offset_y == 0.0 + + +def test_analyze_spot_redaction_integration(sample_page_image): + """analyze_spot_redaction should return full analysis for a known bbox.""" + from unredact.pipeline.ocr import ocr_page + + # Get real OCR data + lines = ocr_page(sample_page_image) + if not lines: + pytest.skip("No OCR lines found in sample image") + + # Find a line with enough text to put a box in the middle + line = None + for l in lines: + if l.w > 100 and len(l.chars) > 5: + line = l + break + if line is None: + pytest.skip("No suitable OCR line found") + + # Create a synthetic redaction box in the middle of the line + box = Redaction( + id="test123", + x=line.x + line.w // 3, + y=line.y, + w=line.w // 3, + h=line.h, + ) + + result = analyze_spot_redaction(sample_page_image, lines, box) + assert result is not None + assert result.box is box + assert result.line is line + assert result.font is not None + assert result.font.font_size > 0 + assert isinstance(result.offset_x, float) + assert isinstance(result.offset_y, float) diff --git a/unredact/pipeline/analyze_page.py b/unredact/pipeline/analyze_page.py index fd617e0..1edbc95 100644 --- a/unredact/pipeline/analyze_page.py +++ b/unredact/pipeline/analyze_page.py @@ -200,3 +200,84 @@ async def analyze_page( on_progress("analysis_complete", {"count": len(results)}) return PageAnalysis(lines=lines, redactions=results) + + +def analyze_spot_redaction( + page_image: Image.Image, + ocr_lines: list[OcrLine], + box: Redaction, +) -> RedactionAnalysis | None: + """Run analysis on a single known redaction bounding box. + + Uses cached OCR data to: + 1. Find the OCR line containing the redaction + 2. Detect the font (with redaction masking) + 3. Extract left/right context text + 4. Compute pixel alignment offsets + + Args: + page_image: PIL Image of the document page. + ocr_lines: Pre-computed OCR lines for the page. + box: Known redaction bounding box. + + Returns: + RedactionAnalysis or None if no suitable OCR line found. + """ + # Find the OCR line that best contains this redaction box. + # Use vertical overlap: the line whose vertical range overlaps most with the box. + best_line = None + best_overlap = 0 + box_top = box.y + box_bottom = box.y + box.h + + for line in ocr_lines: + line_top = line.y + line_bottom = line.y + line.h + overlap = max(0, min(box_bottom, line_bottom) - max(box_top, line_top)) + if overlap > best_overlap: + best_overlap = overlap + best_line = line + + if best_line is None: + return None + + line = best_line + redaction_boxes = [(box.x, box.y, box.w, box.h)] + + # Font detection with masking + font = detect_font_masked(line, page_image, redaction_boxes) + + # Extract left/right text using center-point char filtering + left_chars = [c for c in line.chars if c.x + c.w / 2 < box.x] + right_chars = [c for c in line.chars if c.x + c.w / 2 > box.x + box.w] + left_text = "".join(c.text for c in left_chars).strip() + right_text = "".join(c.text for c in right_chars).strip() + + # Pixel alignment + offset_x = 0.0 + offset_y = 0.0 + + if left_text: + pil_font = font.to_pil_font() + text_region_x1 = max(0, line.x - 20) + text_region_x2 = min(page_image.width, box.x + 20) + text_region_y1 = max(0, line.y - 10) + text_region_y2 = min(page_image.height, line.y + line.h + 10) + text_crop = np.array(page_image.convert("L").crop( + (text_region_x1, text_region_y1, text_region_x2, text_region_y2) + )) + align_dx, align_dy = align_text_to_page( + left_text, pil_font, text_crop, + ) + offset_x = float(text_region_x1 + align_dx - line.x) + offset_y = float(text_region_y1 + align_dy - line.y) + + return RedactionAnalysis( + box=box, + line=line, + font=font, + left_text=left_text, + right_text=right_text, + offset_x=offset_x, + offset_y=offset_y, + )