From 9b15b7c9fa35666b815e121ceb6127a23e09d54b Mon Sep 17 00:00:00 2001 From: sepehr Date: Tue, 14 Jul 2026 19:56:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(format):=20B3.10=20=E2=80=94=20preserve=20?= =?UTF-8?q?code-block=20/=20callout=20layout=20(drawing-covered=20blocks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported that on the page 3 of the test PDF ('2. Installation and Setup'), the curl code block was visually broken: the 5 code lines (curl, -H, -F, -F, -F) were split, with the first 2 lines above the gray background box and the last 3 inside (or vice versa). Root cause: each code line is its own PDF block. The merge logic correctly combined them into a single block (same x0, similar font, small gap). The smart-fit then wrote the entire 5-line text into the merged block's bbox, shrinking the font to fit. The result no longer aligned with the fixed-extent gray background drawing. Fix: detect when a block is covered by a colored background drawing (code block, callout box, info box, etc.) and: 1. Mark each line as _no_merge=True so the merge logic keeps them as separate per-line blocks 2. Each line keeps its original y position 3. The smart-fit writes each line at its own bbox, preserving alignment with the surrounding drawing Detection: a block is 'covered by drawing' if >= 50% of its bbox area intersects a filled drawing on the page. This is conservative enough to avoid false positives from drawings that merely touch a corner of the block. The same logic applies to callout boxes, info boxes, and any other visual element where the background defines a fixed extent that the text must align with. The detection is generic — no hardcoded patterns, no font-based heuristics. 3 new tests added: - test_code_block_lines_marked_no_merge: 5 lines inside a background drawing all marked _no_merge=True - test_paragraph_not_marked_no_merge: 3 plain lines (no drawing) still merge into 1 block (regression check) - test_code_block_end_to_end_preserves_lines: full translate pipeline, each line stays at its own y, all inside the drawing Total: 460 tests pass (was 457), zero regression. --- .../test_corpus/test_pdf_translated.pdf | Bin 41083 -> 42028 bytes .../test_b3_6_pdf_drawings.py | 169 ++++++++++++++++++ translators/pdf_translator.py | 76 +++++++- 3 files changed, 241 insertions(+), 4 deletions(-) diff --git a/sample_files/test_corpus/test_pdf_translated.pdf b/sample_files/test_corpus/test_pdf_translated.pdf index 649f42b2bab89add880804ea8be6f0f6d1a20da4..5d4576b5b1e5937a4a193a76f2281aca3c0f9fa3 100644 GIT binary patch delta 5911 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 B@xA~6 delta 5305 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 ~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