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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user