Internals: EPUB writer, typography, validation, print/PDF

Pipeline

book.toml ──► config.load_config ──► model.load_project ──► model.render_project(target)
                                                               │  (markdown-it-py + render.py rules)
                         ┌─────────────────────────────────────┴───────────────┐
                    epub.build_epub                                    printhtml.print_html
                         │                                                     │
                    validate.validate_epub                          pdf.build_pdf (headless Chrome)

render_project is called once per target because links, image sources and notes differ: the EPUB links to chapter-x.xhtml and ../images/x.jpg; the single-file print HTML links to #x and embeds base64 images and uses endnotes (Chrome cannot do CSS footnotes).

EPUB layout

mimetype                      stored (not deflated), first entry, "application/epub+zip"
META-INF/container.xml
OEBPS/content.opf             package 3.0
OEBPS/nav.xhtml               toc nav (nested <ol>) + landmarks nav
OEBPS/toc.ncx                 EPUB 2 compatibility
OEBPS/css/style.css
OEBPS/text/*.xhtml            cover, titlepage, copyright, dedication, [part-*], chapter-*, appendices, back matter
OEBPS/images/*  OEBPS/fonts/*

Details worth knowing:

Smart typography (typography.py)

Quotes cannot be curled with a regex on the whole document: "*Hi*" she said crosses inline tokens, and code or escaped characters must be left alone. bookforge runs as a markdown-it core rule over each inline token's children and carries the previous character across tokens:

  1. --- becomes an em dash, -- an en dash, ... and . . . an ellipsis.
  2. A straight quote opens if the previous character is whitespace, start of text, an opening bracket, or a dash/opening quote; otherwise it closes.
  3. A ' between two alphanumerics is an apostrophe (It's); a ' before a digit when it would open is an apostrophe too ('90s).
  4. Marks per language: English “ ” ‘ ’, German „ “ ‚ ‘, French « » with no-break spaces inside, and in French a no-break space is placed before : ; ! ?.

Worked example (English):

input previous char output
"Hello," start “Hello,”
*"Hi"* inside bold * markup is transparent <em>“Hi”</em>
rock 'n' roll space then n rock ‘n’ roll (documented limitation)
\"literal\" escapes are text_special tokens left straight

Cross-references

[@id] is split out of text tokens by a core rule (so it never fires inside code) and turned into a link token. The resolver returns (href, title) for the current target. Links to other.md#x are resolved by absolute path to a chapter id. Anything unknown raises BookError naming the file, because a dangling link in a finished EPUB is always a bug.

Validation (validate.py)

Runs on the finished zip, so it also works on EPUBs from other tools (bookforge validate x.epub):

Errors fail build/validate (exit 1); warnings fail only with --strict. If epubcheck is on PATH it is also run and its ERROR/FATAL/WARNING lines are folded into the same report (--no-epubcheck to skip).

The print file is one self-contained HTML document: cover, title page, copyright, dedication, contents, then parts/chapters/back matter, each as <section id="..."> with break-before: page (or right). Layout comes from @page rules (size, margins, page number in @bottom-center) and named pages: front and cover suppress headers and numbers.

Browsers differ in what they support, so two mechanisms coexist:

feature Paged.js / print engines Chrome
TOC page numbers a::after { content: target-counter(attr(href), page) } not supported
running headers string-set: chapter content() not supported; plain content: strings in @page :left/:right margin boxes work

bookforge pdf therefore does a two-pass render for Chrome:

  1. Render with BFPN placeholders in the TOC, print to PDF, extract each page's text with pypdf.
  2. The TOC pages are those containing BFPN. Starting after the last one, scan forward for each TOC title (whitespace-stripped, case-folded, so small caps, letter-spacing and uppercase transforms still match) and record its physical page.
  3. Render again with those numbers as literal <span class="pn"> elements and print the final PDF. Because the placeholders are single-line, the TOC length is identical in both passes and page numbers do not shift.

Page numbers are physical (counter(page)), unnumbered front matter included, so the TOC agrees with the printed folios.

With running_header = "chapter" each chapter gets its own named page (@page d-<id>:right { @top-center { content: "Title" } }) because Chrome cannot read the current chapter title. Chrome also applies :first only to the document's first page, so the chapter title header repeats on a chapter's opening page; the default book mode avoids this.

If a title cannot be found (e.g. an exotic font ligature) the build prints a warning and leaves that TOC page number blank.