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:
+18
-1
@@ -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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+18
-2
@@ -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,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,
|
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)
|
||||||
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"])
|
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,
|
||||||
output_dir=output_dir, source_name=source_name)
|
output_dir=output_dir, source_name=source_name)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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> · up to <span id="maxmb">400</span> MB · <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> · up to <span id="maxmb">400</span> MB · <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> — best fidelity (LibreOffice)</label>
|
||||||
|
<label class="mode-label"><input type="radio" name="render_mode" value="standard"> <span class="mode-name">Standard</span> — 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 — 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 — 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';
|
||||||
|
|||||||
+134
-1
@@ -470,4 +470,137 @@ def test_image_bmp_e2e(client):
|
|||||||
# req 2: .bmp now accepted by the image converter.
|
# req 2: .bmp now accepted by the image converter.
|
||||||
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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user