any-file to PDF (Phase 1): converters/ dispatcher, images/text/md/csv/json/xml/svg/pdf-passthrough, 112 tests green

This commit is contained in:
sirdave73
2026-09-02 01:53:24 -04:00
parent 0b575a3e1f
commit 345c39417b
17 changed files with 1306 additions and 17 deletions
+39 -10
View File
@@ -34,6 +34,8 @@ from werkzeug.exceptions import HTTPException, RequestEntityTooLarge
from lxml import etree
from pypdf import PdfWriter
import converters # PHASE 1: multi-format dispatch (any common file -> PDF)
BASE_DIR = Path(__file__).resolve().parent
# DATA_DIR is overridable via env so container deployments (e.g. Coolify)
# can point uploads/output/work at a persistent volume.
@@ -46,6 +48,12 @@ for _d in (UPLOAD_DIR, OUTPUT_DIR, WORK_DIR):
MAX_UPLOAD_MB = 400
# req 3 (health): supported formats advertised to clients. Additive key — the
# existing /health contract asserts only status + max_upload_mb.
SUPPORTED_FORMATS = ["epub", "html", "htm", "md", "markdown", "txt", "log",
"csv", "tsv", "json", "xml", "svg", "png", "jpg",
"jpeg", "webp", "tiff", "bmp", "pdf"]
# ZIP-bomb guard: total uncompressed size must stay within 8x the upload
# size, with a 2 GiB floor so normal books always fit (finding: zip bomb,
# app.py extractall).
@@ -127,6 +135,10 @@ body {
font-size: 10.5pt;
line-height: 1.65;
color: #1c1b1a;
/* WeasyPrint emits CSS kerning as positive TJ adjustments that
pypdf's extract_text misreads as inter-word spaces ("BRA VO");
disable it to keep text extraction faithful. */
font-kerning: none;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700; line-height: 1.25; color: #111;
@@ -526,8 +538,9 @@ def _run_job(job_id: str, epub_path: Path) -> None:
_set_job(job_id, progress=pct, message=msg)
try:
filename = convert_epub(epub_path, job_id, progress_cb=_cb,
source_name=JOBS.get(job_id, {}).get("epub"))
filename = converters.convert_file(
epub_path, job_id, progress_cb=_cb,
source_name=JOBS.get(job_id, {}).get("epub"))
_set_job(job_id, status="done", progress=100.0, message="Done",
filename=filename, error=None)
except Exception as exc: # noqa: BLE001 - any failure is a job error
@@ -539,7 +552,10 @@ def _run_job(job_id: str, epub_path: Path) -> None:
except ValueError:
pass
try:
(UPLOAD_DIR / f"{job_id}.epub").unlink(missing_ok=True)
# PHASE 1: uploads keep their real suffix ({job_id}.html, ...);
# job_id is 32-hex so the glob cannot match anything else.
for _up in UPLOAD_DIR.glob(f"{job_id}.*"):
_up.unlink(missing_ok=True)
except OSError:
pass
_sweep_stale_work_dirs()
@@ -571,9 +587,11 @@ def health():
problems.append("weasyprint not importable")
if not _cjk_fonts_present():
problems.append("CJK fonts missing (fc-list found none)")
body = {"status": "ok", "max_upload_mb": MAX_UPLOAD_MB}
body = {"status": "ok", "max_upload_mb": MAX_UPLOAD_MB,
"supported_formats": SUPPORTED_FORMATS}
if problems:
body = {"status": "degraded", "max_upload_mb": MAX_UPLOAD_MB, "problems": problems}
body = {"status": "degraded", "max_upload_mb": MAX_UPLOAD_MB,
"supported_formats": SUPPORTED_FORMATS, "problems": problems}
return jsonify(body), 503
return jsonify(body), 200
@@ -583,15 +601,21 @@ def convert_upload():
up = request.files.get("file")
if up is None or not up.filename:
return jsonify(error="No file uploaded"), 400
if not up.filename.lower().endswith(".epub"):
return jsonify(error="Only .epub files are accepted"), 400
if not converters.is_supported(up.filename):
# PHASE 1: gate on the full extension matrix; the error lists every
# accepted extension (incl. .epub) so the legacy 400 contract holds.
ext = Path(up.filename).suffix or "<none>"
return jsonify(error=(
f"Unsupported file type: '{ext}'. "
f"Supported: {', '.join(sorted(converters.SUPPORTED_EXTENSIONS))}"
)), 400
data = up.read()
if len(data) > MAX_UPLOAD_MB * 1024 * 1024:
return jsonify(error=f"File too large (max {MAX_UPLOAD_MB} MB)"), 400
if not WORKER_SLOTS.acquire(blocking=False):
return jsonify(error="Server busy: all conversion slots in use, retry shortly"), 503
job_id = _register_job(Path(up.filename).name)
dest = UPLOAD_DIR / f"{job_id}.epub"
dest = UPLOAD_DIR / f"{job_id}{Path(up.filename).suffix.lower()}"
try:
with open(dest, "wb") as out:
out.write(data)
@@ -621,9 +645,12 @@ def download(name):
return jsonify(error="unknown file"), 404
# req 4: explicit PDF mimetype + defensive .pdf on download_name
download_name = safe if safe.lower().endswith(".pdf") else safe + ".pdf"
return send_file(
resp = send_file(
target, as_attachment=True, mimetype="application/pdf", download_name=download_name
)
# contract: quoted filename in Content-Disposition
resp.headers["Content-Disposition"] = f'attachment; filename="{download_name}"'
return resp
# --------------------------------------------------------------------------
@@ -655,7 +682,9 @@ def _run_cli(argv: list[str]) -> int:
print(f"[{pct:5.1f}%] {message}")
try:
filename = convert_epub(src, job_id, progress_cb=_cb, output_dir=out_dir)
filename = converters.convert_file(src, job_id, progress_cb=_cb,
output_dir=out_dir,
source_name=src.name)
except Exception as exc: # noqa: BLE001 - CLI reports any failure as error:
print(f"error: {exc}")
return 1