feat(format): B3.10 — preserve code-block / callout layout (drawing-covered blocks)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m10s

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.
This commit is contained in:
2026-07-14 19:56:12 +02:00
parent 2da2c4765c
commit 9b15b7c9fa
3 changed files with 241 additions and 4 deletions

View File

@@ -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()