bookforge
Turn a folder of Markdown chapters into a valid, good-looking EPUB 3 for e-readers (Kindle accepts it via Send to Kindle) and a print-ready HTML/PDF book, without fighting Calibre or Pandoc templates. One book.toml, one command, one self-checking build.
Features
- Project file: title, subtitle, authors, language, ISBN or generated stable UUID, publisher, date, description, subjects, cover, chapter globs, parts, dedication, copyright template, acknowledgements, about the author, appendices, theme.
- Markdown via markdown-it-py: headings, emphasis, lists, tables, footnotes, images, blockquotes and epigraphs, scene breaks, cross-references (
[@chapter-id]), links between chapter files. - Typography: curly quotes, em/en dashes, ellipses; language-aware quotes for
en,fr(guillemets, no-break spaces) andde; small-caps first lines or drop caps. - Code highlighted with Pygments using plain CSS classes (works offline in every reader).
- EPUB 3 writer written from scratch on
zipfile: storedmimetypefirst,container.xml, OPF (withdcterms:modified,cover-image), nestednav.xhtmlwith landmarks, NCX for EPUB 2 readers, XHTML checked with lxml, three CSS themes, optional embedded fonts, reproducible withSOURCE_DATE_EPOCH. - EPUB 3 footnotes (
epub:type="noteref"/footnotewith back-links) or per-chapter endnotes. - Images copied once, shrunk to 1600 px, re-encoded as the smaller of PNG/JPEG (flat art as palette PNG).
- Validator: well-formedness, manifest/spine integrity, unique ids, link and fragment resolution, image sizes, missing alt text; runs
epubchecktoo when installed. - Print HTML: single file with paged-media CSS (
@page 5.5x8.5in, margins, running headers, page breaks, TOC withtarget-counter), ready for browser print or Paged.js.bookforge pdfdrives headless Chrome and fills in real TOC page numbers with a two-pass render. - Extras:
stats(words and reading time per chapter),watch(rebuild on change),new(scaffold).
Install
Requires Python 3.11+ (developed on 3.12).
cd bookforge
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
source .venv/bin/activate # or call .venv/bin/bookforge directly
bookforge pdf needs Chrome or Chromium (google-chrome, chromium, ...; or --chrome PATH / $BOOKFORGE_CHROME). epubcheck is optional.
Quickstart
bookforge new mybook --title "My Book" --author "Me"
cd mybook
bookforge build # build/my-book.epub + build/my-book-print.html, then validates
bookforge pdf # build/my-book.pdf via headless Chrome
Or try the bundled novella (outputs already committed in examples/sample-book/build/):
bookforge build examples/sample-book
bookforge pdf examples/sample-book
Usage
bookforge new DIRECTORY [--title T] [--author A]
bookforge build [PATH] [-o OUT] [-f epub|print|all]... [--strict] [--epubcheck/--no-epubcheck]
bookforge validate FILE.epub [--strict] [--epubcheck/--no-epubcheck]
bookforge pdf [PATH] [-o OUT] [--chrome EXE]
bookforge stats [PATH]
bookforge watch [PATH] [-o OUT] [--interval SECONDS]
PATH is the directory containing book.toml (default .), output goes to PATH/build unless -o is given. Errors in the book (missing files, unknown cross-reference, bad config) print error: ... and exit 1; validation errors exit 1, and warnings do too with --strict.
build
$ bookforge build examples/sample-book
wrote examples/sample-book/build/the-salt-ledger.epub (89 KB)
epubcheck not installed; skipped (built-in checks only)
validation: 0 error(s), 0 warning(s)
wrote examples/sample-book/build/the-salt-ledger-print.html (148 KB)
validate
Works on any EPUB. Output for a copy of the sample with a stored-as-deflated mimetype, an unclosed </section> and a duplicated id:
$ bookforge validate broken.epub
epubcheck not installed; skipped (built-in checks only)
ERROR mimetype: must be stored uncompressed
ERROR OEBPS/text/chapter-high-water.xhtml: not well-formed XML: Opening and ending tag mismatch: section line 10 and body, line 34, column 8 (<string>, line 34)
ERROR OEBPS/text/chapter-salvage.xhtml: duplicate id 'dup'
validation: 3 error(s), 0 warning(s)
$ bookforge pdf examples/sample-book
wrote examples/sample-book/build/the-salt-ledger-print.html
wrote examples/sample-book/build/the-salt-ledger.pdf (24 pages, 306 KB)
stats
$ bookforge stats examples/sample-book
Title Kind Words Read Notes Imgs
------------------------------------------------------------------------
Copyright copyright 45 1 min 0 0
Dedication dedication 15 1 min 0 0
The Ninth Entry chapter 444 2 min 1 0
R.W. chapter 342 2 min 1 0
The Shape of Water chapter 353 2 min 0 1
Salvage chapter 350 2 min 0 0
What Pellam Knew chapter 319 2 min 0 0
High Water chapter 389 2 min 1 0
Appendix: Predicting a Tide appendix 118 1 min 0 0
Acknowledgements acknowledgements 72 1 min 0 0
About the Author about 47 1 min 0 0
------------------------------------------------------------------------
Total 2,494 11 min 3 1
2,197 words in 6 chapters; reading time at 230 wpm: 11 min
watch
$ bookforge watch mybook
[20:15:12] building...
wrote mybook/build/my-book.epub (6 KB)
validation: 0 error(s), 0 warning(s)
wrote mybook/build/my-book-print.html (13 KB)
[20:15:13] building... <- after saving a chapter
Writing a chapter
# The Ninth Entry
> The sea keeps no accounts, which is why we keep them for it.
>
> --- Harbour Office motto
"Six feet, two inches," she read aloud.[^1]
***
See also [@salvage] for what happened next.
[^1]: Footnotes become pop-up notes in the EPUB.
See docs/book-toml.md for every setting.
Configuration
Everything lives in book.toml; the complete reference is docs/book-toml.md. The sample project's file is a good template: examples/sample-book/book.toml. Environment: SOURCE_DATE_EPOCH (reproducible timestamps), BOOKFORGE_CHROME (Chrome path).
Other docs: theming guide, EPUB internals, typography, validation and the print/PDF pipeline.
Architecture
src/bookforge/
cli.py click commands, error reporting
config.py book.toml -> Config (validation, glob expansion, identifiers)
model.py Project/Doc loading (front matter, ids, titles), render_project(target)
render.py markdown-it-py parser + rules: typography, notes, xrefs, images, epigraphs, code
typography.py language-aware smart quotes, dashes, ellipses
images.py resize/re-encode, content-hashed names
styles.py CSS assembly (themes + Pygments + fonts + user CSS)
themes/ base.css, classic.css, modern.css, manuscript.css
epub.py EPUB 3 writer (OPF, nav, NCX, XHTML pages, zip)
validate.py built-in EPUB checks + optional epubcheck
printhtml.py single-file paged-media HTML
pdf.py headless Chrome driver with two-pass TOC numbering
stats.py watch.py scaffold.py
Tests
.venv/bin/pytest -q # 131 tests, ~2 s
Covers Markdown conversions (footnotes, smart quotes per language, scene breaks), EPUB structure (zip order, stored mimetype, OPF/nav/NCX parsed with lxml, spine order), validator checks against planted errors, cross-references, print HTML structure, and CLI end-to-end including a real headless-Chrome PDF run when Chrome is installed. ebooklib is used in tests as an independent EPUB reader.
Limitations
- Raw HTML in Markdown is escaped, not passed through.
- Only
en,franddehave language-specific quotes; other languages use English quotes. Nested quotes use the single-quote pair; leading-apostrophe words other than decades ('twas) may curl the wrong way. - Chrome ignores
target-counter()andstring-set, so the PDF path uses the two-pass TOC and literal running headers described in the internals doc. Chrome applies:firstonly to a document's first page, sorunning_header = "chapter"repeats the chapter title on opening pages; the defaultbookmode does not. - PDF page numbers are physical (front matter counted, unnumbered); the print TOC lists parts and chapters, not sub-headings.
- Embedded fonts are not obfuscated; no font subsetting.
- SVG images are embedded unchanged and not size-checked.
- The built-in validator is not a substitute for
epubcheck(it does not check the full XHTML schema or EPUB accessibility metadata); installepubcheckand bookforge will run it automatically.