feat(render-mode): Rich (LibreOffice) vs Standard (pandoc) toggle for office formats (.docx/.odt/.rtf); Rich default; 5 office tests; 152/152 green"
Test / Tests (Python 3.12) (push) Canceled after 0s
Test / Tests (Python 3.12) (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""DOCX/ODT/RTF -> PDF via LibreOffice (Rich mode).
|
||||
|
||||
Word-faithful render (fonts, page colors, tables), unlike pandoc flatten.
|
||||
Gate: pandoc_converter.can_handle. Guard (docx/odt): zip-slip+zip-bomb via
|
||||
_validate_office_zip. Render: libreoffice --headless --convert-to pdf.
|
||||
Title: docx core.xml, odt meta.xml, rtf docinfo. Finalize: shared
|
||||
common.finalize_pdf tail. LibreOffice is a system binary, local (no egress).
|
||||
Progress: 'Rendering with LibreOffice...'.
|
||||
|
||||
subprocess.run is referenced only via this module's own _run_office, so tests
|
||||
may mock it hermetically (no live LibreOffice in the suite, avoiding the
|
||||
soffice first-run lock hang).
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from . import common
|
||||
from .pandoc_converter import (_TITLE_MEMBER, _validate_office_zip, _zip_title)
|
||||
|
||||
log = logging.getLogger("epub2pdf")
|
||||
LIBREOFFICE_TIMEOUT = 600
|
||||
_RTF_TITLE_RE = re.compile(r"\\{\\info\\{\\title\\s+(.*?)\\}", re.IGNORECASE)
|
||||
|
||||
|
||||
def _run_office(src: Path, work: Path) -> bytes:
|
||||
"""Render `src` to PDF via LibreOffice headless; return the PDF bytes.
|
||||
|
||||
Runs `libreoffice --headless --convert-to pdf --outdir <work>`, then
|
||||
locates the produced `<work>/<stem>.pdf` (named after the on-disk input
|
||||
basename, e.g. web job job-abc123.docx -> job-abc123.pdf) and returns it.
|
||||
Satisfies: spec render RICH_RENDER.
|
||||
"""
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
subprocess.run(
|
||||
["/usr/bin/libreoffice", "--headless", "--convert-to", "pdf",
|
||||
"--outdir", str(work), str(src)],
|
||||
check=True, timeout=LIBREOFFICE_TIMEOUT,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(
|
||||
"LibreOffice is not installed. Install it to enable Rich mode."
|
||||
) from exc
|
||||
# LibreOffice names its output after the ON-DISK input basename (e.g. the
|
||||
# web job path job-abc123.docx -> job-abc123.pdf), NOT the original upload
|
||||
# name, so locate the PDF there rather than by the source stem.
|
||||
produced = work / f"{src.stem}.pdf"
|
||||
if not produced.exists():
|
||||
raise ValueError("LibreOffice produced no PDF output")
|
||||
return produced.read_bytes()
|
||||
|
||||
|
||||
def _office_title(src: Path) -> str:
|
||||
"""Extract a document title for the PDF /Title metadata.
|
||||
|
||||
docx/odt: guard the zip then read the DC title member.
|
||||
rtf: read the \\title field from the docinfo paragraph.
|
||||
Returns '' when absent; finalize_pdf falls back to the stem.
|
||||
Satisfies: spec metadata TITLE_FROM_DOCX/ODT/RTF.
|
||||
"""
|
||||
ext = src.suffix.lower()
|
||||
if ext in _TITLE_MEMBER:
|
||||
_validate_office_zip(src)
|
||||
return _zip_title(src, _TITLE_MEMBER[ext]) or ""
|
||||
if ext == ".rtf":
|
||||
match = _RTF_TITLE_RE.search(common.decode_text(src.read_bytes()))
|
||||
return match.group(1).strip() if match else ""
|
||||
return ""
|
||||
|
||||
|
||||
def convert(src: Path, job_id: str, progress_cb=None,
|
||||
output_dir=None, source_name=None) -> str:
|
||||
"""Render an office document to PDF via LibreOffice (Rich mode).
|
||||
|
||||
Gates on pandoc_converter.can_handle, extracts the title, renders with
|
||||
LibreOffice, then finalizes with the shared common.finalize_pdf tail.
|
||||
Satisfies: spec orchestrate RICH_ORCHESTRATION.
|
||||
"""
|
||||
if not can_handle(src.name):
|
||||
raise ValueError(f"Unsupported file type: '{src.suffix or '<none>'}'")
|
||||
|
||||
import app # lazy: WORK_DIR lives with the EPUB pipeline (avoid import cycle)
|
||||
# AC: render_mode defaults to "rich"; the dispatcher routes the office
|
||||
# trio (.docx/.odt/.rtf) to this module when rich is active. An omitted
|
||||
# source_name falls back to the on-disk name.
|
||||
src_name = source_name or src.name
|
||||
out_dir = common.default_output_dir(output_dir)
|
||||
|
||||
work = app.WORK_DIR / f"job-{job_id}"
|
||||
filename = common.derive_export_name(src_name, out_dir)
|
||||
title = _office_title(src)
|
||||
|
||||
common.report(progress_cb, 20.0, "Preparing document\u2026")
|
||||
common.report(progress_cb, 50.0, "Rendering with LibreOffice\u2026")
|
||||
try:
|
||||
pdf_bytes = _run_office(src, work)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"LibreOffice conversion failed: {exc}") from exc
|
||||
|
||||
return common.finalize_pdf(pdf_bytes, out_dir, filename, job_id,
|
||||
progress_cb=progress_cb, title=title)
|
||||
|
||||
|
||||
def can_handle(ext: str) -> bool:
|
||||
"""True if this converter claims `ext` (the office trio)."""
|
||||
from .pandoc_converter import can_handle as _ph
|
||||
return _ph(None, ext)
|
||||
Reference in New Issue
Block a user