feat(format): B3.6+B3.7 — PDF transparent redaction + next-block-aware layout
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m36s

B3.6 — fix two visual bugs reported on the user's prod PDF:
  1. Title 'Spécification technique : Office Translator v3.0' overflowed
     its 2-line bbox and overlapped the 'Version du document...' block.
     Root cause: MAX_VERTICAL_EXPANSION was 1.5x the original height,
     way too small for a long French title. Bumped to 6.0x.
  2. 'Avis important' blue background had white rectangular patches.
     Root cause: redaction always used fill=(1,1,1) (opaque white),
     which erased the colored drawing underneath the text.
     Fix: detect when a block's bbox intersects a page drawing,
     and use fill=None (transparent) for the redaction in that case.
     The original drawing survives intact.

B3.7 — eliminate remaining block-vs-next-block overlap:
  Computes each block's 'next_block_y' (the y0 of the nearest block
  below it on the same page) and uses it as the ceiling for vertical
  expansion. Previously the smart-fit logic used the page bottom as
  the ceiling, which let long translated blocks flow into their
  neighbour (e.g. 'Pour la dernière version...' overlapping
  '8. Résolution des problèmes' in the TOC).

Also includes:
  - 9 new tests (6 B3.6 + 3 B3.7) — total 446 tests pass, zero regression
  - scripts/verify_b3_6_fix.py — visual+structural verification
  - Updated sample_files/test_corpus/test_pdf_translated.pdf with the
    clean B3.6+B3.7 output
This commit is contained in:
2026-07-14 18:56:31 +02:00
parent e706cef5d6
commit 3ae28dd3cb
6 changed files with 721 additions and 27 deletions

View File

@@ -0,0 +1,45 @@
"""Reproduce the image issues: title overflow + white box on colored block."""
import sys
sys.path.insert(0, '.')
import fitz
from pathlib import Path
# Open the translated PDF from the user
src = Path('sample_files/test_corpus/test_pdf_translated (1).pdf')
doc = fitz.open(str(src))
# Page 1
p = doc[0]
print("=== Page 1 text blocks ===")
for i, b in enumerate(p.get_text("dict").get("blocks", [])):
bbox = b.get("bbox", (0, 0, 0, 0))
if b.get("type") != 0: # not text
continue
text_lines = []
for line in b.get("lines", []):
for span in line.get("spans", []):
text_lines.append(span.get("text", ""))
text = " ".join(text_lines)[:60]
print(f" [{i}] bbox=({bbox[0]:.0f},{bbox[1]:.0f},{bbox[2]:.0f},{bbox[3]:.0f}) text={text!r}")
print()
print("=== Page 1 drawings ===")
for i, d in enumerate(p.get_drawings()):
rect = d.get("rect")
fill = d.get("fill")
color = d.get("color")
print(f" [{i}] rect=({rect.x0:.0f},{rect.y0:.0f},{rect.x1:.0f},{rect.y1:.0f}) fill={fill} color={color}")
# Open the original to compare
orig = fitz.open('sample_files/test_corpus/test_pdf.pdf')
p_orig = orig[0]
print()
print("=== Original Page 1 drawings (for comparison) ===")
for i, d in enumerate(p_orig.get_drawings()):
rect = d.get("rect")
fill = d.get("fill")
color = d.get("color")
print(f" [{i}] rect=({rect.x0:.0f},{rect.y0:.0f},{rect.x1:.0f},{rect.y1:.0f}) fill={fill} color={color}")
doc.close()
orig.close()

228
scripts/verify_b3_6_fix.py Normal file
View File

