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
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user