diff --git a/sample_files/test_corpus/test_pdf_translated.pdf b/sample_files/test_corpus/test_pdf_translated.pdf index 2a0aad2..6a525fc 100644 Binary files a/sample_files/test_corpus/test_pdf_translated.pdf and b/sample_files/test_corpus/test_pdf_translated.pdf differ diff --git a/scripts/reproduce_image_issues.py b/scripts/reproduce_image_issues.py new file mode 100644 index 0000000..f5b562a --- /dev/null +++ b/scripts/reproduce_image_issues.py @@ -0,0 +1,45 @@ +"""Reproduce the image issues: title overflow + white box on colored block.""" +import sys +sys.path.insert(0, '.') +import fitz +from pathlib import Path + +# Open the translated PDF from the user +src = Path('sample_files/test_corpus/test_pdf_translated (1).pdf') +doc = fitz.open(str(src)) + +# Page 1 +p = doc[0] +print("=== Page 1 text blocks ===") +for i, b in enumerate(p.get_text("dict").get("blocks", [])): + bbox = b.get("bbox", (0, 0, 0, 0)) + if b.get("type") != 0: # not text + continue + text_lines = [] + for line in b.get("lines", []): + for span in line.get("spans", []): + text_lines.append(span.get("text", "")) + text = " ".join(text_lines)[:60] + print(f" [{i}] bbox=({bbox[0]:.0f},{bbox[1]:.0f},{bbox[2]:.0f},{bbox[3]:.0f}) text={text!r}") + +print() +print("=== Page 1 drawings ===") +for i, d in enumerate(p.get_drawings()): + rect = d.get("rect") + fill = d.get("fill") + color = d.get("color") + print(f" [{i}] rect=({rect.x0:.0f},{rect.y0:.0f},{rect.x1:.0f},{rect.y1:.0f}) fill={fill} color={color}") + +# Open the original to compare +orig = fitz.open('sample_files/test_corpus/test_pdf.pdf') +p_orig = orig[0] +print() +print("=== Original Page 1 drawings (for comparison) ===") +for i, d in enumerate(p_orig.get_drawings()): + rect = d.get("rect") + fill = d.get("fill") + color = d.get("color") + print(f" [{i}] rect=({rect.x0:.0f},{rect.y0:.0f},{rect.x1:.0f},{rect.y1:.0f}) fill={fill} color={color}") + +doc.close() +orig.close() diff --git a/scripts/verify_b3_6_fix.py b/scripts/verify_b3_6_fix.py new file mode 100644 index 0000000..4088ee6 --- /dev/null +++ b/scripts/verify_b3_6_fix.py @@ -0,0 +1,228 @@ +""" +B3.6 visual verification script. + +Steps: + 1. Re-translate the test PDF using a mock provider (cheap, no API cost). + 2. Render both the OLD broken PDF and the NEW (B3.6) PDF as PNGs. + 3. Inspect drawings: the blue "Avis important" box must still be present + in the new PDF but was lost in the old PDF. + 4. Inspect title block: it must not overlap the next block ("Version du document..."). + 5. Open both PNGs side-by-side so we can eyeball the difference. + +Usage: python scripts/verify_b3_6_fix.py +""" +import sys +import os +from pathlib import Path +from unittest.mock import MagicMock + +# Make repo root importable +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import fitz # PyMuPDF + +REPO_ROOT = Path(__file__).resolve().parent.parent +OLD_PDF = REPO_ROOT / "sample_files" / "test_corpus" / "test_pdf_translated (1).pdf" +ORIG_PDF = REPO_ROOT / "sample_files" / "test_corpus" / "test_pdf.pdf" +NEW_PDF = REPO_ROOT / "sample_files" / "test_corpus" / "test_pdf_translated_fresh.pdf" +RENDER_DIR = REPO_ROOT / "scripts" / "_renders" +RENDER_DIR.mkdir(parents=True, exist_ok=True) + + +def _render_pdf_page(pdf_path: Path, page_num: int = 0, out_png: Path = None, + zoom: float = 1.5) -> Path: + """Render a single PDF page as PNG.""" + doc = fitz.open(str(pdf_path)) + page = doc[page_num] + mat = fitz.Matrix(zoom, zoom) + pix = page.get_pixmap(matrix=mat) + if out_png is None: + out_png = RENDER_DIR / f"{pdf_path.stem}_p{page_num + 1}.png" + pix.save(str(out_png)) + doc.close() + return out_png + + +def _translate_with_mock(input_path: Path, output_path: Path) -> Path: + """Translate using a mock provider that returns the French text we want.""" + from translators.pdf_translator import PDFTranslator + + translator = PDFTranslator() + + # Mock provider: a fake French translation that's longer than the English original + # (this is the realistic case that triggered the overflow bug) + def mock_translate(text, target_language, source_language): + # Return a slightly longer French version to stress the layout + if not text: + return text + # Replace common patterns with longer French to trigger overflow + replacements = { + "Technical Specification": "Spécification technique : Office Translator", + "Document Version": "Version du document", + "Table of Contents": "Table des matières", + "1. Introduction": "1. Introduction", + "2. Installation and Configuration": "2. Installation et configuration", + "3. Translation Engine": "3. Moteur de traduction", + "4. Format Preservation": "4. Préservation des formats", + "5. Quality Assurance": "5. Assurance qualité", + "6. Performance and Scaling": "6. Performances et mise à l'échelle", + "7. API Reference": "7. Référence API", + "8. Troubleshooting": "8. Résolution des problèmes", + "Important Notice": "Avis important", + "This is a preliminary document.": "Ceci est un document préliminaire.", + "Subject to change without notice.": "Sujet à changement sans préavis.", + "For the latest version of this document, visit:": "Pour la dernière version de ce document, visitez :", + "http://docs.translator.example.com/v3": "http://docs.translator.example.com/v3", + } + result = text + for eng, fr in replacements.items(): + result = result.replace(eng, fr) + return result + + mock = MagicMock() + mock.translate_batch = MagicMock(side_effect=lambda texts, tgt, src: [mock_translate(t, tgt, src) for t in texts]) + + translator.set_provider(mock) + translator.translate_file( + input_path, output_path, + target_language="fr", + source_language="en", + ) + return output_path + + +def _inspect_drawings(pdf_path: Path) -> dict: + """Inspect page 1: count blue background drawings (the "Avis important" box).""" + doc = fitz.open(str(pdf_path)) + page = doc[0] + drawings = page.get_drawings() + # The "Avis important" box has fill (0.85, 0.92, 1.0) — light blue + blue_boxes = [] + for d in drawings: + fill = d.get("fill") + if fill is None: + continue + # Match light blue (R=0.85, G=0.92, B=1.0) + if all(abs(fill[i] - target) < 0.05 for i, target in enumerate([0.85, 0.92, 1.0])): + blue_boxes.append(d) + doc.close() + return { + "total_drawings": len(drawings), + "blue_boxes": len(blue_boxes), + "blue_rects": [(round(d["rect"].x0, 1), round(d["rect"].y0, 1), + round(d["rect"].x1, 1), round(d["rect"].y1, 1)) + for d in blue_boxes], + } + + +def _inspect_title_overlap(pdf_path: Path) -> dict: + """Check if the title block bottom overlaps the next block top.""" + doc = fitz.open(str(pdf_path)) + page = doc[0] + text_dict = page.get_text("dict") + + # Find the title block (largest font on the page) + title_block = None + next_block = None + sorted_blocks = sorted( + [b for b in text_dict.get("blocks", []) if b.get("type") == 0], + key=lambda b: -( + max((s.get("size", 12) for line in b.get("lines", []) + for s in line.get("spans", [])), default=12) + ) + ) + + if sorted_blocks: + title_block = sorted_blocks[0] + title_text = " ".join( + s.get("text", "") + for line in title_block.get("lines", []) + for s in line.get("spans", []) + ) + title_bottom = title_block["bbox"][3] + + # Find the next block right below the title + candidates = [ + b for b in text_dict.get("blocks", []) + if b.get("type") == 0 + and b is not title_block + and b["bbox"][1] >= title_bottom - 5 + and b["bbox"][1] <= title_bottom + 50 + ] + if candidates: + next_block = min(candidates, key=lambda b: b["bbox"][1]) + + result = { + "title": " ".join( + s.get("text", "") + for line in (title_block or {}).get("lines", []) + for s in line.get("spans", []) + )[:60], + "title_bbox": title_block["bbox"] if title_block else None, + } + if next_block: + next_text = " ".join( + s.get("text", "") + for line in next_block.get("lines", []) + for s in line.get("spans", []) + )[:60] + result["next_text"] = next_text + result["next_bbox"] = next_block["bbox"] + result["overlap_px"] = round(title_block["bbox"][3] - next_block["bbox"][1], 2) + result["overlaps"] = result["overlap_px"] > 2 + doc.close() + return result + + +def main(): + print("=" * 60) + print("B3.6 VERIFICATION — visual & structural") + print("=" * 60) + + # 1. Translate the original with B3.6 fix + print("\n[1] Re-translating test_pdf.pdf with B3.6 fix...") + if not ORIG_PDF.exists(): + print(f" ERROR: original PDF not found at {ORIG_PDF}") + return 1 + _translate_with_mock(ORIG_PDF, NEW_PDF) + print(f" wrote {NEW_PDF.name}") + + # 2. Render both PDFs as PNG for visual comparison + print("\n[2] Rendering PDFs to PNG...") + old_png = _render_pdf_page(OLD_PDF) + new_png = _render_pdf_page(NEW_PDF) + orig_png = _render_pdf_page(ORIG_PDF) + print(f" OLD broken: {old_png.relative_to(REPO_ROOT)}") + print(f" NEW (B3.6): {new_png.relative_to(REPO_ROOT)}") + print(f" ORIGINAL ref: {orig_png.relative_to(REPO_ROOT)}") + + # 3. Inspect drawings - blue box preservation + print("\n[3] Inspecting drawings (blue 'Avis important' box)...") + for label, path in [("OLD", OLD_PDF), ("NEW (B3.6)", NEW_PDF), ("ORIGINAL", ORIG_PDF)]: + info = _inspect_drawings(path) + verdict = "[OK]" if info["blue_boxes"] >= 1 else "[FAIL]" + print(f" {verdict} {label:12} -> {info['blue_boxes']} blue box(es) of " + f"{info['total_drawings']} total drawings") + + # 4. Inspect title overlap + print("\n[4] Inspecting title-vs-next-block overlap...") + for label, path in [("OLD", OLD_PDF), ("NEW (B3.6)", NEW_PDF)]: + info = _inspect_title_overlap(path) + if "overlaps" in info: + verdict = "[FAIL OVERLAPS]" if info["overlaps"] else "[OK no overlap]" + print(f" {verdict} {label:12} -> title={info['title']!r:40} " + f"overlap_px={info['overlap_px']:+.1f}") + else: + print(f" [?] {label:12} -> could not determine (insufficient blocks)") + + print("\n" + "=" * 60) + print("Visual comparison rendered to:") + print(f" {old_png}") + print(f" {new_png}") + print(f" {orig_png}") + print("=" * 60) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_translators/test_b3_5_pdf_smart_fit.py b/tests/test_translators/test_b3_5_pdf_smart_fit.py index 5725c64..4efde05 100644 --- a/tests/test_translators/test_b3_5_pdf_smart_fit.py +++ b/tests/test_translators/test_b3_5_pdf_smart_fit.py @@ -50,8 +50,10 @@ class TestConstants: ) def test_vertical_expansion_3x(self, pdf_mod): - """MAX_VERTICAL_EXPANSION should be 3x (was 1.5x in B3).""" - assert pdf_mod.MAX_VERTICAL_EXPANSION == pytest.approx(3.0, abs=0.1) + """MAX_VERTICAL_EXPANSION should be 6x (was 1.5x in B3, then 3x in B3.5). + Track B3.6 bumped it to 6x to handle long French titles that + wrap to multiple lines (a 22pt title may need 4-5 lines in French).""" + assert pdf_mod.MAX_VERTICAL_EXPANSION == pytest.approx(6.0, abs=0.1) def test_heading_min_size_threshold(self, pdf_mod): assert pdf_mod.HEADING_MIN_SIZE == 14.0 diff --git a/tests/test_translators/test_b3_6_pdf_drawings.py b/tests/test_translators/test_b3_6_pdf_drawings.py new file mode 100644 index 0000000..4f32726 --- /dev/null +++ b/tests/test_translators/test_b3_6_pdf_drawings.py @@ -0,0 +1,358 @@ +""" +Tests for Track B3.6 — PDF drawing preservation. + +Covers: + - Color backgrounds (e.g. "Avis important" box on light blue) are + preserved through translation (not erased by redaction). + - Title overflow: long translated titles expand horizontally + vertically + to avoid overlapping the next block. + - Transparent redaction (fill=None) is used when a block overlaps a + drawing, to preserve the drawing. +""" +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import importlib.util + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent + + +def _load_pdf_translator(): + spec = importlib.util.spec_from_file_location( + "pdf_translator_under_test", + _REPO_ROOT / "translators" / "pdf_translator.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def pdf_mod(): + return _load_pdf_translator() + + +# ============================================================================ +# Constants +# ============================================================================ + +class TestB3_6Constants: + def test_vertical_expansion_6x(self, pdf_mod): + """MAX_VERTICAL_EXPANSION should be 6x (B3.6) to handle long titles.""" + assert pdf_mod.MAX_VERTICAL_EXPANSION == pytest.approx(6.0, abs=0.1) + + def test_transparent_redaction_uses_fill_none(self, pdf_mod): + """When a block overlaps a drawing, redaction uses fill=None (transparent).""" + import fitz + page = MagicMock() + # Return one drawing (a colored rect) for get_drawings() + drawing = MagicMock() + drawing.get.return_value = fitz.Rect(72, 480, 500, 550) + drawing.get.side_effect = lambda key, default=None: { + "rect": fitz.Rect(72, 480, 500, 550), + "fill": (0.85, 0.92, 1.0), + }.get(key, default) + page.get_drawings = MagicMock(return_value=[drawing]) + page.add_redact_annot = MagicMock() + + # Simulate the logic from _process_pages_inplace + drawing_rects = [] + for d in page.get_drawings(): + r = d.get("rect") + if r is not None and d.get("fill") is not None: + drawing_rects.append(fitz.Rect(r)) + + # Block that intersects the drawing + block_bbox = fitz.Rect(85, 500, 200, 520) + intersects = any( + block_bbox.intersects(dr) for dr in drawing_rects + ) + assert intersects is True + + if intersects: + page.add_redact_annot(block_bbox, fill=None) + else: + page.add_redact_annot(block_bbox, fill=(1, 1, 1)) + + # Verify the redaction was added with fill=None (transparent) + call_kwargs = page.add_redact_annot.call_args[1] + assert call_kwargs.get("fill") is None, ( + f"Expected fill=None for block overlapping drawing, " + f"got fill={call_kwargs.get('fill')}" + ) + + def test_opaque_redaction_for_text_only_blocks(self, pdf_mod): + """For blocks WITHOUT drawings, redaction uses white fill (opaque).""" + import fitz + page = MagicMock() + page.get_drawings = MagicMock(return_value=[]) # no drawings + page.add_redact_annot = MagicMock() + + drawing_rects = [] + for d in page.get_drawings(): + r = d.get("rect") + if r is not None and d.get("fill") is not None: + drawing_rects.append(fitz.Rect(r)) + + block_bbox = fitz.Rect(100, 200, 400, 220) + intersects = any( + block_bbox.intersects(dr) for dr in drawing_rects + ) + assert intersects is False # no drawings → no intersection + + if intersects: + page.add_redact_annot(block_bbox, fill=None) + else: + page.add_redact_annot(block_bbox, fill=(1, 1, 1)) + + call_kwargs = page.add_redact_annot.call_args[1] + assert call_kwargs.get("fill") == (1, 1, 1), ( + f"Expected white fill for text-only block, got {call_kwargs.get('fill')}" + ) + + +# ============================================================================ +# Real PDF test +# ============================================================================ + +class TestB3_6RealPDF: + def test_colored_box_preserved_in_translation(self, tmp_path): + """A PDF with a colored background box (e.g. "Avis important") should + keep that color in the translated output.""" + import fitz + + # Build a minimal PDF: light blue box with text inside + doc = fitz.open() + page = doc.new_page() + # Light blue background + page.draw_rect( + fitz.Rect(72, 480, 500, 550), + color=(0.9, 0.95, 1.0), + fill=(0.85, 0.92, 1.0), + ) + page.insert_text((85, 500), "Important notice", fontsize=14, color=(0.1, 0.2, 0.5)) + + in_path = tmp_path / "input.pdf" + out_path = tmp_path / "output.pdf" + doc.save(str(in_path)) + doc.close() + + # Translate + from translators.pdf_translator import PDFTranslator + translator = PDFTranslator() + translator.translate_file( + in_path, out_path, + target_language="fr", + source_language="en", + ) + + # Verify the blue box is still there + out_doc = fitz.open(str(out_path)) + out_page = out_doc[0] + drawings = out_page.get_drawings() + # Find a drawing with the blue fill + blue_drawings = [ + d for d in drawings + if d.get("fill") == pytest.approx((0.85, 0.92, 1.0), abs=0.01) + ] + assert len(blue_drawings) >= 1, ( + f"Blue background drawing was lost! Got {len(drawings)} drawings: " + f"{[(d.get('rect'), d.get('fill')) for d in drawings]}" + ) + out_doc.close() + + def test_title_does_not_overflow_into_next_block(self, tmp_path): + """A long translated title should not overlap the next block.""" + import fitz + doc = fitz.open() + page = doc.new_page() + # Title at top + page.insert_text( + (72, 80), + "A very long title that should wrap to two lines in translation", + fontsize=22, + ) + # Next block at y=110 (very close — should be problematic) + page.insert_text( + (72, 110), + "Subtitle text", + fontsize=11, + ) + + in_path = tmp_path / "input.pdf" + out_path = tmp_path / "output.pdf" + doc.save(str(in_path)) + doc.close() + + from translators.pdf_translator import PDFTranslator + translator = PDFTranslator() + translator.translate_file( + in_path, out_path, + target_language="fr", + source_language="en", + ) + + # Verify the title doesn't overlap the subtitle + out_doc = fitz.open(str(out_path)) + out_page = out_doc[0] + text_dict = out_page.get_text("dict") + + # Get title bbox and subtitle bbox + title_bbox = None + subtitle_bbox = None + for block in text_dict.get("blocks", []): + if block.get("type") != 0: + continue + block_text = " ".join( + s.get("text", "") + for line in block.get("lines", []) + for s in line.get("spans", []) + ).lower() + if "specification" in block_text or "sp" in block_text and "technique" in block_text: + title_bbox = block.get("bbox") + elif "version" in block_text or "subtitle" in block_text: + subtitle_bbox = block.get("bbox") + + if title_bbox and subtitle_bbox: + title_bottom = title_bbox[3] + subtitle_top = subtitle_bbox[1] + assert title_bottom <= subtitle_top + 5, ( + f"Title bottom ({title_bottom}) overlaps subtitle top ({subtitle_top}). " + f"Title: {title_bbox}, Subtitle: {subtitle_bbox}" + ) + out_doc.close() + + +# ============================================================================ +# Drawing re-application (alternative strategy) +# ============================================================================ + +class TestB3_6DrawingReapplication: + """The alternative strategy: re-apply original drawings on top of + redaction. We use the cleaner strategy (transparent redaction) but + keep this test for the legacy approach.""" + + def test_drawings_collected_before_redaction(self, pdf_mod): + """The page's drawings should be captured BEFORE the redaction.""" + import fitz + page = MagicMock() + page.get_drawings = MagicMock(return_value=[ + {"rect": fitz.Rect(0, 0, 100, 100), "fill": (1, 0, 0)}, + ]) + + # Capture + captured = [] + if hasattr(page, "get_drawings"): + try: + captured = list(page.get_drawings()) + except Exception: + captured = [] + assert len(captured) == 1 + assert captured[0]["fill"] == (1, 0, 0) + + +# ============================================================================ +# Track B3.7 — next-block-aware vertical expansion +# ============================================================================ + +class TestB3_7NextBlockCeiling: + """Track B3.7: the smart-fit logic must cap vertical expansion at + the next block's y0, not at the page bottom. Otherwise, a long + translated block can flow into the block below it.""" + + def test_next_block_y_populated_for_each_block(self, pdf_mod): + """For 3 vertically stacked blocks, the middle one should have + the third's y0 as its next_block_y, and the last one the page bottom.""" + import fitz + translator = pdf_mod.PDFTranslator() + page = MagicMock() + page.rect = fitz.Rect(0, 0, 612, 792) + blocks = [ + {"bbox": (72, 100, 540, 120), "font_size": 12, "text": "A"}, + {"bbox": (72, 200, 540, 220), "font_size": 12, "text": "B"}, + {"bbox": (72, 300, 540, 320), "font_size": 12, "text": "C"}, + ] + translator._populate_next_block_y(blocks, page.rect) + assert blocks[0]["_next_block_y"] == 198 # block 1 y0 - 2 + assert blocks[1]["_next_block_y"] == 298 # block 2 y0 - 2 + assert blocks[2]["_next_block_y"] == 774 # page bottom (792 - 18) + + def test_next_block_y_sorted_by_y0(self, pdf_mod): + """If blocks are passed out of order, _populate_next_block_y + must still produce correct next-block mappings.""" + import fitz + translator = pdf_mod.PDFTranslator() + page = MagicMock() + page.rect = fitz.Rect(0, 0, 612, 792) + blocks = [ + {"bbox": (72, 300, 540, 320), "font_size": 12, "text": "C"}, + {"bbox": (72, 100, 540, 120), "font_size": 12, "text": "A"}, + {"bbox": (72, 200, 540, 220), "font_size": 12, "text": "B"}, + ] + translator._populate_next_block_y(blocks, page.rect) + # Find the 'A' block (y0=100) and verify it points to 'B' (y0=200) + a_block = next(b for b in blocks if b["text"] == "A") + assert a_block["_next_block_y"] == 198 + + def test_long_block_does_not_overflow_into_neighbour(self, tmp_path): + """End-to-end: a long French translation should not visually + overlap the block below it.""" + import fitz + doc = fitz.open() + page = doc.new_page() + # Block 1: a short English title that will translate to a long French title + page.insert_textbox( + fitz.Rect(72, 100, 540, 120), + "Latest version", + fontsize=12, + ) + # Block 2: a TOC entry very close below + page.insert_textbox( + fitz.Rect(72, 130, 540, 145), + "8. Troubleshooting", + fontsize=12, + ) + + in_path = tmp_path / "input.pdf" + out_path = tmp_path / "output.pdf" + doc.save(str(in_path)) + doc.close() + + # Translate with a provider that returns a much longer French string + from translators.pdf_translator import PDFTranslator + translator = PDFTranslator() + mock = MagicMock() + mock.translate_batch = MagicMock( + side_effect=lambda texts, tgt, src: [ + "Pour la dernière version de ce document, veuillez consulter le site web officiel à l'adresse suivante : " + t + if t and t != "8. Troubleshooting" else t + for t in texts + ] + ) + translator.set_provider(mock) + translator.translate_file( + in_path, out_path, + target_language="fr", + source_language="en", + ) + + # Verify: no block bottom should exceed the next block's top + out_doc = fitz.open(str(out_path)) + out_page = out_doc[0] + text_blocks = [ + b for b in out_page.get_text("dict").get("blocks", []) + if b.get("type") == 0 + ] + # Sort by y0 and check that no two consecutive blocks overlap + text_blocks.sort(key=lambda b: b["bbox"][1]) + for i in range(len(text_blocks) - 1): + top_of_next = text_blocks[i + 1]["bbox"][1] + bottom_of_curr = text_blocks[i]["bbox"][3] + assert bottom_of_curr <= top_of_next + 1, ( + f"Block {i} bottom ({bottom_of_curr}) overlaps block {i+1} top ({top_of_next}). " + f"Block {i}: {text_blocks[i]['bbox']}, Block {i+1}: {text_blocks[i+1]['bbox']}" + ) + out_doc.close() diff --git a/translators/pdf_translator.py b/translators/pdf_translator.py index d3669d7..bec6acd 100644 --- a/translators/pdf_translator.py +++ b/translators/pdf_translator.py @@ -41,7 +41,9 @@ FONT_SHRINK_FACTOR = 0.93 # Track B3.5: was 0.87 (too aggressive, broke hierarc # Maximum vertical expansion factor (relative to original bbox height) # Track B3.5: was 1.5x; bumped to 3x so longer translations can flow down -MAX_VERTICAL_EXPANSION = 3.0 +# Track B3.6: bumped to 6x to handle long French titles that wrap to +# multiple lines (a 22pt title may need 4-5 lines in French). +MAX_VERTICAL_EXPANSION = 6.0 # Maximum font shrink for headings (font >= HEADING_MIN_SIZE points) # Track B3.5: never shrink a heading below 90% of its original size @@ -274,6 +276,13 @@ class PDFTranslator: blocks = self._merge_adjacent_blocks(raw_blocks, page.rect) total_blocks += len(blocks) + # Track B3.7: compute each block's "next block" y0 so the + # smart-fit logic never expands a block into its neighbour. + # Without this, a long translated block can flow into the + # block below it (e.g. "Pour la dernière version" overlapping + # "8. Résolution des problèmes"). + self._populate_next_block_y(blocks, page.rect) + # Phase 1: translate all blocks on this page for block in blocks: original = block["text"] @@ -302,7 +311,13 @@ class PDFTranslator: # Phase 2: redact original text areas # - # Before redaction, we need to capture the page's hyperlinks + # CRITICAL (Track B3.6): For blocks that contain a vector + # drawing (e.g. a colored background box), we need to use a + # TRANSPARENT redaction (fill=None) so the original drawing + # isn't erased. For all other blocks, we use the standard + # white redaction to fully erase the text. + # + # Before redaction, we also capture the page's hyperlinks # (PDF links are not stored in text blocks; they're attached # to page annotations). After redaction, we re-insert the # links so the translated page remains navigable. @@ -319,16 +334,43 @@ class PDFTranslator: except Exception: page_links_before = [] + # Find the rectangles of all vector drawings on the page. + # If a block's bbox intersects a drawing rect, we use + # transparent redaction to preserve the drawing. + drawing_rects: list = [] + if hasattr(page, "get_drawings"): + try: + for d in page.get_drawings(): + r = d.get("rect") + if r is not None and d.get("fill") is not None: + drawing_rects.append(fitz.Rect(r)) + except Exception: + pass + + def _block_intersects_drawing(block_bbox): + """True if the block's bbox intersects any drawing rect.""" + if not drawing_rects: + return False + b = fitz.Rect(block_bbox) + for dr in drawing_rects: + if b.intersects(dr): + return True + return False + for block in blocks: if block.get("translated"): # Track B3.5: single redaction per block, not per sub-bbox. - # This reduces the number of redact annots (and the - # number of resulting "drawings" in the output PDF) - # from ~100 down to ~10 per page. - page.add_redact_annot( - fitz.Rect(block["bbox"]), - fill=(1, 1, 1), - ) + # Track B3.6: if the block contains a drawing, use + # transparent redaction (fill=None) so the drawing + # underneath isn't erased. Otherwise use white fill + # to fully remove the original text. + block_bbox = fitz.Rect(block["bbox"]) + if _block_intersects_drawing(block_bbox): + # Transparent redaction: removes text but keeps + # the underlying drawing intact. + page.add_redact_annot(block_bbox, fill=None) + else: + page.add_redact_annot(block_bbox, fill=(1, 1, 1)) page.apply_redactions(images=fitz.PDF_REDACT_IMAGE_NONE) @@ -537,6 +579,28 @@ class PDFTranslator: return True + def _populate_next_block_y(self, blocks: List[Dict], page_rect) -> None: + """For each block, set ``block['_next_block_y']`` to the y0 of the + nearest block below it (or the page bottom margin if none). + + Track B3.7: this lets the smart-fit logic use the next block as + a ceiling for vertical expansion, so a long translated block + never overlaps its neighbour. + """ + margin = 18 + page_bottom = page_rect.y1 - margin + + # Sort by y0 ascending; for each block, the next one in this list + # is its nearest downward neighbour. + sorted_blocks = sorted(blocks, key=lambda b: b["bbox"][1]) + for i, block in enumerate(sorted_blocks): + if i + 1 < len(sorted_blocks): + block["_next_block_y"] = sorted_blocks[i + 1]["bbox"][1] - 2 + else: + block["_next_block_y"] = page_bottom + + # Keep a small visual gap so we don't crowd the next block's top edge. + def _write_translated_block( self, page, @@ -586,24 +650,21 @@ class PDFTranslator: page_rect = page.rect margin = 18 - # Try to find a wider bbox that respects nearby blocks - # (don't overlap with the next text block on the page) + # Track B3.6: try a wider bbox that respects the page margin. + # For headings, also allow horizontal expansion because long + # translated titles often don't fit in the original width. max_x1 = page_rect.x1 - margin - if not is_heading: - # For body text, allow horizontal expansion up to the page margin - expanded_h = fitz.Rect( - max(original_rect.x0, page_rect.x0 + margin), - original_rect.y0, - max_x1, - original_rect.y1, - ) - else: - # For headings, keep original width (preserves visual alignment) - expanded_h = original_rect + expanded_h = fitz.Rect( + max(original_rect.x0, page_rect.x0 + margin), + original_rect.y0, + max_x1, + original_rect.y1, + ) - # Try to find vertical room below this block - next_block_y = page_rect.y1 - margin - # (page.next_block_y is not exposed, so we use page bottom as ceiling) + # Track B3.7: cap vertical expansion at the next block's y0 + # (not the page bottom), so a long translated block cannot + # overflow into its neighbour. + next_block_y = block.get("_next_block_y", page_rect.y1 - margin) max_expand_y = min( next_block_y - original_rect.y1, original_rect.height * MAX_VERTICAL_EXPANSION,