#!/usr/bin/env python3 """EPUB3 -> PDF local web converter. Serves a small web UI where you upload an EPUB3 book and download a typeset PDF. Rendering is done by WeasyPrint (HTML/CSS -> PDF), which preserves the EPUB's own CSS, images, headings and CJK text. The final PDF is assembled per spine item and merged with pypdf. Runs entirely locally; nothing is sent to any external service. Usage: python app.py # web server on 0.0.0.0:8030 python app.py --port 9000 # custom port python app.py --cli book.epub # headless one-shot conversion """ from __future__ import annotations import argparse import codecs import logging import os import re import shutil import subprocess import sys import threading import time import uuid import zipfile from pathlib import Path from flask import Flask, abort, jsonify, render_template, request, send_file from werkzeug.exceptions import HTTPException, RequestEntityTooLarge from lxml import etree from pypdf import PdfWriter 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. DATA_DIR = Path(os.environ.get("DATA_DIR", str(BASE_DIR / "data"))) UPLOAD_DIR = DATA_DIR / "uploads" OUTPUT_DIR = DATA_DIR / "output" WORK_DIR = DATA_DIR / "work" for _d in (UPLOAD_DIR, OUTPUT_DIR, WORK_DIR): _d.mkdir(parents=True, exist_ok=True) MAX_UPLOAD_MB = 400 # 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). ZIP_BOMB_MIN_CAP = 2 * 1024 ** 3 # Bounded conversion pool: at most 4 concurrent conversions; extra uploads # receive a JSON 503 (backpressure) instead of an unbounded Thread per # upload (finding: unbounded threads, app.py:293-307). WORKER_SLOTS = threading.BoundedSemaphore(4) # Crash leftovers: sweep work dirs / uploads older than these at startup. STALE_WORK_SECONDS = 6 * 3600 STALE_UPLOAD_SECONDS = 3600 app = Flask(__name__) app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_MB * 1024 * 1024 log = logging.getLogger("epub2pdf") # -------------------------------------------------------------------------- # conversion jobs (in-memory, single worker process) # -------------------------------------------------------------------------- JOBS: dict[str, dict] = {} _JOBS_LOCK = threading.Lock() TERMINAL_STATUSES = ("done", "error") def _register_job(epub_name: str) -> str: """Register a job and enforce the 50-entry cap. Eviction never touches in-flight (queued/working) jobs while any terminal (done/error) entries exist to evict — the UI must not lose a live job mid-conversion (finding: eviction kills in-flight jobs, 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 = { "id": uuid.uuid4().hex[:12], "epub": epub_name, "status": "queued", "progress": 0.0, "message": "queued", "filename": None, "error": None, "created": time.time(), } with _JOBS_LOCK: JOBS[job["id"]] = job while len(JOBS) > 50: terminal = [k for k, v in JOBS.items() if v["status"] in TERMINAL_STATUSES] if terminal and len(terminal) <= 50: break # terminal entries fit the cap; keep in-flight jobs pool = terminal or list(JOBS) oldest = min(pool, key=lambda k: JOBS[k]["created"]) JOBS.pop(oldest, None) return job["id"] def _set_job(job_id: str, **kw) -> None: with _JOBS_LOCK: if job_id in JOBS: JOBS[job_id].update(kw) # -------------------------------------------------------------------------- # -------------------------------------------------------------------------- # CSS handed to WeasyPrint (EPUB's own stylesheets still apply on top) # -------------------------------------------------------------------------- BASE_CSS = """ @page { size: A4; margin: 20mm 18mm; } body { font-family: Georgia, "Times New Roman", "Noto Serif", "Noto Serif CJK SC", "Noto Serif CJK TC", "Noto Serif CJK JP", "Noto Serif CJK KR", serif; font-size: 10.5pt; line-height: 1.65; color: #1c1b1a; } h1, h2, h3, h4, h5, h6 { font-weight: 700; line-height: 1.25; color: #111; page-break-after: avoid; } h1 { font-size: 21pt; margin: 0 0 0.8em 0; } h2 { font-size: 16pt; margin: 1.2em 0 0.5em 0; } h3 { font-size: 13pt; margin: 1em 0 0.4em 0; } p { margin: 0 0 0.7em 0; } img, svg { max-width: 100%; height: auto; } pre, code { font-family: "DejaVu Sans Mono", "Courier New", monospace; font-size: 9pt; white-space: pre-wrap; word-wrap: break-word; } pre { background: #f4f1ec; border-radius: 4px; padding: 0.6em 0.8em; margin: 0 0 1em 0; } blockquote { margin: 1em 0; padding-left: 1em; border-left: 3px solid #b5541c; color: #444; font-style: italic; } table { border-collapse: collapse; margin: 1em 0; width: 100%; } th, td { border: 0.5pt solid #999; padding: 0.35em 0.6em; text-align: left; } th { background: #f4f1ec; } hr { border: none; border-top: 0.5pt solid #bbb; margin: 2em 0; } ul, ol { margin: 0 0 0.8em 0; padding-left: 1.6em; } a { color: inherit; text-decoration: none; } figure { margin: 1.2em 0; text-align: center; } figcaption { font-size: 8.5pt; color: #666; margin-top: 0.4em; } [hidden] { display: none; } """ COVER_CSS = """ @page { margin: 0; } html, body { margin: 0; padding: 0; height: 100%; } body img, body svg { width: 210mm; height: 297mm; object-fit: cover; display: block; } """ # -------------------------------------------------------------------------- # EPUB structure (OPF) parsing # -------------------------------------------------------------------------- NS = { "opf": "http://www.idpf.org/2007/opf", "dc": "http://purl.org/dc/elements/1.1/", } def _contained(base: Path, rel: str) -> Path: """Resolve `rel` against `base`, refusing to leave the tree. Same guard class as zip-slip: a crafted OPF (rootfile full-path or a spine/manifest href) must not be able to point outside the extraction dir (finding: spine/manifest href containment check). """ p = (base / rel).resolve() if not p.is_relative_to(base.resolve()): raise ValueError(f"path escapes extraction dir: {rel}") return p def _find_opf_path(extract_dir: Path) -> Path: container = extract_dir / "META-INF" / "container.xml" if not container.exists(): raise ValueError("Not an EPUB: missing META-INF/container.xml") root = etree.parse(str(container)).getroot() rootfile = root.find(".//rootfile") if rootfile is None: # tolerate spec-correct namespaced container.xml for el in root.iter(): if el.tag.rsplit("}", 1)[-1] == "rootfile": rootfile = el break if rootfile is None: raise ValueError("Not an EPUB: container.xml has no rootfile") return _contained(extract_dir, rootfile.get("full-path")) def parse_opf(extract_dir: Path): """Return (meta, opf_dir, spine_items, page_progression_direction).""" opf_path = _find_opf_path(extract_dir) opf_dir = opf_path.parent root = etree.parse(str(opf_path)).getroot() def _local(tag: str) -> str: return tag.rsplit("}", 1)[-1] meta = {} for el in root.iter(): if not isinstance(el.tag, str) or _local(el.tag) not in ("title", "creator", "language", "description"): continue ns = el.tag.rsplit("}", 1)[0].lstrip("{") if "}" in el.tag else "" if "dc" in ns or not ns: # Dublin Core metadata (namespaced or plain) meta.setdefault(_local(el.tag), (el.text or "").strip()) manifest: dict[str, dict] = {} for el in root.iter(): if not isinstance(el.tag, str) or _local(el.tag) != "item" or not el.get("id"): continue manifest[el.get("id")] = { "href": el.get("href", ""), "media_type": el.get("media-type") or el.get("media_type") or "", "properties": el.get("properties") or "", } spine: list[dict] = [] for ref in root.iter(): if not isinstance(ref.tag, str) or _local(ref.tag) != "itemref": continue item = manifest.get(ref.get("idref")) if item is None or ref.get("linear", "yes") == "no": continue if "html" not in item["media_type"]: continue try: # containment: a spine href must stay inside the extraction tree _contained(opf_dir, item["href"]) except ValueError: continue spine.append(item) if not spine: # malformed book: fall back to all readable documents for item in manifest.values(): if "html" in item["media_type"] and "nav" not in item["properties"]: spine.append(item) if not spine: raise ValueError("No readable content found in EPUB spine") pdir = root.get("page-progression-direction", "ltr") or "ltr" return meta, opf_dir, spine, pdir def _looks_like_cover(opf_dir: Path, item: dict) -> bool: props = item["properties"].split() if "cover-image" in props: return True try: from bs4 import BeautifulSoup text = _decode_html((opf_dir / item["href"]).read_bytes()) except OSError: return False if "]*?encoding[\s=]+["\']?([A-Za-z0-9_\-\.]+)', re.IGNORECASE ) _META_ENC = re.compile( rb']*charset[\s=]+["\']?([A-Za-z0-9_\-\.]+)', re.IGNORECASE ) _BODY_INNER = re.compile(r"]*>(.*)", re.IGNORECASE | re.DOTALL) def _decode_html(raw: bytes) -> str: """Decode a chapter document honoring its declared charset. Order: XML declaration encoding, then , then UTF-8 with errors='replace'. Fixes legacy EPUB2 books in windows-1252/latin-1 that would mojibake under a blind UTF-8 decode (same bug class as the em-dash incident). Undecodable input must not raise. """ m = _XML_DECL_ENC.search(raw[:4096]) or _META_ENC.search(raw[:8192]) if m: enc = m.group(1).decode("ascii", errors="replace").strip() try: codecs.lookup(enc) text = raw.decode(enc) except (LookupError, UnicodeDecodeError): text = raw.decode("utf-8", errors="replace") else: text = raw.decode("utf-8", errors="replace") # WeasyPrint receives the content: a bare fragment keeps per-chapter # layout stable and drops EPUB head cruft (meta/style) that WeasyPrint # would otherwise apply globally. bm = _BODY_INNER.search(text) return bm.group(1) if bm else text # -------------------------------------------------------------------------- # environment validation # -------------------------------------------------------------------------- # Non-numeric PORT must fail fast with a clear message (finding: launcher # divergence) — printed to stdout and exit 2 so both `python app.py` and the # gunicorn import path stop instead of binding a garbage port. try: PORT = int(os.environ.get("PORT", "8030")) except (TypeError, ValueError): print("PORT must be an integer, got: %r" % os.environ.get("PORT")) sys.exit(2) def _format_progress(done: int, total: int, label: str) -> str: """Progress line matching the UI regex \\(\\d+/\\d+\\) exactly.""" return f"Rendering {label} ({done}/{total})" def _cjk_fonts_present() -> bool: """True if fontconfig reports at least one CJK font (health check).""" try: out = subprocess.run( ["fc-list", ":lang=zh", "family"], capture_output=True, text=True, timeout=15, ) return bool(out.stdout.strip()) except (OSError, subprocess.TimeoutExpired): return False def _sweep_stale_work_dirs() -> None: """Remove WORK_DIR/job-* left by crashed runs older than the 1h window.""" now = time.time() try: entries = list(WORK_DIR.iterdir()) except OSError: return for entry in entries: if not entry.is_dir() or not entry.name.startswith("job-"): continue try: if now - entry.stat().st_mtime > STALE_WORK_SECONDS: shutil.rmtree(entry, ignore_errors=True) except OSError: pass # Crash leftovers from the previous process start (finding: stale work dirs). _sweep_stale_work_dirs() # -------------------------------------------------------------------------- # JSON error handlers (client pollers must never receive an HTML error page) # -------------------------------------------------------------------------- @app.errorhandler(RequestEntityTooLarge) @app.errorhandler(413) def _handle_413(err): # noqa: ARG001 """Oversized upload: JSON 413 with the human-readable limit.""" return jsonify(error=f"File too large (max {MAX_UPLOAD_MB} MB)"), 413 @app.errorhandler(HTTPException) def _handle_http_exception(err): """Any other HTTP error: JSON body so API clients never parse HTML.""" return jsonify(error=err.description or err.name), err.code # -------------------------------------------------------------------------- # safe extraction (zip-slip + zip-bomb, finding: app.py extractall) # -------------------------------------------------------------------------- def _safe_extract_zip(epub_path: Path, extract_dir: Path) -> None: """Unzip an EPUB refusing path-escaping, absolute and symlink members. Also enforces the zip-bomb cap: total uncompressed size must stay within max(8x upload size, ZIP_BOMB_MIN_CAP) or the job fails with a clear error. """ upload_size = epub_path.stat().st_size cap = max(8 * upload_size, ZIP_BOMB_MIN_CAP) try: zf = zipfile.ZipFile(epub_path) except zipfile.BadZipFile: raise ValueError("Not a valid EPUB: file is not a readable zip archive") with zf: infos = zf.infolist() total = sum(i.file_size for i in infos) if total > cap: raise ValueError( f"zip-bomb guard: uncompressed size {total} exceeds cap {cap}" ) for info in infos: if info.file_size > ZIP_BOMB_MIN_CAP: raise ValueError( f"zip-bomb guard: member {info.filename} is {info.file_size} bytes " f"(max member size {ZIP_BOMB_MIN_CAP})" ) for info in infos: name = info.filename if ((info.external_attr >> 16) & 0xF000) == 0xA000: # S_IFLNK raise ValueError(f"unsafe zip member (symlink): {name}") if name.startswith("/") or name.startswith("\\"): raise ValueError(f"unsafe zip member (absolute): {name}") parts = name.replace("\\", "/").split("/") if ".." in parts: raise ValueError(f"unsafe zip member (dotdot): {name}") for info in infos: target = (extract_dir / info.filename).resolve() if not target.is_relative_to(extract_dir.resolve()): raise ValueError(f"unsafe zip member (escapes): {info.filename}") if info.is_dir(): target.mkdir(parents=True, exist_ok=True) continue target.parent.mkdir(parents=True, exist_ok=True) with zf.open(info) as src, open(target, "wb") as dst: shutil.copyfileobj(src, dst) # -------------------------------------------------------------------------- # conversion pipeline # -------------------------------------------------------------------------- def convert_epub(epub_path: Path, job_id: str, progress_cb=None, output_dir=None, source_name=None) -> str: """Render an EPUB to a merged PDF; return the output file name (no dir). progress_cb(pct, message) is called per spine chapter and before the merge. The message format matches the UI poll regex exactly. output_dir overrides OUTPUT_DIR (CLI mode); defaults to OUTPUT_DIR. """ import io from pypdf import PdfReader from weasyprint import CSS, HTML out_dir = Path(output_dir) if output_dir else OUTPUT_DIR out_dir.mkdir(parents=True, exist_ok=True) extract_dir = WORK_DIR / f"job-{job_id}" extract_dir.mkdir(parents=True, exist_ok=True) try: _safe_extract_zip(epub_path, extract_dir) meta, opf_dir, spine, pdir = parse_opf(extract_dir) total = len(spine) writer = PdfWriter() for idx, item in enumerate(spine, start=1): label = item["href"] if progress_cb: progress_cb(min(99.0, 100.0 * idx / total), _format_progress(idx, total, label)) try: text = _decode_html(_contained(opf_dir, item["href"]).read_bytes()) except (ValueError, OSError): continue # unreadable or escaping chapter: skip, keep going css = COVER_CSS if _looks_like_cover(opf_dir, item) else BASE_CSS if pdir == "rtl": # right-to-left books (finding: page-progression) css += "\nhtml { direction: rtl; }\n" buf = io.BytesIO() HTML(string=text, base_url=str(opf_dir)).write_pdf( buf, stylesheets=[CSS(string=css)] ) for page in PdfReader(io.BytesIO(buf.getvalue())).pages: writer.add_page(page) if len(writer.pages) == 0: raise ValueError("no renderable content: EPUB produced 0 PDF pages") if progress_cb: progress_cb(99.0, "Merging pages\u2026") title = (meta.get("title") or "").strip() or "Untitled" author = (meta.get("creator") or "").strip() meta_info = {"/Title": title} # pypdf 5+/6x requires NameObject keys if author: meta_info["/Author"] = author writer.add_metadata(meta_info) # req 1: export name = clean book title, else uploaded .epub stem, else 'book' base = re.sub(r"[^A-Za-z0-9._ -]+", "_", (meta.get("title") or "").strip()).strip()[:60].strip() if not base: # req 1 fallback: uploaded .epub stem (original name when provided, # else the on-disk stem). Guard: stem may be empty -> 'book'. stem_src = source_name if source_name else Path(epub_path).stem base = re.sub(r"[^A-Za-z0-9._ -]+", "_", Path(stem_src).stem).strip()[:60].strip() or "book" # req 2: never double the extension (case-insensitive 'x.pdf' -> 'x') if base.lower().endswith(".pdf"): base = base[:-4].rstrip() or "book" # req 3: collision-safe name: first free of base.pdf, base-2.pdf, base-3.pdf, ... filename = f"{base}.pdf" counter = 2 while (out_dir / filename).exists(): filename = f"{base}-{counter}.pdf" counter += 1 tmp_path = out_dir / (filename + ".tmp") # atomic: write .tmp then rename with open(tmp_path, "wb") as out: writer.write(out) os.replace(tmp_path, out_dir / filename) return filename finally: shutil.rmtree(extract_dir, ignore_errors=True) # -------------------------------------------------------------------------- # worker wrapper # -------------------------------------------------------------------------- def _run_job(job_id: str, epub_path: Path) -> None: """Worker-thread body: run the pipeline, track status, always release.""" _set_job(job_id, status="working", message="Working\u2026", progress=0.0) def _cb(pct, msg): _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")) _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 log.exception("job %s failed", job_id) _set_job(job_id, status="error", error=str(exc), message="failed") finally: try: WORKER_SLOTS.release() except ValueError: pass try: (UPLOAD_DIR / f"{job_id}.epub").unlink(missing_ok=True) except OSError: pass _sweep_stale_work_dirs() # -------------------------------------------------------------------------- # routes # -------------------------------------------------------------------------- @app.route("/") def index(): return render_template("index.html") @app.route("/health") def health(): """Real liveness: DATA_DIR writable, weasyprint importable, CJK fonts.""" problems = [] try: probe = DATA_DIR / ".health_probe" probe.write_text("ok", encoding="utf-8") probe.unlink(missing_ok=True) except OSError: problems.append(f"DATA_DIR not writable: {DATA_DIR}") try: import weasyprint # noqa: F401 except Exception: # noqa: BLE001 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} if problems: body = {"status": "degraded", "max_upload_mb": MAX_UPLOAD_MB, "problems": problems} return jsonify(body), 503 return jsonify(body), 200 @app.route("/api/convert", methods=["POST"]) 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 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" try: with open(dest, "wb") as out: out.write(data) except OSError: WORKER_SLOTS.release() _set_job(job_id, status="error", error="failed to store upload", message="failed") return jsonify(error="failed to store upload"), 500 threading.Thread(target=_run_job, args=(job_id, dest), daemon=True).start() return jsonify(job_id=job_id), 202 @app.route("/api/jobs/") def job_status(job_id): with _JOBS_LOCK: snapshot = dict(JOBS[job_id]) if job_id in JOBS else None if snapshot is None: return jsonify(error="unknown job"), 404 return jsonify(snapshot), 200 @app.route("/download/") def download(name): """Serve a finished PDF; the name is re-rooted to OUTPUT_DIR (no traversal).""" safe = Path(name).name target = (OUTPUT_DIR / safe).resolve() if not target.is_relative_to(OUTPUT_DIR.resolve()) or not target.is_file(): 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( target, as_attachment=True, mimetype="application/pdf", download_name=download_name ) # -------------------------------------------------------------------------- # CLI mode + entrypoint # -------------------------------------------------------------------------- def _run_cli(argv: list[str]) -> int: """`python app.py --cli [--output ]` — headless conversion.""" import argparse parser = argparse.ArgumentParser(description="EPUB -> PDF converter") parser.add_argument("--cli", metavar="EPUB", help="convert EPUB headlessly") parser.add_argument("--output", metavar="DIR", default=str(OUTPUT_DIR), help="output directory (default: data/output)") args = parser.parse_args(argv) if not args.cli: # No --cli: fall through to the dev server (see __main__ guard). return None src = Path(args.cli) if not src.is_file(): print(f"{src} not found") return 2 out_dir = Path(args.output) out_dir.mkdir(parents=True, exist_ok=True) job_id = uuid.uuid4().hex def _cb(pct, message): print(f"[{pct:5.1f}%] {message}") try: filename = convert_epub(src, job_id, progress_cb=_cb, output_dir=out_dir) except Exception as exc: # noqa: BLE001 - CLI reports any failure as error: print(f"error: {exc}") return 1 print(f"Saved: {out_dir / filename}") return 0 if __name__ == "__main__": _code = _run_cli(sys.argv[1:]) if _code is not None: sys.exit(_code) app.run(host="0.0.0.0", port=PORT) # dev server; gunicorn uses app:app