feat(format): B3 — PDF hyperlink preservation + safe redaction + LibreOffice log
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m29s

This commit is contained in:
2026-07-14 17:02:21 +02:00
parent d40d7f3e86
commit 04a9328860
2 changed files with 478 additions and 3 deletions

View File

@@ -43,6 +43,51 @@ FONT_SHRINK_FACTOR = 0.87
RTL_LANGUAGES = frozenset({"ar", "he", "fa", "ur", "ku", "ps", "ug", "sd", "yi", "dv", "ckb"})
def _record_format_loss_metric(element_type: str, count: int = 1) -> None:
"""Best-effort emission of a format-loss metric (never raises).
Used by the PDF translator to track hyperlinks/annotations lost
during in-place text replacement.
"""
if count <= 0:
return
try:
from middleware.metrics import record_format_loss
for _ in range(count):
record_format_loss(format="pdf", element_type=element_type)
except Exception:
# Metrics must never break a job
pass
def _libreoffice_available() -> bool:
"""Check if the LibreOffice CLI is available on this host.
Result is cached for 5 minutes to avoid spawning a subprocess
on every PDF translation.
"""
import time
now = time.time()
cached = _libreoffice_available._cache
if cached and (now - cached[0]) < 300:
return cached[1]
try:
result = subprocess.run(
["libreoffice", "--version"],
capture_output=True,
text=True,
timeout=5,
)
ok = result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired, Exception):
ok = False
_libreoffice_available._cache = (now, ok)
return ok
_libreoffice_available._cache = None # type: ignore[attr-defined]
class PDFTranslator:
"""Translates PDF files with layout preservation using PyMuPDF."""
@@ -244,6 +289,24 @@ class PDFTranslator:
)
# Phase 2: redact original text areas
#
# Before redaction, we need to 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.
#
# PDF_REDACT_IMAGE_NONE is critical: it tells PyMuPDF NOT to
# rasterize image content that intersects redaction areas.
# Without this flag, the redaction would replace the text
# AND the underlying image with a blank rectangle — wiping
# out any diagrams, charts, or photos in the same area.
page_links_before: list = []
if hasattr(page, "get_links"):
try:
page_links_before = list(page.get_links())
except Exception:
page_links_before = []
for block in blocks:
if block.get("translated"):
for sub_rect in block["sub_bboxes"]:
@@ -251,6 +314,57 @@ class PDFTranslator:
page.apply_redactions(images=fitz.PDF_REDACT_IMAGE_NONE)
# Re-insert hyperlinks that were inside the redacted area.
# We use the original link geometry (it's unaffected by the
# redaction). URIs are preserved verbatim; only the visible
# text changes.
if page_links_before:
reinserted = 0
lost = 0
page_rect = page.rect
for link in page_links_before:
try:
from_rect = link.get("from")
if from_rect is None:
lost += 1
continue
# Clamp the rect to the page (in case redaction
# shrunk it)
if not page_rect.intersects(from_rect):
lost += 1
continue
# Build the link insertion kwargs based on kind
if link.get("uri"):
# External URI link
page.insert_link({
"kind": fitz.LINK_URI,
"from": from_rect,
"uri": link["uri"],
})
reinserted += 1
elif link.get("page") is not None:
# Internal goto link
page.insert_link({
"kind": fitz.LINK_GOTO,
"from": from_rect,
"page": link["page"],
"to": link.get("to", fitz.Point(0, 0)),
})
reinserted += 1
else:
lost += 1
except Exception:
lost += 1
if reinserted > 0:
logger.info(
"pdf_hyperlinks_reinserted",
page=page_num + 1,
reinserted=reinserted,
lost=lost,
)
if lost > 0:
_record_format_loss_metric("hyperlink", count=lost)
# Phase 3: write translated text
for block in blocks:
if block.get("translated"):
@@ -622,6 +736,13 @@ class PDFTranslator:
def _convert_docx_to_pdf(self, docx_path: Path, target_pdf: Path) -> Path:
"""Convert DOCX → PDF using LibreOffice headless."""
if not _libreoffice_available():
# Cache miss → known-missing path
logger.warning(
"libreoffice_not_found",
hint="Install libreoffice-core on the host for DOCX→PDF fallback",
)
return None # type: ignore[return-value]
try:
result = subprocess.run(
[
@@ -639,13 +760,13 @@ class PDFTranslator:
shutil.move(str(expected_pdf), str(target_pdf))
logger.info("docx_to_pdf_success")
return target_pdf
logger.warning("docx_to_pdf_no_output", stderr=result.stderr)
logger.warning("docx_to_pdf_no_output", stderr=result.stderr[:200])
except FileNotFoundError:
logger.warning("libreoffice_not_found")
logger.warning("libreoffice_not_found_runtime")
except subprocess.TimeoutExpired:
logger.warning("libreoffice_timeout")
except Exception as e:
logger.warning("docx_to_pdf_failed", error=str(e))
logger.warning("docx_to_pdf_failed", error=str(e)[:200])
docx_output = target_pdf.with_suffix(".docx")
if docx_path != docx_output and docx_path.exists():