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:
354
tests/test_translators/test_b3_pdf_fixes.py
Normal file
354
tests/test_translators/test_b3_pdf_fixes.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Tests for Track B3 — PDF format preservation fixes.
|
||||
|
||||
Covers:
|
||||
B3.1 — Hyperlink preservation: links inside redacted text areas are
|
||||
re-inserted after redaction so the page remains navigable.
|
||||
B3.2 — Safe redaction: PDF_REDACT_IMAGE_NONE is used so images
|
||||
intersecting the redaction area are NOT rasterized away.
|
||||
B3.3 — Block merge: paragraph-spanning blocks are merged into one
|
||||
before translation (better context, less font shrinking).
|
||||
B3.4 — LibreOffice absence is logged with hint, not just a warning.
|
||||
B3.5 — _record_format_loss_metric and _libreoffice_available helpers
|
||||
never raise.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import tempfile
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load pdf_translator directly to avoid pulling the heavy app imports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_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
|
||||
def pdf_mod():
|
||||
return _load_pdf_translator()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# B3.1 — Hyperlink preservation
|
||||
# ============================================================================
|
||||
|
||||
class TestPdfHyperlinkPreservation:
|
||||
"""A PDF page with a hyperlink should keep the link after the
|
||||
in-place text replacement."""
|
||||
|
||||
def test_hyperlink_captured_before_redaction(self, pdf_mod):
|
||||
"""The pipeline must read links BEFORE applying redactions."""
|
||||
# Build a fake page
|
||||
fake_page = MagicMock()
|
||||
fake_page.get_links.return_value = [
|
||||
{"uri": "https://example.com", "from": MagicMock()}
|
||||
]
|
||||
# The from_rect needs to be a real fitz.Rect for the page_rect.intersects() check
|
||||
import fitz
|
||||
fake_page.get_links.return_value = [
|
||||
{"uri": "https://example.com", "from": fitz.Rect(0, 0, 100, 20)}
|
||||
]
|
||||
fake_page.rect = fitz.Rect(0, 0, 612, 792)
|
||||
|
||||
links = list(fake_page.get_links())
|
||||
assert len(links) == 1
|
||||
assert links[0]["uri"] == "https://example.com"
|
||||
|
||||
def test_internal_goto_link_recognized(self, pdf_mod):
|
||||
"""An internal goto link (page number) should be re-insertable."""
|
||||
import fitz
|
||||
link = {
|
||||
"kind": fitz.LINK_GOTO,
|
||||
"from": fitz.Rect(10, 10, 80, 30),
|
||||
"page": 5,
|
||||
"to": fitz.Point(0, 0),
|
||||
}
|
||||
# The branching logic in the PDF translator should recognize
|
||||
# `link.get("page") is not None` as a goto link
|
||||
assert link.get("page") is not None
|
||||
assert "uri" not in link
|
||||
|
||||
def test_external_uri_link_recognized(self, pdf_mod):
|
||||
"""An external URI link should be re-insertable."""
|
||||
import fitz
|
||||
link = {
|
||||
"kind": fitz.LINK_URI,
|
||||
"from": fitz.Rect(10, 10, 80, 30),
|
||||
"uri": "https://example.com",
|
||||
}
|
||||
assert link.get("uri") is not None
|
||||
|
||||
def test_link_without_uri_or_page_is_lost(self, pdf_mod):
|
||||
"""A link with no URI and no target page can't be re-inserted —
|
||||
it should be counted as lost, not crash the pipeline."""
|
||||
link = {"from": MagicMock()}
|
||||
# The branching should fall through to the `lost += 1` branch
|
||||
assert "uri" not in link
|
||||
assert link.get("page") is None
|
||||
|
||||
def test_page_insert_link_uri(self, tmp_path):
|
||||
"""Smoke test: insert_link accepts the URI shape we build."""
|
||||
import fitz
|
||||
# Create an empty PDF in memory
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
rect = fitz.Rect(50, 50, 200, 70)
|
||||
# The actual insertion
|
||||
page.insert_link({
|
||||
"kind": fitz.LINK_URI,
|
||||
"from": rect,
|
||||
"uri": "https://example.com",
|
||||
})
|
||||
# Save and re-open to verify persistence (PyMuPDF only shows
|
||||
# links after save in some cases).
|
||||
out = tmp_path / "test_uri.pdf"
|
||||
doc.save(str(out))
|
||||
doc.close()
|
||||
# Re-open and check
|
||||
doc2 = fitz.open(str(out))
|
||||
links = list(doc2[0].get_links())
|
||||
doc2.close()
|
||||
assert len(links) == 1
|
||||
assert links[0].get("uri") == "https://example.com"
|
||||
|
||||
def test_page_insert_link_goto(self, tmp_path):
|
||||
"""Smoke test: insert_link accepts the goto shape we build."""
|
||||
import fitz
|
||||
doc = fitz.open()
|
||||
# Create at least 2 pages first
|
||||
doc.new_page()
|
||||
page2 = doc.new_page()
|
||||
# Now insert the link on page 2 (last created page is the
|
||||
# only valid reference; the first page reference is invalid
|
||||
# because PyMuPDF invalidates earlier page refs when new
|
||||
# pages are added)
|
||||
page2.insert_link({
|
||||
"kind": fitz.LINK_GOTO,
|
||||
"from": fitz.Rect(50, 50, 200, 70),
|
||||
"page": 0,
|
||||
"to": fitz.Point(0, 0),
|
||||
})
|
||||
out = tmp_path / "test_goto.pdf"
|
||||
doc.save(str(out))
|
||||
doc.close()
|
||||
doc2 = fitz.open(str(out))
|
||||
links = list(doc2[1].get_links()) # page 2 (index 1)
|
||||
doc2.close()
|
||||
assert len(links) == 1
|
||||
assert links[0].get("page") == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# B3.2 — Safe redaction (PDF_REDACT_IMAGE_NONE)
|
||||
# ============================================================================
|
||||
|
||||
class TestSafeRedaction:
|
||||
def test_redact_image_none_used(self):
|
||||
"""The code MUST use PDF_REDACT_IMAGE_NONE so images intersecting
|
||||
the redaction area are not rasterized away."""
|
||||
import fitz
|
||||
# The flag exists in PyMuPDF
|
||||
assert hasattr(fitz, "PDF_REDACT_IMAGE_NONE")
|
||||
# PDF_REDACT_IMAGE_NONE = 0 means "do not touch images in the
|
||||
# redaction area". This is the SAFE choice — images, vector
|
||||
# graphics, etc. stay intact.
|
||||
# PDF_REDACT_IMAGE_OTHER (1) or PDF_REDACT_IMAGE_ALL (2) would
|
||||
# rasterize the affected area and remove the image.
|
||||
assert fitz.PDF_REDACT_IMAGE_NONE == 0
|
||||
# The PDF translator must use the NONE flag.
|
||||
import inspect
|
||||
src = inspect.getsource(_load_pdf_translator())
|
||||
assert "PDF_REDACT_IMAGE_NONE" in src, (
|
||||
"pdf_translator.py should use PDF_REDACT_IMAGE_NONE for safe "
|
||||
"redaction of image content."
|
||||
)
|
||||
|
||||
def test_redact_with_no_image_param(self):
|
||||
"""The pipeline calls apply_redactions with images=PDF_REDACT_IMAGE_NONE.
|
||||
We can verify by mocking and inspecting the call."""
|
||||
import fitz
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
# Insert some text and add a redaction
|
||||
page.insert_text((50, 50), "Hello world")
|
||||
page.add_redact_annot(fitz.Rect(0, 0, 200, 100), fill=(1, 1, 1))
|
||||
|
||||
# Spy on apply_redactions
|
||||
original = page.apply_redactions
|
||||
called_with = {}
|
||||
|
||||
def spy(images=None, **kwargs):
|
||||
called_with["images"] = images
|
||||
called_with["kwargs"] = kwargs
|
||||
return original(images=images, **kwargs)
|
||||
|
||||
with patch.object(page, "apply_redactions", side_effect=spy):
|
||||
page.apply_redactions(images=fitz.PDF_REDACT_IMAGE_NONE)
|
||||
|
||||
assert called_with["images"] == fitz.PDF_REDACT_IMAGE_NONE
|
||||
doc.close()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# B3.3 — Block merge
|
||||
# ============================================================================
|
||||
|
||||
class TestBlockMerge:
|
||||
def test_should_merge_same_paragraph(self, pdf_mod):
|
||||
"""Two blocks with the same font, vertically close, and same x
|
||||
should be merged."""
|
||||
a = {
|
||||
"bbox": (50, 100, 200, 120),
|
||||
"font_size": 12,
|
||||
"line_count": 1,
|
||||
}
|
||||
b = {
|
||||
"bbox": (50, 125, 200, 145),
|
||||
"font_size": 12,
|
||||
"line_count": 1,
|
||||
}
|
||||
assert pdf_mod.PDFTranslator._should_merge_blocks(None, a, b) is True
|
||||
|
||||
def test_should_not_merge_different_size(self, pdf_mod):
|
||||
"""Big font-size gap → don't merge."""
|
||||
a = {"bbox": (50, 100, 200, 120), "font_size": 12, "line_count": 1}
|
||||
b = {"bbox": (50, 125, 200, 200), "font_size": 24, "line_count": 1}
|
||||
assert pdf_mod.PDFTranslator._should_merge_blocks(None, a, b) is False
|
||||
|
||||
def test_should_not_merge_different_x_origin(self, pdf_mod):
|
||||
"""Far-apart x origins → don't merge (different columns)."""
|
||||
a = {"bbox": (50, 100, 200, 120), "font_size": 12, "line_count": 1}
|
||||
b = {"bbox": (300, 125, 450, 145), "font_size": 12, "line_count": 1}
|
||||
assert pdf_mod.PDFTranslator._should_merge_blocks(None, a, b) is False
|
||||
|
||||
def test_should_not_merge_too_far_apart(self, pdf_mod):
|
||||
"""Vertical gap > 1.5x line height → don't merge."""
|
||||
a = {"bbox": (50, 100, 200, 120), "font_size": 12, "line_count": 1}
|
||||
b = {"bbox": (50, 200, 200, 220), "font_size": 12, "line_count": 1}
|
||||
# vertical_gap = 200 - 120 = 80; line_height = 12 * 1.4 = 16.8
|
||||
# 80 > 16.8 * 1.5 = 25.2
|
||||
assert pdf_mod.PDFTranslator._should_merge_blocks(None, a, b) is False
|
||||
|
||||
def test_merge_two_blocks(self, pdf_mod):
|
||||
"""End-to-end merge: text combined, bbox expanded."""
|
||||
blocks = [
|
||||
{"bbox": (50, 100, 200, 120), "font_size": 12, "line_count": 1,
|
||||
"text": "Line 1", "sub_bboxes": [(50, 100, 200, 120)]},
|
||||
{"bbox": (50, 125, 200, 145), "font_size": 12, "line_count": 1,
|
||||
"text": "Line 2", "sub_bboxes": [(50, 125, 200, 145)]},
|
||||
]
|
||||
translator = pdf_mod.PDFTranslator()
|
||||
merged = translator._merge_adjacent_blocks(blocks, page_rect=None)
|
||||
assert len(merged) == 1
|
||||
assert "Line 1" in merged[0]["text"]
|
||||
assert "Line 2" in merged[0]["text"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# B3.4 — LibreOffice absence log
|
||||
# ============================================================================
|
||||
|
||||
class TestLibreOfficeAbsence:
|
||||
def test_libreoffice_available_returns_bool(self, pdf_mod):
|
||||
result = pdf_mod._libreoffice_available()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_libreoffice_available_caches_result(self, pdf_mod):
|
||||
"""Calling twice should NOT spawn two subprocesses (cached)."""
|
||||
# Reset cache
|
||||
pdf_mod._libreoffice_available._cache = None
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
r1 = pdf_mod._libreoffice_available()
|
||||
r2 = pdf_mod._libreoffice_available()
|
||||
# Only one subprocess call should have happened
|
||||
assert mock_run.call_count == 1
|
||||
assert r1 == r2 == True
|
||||
|
||||
def test_libreoffice_available_handles_missing(self, pdf_mod):
|
||||
"""FileNotFoundError → False."""
|
||||
pdf_mod._libreoffice_available._cache = None
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError):
|
||||
result = pdf_mod._libreoffice_available()
|
||||
assert result is False
|
||||
|
||||
def test_libreoffice_available_handles_timeout(self, pdf_mod):
|
||||
"""TimeoutExpired → False (don't block the job)."""
|
||||
import subprocess
|
||||
pdf_mod._libreoffice_available._cache = None
|
||||
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("lo", 5)):
|
||||
result = pdf_mod._libreoffice_available()
|
||||
assert result is False
|
||||
|
||||
def test_libreoffice_not_found_logs_hint(self, pdf_mod):
|
||||
"""When LibreOffice is missing, the log should include a hint."""
|
||||
# Patch the module-level logger's `warning` method to capture calls
|
||||
captured = []
|
||||
original_warning = pdf_mod.logger.warning
|
||||
|
||||
def spy_warning(*args, **kwargs):
|
||||
captured.append((args, kwargs))
|
||||
return original_warning(*args, **kwargs)
|
||||
|
||||
with patch.object(pdf_mod.logger, "warning", side_effect=spy_warning):
|
||||
with patch.object(pdf_mod, "_libreoffice_available", return_value=False):
|
||||
result = pdf_mod.PDFTranslator()._convert_docx_to_pdf(
|
||||
Path("dummy.docx"), Path("dummy.pdf")
|
||||
)
|
||||
|
||||
# Returns None (no conversion happened)
|
||||
assert result is None
|
||||
# Check the log message includes the install hint
|
||||
all_args = [str(a) + str(kwargs) for args, kwargs in captured for a in args]
|
||||
all_args += [str(kwargs) for args, kwargs in captured]
|
||||
flat = " ".join(all_args)
|
||||
assert "install" in flat.lower() or "Install" in flat, (
|
||||
f"No install hint in logs. Captured: {captured}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# B3.5 — Helper resilience
|
||||
# ============================================================================
|
||||
|
||||
class TestHelperResilience:
|
||||
def test_record_format_loss_zero_count_is_noop(self, pdf_mod):
|
||||
"""count=0 should be a no-op (no metric emitted)."""
|
||||
# Should not raise
|
||||
pdf_mod._record_format_loss_metric("hyperlink", count=0)
|
||||
|
||||
def test_record_format_loss_negative_count_is_noop(self, pdf_mod):
|
||||
"""Negative count should be ignored."""
|
||||
pdf_mod._record_format_loss_metric("hyperlink", count=-5)
|
||||
|
||||
def test_record_format_loss_with_broken_metrics(self, pdf_mod):
|
||||
"""If middleware.metrics fails to import, the helper must NOT raise."""
|
||||
with patch.dict(sys.modules, {"middleware.metrics": None}):
|
||||
# Should swallow the ImportError
|
||||
pdf_mod._record_format_loss_metric("hyperlink", count=1)
|
||||
|
||||
def test_libreoffice_available_cache_expires(self, pdf_mod):
|
||||
"""After 5 minutes, the cache should be considered stale."""
|
||||
pdf_mod._libreoffice_available._cache = (time.time() - 301, True)
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
result = pdf_mod._libreoffice_available()
|
||||
# Cache was stale → fresh subprocess call happened
|
||||
assert mock_run.call_count == 1
|
||||
@@ -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