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:
- Package namespace is
http://www.idpf.org/2007/opf(a 2005 typo made ebooklib reject the first draft; there is a regression test). - Metadata:
dc:identifier(idpub-id), main/subtitledc:titlewithtitle-typerefinements,dc:creatorwith MARC roleaut,dc:language,dcterms:modifiedinCCYY-MM-DDThh:mm:ssZ.SOURCE_DATE_EPOCHfixes the timestamp and the zip entry dates, making builds byte-reproducible. - Manifest ids are
t-<doc id>for pages,img-N,font-N,css,nav,ncx. The cover image carriesproperties="cover-image"plus the legacy<meta name="cover">. - Spine: cover, title page, copyright, dedication, nav (so the contents page is readable), then parts/chapters, then back matter.
- Nav tree: parts contain chapters; each chapter contains its
##(and###withtoc_depth = 3) headings as nested entries. Heading ids are<doc>--<slug>, deduplicated with-2,-3. The NCX is generated from the same tree with sequentialplayOrder. - Footnotes (
footnotes = "footnotes"): reference<sup class="noteref"><a epub:type="noteref" href="#fn-ch-1" id="fnref-ch-1">1</a></sup>, definition<aside epub:type="footnote" id="fn-ch-1"><p>... <a epub:type="backlink" href="#fnref-ch-1">↩</a></p></aside>collected in<section epub:type="footnotes">. Reading systems turn this into pop-ups. A note referenced twice gets one backlink per reference (fnref-ch-1,fnref-ch-1-1).endnotesmode emits<section epub:type="endnotes"><ol><li epub:type="endnote">under a "Notes" heading. Ids are prefixed with the chapter id, so the single-file print edition stays collision free. - Images are decoded once (cached per source path), EXIF-rotated, shrunk to at most 1600 px on the long edge, then encoded: images with alpha stay PNG; flat-colour art (at most 256 colours) becomes palette PNG; everything else picks the smaller of PNG and JPEG (quality 85, progressive). File names carry a content hash:
cover-f58e4bb2.jpg.
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:
---becomes an em dash,--an en dash,...and. . .an ellipsis.- A straight quote opens if the previous character is whitespace, start of text, an opening bracket, or a dash/opening quote; otherwise it closes.
- A
'between two alphanumerics is an apostrophe (It's); a'before a digit when it would open is an apostrophe too ('90s). - 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):
- zip:
mimetypefirst, stored, exact content container.xmlrootfile exists; OPF root isopf:packagev3.0 in the right namespace- metadata: identifier resolves from
unique-identifier, title, language,dcterms:modifiedformat; no creator is a warning - manifest: unique ids, files exist, media type matches extension, every archive file is declared (error), every item is referenced by spine/nav/ncx/links/CSS (warning); exactly one
nav; at most onecover-imagewhich must be an image - spine: idrefs exist, XHTML only, no repeats
- every XHTML parses as XML; ids unique per file;
href/srcresolve inside the archive and#fragmentsexist in their target file; CSSurl()s resolve - images: decodable, longer side at most 1600 px and at most 3 MB (warnings); missing or empty
alt(warnings) - NCX targets exist and fragments resolve
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).
Print HTML and PDF
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:
- Render with
BFPNplaceholders in the TOC, print to PDF, extract each page's text with pypdf. - 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. - 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.