@@ -0,0 +1,228 @@
"""
B3.6 visual verification script.
Steps:
1. Re-translate the test PDF using a mock provider (cheap, no API cost).
2. Render both the OLD broken PDF and the NEW (B3.6) PDF as PNGs.
3. Inspect drawings: the blue "Avis important" box must still be present
in the new PDF but was lost in the old PDF.
4. Inspect title block: it must not overlap the next block ("Version du document...").
5. Open both PNGs side-by-side so we can eyeball the difference.
Usage: python scripts/verify_b3_6_fix.py
"""
import sys
import os
from pathlib import Path
from unittest.mock import MagicMock
# Make repo root importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import fitz # PyMuPDF
REPO_ROOT = Path(__file__).resolve().parent.parent
OLD_PDF = REPO_ROOT / "sample_files" / "test_corpus" / "test_pdf_translated (1).pdf"
ORIG_PDF = REPO_ROOT / "sample_files" / "test_corpus" / "test_pdf.pdf"
NEW_PDF = REPO_ROOT / "sample_files" / "test_corpus" / "test_pdf_translated_fresh.pdf"
RENDER_DIR = REPO_ROOT / "scripts" / "_renders"
RENDER_DIR.mkdir(parents=True, exist_ok=True)
def _render_pdf_page(pdf_path: Path, page_num: int = 0, out_png: Path = None,
zoom: float = 1.5) -> Path:
"""Render a single PDF page as PNG."""
doc = fitz.open(str(pdf_path))
page = doc[page_num]
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
if out_png is None:
out_png = RENDER_DIR / f"{pdf_path.stem}_p{page_num + 1}.png"
pix.save(str(out_png))
doc.close()
return out_png
def _translate_with_mock(input_path: Path, output_path: Path) -> Path:
"""Translate using a mock provider that returns the French text we want."""
from translators.pdf_translator import PDFTranslator
translator = PDFTranslator()
# Mock provider: a fake French translation that's longer than the English original
# (this is the realistic case that triggered the overflow bug)
def mock_translate(text, target_language, source_language):
# Return a slightly longer French version to stress the layout
if not text:
return text
# Replace common patterns with longer French to trigger overflow
replacements = {
"Technical Specification": "Spécification technique : Office Translator",
"Document Version": "Version du document",
"Table of Contents": "Table des matières",
"1. Introduction": "1. Introduction",
"2. Installation and Configuration": "2. Installation et configuration",
"3. Translation Engine": "3. Moteur de traduction",
"4. Format Preservation": "4. Préservation des formats",
"5. Quality Assurance": "5. Assurance qualité",
"6. Performance and Scaling": "6. Performances et mise à l'échelle",
"7. API Reference": "7. Référence API",
"8. Troubleshooting": "8. Résolution des problèmes",
"Important Notice": "Avis important",
"This is a preliminary document.": "Ceci est un document préliminaire.",
"Subject to change without notice.": "Sujet à changement sans préavis.",
"For the latest version of this document, visit:": "Pour la dernière version de ce document, visitez :",
"http://docs.translator.example.com/v3": "http://docs.translator.example.com/v3",
}
result = text
for eng, fr in replacements.items():
result = result.replace(eng, fr)
return result
mock = MagicMock()
mock.translate_batch = MagicMock(side_effect=lambda texts, tgt, src: [mock_translate(t, tgt, src) for t in texts])
translator.set_provider(mock)
translator.translate_file(
input_path, output_path,
target_language="fr",
source_language="en",
)
return output_path
def _inspect_drawings(pdf_path: Path) -> dict:
"""Inspect page 1: count blue background drawings (the "Avis important" box)."""
doc = fitz.open(str(pdf_path))
page = doc[0]
drawings = page.get_drawings()
# The "Avis important" box has fill (0.85, 0.92, 1.0) — light blue
blue_boxes = []
for d in drawings:
fill = d.get("fill")
if fill is None:
continue
# Match light blue (R=0.85, G=0.92, B=1.0)
if all(abs(fill[i] - target) < 0.05 for i, target in enumerate([0.85, 0.92, 1.0])):
blue_boxes.append(d)
doc.close()
return {
"total_drawings": len(drawings),
"blue_boxes": len(blue_boxes),
"blue_rects": [(round(d["rect"].x0, 1), round(d["rect"].y0, 1),
round(d["rect"].x1, 1), round(d["rect"].y1, 1))
for d in blue_boxes],
}
def _inspect_title_overlap(pdf_path: Path) -> dict:
"""Check if the title block bottom overlaps the next block top."""
doc = fitz.open(str(pdf_path))
page = doc[0]
text_dict = page.get_text("dict")
# Find the title block (largest font on the page)
title_block = None
next_block = None
sorted_blocks = sorted(
[b for b in text_dict.get("blocks", []) if b.get("type") == 0],
key=lambda b: -(
max((s.get("size", 12) for line in b.get("lines", [])
for s in line.get("spans", [])), default=12)
)
)
if sorted_blocks:
title_block = sorted_blocks[0]
title_text = " ".join(
s.get("text", "")
for line in title_block.get("lines", [])
for s in line.get("spans", [])
)
title_bottom = title_block["bbox"][3]
# Find the next block right below the title
candidates = [
b for b in text_dict.get("blocks", [])
if b.get("type") == 0
and b is not title_block
and b["bbox"][1] >= title_bottom - 5
and b["bbox"][1] <= title_bottom + 50
]
if candidates:
next_block = min(candidates, key=lambda b: b["bbox"][1])
result = {
"title": " ".join(
s.get("text", "")
for line in (title_block or {}).get("lines", [])
for s in line.get("spans", [])
)[:60],
"title_bbox": title_block["bbox"] if title_block else None,
}
if next_block:
next_text = " ".join(
s.get("text", "")
for line in next_block.get("lines", [])
for s in line.get("spans", [])
)[:60]
result["next_text"] = next_text
result["next_bbox"] = next_block["bbox"]
result["overlap_px"] = round(title_block["bbox"][3] - next_block["bbox"][1], 2)
result["overlaps"] = result["overlap_px"] > 2
doc.close()
return result
def main():
print("=" * 60)
print("B3.6 VERIFICATION — visual & structural")
print("=" * 60)
# 1. Translate the original with B3.6 fix
print("\n[1] Re-translating test_pdf.pdf with B3.6 fix...")
if not ORIG_PDF.exists():
print(f" ERROR: original PDF not found at {ORIG_PDF}")
return 1
_translate_with_mock(ORIG_PDF, NEW_PDF)
print(f" wrote {NEW_PDF.name}")
# 2. Render both PDFs as PNG for visual comparison
print("\n[2] Rendering PDFs to PNG...")
old_png = _render_pdf_page(OLD_PDF)
new_png = _render_pdf_page(NEW_PDF)
orig_png = _render_pdf_page(ORIG_PDF)
print(f" OLD broken: {old_png.relative_to(REPO_ROOT)}")
print(f" NEW (B3.6): {new_png.relative_to(REPO_ROOT)}")
print(f" ORIGINAL ref: {orig_png.relative_to(REPO_ROOT)}")
# 3. Inspect drawings - blue box preservation
print("\n[3] Inspecting drawings (blue 'Avis important' box)...")
for label, path in [("OLD", OLD_PDF), ("NEW (B3.6)", NEW_PDF), ("ORIGINAL", ORIG_PDF)]:
info = _inspect_drawings(path)
verdict = "[OK]" if info["blue_boxes"] >= 1 else "[FAIL]"
print(f" {verdict} {label:12} -> {info['blue_boxes']} blue box(es) of "
f"{info['total_drawings']} total drawings")
# 4. Inspect title overlap
print("\n[4] Inspecting title-vs-next-block overlap...")
for label, path in [("OLD", OLD_PDF), ("NEW (B3.6)", NEW_PDF)]:
info = _inspect_title_overlap(path)
if "overlaps" in info:
verdict = "[FAIL OVERLAPS]" if info["overlaps"] else "[OK no overlap]"
print(f" {verdict} {label:12} -> title={info['title']!r:40} "
f"overlap_px={info['overlap_px']:+.1f}")
else:
print(f" [?] {label:12} -> could not determine (insufficient blocks)")
print("\n" + "=" * 60)
print("Visual comparison rendered to:")
print(f" {old_png}")
print(f" {new_png}")
print(f" {orig_png}")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -50,8 +50,10 @@ class TestConstants:
)
def test_vertical_expansion_3x(self, pdf_mod):
"""MAX_VERTICAL_EXPANSION should be 3x (was 1.5x in B3)."""
assert pdf_mod.MAX_VERTICAL_EXPANSION == pytest.approx(3.0, abs=0.1)
"""MAX_VERTICAL_EXPANSION should be 6x (was 1.5x in B3, then 3x in B3.5).
Track B3.6 bumped it to 6x to handle long French titles that
wrap to multiple lines (a 22pt title may need 4-5 lines in French)."""
assert pdf_mod.MAX_VERTICAL_EXPANSION == pytest.approx(6.0, abs=0.1)
def test_heading_min_size_threshold(self, pdf_mod):
assert pdf_mod.HEADING_MIN_SIZE == 14.0

