4"""Compile an EPUB into a flat, execute-in-place .rabook blob.
6The on-device reader (apps/shared_libs/book) never unzips or parses XHTML at runtime.
7This host tool does it once: it unzips the EPUB, parses every spine document
8into a faithful DOM (every tag, attribute and text run preserved), keeps each
9stylesheet verbatim, transcodes raster images to the panel-native 4bpp
10grayscale at source resolution (downscale is an opt-in --max-edge knob;
11issue #210) and preserves SVG as
12vector source, then serializes everything into the binary layout described by
13apps/shared_libs/book/inc/book.h.
15Fidelity is the rule: nothing in the markup is dropped to match what the
16renderer understands today. The only content that changes form is raster
17images, because the e-ink panel is physically 4bpp.
20 epub_compile.py INPUT.epub OUTPUT.rabook [--stats]
22@copyright Copyright (c) 2026 Brighton Sikarskie
23SPDX-License-Identifier: MIT
28from pathlib
import Path
30sys.path.insert(0, str(Path(__file__).resolve().parent))
32from epub_pipeline
import compile_epub
33from rabook_blob
import MAX_IMAGE_EDGE, BlobBuilder
34from rabook_format
import CONTAINER_CHUNK_BYTES, PIXFMT_GRAY4, PIXFMT_GRAY8, wrap_container
39_PIXFMT_BY_NAME = {
"gray4": PIXFMT_GRAY4,
"gray8": PIXFMT_GRAY8}
42def _build_arg_parser() -> argparse.ArgumentParser:
43 """Construct the epub_compile.py command-line parser.
45 Kept out of :func:`main` so the entry point stays short; every option's help
46 text lives here next to the flag it documents.
49 The configured ``argparse.ArgumentParser``.
51 ap = argparse.ArgumentParser(description=
"Compile an EPUB into a .rabook blob.")
52 ap.add_argument(
"input", nargs=
"?", help=
"source .epub")
53 ap.add_argument(
"output", nargs=
"?", help=
"destination .rabook")
54 ap.add_argument(
"--stats", action=
"store_true", help=
"print size/structure stats")
58 default=MAX_IMAGE_EDGE,
59 help=
"opt-in: downscale raster image long edge to at most this many "
60 "pixels (default 0 = preserve source resolution)",
65 help=
"drop all images (text-only); tiny blob for a baked fixture",
69 choices=sorted(_PIXFMT_BY_NAME),
71 help=
"device profile: raster depth to emit (default gray4 = 4bpp packed, "
72 "half the storage for a grayscale panel; gray8 = lossless 8bpp)",
77 default=CONTAINER_CHUNK_BYTES,
78 help=
"inflated bytes per independently-compressed container chunk "
79 "(must equal the reader's ra8_vmem frame size)",
84 help=
"compile the fixed-layout fixture and run the #196 self-check, then exit",
96 """Print the ``--stats`` size/structure summary for one compile.
99 input_path: Path to the source .epub, for its on-disk size.
100 meta: Metadata dict with the book "title" and "author".
101 blob: The inflated RABOOK1 blob.
102 container: The RBKC-wrapped bytes actually written to disk.
103 bb: The BlobBuilder, for its table counts.
105 src = Path(input_path).stat().st_size
107 print(f
"{meta['title']} -- {meta['author']}")
109 f
" chapters={len(bb.chapters)} nodes={len(bb.nodes)} "
110 f
"attrs={len(bb.attrs)} css={len(bb.stylesheets)} images={len(bb.images)}"
113 f
" epub={src // 1024} KB -> rabook={out // 1024} KB "
114 f
"({100 * out // max(src, 1)}%); inflated={len(blob) // 1024} KB"
118def _run_selftest() -> int:
119 """Load the test-only fixed-layout contract and return its result."""
120 tests_dir = Path(__file__).resolve().parents[1] /
"tests"
121 sys.path.insert(0, str(tests_dir))
122 from epub_selftest
import selftest
128 """Parse the command line, compile, and write the container to disk.
130 Two modes: `--selftest` runs the issue #196 fixed-layout self-check and
131 ignores the positional arguments entirely, otherwise both input and output
132 are required. The output written is the RBKC-wrapped container, not the raw
133 blob -- `--chunk-bytes` must equal the reader's `ra8_vmem` frame size or the
134 device cannot page the book.
136 Errors are not caught here. A malformed EPUB surfaces as a traceback rather
137 than a diagnostic; the exception type names the failing stage.
140 0 on success. Non-zero exits arrive as SystemExit from argparse or the
141 selftest, not through this return.
143 ap = _build_arg_parser()
144 args = ap.parse_args()
147 return _run_selftest()
148 if not args.input
or not args.output:
149 ap.error(
"input and output are required unless --selftest")
151 blob, meta, bb = compile_epub(
152 args.input, args.max_edge, args.no_images, _PIXFMT_BY_NAME[args.pixel_format]
154 container = wrap_container(blob, args.chunk_bytes)
155 with Path(args.output).open(
"wb")
as fh:
159 _print_stats(args.input, meta, blob, container, bb)
163if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.