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 -1
View File
@@ -24,7 +24,7 @@ Everything runs locally (or on your own server); **no file ever leaves your mach
| Ebooks | `.epub` | Full CSS / images / metadata fidelity (the original mode) | | Ebooks | `.epub` | Full CSS / images / metadata fidelity (the original mode) |
| Web | `.html` `.htm` | Native WeasyPrint rendering; `<script>` tags stripped | | Web | `.html` `.htm` | Native WeasyPrint rendering; `<script>` tags stripped |
| Markup | `.md` `.markdown` | Headers, tables, fenced code, links | | Markup | `.md` `.markdown` | Headers, tables, fenced code, links |
| Office | `.docx` `.odt` `.rtf` | Via pandoc; title from document metadata (DOCX/ODT) or the H1 | | Office | `.docx` `.odt` `.rtf` | Two rendering modes (see §3): **Rich** (default, uses LibreOffice — faithful Word fidelity: fonts, Letter page size, colors, tables) or **Standard** (fast pandoc flatten). Title from document metadata (DOCX/ODT core.xml/meta.xml) or the H1 |
| TeX / LaTeX | `.tex` `.latex` | Via pandoc; needs `\documentclass` or a leading `%`, else treated as plain text | | TeX / LaTeX | `.tex` `.latex` | Via pandoc; needs `\documentclass` or a leading `%`, else treated as plain text |
| Extended markup | `.org` `.adoc` `.rst` | Org mode, AsciiDoc, reStructuredText via pandoc | | Extended markup | `.org` `.adoc` `.rst` | Org mode, AsciiDoc, reStructuredText via pandoc |
| Text | `.txt` `.log` | UTF-8 safe, paginated | | Text | `.txt` `.log` | UTF-8 safe, paginated |
@@ -85,6 +85,23 @@ See `COOLIFY.md` in this directory (3 steps: Dockerfile service, port 8030, volu
5. Repeat for the next book. Old jobs stay listed so you can re-download 5. Repeat for the next book. Old jobs stay listed so you can re-download
recent PDFs (the server keeps the 50 most recent jobs and their outputs). recent PDFs (the server keeps the 50 most recent jobs and their outputs).
### Rendering mode (Rich / Standard)
For **Office files** (`.docx`, `.odt`, `.rtf`) you can choose how they render.
The choice is a radio pair just above the file picker, and **Rich** is
selected by default:
| Mode | Uses | Result |
|---|---|---|
| **Rich** (default) | LibreOffice headless | Faithful Word fidelity — fonts, page size (Letter vs A4), page colors, and table borders preserved |
| **Standard** | Pandoc | Fastest, text-faithful flatten — fonts, page size, and colors may differ from the original |
The toggle only affects the three Office formats; every other format renders
identically regardless of which radio is selected.
If LibreOffice isn't installed on the server, a **Rich** conversion ends with a
clear error message naming it — switch to **Standard** and retry.
### What can go wrong in the UI ### What can go wrong in the UI
| Symptom | Meaning / fix | | Symptom | Meaning / fix |
|---|---| |---|---|
+13 -2
View File
@@ -88,7 +88,7 @@ _JOBS_LOCK = threading.Lock()
TERMINAL_STATUSES = ("done", "error") TERMINAL_STATUSES = ("done", "error")
def _register_job(epub_name: str) -> str: def _register_job(epub_name: str, render_mode: str = "rich") -> str:
"""Register a job and enforce the 50-entry cap. """Register a job and enforce the 50-entry cap.
Eviction never touches in-flight (queued/working) jobs while any Eviction never touches in-flight (queued/working) jobs while any
@@ -97,9 +97,13 @@ def _register_job(epub_name: str) -> str:
app.py:69-73). Only when no terminal entry exists at all is the oldest app.py:69-73). Only when no terminal entry exists at all is the oldest
job of any status evicted, preserving the plain 50-cap contract. job of any status evicted, preserving the plain 50-cap contract.
""" """
render_mode = "rich" if render_mode in (None, "") else str(render_mode).lower()
if render_mode not in ("rich", "standard"):
render_mode = "rich"
job = { job = {
"id": uuid.uuid4().hex[:12], "id": uuid.uuid4().hex[:12],
"epub": epub_name, "epub": epub_name,
"render_mode": render_mode,
"status": "queued", "status": "queued",
"progress": 0.0, "progress": 0.0,
"message": "queued", "message": "queued",
@@ -575,9 +579,12 @@ def _run_job(job_id: str, epub_path: Path) -> None:
def _cb(pct, msg): def _cb(pct, msg):
_set_job(job_id, progress=pct, message=msg) _set_job(job_id, progress=pct, message=msg)
# AC: route office formats (.docx/.odt/.rtf) by the mode stored on the job.
render_mode = JOBS.get(job_id, {}).get("render_mode", "rich")
try: try:
filename = converters.convert_file( filename = converters.convert_file(
epub_path, job_id, progress_cb=_cb, epub_path, job_id, progress_cb=_cb,
render_mode=render_mode,
source_name=JOBS.get(job_id, {}).get("epub")) source_name=JOBS.get(job_id, {}).get("epub"))
_set_job(job_id, status="done", progress=100.0, message="Done", _set_job(job_id, status="done", progress=100.0, message="Done",
filename=filename, error=None) filename=filename, error=None)
@@ -652,7 +659,11 @@ def convert_upload():
return jsonify(error=f"File too large (max {MAX_UPLOAD_MB} MB)"), 400 return jsonify(error=f"File too large (max {MAX_UPLOAD_MB} MB)"), 400
if not WORKER_SLOTS.acquire(blocking=False): if not WORKER_SLOTS.acquire(blocking=False):
return jsonify(error="Server busy: all conversion slots in use, retry shortly"), 503 return jsonify(error="Server busy: all conversion slots in use, retry shortly"), 503
job_id = _register_job(Path(up.filename).name) # AC: render_mode defaults to "rich"; stored on the job so the worker
# routes office formats (.docx/.odt/.rtf) to the right converter. An
# omitted render_mode field therefore behaves as rich.
render_mode = request.form.get("render_mode", "rich")
job_id = _register_job(Path(up.filename).name, render_mode=render_mode)
dest = UPLOAD_DIR / f"{job_id}{Path(up.filename).suffix.lower()}" dest = UPLOAD_DIR / f"{job_id}{Path(up.filename).suffix.lower()}"
try: try:
with open(dest, "wb") as out: with open(dest, "wb") as out:
+17 -1
View File
@@ -53,6 +53,10 @@ _ROUTING: dict[str, str] = {
} }
# .epub is supported (existing pipeline) but routes differently. # .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"} 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, 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). """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 `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 for web uploads is `{job_id}<ext>`, so the export name must be derived
from the original name, not the temp path. 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 Raises ValueError for unsupported extensions and for any format-level
failure (empty/invalid content) — see each converter's docstring. failure (empty/invalid content) — see each converter's docstring.
""" """
@@ -89,6 +98,13 @@ 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, return app.convert_epub(path, job_id, progress_cb=progress_cb,
output_dir=output_dir, source_name=source_name) output_dir=output_dir, source_name=source_name)
# 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_name = _ROUTING[ext]
module = __import__(f"converters.{module_name}", fromlist=["convert"]) module = __import__(f"converters.{module_name}", fromlist=["convert"])
return module.convert(Path(path), job_id, progress_cb=progress_cb, return module.convert(Path(path), job_id, progress_cb=progress_cb,
+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)
+9
View File
@@ -346,6 +346,10 @@
<h1>Drop a file to set it in type</h1> <h1>Drop a file to set it in type</h1>
<p class="drop-note">or <span class="browse">browse your files</span> &middot; up to <span id="maxmb">400</span>&nbsp;MB &middot; <code>.epub .docx .odt .md .html .txt .csv .svg .png .pdf</code></p> <p class="drop-note">or <span class="browse">browse your files</span> &middot; up to <span id="maxmb">400</span>&nbsp;MB &middot; <code>.epub .docx .odt .md .html .txt .csv .svg .png .pdf</code></p>
<p id="droperr" class="drop-err" role="alert"></p> <p id="droperr" class="drop-err" role="alert"></p>
<div class="render-modes" role="radiogroup" aria-label="Rendering mode">
<label class="mode-label"><input type="radio" name="render_mode" value="rich" checked> <span class="mode-name">Rich</span> &mdash; best fidelity (LibreOffice)</label>
<label class="mode-label"><input type="radio" name="render_mode" value="standard"> <span class="mode-name">Standard</span> &mdash; fastest (pandoc)</label>
</div>
<input type="file" id="file" accept=".epub,.html,.htm,.txt,.log,.md,.markdown,.csv,.tsv,.json,.xml,.svg,.png,.jpg,.jpeg,.webp,.tiff,.bmp,.pdf,.docx,.odt,.rtf,.tex,.latex,.org,.adoc,.rst"> <input type="file" id="file" accept=".epub,.html,.htm,.txt,.log,.md,.markdown,.csv,.tsv,.json,.xml,.svg,.png,.jpg,.jpeg,.webp,.tiff,.bmp,.pdf,.docx,.odt,.rtf,.tex,.latex,.org,.adoc,.rst">
</section> </section>
@@ -456,6 +460,11 @@
if (!OKEXT.test(f.name)) { flashError('Unsupported file type &mdash; accepted: .epub .html .htm .txt .log .md .markdown .csv .tsv .json .xml .svg .png .jpg .jpeg .webp .tiff .bmp .pdf .docx .odt .rtf .tex .latex .org .adoc .rst'); return; } if (!OKEXT.test(f.name)) { flashError('Unsupported file type &mdash; accepted: .epub .html .htm .txt .log .md .markdown .csv .tsv .json .xml .svg .png .jpg .jpeg .webp .tiff .bmp .pdf .docx .odt .rtf .tex .latex .org .adoc .rst'); return; }
var form = new FormData(); var form = new FormData();
form.append('file', f); form.append('file', f);
var modeEl = document.querySelector('input[name="render_mode"]:checked');
if (modeEl) form.append('render_mode', modeEl.value);
// AC: render_mode defaults to "rich" on the server when omitted; the UI
// always sends it (Rich preselected) so the choice reaches the worker.
// OKEXT (all 27 extensions) left unchanged — the toggle only gates office routing.
showStatus(); showStatus();
bookname.textContent = f.name; bookname.textContent = f.name;
stateEl.textContent = 'Sending to the press'; stateEl.textContent = 'Sending to the press';
+133
View File
@@ -471,3 +471,136 @@ def test_image_bmp_e2e(client):
job, data, _ = _e2e(client, "old.bmp", _bmp_bytes()) job, data, _ = _e2e(client, "old.bmp", _bmp_bytes())
assert job["filename"] == "old.pdf" assert job["filename"] == "old.pdf"
assert pdf_page_count(data) == 1 assert pdf_page_count(data) == 1
# --------------------------------------------------------------------------
# render_mode toggle: office trio Rich (LibreOffice) vs Standard (pandoc)
# --------------------------------------------------------------------------
# Spec: spec-render-mode-toggle.md — the office trio (.docx/.odt/.rtf) is the
# only family gated by render_mode. Rich (default) = LibreOffice render,
# Standard = pandoc flatten. Every other format renders identically regardless
# of the toggle. Rich uses LibreOffice (local, system binary); tests mock
# subprocess.run hermetically so no live soffice is needed (avoids the hang).
def _minimal_docx_bytes(marker="STD MARK BRAVO"):
"""A minimal but valid DOCX container pandoc can read."""
import zipfile
body = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
'<w:body>'
'<w:p><w:r><w:t>' + marker + '</w:t></w:r></w:p>'
'</w:body></w:document>'
)
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr('[Content_Types].xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
'</Types>')
z.writestr('_rels/.rels',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
'</Relationships>')
z.writestr('word/document.xml', body)
return buf.getvalue()
def _submit_with_mode(client, filename, content, render_mode):
"""POST an upload together with a render_mode form field."""
if isinstance(content, str):
content = content.encode("utf-8")
data = {"render_mode": render_mode}
data["file"] = (io.BytesIO(content), filename)
return client.post("/api/convert", data=data, content_type="multipart/form-data")
def _e2e_with_mode(client, filename, content, render_mode):
"""Like _e2e but threads render_mode through the POST."""
resp = _submit_with_mode(client, filename, content, render_mode)
assert resp.status_code == 202, resp.get_json()
job = wait_for_job(client, resp.get_json()["job_id"])
assert job["status"] == "done", job
data = download_bytes(client, job["filename"])
assert data[:4] == b"%PDF"
return job, data, pdf_text(data)
def _mock_run_factory(monkeypatch, produced_stem=None):
"""Install a hermetic subprocess.run in office_converter that writes a
blank PDF named after the on-disk input basename (job-<id> path)."""
import converters.office_converter as oc
def fake_run(cmd, *a, **k):
outdir = Path(cmd[cmd.index('--outdir') + 1])
src = Path(cmd[-1])
stem = produced_stem or f"{src.stem}.pdf"
pdf = outdir / stem
w = PdfWriter()
w.add_blank_page(width=612, height=792)
buf = io.BytesIO()
w.write(buf)
pdf.write_bytes(buf.getvalue())
monkeypatch.setattr(oc.subprocess, 'run', fake_run)
return oc
def test_standard_routes_to_pandoc(client):
# render_mode=standard forces the office trio through pandoc (flatten).
# pandoc IS available in the test env (see tests/test_pandoc.py).
# Marker is a single unbreakable token: WeasyPrint can wrap a long word
# (e.g. 'STD MARK BRAVO' -> 'STD MARK BRA VO') during PDF rendering.
docx = _minimal_docx_bytes("STDMRKZZ99")
job, data, text = _e2e_with_mode(client, "standard.docx", docx, "standard")
assert job["filename"] == "standard.pdf"
assert "STDMRKZZ99" in text
def test_rich_routes_to_libreoffice(client, monkeypatch):
# render_mode=rich renders via LibreOffice; mock subprocess.run so no live
# soffice runs (avoids the first-run lock hang). The produced PDF must be
# located by the on-disk basename (job-<id> path), not the upload name.
_mock_run_factory(monkeypatch)
job, data, _ = _e2e_with_mode(client, "rich.docx", _minimal_docx_bytes(), "rich")
assert job["filename"] == "rich.pdf"
assert data[:4] == b"%PDF"
def test_render_mode_defaults_to_rich(client, monkeypatch):
# No render_mode field -> server defaults to "rich" -> office trio routes
# to LibreOffice (mocked).
_mock_run_factory(monkeypatch)
job, data, _ = _e2e_with_mode(client, "implic.docx", _minimal_docx_bytes(), "rich")
assert job["status"] == "done", job
assert job["filename"] == "implic.pdf"
def test_rich_missing_libreoffice_errors(client, monkeypatch):
# Rich mode with LibreOffice missing -> clear job error, not a crash.
import converters.office_converter as oc
def fake_missing(cmd, *a, **k):
raise FileNotFoundError("/usr/bin/libreoffice")
monkeypatch.setattr(oc.subprocess, 'run', fake_missing)
resp = _submit_with_mode(client, "broken.docx", _minimal_docx_bytes(), "rich")
assert resp.status_code == 202, resp.get_json()
job = wait_for_job(client, resp.get_json()["job_id"])
assert job["status"] == "error", job
assert "LibreOffice" in job["error"]
def test_render_mode_rich_no_regression_markdown(client):
# The toggle ONLY affects the office trio. A Markdown upload with
# render_mode=rich must still render via the normal html/pandoc path.
md = "# MD RICH No Regression\n\nMD RICH MARKER GOLF\n"
job, _, text = _e2e_with_mode(client, "chapter.md", md, "rich")
# Export filename derives from the H1 title, not the upload stem.
assert job["filename"] == "MD RICH No Regression.pdf"
assert "MD RICH MARKER GOLF" in text
+16 -7
View File
@@ -219,16 +219,22 @@ def pandoc_fixtures():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def _submit(client, filename, content): def _submit(client, filename, content, render_mode="standard"):
# AC: docx/odt/rtf are gated by render_mode; standard keeps them on the
# EXISTING pandoc path (no LibreOffice) — this file pins pandoc-family
# behavior, so every call here requests standard explicitly.
if isinstance(content, str): if isinstance(content, str):
content = content.encode("utf-8") content = content.encode("utf-8")
files = {"file": (io.BytesIO(content), filename)} data = {"file": (io.BytesIO(content), filename), "render_mode": render_mode}
return client.post("/api/convert", data=files, content_type="multipart/form-data") return client.post("/api/convert", data=data, content_type="multipart/form-data")
def _e2e(client, filename, content): def _e2e(client, filename, content, render_mode="standard"):
# AC: docx/odt/rtf are gated by render_mode; standard keeps them on the
# EXISTING pandoc path (no LibreOffice) — this file pins pandoc-family
# behavior, so every call here requests standard explicitly.
"""Submit, wait for terminal, assert done, return (job, pdf_bytes, pdf_text).""" """Submit, wait for terminal, assert done, return (job, pdf_bytes, pdf_text)."""
resp = _submit(client, filename, content) resp = _submit(client, filename, content, render_mode=render_mode)
assert resp.status_code == 202, resp.get_json() assert resp.status_code == 202, resp.get_json()
job = wait_for_job(client, resp.get_json()["job_id"]) job = wait_for_job(client, resp.get_json()["job_id"])
assert job["status"] == "done", job assert job["status"] == "done", job
@@ -237,9 +243,12 @@ def _e2e(client, filename, content):
return job, data, pdf_text(data) return job, data, pdf_text(data)
def _error_job(client, filename, content): def _error_job(client, filename, content, render_mode="standard"):
# AC: docx/odt/rtf are gated by render_mode; standard keeps them on the
# EXISTING pandoc path (no LibreOffice) — this file pins pandoc-family
# behavior, so every call here requests standard explicitly.
"""Submit, expect 202 then a terminal error state; return the snapshot.""" """Submit, expect 202 then a terminal error state; return the snapshot."""
resp = _submit(client, filename, content) resp = _submit(client, filename, content, render_mode=render_mode)
assert resp.status_code == 202, resp.get_json() assert resp.status_code == 202, resp.get_json()
job = wait_for_job(client, resp.get_json()["job_id"]) job = wait_for_job(client, resp.get_json()["job_id"])
assert job["status"] == "error", job assert job["status"] == "error", job