Files
office_translator/scripts/verify_b3_8_multicolumn.py
sepehr 4255a1a0c5
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m32s
feat(format): B3.8 — column-aware next-block layout for multi-column PDFs
User asked whether B3.6+B3.7 are generic for ALL PDFs or just for the
test_pdf.pdf. Audit found 2 genericity bugs:

1. _populate_next_block_y was sorting blocks globally by y0. In a
   multi-column PDF (journals, brochures, newspapers), the 'next
   block' of a left-column block would point to the right-column
   block at the same y, which is wrong. Fix: group blocks into
   columns by x0 proximity (15pt tolerance), then sort each column
   by y0. Each block's next_block_y is the y0 of its column-mate
   directly below it, not just the next block in y-order globally.

2. max_expand_y could go negative if next_block_y was above the
   current block (rare edge case in extracted blocks with weird
   bbox ordering). A negative max_expand_y would create an invalid
   fitz.Rect with y1 < y0, causing silent failures. Fix: clamp
   max_expand_y to >= 0.

7 new tests added:
  - test_two_columns_get_separate_next_block_y: 2-col layout,
    left and right columns get independent next_block_y mappings
  - test_centered_full_width_header_gets_own_column: full-width
    header between 2 columns is its own column
  - test_three_columns: 3-column newspaper layout
  - test_single_block_page, test_empty_block_list: edge cases
  - test_max_expand_y_clamped_to_zero: negative-expansion safety
  - test_two_column_pdf_translation_end_to_end: e2e test on a
    2-col journal PDF, 4 input blocks -> 4 output blocks preserved
    at correct positions, no cross-column overlap

Visual verification:
  scripts/verify_b3_8_multicolumn.py renders a 2-col journal PDF
  before and after translation, confirms 4 left + 4 right blocks
  preserved at exact positions.

Total tests: 453 (was 446), zero regression.
2026-07-14 19:05:47 +02:00

145 lines
4.9 KiB
Python

"""
B3.8 visual verification on a REAL multi-column layout.
Generates a 2-column journal-style PDF (3 paragraphs in each column,
each paragraph = 3 lines), then translates it with B3.8 enabled.
Without B3.8, the next_block_y of a left-column block would point to
the right-column block at the same y (the closest in y-order), which
is wrong — the smart-fit would never let left-column text expand down
into the left-column space below it.
"""
import sys
sys.path.insert(0, '.')
import fitz
from pathlib import Path
from unittest.mock import MagicMock
REPO_ROOT = Path('.').resolve()
OUT_DIR = REPO_ROOT / 'scripts' / '_renders_b3_8'
OUT_DIR.mkdir(parents=True, exist_ok=True)
def build_2col_journal_pdf(path: Path) -> None:
"""Build a 2-column journal-style PDF."""
doc = fitz.open()
page = doc.new_page(width=612, height=792)
# Centered title
title_rect = fitz.Rect(72, 60, 540, 100)
page.insert_textbox(title_rect, "Article Title", fontsize=20)
# Author under title
page.insert_text((250, 120), "By John Doe", fontsize=10)
# Left column: 3 paragraphs
left_x0 = 72
left_x1 = 300
paras_left = [
"This is the first paragraph of the left column. It has multiple lines that span across the column width.",
"Second paragraph of the left column. This text continues to fill the left side of the page.",
"Third and final paragraph on the left side. The text wraps to fill the available space.",
]
y = 170
for p in paras_left:
rect = fitz.Rect(left_x0, y, left_x1, y + 80)
page.insert_textbox(rect, p, fontsize=10)
y += 100
# Right column: 3 paragraphs (same y positions as left, so the
# "wrong" next-block mapping would matter here)
right_x0 = 312
right_x1 = 540
paras_right = [
"Right column, first paragraph. This should stay in the right column and not be affected by left.",
"Right column, second paragraph. Independent of the left column's translation length.",
"Right column final paragraph. Verifies column-aware layout.",
]
y = 170
for p in paras_right:
rect = fitz.Rect(right_x0, y, right_x1, y + 80)
page.insert_textbox(rect, p, fontsize=10)
y += 100
doc.save(str(path))
doc.close()
def translate_with_mock(input_path: Path, output_path: Path) -> None:
"""Translate using a mock that returns the same text (identity)."""
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(input_path, output_path,
target_language='fr', source_language='en')
def inspect_columns(pdf_path: Path) -> dict:
"""Count blocks per column and check no cross-column overlap."""
doc = fitz.open(str(pdf_path))
page = doc[0]
blocks = [b for b in page.get_text("dict").get("blocks", [])
if b.get("type") == 0]
left_blocks = [b for b in blocks if b["bbox"][0] < 200]
right_blocks = [b for b in blocks if b["bbox"][0] >= 200]
doc.close()
return {
"total": len(blocks),
"left": len(left_blocks),
"right": len(right_blocks),
}
def render(pdf_path: Path, out_png: Path) -> None:
doc = fitz.open(str(pdf_path))
pix = doc[0].get_pixmap(matrix=fitz.Matrix(1.5, 1.5))
pix.save(str(out_png))
doc.close()
def main():
print("B3.8 multi-column verification")
print("=" * 60)
in_pdf = OUT_DIR / 'journal_in.pdf'
out_pdf = OUT_DIR / 'journal_out.pdf'
in_png = OUT_DIR / 'journal_in.png'
out_png = OUT_DIR / 'journal_out.png'
print(f"[1] Building 2-column journal PDF -> {in_pdf.name}")
build_2col_journal_pdf(in_pdf)
print(f"[2] Translating with B3.8 enabled -> {out_pdf.name}")
translate_with_mock(in_pdf, out_pdf)
print(f"[3] Inspecting input column distribution...")
in_info = inspect_columns(in_pdf)
print(f" input : total={in_info['total']} left={in_info['left']} right={in_info['right']}")
print(f"[4] Inspecting output column distribution...")
out_info = inspect_columns(out_pdf)
print(f" output : total={out_info['total']} left={out_info['left']} right={out_info['right']}")
print(f"[5] Rendering both to PNG...")
render(in_pdf, in_png)
render(out_pdf, out_png)
print(f" input : {in_png}")
print(f" output : {out_png}")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())