feat(format): B3 — PDF hyperlink preservation + safe redaction + LibreOffice log
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m29s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m29s
This commit is contained in:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user