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

This commit is contained in:
Amelia
2026-09-04 17:31:26 -04:00
parent 383635c57c
commit 069dab0411
7 changed files with 318 additions and 13 deletions
+18 -2
View File
@@ -53,6 +53,10 @@ _ROUTING: dict[str, str] = {
}
# .epub is supported (existing pipeline) but routes differently.
# Office trio: the ONLY family gated by render_mode (rich->LibreOffice,
# standard->pandoc). All other extensions route via _ROUTING unchanged.
_OFFICE_EXTS = frozenset({".docx", ".odt", ".rtf"})
SUPPORTED_EXTENSIONS = frozenset(_ROUTING) | {".epub"}
@@ -68,13 +72,18 @@ def is_supported(filename: str) -> bool:
def convert_file(path: Path, job_id: str, progress_cb=None, output_dir=None,
source_name=None) -> str:
source_name=None, render_mode="rich") -> str:
"""Convert `path` to a PDF; return the output file name (no dir).
`source_name` is the original upload/CLI file name — the on-disk name
for web uploads is `{job_id}<ext>`, so the export name must be derived
from the original name, not the temp path.
`render_mode` ("rich" default | "standard") gates ONLY the office trio
(.docx/.odt/.rtf): rich -> LibreOffice, standard -> pandoc. Every other
extension (incl. .tex/.latex/.org/.adoc/.rst, which stay pandoc-only) and
.epub route exactly as before. An omitted/invalid render_mode is "rich".
Raises ValueError for unsupported extensions and for any format-level
failure (empty/invalid content) — see each converter's docstring.
"""
@@ -89,7 +98,14 @@ def convert_file(path: Path, job_id: str, progress_cb=None, output_dir=None,
return app.convert_epub(path, job_id, progress_cb=progress_cb,
output_dir=output_dir, source_name=source_name)
module_name = _ROUTING[ext]
# AC (matrix HAPPY_PATH_STANDARD / HAPPY_PATH_RICH): the office trio is the
# only family gated by render_mode. rich -> LibreOffice, standard -> pandoc.
# .tex/.latex/.org/.adoc/.rst stay pandoc-only (the pandoc module itself
# gates tex via \documentclass); every other extension routes as before.
if ext in _OFFICE_EXTS:
module_name = "office_converter" if render_mode == "rich" else "pandoc_converter"
else:
module_name = _ROUTING[ext]
module = __import__(f"converters.{module_name}", fromlist=["convert"])
return module.convert(Path(path), job_id, progress_cb=progress_cb,
output_dir=output_dir, source_name=source_name)
+110
View File
@@ -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)