Files

671 lines
25 KiB
Python

#!/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 "<img" not in text.lower():
return False
soup = BeautifulSoup(text, "html.parser")
return len(soup.get_text(" ", strip=True).split()) <= 20
# --------------------------------------------------------------------------
# chapter decoding (declared charset, finding: app.py:262)
# --------------------------------------------------------------------------
_XML_DECL_ENC = re.compile(
rb'^<\?xml[^>]*?encoding[\s=]+["\']?([A-Za-z0-9_\-\.]+)', re.IGNORECASE
)
_META_ENC = re.compile(
rb'<meta[^>]*charset[\s=]+["\']?([A-Za-z0-9_\-\.]+)', re.IGNORECASE
)
_BODY_INNER = re.compile(r"<body[^>]*>(.*)</body>", re.IGNORECASE | re.DOTALL)
def _decode_html(raw: bytes) -> str:
"""Decode a chapter document honoring its declared charset.
Order: XML declaration encoding, then <meta charset>, 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 <body> 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/<job_id>")
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/<name>")
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 <epub> [--output <dir>]` — 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