diff --git a/sample_files/test_corpus/test_pdf_translated.pdf b/sample_files/test_corpus/test_pdf_translated.pdf index 649f42b..5d4576b 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/tests/test_translators/test_b3_6_pdf_drawings.py b/tests/test_translators/test_b3_6_pdf_drawings.py index 017fd84..131b9ca 100644 --- a/tests/test_translators/test_b3_6_pdf_drawings.py +++ b/tests/test_translators/test_b3_6_pdf_drawings.py @@ -750,3 +750,172 @@ class TestB3_9TableRowSplitting: f"Cell x0s are {cell_x0s}, expected [90, 220, 350]" ) out_doc.close() + + +# ============================================================================ +# Track B3.10 — code-block / callout preservation (drawing-covered blocks) +# ============================================================================ + +class TestB3_10DrawingCoveredBlocks: + """Track B3.10: when a block is covered by a colored background + drawing (code block, callout box, info box), each line must stay + at its own y position. The merge logic must NOT collapse them + into a single block, because the surrounding drawing has a fixed + extent that the rewritten text must align with. + + Without B3.10, the merge logic joins all 5 code lines into one + block, the smart-fit shrinks the font to fit, and the rewritten + text no longer aligns with the background drawing — half the code + spills outside the box, the box appears clipped, etc. + """ + + def test_code_block_lines_marked_no_merge(self, tmp_path): + """A code block (multiple lines, all covered by a background + drawing) should be split into per-line blocks with _no_merge=True.""" + import fitz + doc = fitz.open() + page = doc.new_page(width=612, height=792) + # Add a light-gray background drawing + page.draw_rect( + fitz.Rect(72, 290, 540, 400), + color=(0.9, 0.9, 0.9), + fill=(0.95, 0.95, 0.95), + ) + # 5 code lines inside the drawing (use larger spacing for + # reliable y separation after PyMuPDF baseline adjustment) + for i, y in enumerate([305, 325, 345, 365, 385]): + page.insert_text((85, y), f"line {i+1}", fontsize=10) + + in_path = tmp_path / "input_code.pdf" + doc.save(str(in_path)) + doc.close() + + from translators.pdf_translator import PDFTranslator + translator = PDFTranslator() + in_doc = fitz.open(str(in_path)) + blocks = translator._extract_text_blocks(in_doc[0]) + in_doc.close() + + # Should have 5 separate blocks, each with no_merge=True + code_blocks = [b for b in blocks if b.get("_no_merge")] + assert len(code_blocks) == 5, ( + f"Expected 5 code blocks with no_merge=True, got {len(code_blocks)}" + ) + # Each should have a distinct y position (within 1pt of each other) + y_positions = [b["bbox"][1] for b in code_blocks] + # Sort and check gaps are >= ~16pt (line spacing) + sorted_ys = sorted(y_positions) + for i in range(len(sorted_ys) - 1): + gap = sorted_ys[i + 1] - sorted_ys[i] + assert gap > 10, ( + f"y positions too close: {sorted_ys}, gap={gap}" + ) + + def test_paragraph_not_marked_no_merge(self, tmp_path): + """A regular paragraph (no background drawing) should NOT + be marked as no_merge. The merge logic should still combine + consecutive lines into a single block.""" + import fitz + doc = fitz.open() + page = doc.new_page(width=612, height=792) + # 3 lines, no background drawing + page.insert_text((72, 100), "Line 1", fontsize=12) + page.insert_text((72, 120), "Line 2", fontsize=12) + page.insert_text((72, 140), "Line 3", fontsize=12) + + in_path = tmp_path / "input_para.pdf" + doc.save(str(in_path)) + doc.close() + + from translators.pdf_translator import PDFTranslator + translator = PDFTranslator() + in_doc = fitz.open(str(in_path)) + blocks = translator._extract_text_blocks(in_doc[0]) + merged = translator._merge_adjacent_blocks(blocks, in_doc[0].rect) + in_doc.close() + + # All 3 lines should be merged into 1 block + assert len(merged) == 1, ( + f"Expected 1 merged paragraph, got {len(merged)}" + ) + assert "Line 1" in merged[0]["text"] + assert "Line 2" in merged[0]["text"] + assert "Line 3" in merged[0]["text"] + + def test_code_block_end_to_end_preserves_lines(self, tmp_path): + """End-to-end: a code block translated must keep each line + at its original y position, inside the background drawing.""" + import fitz + doc = fitz.open() + page = doc.new_page(width=612, height=792) + # Background drawing + page.draw_rect( + fitz.Rect(72, 290, 540, 410), + color=(0.9, 0.9, 0.9), + fill=(0.95, 0.95, 0.95), + ) + # 5 code lines (use larger spacing for reliable separation) + code_lines = [ + "$ npm install", + "$ npm start", + "$ curl http://localhost:3000", + "$ echo done", + "$ exit", + ] + for i, line in enumerate(code_lines): + page.insert_text((85, 305 + i * 20), line, fontsize=10) + + in_path = tmp_path / "input_code_e2e.pdf" + out_path = tmp_path / "output_code_e2e.pdf" + doc.save(str(in_path)) + doc.close() + + from translators.pdf_translator import PDFTranslator + translator = PDFTranslator() + + def _flex(*args, **kwargs): + if args and hasattr(args[0], "__iter__") and not isinstance(args[0], str): + first = args[0][0] if len(args[0]) else None + if first is not None and hasattr(first, "text"): + class _R: + def __init__(self, t): self.translated_text = t + return [_R(r.text) for r in args[0]] + return list(args[0]) if args else [] + + mock = MagicMock() + mock.__class__.__name__ = "MagicMock" + mock.translate_batch = MagicMock(side_effect=_flex) + translator.set_provider(mock) + translator.translate_file( + in_path, out_path, + target_language="fr", + source_language="en", + ) + + 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 + ] + # Should have 5 separate code-line blocks, each at its own y + code_blocks = [ + b for b in text_blocks + if b["bbox"][0] == 85 and b["bbox"][1] >= 290 + ] + assert len(code_blocks) == 5, ( + f"Expected 5 separate code lines in output, got {len(code_blocks)}" + ) + # Each line should be at a unique y position (no merging) + y_positions = [b["bbox"][1] for b in code_blocks] + assert len(set(y_positions)) == 5, ( + f"Code lines were collapsed to fewer y positions: {y_positions}" + ) + # All code lines should be inside the background drawing + for b in code_blocks: + y = b["bbox"][1] + assert 290 <= y <= 400, ( + f"Code line at y={y} is outside the background drawing " + f"(y range 290-410)" + ) + out_doc.close() diff --git a/translators/pdf_translator.py b/translators/pdf_translator.py index 80a9003..4aa9742 100644 --- a/translators/pdf_translator.py +++ b/translators/pdf_translator.py @@ -463,11 +463,21 @@ class PDFTranslator: a table row instead of collapsing into a single left-aligned column. + Track B3.10: when a block is COVERED by a drawing (code block + with a background rect, callout box, etc.), split it into + per-line blocks and mark them as ``no_merge``. Without this, + the merge logic collapses 5 code lines into one block, and the + smart-fit rewrites them all at the block's bbox, which + destroys the line-by-line layout and the surrounding + background drawing's apparent extent. + The detection is: if a block has >= 2 lines whose y0 is within ``SAME_ROW_Y_TOLERANCE`` of each other, treat it as a multi-column row and emit one sub-block per line. Blocks whose lines are vertically stacked (multi-line paragraphs) keep the - old behavior. + old behavior, UNLESS they are covered by a drawing — in which + case we split per-line and set ``no_merge`` to preserve the + code-block / callout layout. """ import fitz @@ -476,6 +486,44 @@ class PDFTranslator: SAME_ROW_Y_TOLERANCE = 3.0 # points + # B3.10: pre-compute the set of page drawings with a fill + # (colored rects, callout boxes, etc.) so we can detect + # "code block" or "callout" patterns and avoid merging their + # constituent lines. + 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_covered_by_drawing(block_bbox_tuple) -> bool: + """True if the block's bbox is covered by a filled drawing. + + We use "covered" loosely — a substantial intersection + (>= 50% of the block's area) qualifies. This avoids + false positives from drawings that merely touch a corner + of the block. + """ + if not drawing_rects: + return False + b = fitz.Rect(block_bbox_tuple) + block_area = b.width * b.height + if block_area <= 0: + return False + for dr in drawing_rects: + if not b.intersects(dr): + continue + # Compute intersection area + inter = b & dr + inter_area = inter.width * inter.height + if inter_area / block_area >= 0.5: + return True + return False + for block in data.get("blocks", []): if block.get("type") != 0: continue @@ -500,8 +548,18 @@ class PDFTranslator: ) ) - if has_horizontal_layout: - # Multi-column row: emit one block per line. + # B3.10: detect code-block / callout pattern — a block + # whose lines are covered by a colored background drawing. + # Note: each code line is its own PDF block, so we check + # the block bbox (which is the line's bbox) against the + # drawing, not "len(lines) > 1". + covered_by_drawing = ( + not has_horizontal_layout + and _block_covered_by_drawing(block["bbox"]) + ) + + if has_horizontal_layout or covered_by_drawing: + # Multi-column row OR code block: emit one block per line. for line in lines: span_parts = [] spans_info = [] @@ -538,7 +596,8 @@ class PDFTranslator: "line_count": 1, "translated": None, "sub_bboxes": [tuple(line["bbox"])], - "_is_table_cell": True, + "_is_table_cell": has_horizontal_layout, + "_no_merge": covered_by_drawing, }) continue @@ -632,6 +691,15 @@ class PDFTranslator: a_bbox = a["bbox"] b_bbox = b["bbox"] + # Track B3.10: never merge blocks marked as no_merge. + # These are lines inside a code block, callout box, or any + # other structure that needs to preserve its line-by-line + # layout (e.g. each line of a curl command must stay at its + # own y position to remain aligned with the background + # drawing that covers them). + if a.get("_no_merge") or b.get("_no_merge"): + return False + # Must have similar font size (within 20%) if abs(a["font_size"] - b["font_size"]) > max(a["font_size"], b["font_size"]) * 0.2: return False