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
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:
Binary file not shown.
@@ -750,3 +750,172 @@ class TestB3_9TableRowSplitting:
|
|||||||
f"Cell x0s are {cell_x0s}, expected [90, 220, 350]"
|
f"Cell x0s are {cell_x0s}, expected [90, 220, 350]"
|
||||||
)
|
)
|
||||||
out_doc.close()
|
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()
|
||||||
|
|||||||
@@ -463,11 +463,21 @@ class PDFTranslator:
|
|||||||
a table row instead of collapsing into a single left-aligned
|
a table row instead of collapsing into a single left-aligned
|
||||||
column.
|
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
|
The detection is: if a block has >= 2 lines whose y0 is within
|
||||||
``SAME_ROW_Y_TOLERANCE`` of each other, treat it as a
|
``SAME_ROW_Y_TOLERANCE`` of each other, treat it as a
|
||||||
multi-column row and emit one sub-block per line. Blocks whose
|
multi-column row and emit one sub-block per line. Blocks whose
|
||||||
lines are vertically stacked (multi-line paragraphs) keep the
|
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
|
import fitz
|
||||||
|
|
||||||
@@ -476,6 +486,44 @@ class PDFTranslator:
|
|||||||
|
|
||||||
SAME_ROW_Y_TOLERANCE = 3.0 # points
|
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", []):
|
for block in data.get("blocks", []):
|
||||||
if block.get("type") != 0:
|
if block.get("type") != 0:
|
||||||
continue
|
continue
|
||||||
@@ -500,8 +548,18 @@ class PDFTranslator:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if has_horizontal_layout:
|
# B3.10: detect code-block / callout pattern — a block
|
||||||
# Multi-column row: emit one block per line.
|
# 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:
|
for line in lines:
|
||||||
span_parts = []
|
span_parts = []
|
||||||
spans_info = []
|
spans_info = []
|
||||||
@@ -538,7 +596,8 @@ class PDFTranslator:
|
|||||||
"line_count": 1,
|
"line_count": 1,
|
||||||
"translated": None,
|
"translated": None,
|
||||||
"sub_bboxes": [tuple(line["bbox"])],
|
"sub_bboxes": [tuple(line["bbox"])],
|
||||||
"_is_table_cell": True,
|
"_is_table_cell": has_horizontal_layout,
|
||||||
|
"_no_merge": covered_by_drawing,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -632,6 +691,15 @@ class PDFTranslator:
|
|||||||
a_bbox = a["bbox"]
|
a_bbox = a["bbox"]
|
||||||
b_bbox = b["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%)
|
# Must have similar font size (within 20%)
|
||||||
if abs(a["font_size"] - b["font_size"]) > max(a["font_size"], b["font_size"]) * 0.2:
|
if abs(a["font_size"] - b["font_size"]) > max(a["font_size"], b["font_size"]) * 0.2:
|
||||||
return False
|
return False
|
||||||
|
|||||||
Reference in New Issue
Block a user