6.8 KiB
EPUB → PDF Converter — User Guide
A small, self-hosted web app that converts EPUB3 ebooks into typeset PDFs. Everything runs locally (or on your own server); no book ever leaves your machine.
1. What it does (and doesn't)
| Does | Doesn't |
|---|---|
| Preserves the EPUB's own CSS, images, headings, tables, code blocks | Re-flow or re-typeset like a print publisher would |
| Renders CJK (Chinese / Japanese / Korean) correctly via Noto fonts | Handle DRM-protected (e.g. protected) EPUBs |
| Supports right-to-left books (Arabic, Hebrew) | Convert EPUB → other formats (only PDF out) |
| Keeps book title/author as PDF metadata | — |
Limits: one book at a time per job (you can queue several, they render one after another in the same server process), max upload size 400 MB.
2. Quick start
Option A — Run directly (no Docker)
# 1. install dependencies (Python 3.11+; WeasyPrint also needs the pango
# system libraries, e.g. on Debian/Ubuntu: sudo apt install libpango-1.0-0
# libpangoft2-1.0-0 libharfbuzz0b fontconfig fonts-noto-cjk shared-mime-info)
pip install -r requirements.txt
# 2. start the web server
python app.py # -> http://localhost:8030
Option B — Docker (recommended for a persistent install)
docker build -t epub2pdf .
docker run -d --name epub2pdf \
-p 8030:8030 \
-v epub2pdf-data:/app/data \
epub2pdf
The volume epub2pdf-data keeps uploaded EPUBs and generated PDFs across restarts.
Option C — Coolify
See COOLIFY.md in this directory (3 steps: Dockerfile service, port 8030, volume at /app/data).
3. Using the web UI
- Open
http://<host>:8030in your browser. - Drop an
.epubfile onto the upload box (or click to browse).- Only
.epubfiles are accepted; anything else is rejected with a message. - Files over 400 MB are rejected before upload finishes.
- Only
- The job starts immediately — a progress bar shows which chapter is being
rendered (e.g.
Rendering chapter-04.xhtml (4/12)), thenMerging pages…. - When it finishes, a Download PDF button appears.
- The PDF is named after the book's title (e.g.
My_Book.pdf).
- The PDF is named after the book's title (e.g.
- 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).
What can go wrong in the UI
| Symptom | Meaning / fix |
|---|---|
File too large (max 400 MB) |
Shrink or split the book, or raise MAX_UPLOAD_MB in app.py |
Not a valid EPUB (bad zip container) |
File is corrupted or renamed — re-export from your ebook store/library |
No readable content found in EPUB spine |
The EPUB has no HTML chapters (images-only books, broken exports) |
DRM protected style errors |
Remove DRM first (Calibre / Calibre-DB tools) — this app can't decrypt |
Stuck at Working… forever |
The server restarted mid-job; refresh the page and re-upload |
4. Command-line usage (no server)
# convert one file and exit
python app.py --cli book.epub
# choose where the PDF lands
python app.py --cli book.epub --output /path/to/dir
# inside a running container
docker exec epub2pdf python app.py --cli /app/data/uploads/book.epub --output /app/data/output
Progress lines like [3/12] chapter-03.xhtml print as each chapter renders.
5. HTTP API (for automation)
Three endpoints, all JSON:
# 1. upload (multipart form, field name: file) -> starts a job
curl -X POST -F "file=@book.epub" http://localhost:8030/api/convert
# -> {"job_id": "c524be5a9d85"}
# 2. poll status
curl http://localhost:8030/api/jobs/c524be5a9d85
# -> {"status": "working", "progress": 42.5, "message": "Rendering ch-05.xhtml (5/12)"}
# -> {"status": "done", "progress": 100, "filename": "Book_Title-20260828-203943.pdf"}
# -> {"status": "error", "error": "Not a valid EPUB (bad zip container)"}
# 3. download the finished PDF
curl -OJ http://localhost:8030/download/Book_Title-20260828-203943.pdf
Other endpoints:
GET /— the web UIGET /health—{"status":"ok","max_upload_mb":400}(use for healthchecks)
6. Configuration
Everything works with defaults. Optional overrides:
| Setting | Default | How to change |
|---|---|---|
| Port | 8030 |
Env PORT, or --port (web mode) |
| Data location | <project>/data |
Env DATA_DIR (Docker: volume at /app/data) — holds uploads/, output/, work/ |
| Max upload size | 400 MB |
Constant MAX_UPLOAD_MB in app.py (edit + restart) |
| Page geometry | A4, 20×18 mm margins | Edit BASE_CSS in app.py (e.g. size: A4 → size: Letter) |
| Base typography | Georgia/serif 10.5 pt, line-height 1.65 | Edit BASE_CSS in app.py — the EPUB's own CSS still layers on top of it |
| RTL handling | auto (reads EPUB page-progression-direction) |
automatic; no setting |
Fonts (affects what renders correctly)
- The Docker image ships DejaVu + Noto CJK fonts.
- When running without Docker, install at least:
fontconfig fonts-dejavu-core fonts-noto-cjk(pluslibpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b shared-mime-infofor WeasyPrint itself). - If a book uses an exotic font not installed, WeasyPrint falls back to the nearest available font — layout stays intact, letterforms may differ.
7. Where files live
<DATA_DIR>/
├── uploads/ # your uploaded .epub files (kept after conversion)
├── output/ # generated PDFs (kept; served by /download/<name>)
└── work/ # per-job temp dir (extracted EPUB + chunk PDFs; deleted after each job)
You can delete anything in uploads/ and output/ at any time (the server
just returns 404 for downloads of deleted files). work/ is always empty when
idle.
8. Troubleshooting
| Problem | Check |
|---|---|
Server won't start: OSError: cannot load library 'libpango-1.0-0' |
Missing system libraries — see §2 Option A note |
| CJK text shows as boxes (□□□) | fonts-noto-cjk not installed — install, then run fc-cache -f, restart server |
Strange characters (—) instead of em dashes |
This build reads EPUB text as UTF-8 explicitly; if you see this, an old server is running — restart it |
| Conversion is slow | Normal: WeasyPrint typesets chapter-by-chapter in-process. Big/illustrated books can take minutes; gunicorn timeout is 1200 s by default |
| Port already in use | --port 9000 or change env PORT |
9. Security notes (self-hosted)
- The app has no login. Anyone who can reach the port can upload and convert. On a LAN, that's usually fine; on the public internet, put it behind a reverse proxy with auth, or firewall the port.
- Uploads are stored under a random job ID (not your filename), and downloads are restricted to the output directory (path traversal is blocked).
- Nothing is transmitted anywhere: conversion happens entirely in-process.