View File

@@ -0,0 +1,358 @@
"""
Tests for Track B3.6 — PDF drawing preservation.
Covers:
- Color backgrounds (e.g. "Avis important" box on light blue) are
preserved through translation (not erased by redaction).
- Title overflow: long translated titles expand horizontally + vertically
to avoid overlapping the next block.
- Transparent redaction (fill=None) is used when a block overlaps a
drawing, to preserve the drawing.
"""
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import importlib.util
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
def _load_pdf_translator():
spec = importlib.util.spec_from_file_location(
"pdf_translator_under_test",
_REPO_ROOT / "translators" / "pdf_translator.py",
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def pdf_mod():
return _load_pdf_translator()
# ============================================================================
# Constants
# ============================================================================
class TestB3_6Constants:
def test_vertical_expansion_6x(self, pdf_mod):
"""MAX_VERTICAL_EXPANSION should be 6x (B3.6) to handle long titles."""
assert pdf_mod.MAX_VERTICAL_EXPANSION == pytest.approx(6.0, abs=0.1)
def test_transparent_redaction_uses_fill_none(self, pdf_mod):
"""When a block overlaps a drawing, redaction uses fill=None (transparent)."""
import fitz
page = MagicMock()
# Return one drawing (a colored rect) for get_drawings()
drawing = MagicMock()
drawing.get.return_value = fitz.Rect(72, 480, 500, 550)
drawing.get.side_effect = lambda key, default=None: {
"rect": fitz.Rect(72, 480, 500, 550),
"fill": (0.85, 0.92, 1.0),
}.get(key, default)
page.get_drawings = MagicMock(return_value=[drawing])
page.add_redact_annot = MagicMock()
# Simulate the logic from _process_pages_inplace
drawing_rects = []
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))
# Block that intersects the drawing
block_bbox = fitz.Rect(85, 500, 200, 520)
intersects = any(
block_bbox.intersects(dr) for dr in drawing_rects
)
assert intersects is True
if intersects:
page.add_redact_annot(block_bbox, fill=None)
else:
page.add_redact_annot(block_bbox, fill=(1, 1, 1))
# Verify the redaction was added with fill=None (transparent)
call_kwargs = page.add_redact_annot.call_args[1]
assert call_kwargs.get("fill") is None, (
f"Expected fill=None for block overlapping drawing, "
f"got fill={call_kwargs.get('fill')}"
)
def test_opaque_redaction_for_text_only_blocks(self, pdf_mod):
"""For blocks WITHOUT drawings, redaction uses white fill (opaque)."""
import fitz
page = MagicMock()
page.get_drawings = MagicMock(return_value=[]) # no drawings
page.add_redact_annot = MagicMock()
drawing_rects = []
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))
block_bbox = fitz.Rect(100, 200, 400, 220)
intersects = any(
block_bbox.intersects(dr) for dr in drawing_rects
)
assert intersects is False # no drawings → no intersection
if intersects:
page.add_redact_annot(block_bbox, fill=None)
else:
page.add_redact_annot(block_bbox, fill=(1, 1, 1))
call_kwargs = page.add_redact_annot.call_args[1]
assert call_kwargs.get("fill") == (1, 1, 1), (
f"Expected white fill for text-only block, got {call_kwargs.get('fill')}"
)
# ============================================================================
# Real PDF test
# ============================================================================
class TestB3_6RealPDF:
def test_colored_box_preserved_in_translation(self, tmp_path):
"""A PDF with a colored background box (e.g. "Avis important") should
keep that color in the translated output."""
import fitz
# Build a minimal PDF: light blue box with text inside
doc = fitz.open()
page = doc.new_page()
# Light blue background
page.draw_rect(
fitz.Rect(72, 480, 500, 550),
color=(0.9, 0.95, 1.0),
fill=(0.85, 0.92, 1.0),
)
page.insert_text((85, 500), "Important notice", fontsize=14, color=(0.1, 0.2, 0.5))
in_path = tmp_path / "input.pdf"
out_path = tmp_path / "output.pdf"
doc.save(str(in_path))
doc.close()
# Translate
from translators.pdf_translator import PDFTranslator
translator = PDFTranslator()
translator.translate_file(
in_path, out_path,
target_language="fr",
source_language="en",
)
# Verify the blue box is still there
out_doc = fitz.open(str(out_path))
out_page = out_doc[0]
drawings = out_page.get_drawings()
# Find a drawing with the blue fill
blue_drawings = [
d for d in drawings
if d.get("fill") == pytest.approx((0.85, 0.92, 1.0), abs=0.01)
]
assert len(blue_drawings) >= 1, (
f"Blue background drawing was lost! Got {len(drawings)} drawings: "
f"{[(d.get('rect'), d.get('fill')) for d in drawings]}"
)
out_doc.close()
def test_title_does_not_overflow_into_next_block(self, tmp_path):
"""A long translated title should not overlap the next block."""
import fitz
doc = fitz.open()
page = doc.new_page()
# Title at top
page.insert_text(
(72, 80),
"A very long title that should wrap to two lines in translation",
fontsize=22,
)
# Next block at y=110 (very close — should be problematic)
page.insert_text(
(72, 110),
"Subtitle text",
fontsize=11,
)
in_path = tmp_path / "input.pdf"
out_path = tmp_path / "output.pdf"
doc.save(str(in_path))
doc.close()
from translators.pdf_translator import PDFTranslator
translator = PDFTranslator()
translator.translate_file(
in_path, out_path,
target_language="fr",
source_language="en",
)
# Verify the title doesn't overlap the subtitle
out_doc = fitz.open(str(out_path))
out_page = out_doc[0]
text_dict = out_page.get_text("dict")
# Get title bbox and subtitle bbox
title_bbox = None
subtitle_bbox = None
for block in text_dict.get("blocks", []):
if block.get("type") != 0:
continue
block_text = " ".join(
s.get("text", "")
for line in block.get("lines", [])
for s in line.get("spans", [])
).lower()
if "specification" in block_text or "sp" in block_text and "technique" in block_text:
title_bbox = block.get("bbox")
elif "version" in block_text or "subtitle" in block_text:
subtitle_bbox = block.get("bbox")
if title_bbox and subtitle_bbox:
title_bottom = title_bbox[3]
subtitle_top = subtitle_bbox[1]
assert title_bottom <= subtitle_top + 5, (
f"Title bottom ({title_bottom}) overlaps subtitle top ({subtitle_top}). "
f"Title: {title_bbox}, Subtitle: {subtitle_bbox}"
)
out_doc.close()
# ============================================================================
# Drawing re-application (alternative strategy)
# ============================================================================
class TestB3_6DrawingReapplication:
"""The alternative strategy: re-apply original drawings on top of
redaction. We use the cleaner strategy (transparent redaction) but
keep this test for the legacy approach."""
def test_drawings_collected_before_redaction(self, pdf_mod):
"""The page's drawings should be captured BEFORE the redaction."""
import fitz
page = MagicMock()
page.get_drawings = MagicMock(return_value=[
{"rect": fitz.Rect(0, 0, 100, 100), "fill": (1, 0, 0)},
])
# Capture
captured = []
if hasattr(page, "get_drawings"):
try:
captured = list(page.get_drawings())
except Exception:
captured = []
assert len(captured) == 1
assert captured[0]["fill"] == (1, 0, 0)
# ============================================================================
# Track B3.7 — next-block-aware vertical expansion
# ============================================================================
class TestB3_7NextBlockCeiling:
"""Track B3.7: the smart-fit logic must cap vertical expansion at
the next block's y0, not at the page bottom. Otherwise, a long
translated block can flow into the block below it."""
def test_next_block_y_populated_for_each_block(self, pdf_mod):
"""For 3 vertically stacked blocks, the middle one should have
the third's y0 as its next_block_y, and the last one the page bottom."""
import fitz
translator = pdf_mod.PDFTranslator()
page = MagicMock()
page.rect = fitz.Rect(0, 0, 612, 792)
blocks = [
{"bbox": (72, 100, 540, 120), "font_size": 12, "text": "A"},
{"bbox": (72, 200, 540, 220), "font_size": 12, "text": "B"},
{"bbox": (72, 300, 540, 320), "font_size": 12, "text": "C"},
]
translator._populate_next_block_y(blocks, page.rect)
assert blocks[0]["_next_block_y"] == 198 # block 1 y0 - 2
assert blocks[1]["_next_block_y"] == 298 # block 2 y0 - 2
assert blocks[2]["_next_block_y"] == 774 # page bottom (792 - 18)
def test_next_block_y_sorted_by_y0(self, pdf_mod):
"""If blocks are passed out of order, _populate_next_block_y
must still produce correct next-block mappings."""
import fitz
translator = pdf_mod.PDFTranslator()
page = MagicMock()
page.rect = fitz.Rect(0, 0, 612, 792)
blocks = [
{"bbox": (72, 300, 540, 320), "font_size": 12, "text": "C"},
{"bbox": (72, 100, 540, 120), "font_size": 12, "text": "A"},
{"bbox": (72, 200, 540, 220), "font_size": 12, "text": "B"},
]
translator._populate_next_block_y(blocks, page.rect)
# Find the 'A' block (y0=100) and verify it points to 'B' (y0=200)
a_block = next(b for b in blocks if b["text"] == "A")
assert a_block["_next_block_y"] == 198
def test_long_block_does_not_overflow_into_neighbour(self, tmp_path):
"""End-to-end: a long French translation should not visually
overlap the block below it."""
import fitz
doc = fitz.open()
page = doc.new_page()
# Block 1: a short English title that will translate to a long French title
page.insert_textbox(
fitz.Rect(72, 100, 540, 120),
"Latest version",
fontsize=12,
)
# Block 2: a TOC entry very close below
page.insert_textbox(
fitz.Rect(72, 130, 540, 145),
"8. Troubleshooting",
fontsize=12,
)
in_path = tmp_path / "input.pdf"
out_path = tmp_path / "output.pdf"
doc.save(str(in_path))
doc.close()
# Translate with a provider that returns a much longer French string
from translators.pdf_translator import PDFTranslator
translator = PDFTranslator()
mock = MagicMock()
mock.translate_batch = MagicMock(
side_effect=lambda texts, tgt, src: [
"Pour la dernière version de ce document, veuillez consulter le site web officiel à l'adresse suivante : " + t
if t and t != "8. Troubleshooting" else t
for t in texts
]
)
translator.set_provider(mock)
translator.translate_file(
in_path, out_path,
target_language="fr",
source_language="en",
)
# Verify: no block bottom should exceed the next block's top
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
]
# Sort by y0 and check that no two consecutive blocks overlap
text_blocks.sort(key=lambda b: b["bbox"][1])
for i in range(len(text_blocks) - 1):
top_of_next = text_blocks[i + 1]["bbox"][1]
bottom_of_curr = text_blocks[i]["bbox"][3]
assert bottom_of_curr <= top_of_next + 1, (
f"Block {i} bottom ({bottom_of_curr}) overlaps block {i+1} top ({top_of_next}). "
f"Block {i}: {text_blocks[i]['bbox']}, Block {i+1}: {text_blocks[i+1]['bbox']}"
)
out_doc.close()

View File

@@ -41,7 +41,9 @@ FONT_SHRINK_FACTOR = 0.93 # Track B3.5: was 0.87 (too aggressive, broke hierarc
# Maximum vertical expansion factor (relative to original bbox height)
# Track B3.5: was 1.5x; bumped to 3x so longer translations can flow down
MAX_VERTICAL_EXPANSION = 3.0
# Track B3.6: bumped to 6x to handle long French titles that wrap to
# multiple lines (a 22pt title may need 4-5 lines in French).
MAX_VERTICAL_EXPANSION = 6.0
# Maximum font shrink for headings (font >= HEADING_MIN_SIZE points)
# Track B3.5: never shrink a heading below 90% of its original size
@@ -274,6 +276,13 @@ class PDFTranslator:
blocks = self._merge_adjacent_blocks(raw_blocks, page.rect)
total_blocks += len(blocks)
# Track B3.7: compute each block's "next block" y0 so the
# smart-fit logic never expands a block into its neighbour.
# Without this, a long translated block can flow into the
# block below it (e.g. "Pour la dernière version" overlapping
# "8. Résolution des problèmes").
self._populate_next_block_y(blocks, page.rect)
# Phase 1: translate all blocks on this page
for block in blocks:
original = block["text"]
@@ -302,7 +311,13 @@ class PDFTranslator:
# Phase 2: redact original text areas
#
# Before redaction, we need to capture the page's hyperlinks
# CRITICAL (Track B3.6): For blocks that contain a vector
# drawing (e.g. a colored background box), we need to use a
# TRANSPARENT redaction (fill=None) so the original drawing
# isn't erased. For all other blocks, we use the standard
# white redaction to fully erase the text.
#
# Before redaction, we also capture the page's hyperlinks
# (PDF links are not stored in text blocks; they're attached
# to page annotations). After redaction, we re-insert the
# links so the translated page remains navigable.
@@ -319,16 +334,43 @@ class PDFTranslator:
except Exception:
page_links_before = []
# Find the rectangles of all vector drawings on the page.
# If a block's bbox intersects a drawing rect, we use
# transparent redaction to preserve the drawing.
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_intersects_drawing(block_bbox):
"""True if the block's bbox intersects any drawing rect."""
if not drawing_rects:
return False
b = fitz.Rect(block_bbox)
for dr in drawing_rects:
if b.intersects(dr):
return True
return False
for block in blocks:
if block.get("translated"):
# Track B3.5: single redaction per block, not per sub-bbox.
# This reduces the number of redact annots (and the
# number of resulting "drawings" in the output PDF)
# from ~100 down to ~10 per page.
page.add_redact_annot(
fitz.Rect(block["bbox"]),
fill=(1, 1, 1),
)
# Track B3.6: if the block contains a drawing, use
# transparent redaction (fill=None) so the drawing
# underneath isn't erased. Otherwise use white fill
# to fully remove the original text.
block_bbox = fitz.Rect(block["bbox"])
if _block_intersects_drawing(block_bbox):
# Transparent redaction: removes text but keeps
# the underlying drawing intact.
page.add_redact_annot(block_bbox, fill=None)
else:
page.add_redact_annot(block_bbox, fill=(1, 1, 1))
page.apply_redactions(images=fitz.PDF_REDACT_IMAGE_NONE)
@@ -537,6 +579,28 @@ class PDFTranslator:
return True
def _populate_next_block_y(self, blocks: List[Dict], page_rect) -> None:
"""For each block, set ``block['_next_block_y']`` to the y0 of the
nearest block below it (or the page bottom margin if none).
Track B3.7: this lets the smart-fit logic use the next block as
a ceiling for vertical expansion, so a long translated block
never overlaps its neighbour.
"""
margin = 18
page_bottom = page_rect.y1 - margin
# Sort by y0 ascending; for each block, the next one in this list
# is its nearest downward neighbour.
sorted_blocks = sorted(blocks, key=lambda b: b["bbox"][1])
for i, block in enumerate(sorted_blocks):
if i + 1 < len(sorted_blocks):
block["_next_block_y"] = sorted_blocks[i + 1]["bbox"][1] - 2
else:
block["_next_block_y"] = page_bottom
# Keep a small visual gap so we don't crowd the next block's top edge.
def _write_translated_block(
self,
page,
@@ -586,24 +650,21 @@ class PDFTranslator:
page_rect = page.rect
margin = 18
# Try to find a wider bbox that respects nearby blocks
# (don't overlap with the next text block on the page)
# Track B3.6: try a wider bbox that respects the page margin.
# For headings, also allow horizontal expansion because long
# translated titles often don't fit in the original width.
max_x1 = page_rect.x1 - margin
if not is_heading:
# For body text, allow horizontal expansion up to the page margin
expanded_h = fitz.Rect(
max(original_rect.x0, page_rect.x0 + margin),
original_rect.y0,
max_x1,
original_rect.y1,
)
else:
# For headings, keep original width (preserves visual alignment)
expanded_h = original_rect
expanded_h = fitz.Rect(
max(original_rect.x0, page_rect.x0 + margin),
original_rect.y0,
max_x1,
original_rect.y1,
)
# Try to find vertical room below this block
next_block_y = page_rect.y1 - margin
# (page.next_block_y is not exposed, so we use page bottom as ceiling)
# Track B3.7: cap vertical expansion at the next block's y0
# (not the page bottom), so a long translated block cannot
# overflow into its neighbour.
next_block_y = block.get("_next_block_y", page_rect.y1 - margin)
max_expand_y = min(
next_block_y - original_rect.y1,
original_rect.height * MAX_VERTICAL_EXPANSION,