ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
epub_compile.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Compile an EPUB into a flat, execute-in-place .rabook blob.
5
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.
14
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.
18
19Usage:
20 epub_compile.py INPUT.epub OUTPUT.rabook [--stats]
21
22@copyright Copyright (c) 2026 Brighton Sikarskie
23SPDX-License-Identifier: MIT
24"""
25
26import argparse
27import sys
28from pathlib import Path
29
30sys.path.insert(0, str(Path(__file__).resolve().parent))
31
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
35
36# Device-profile raster depth selector for --pixel-format (issue #343). gray4 is
37# the default so an existing compile emits the same 4bpp packing; gray8 keeps the
38# lossless 8bpp source for a deeper panel.
39_PIXFMT_BY_NAME = {"gray4": PIXFMT_GRAY4, "gray8": PIXFMT_GRAY8}
40
41
42def _build_arg_parser() -> argparse.ArgumentParser:
43 """Construct the epub_compile.py command-line parser.
44
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.
47
48 Returns:
49 The configured ``argparse.ArgumentParser``.
50 """
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")
55 ap.add_argument(
56 "--max-edge",
57 type=int,
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)",
61 )
62 ap.add_argument(
63 "--no-images",
64 action="store_true",
65 help="drop all images (text-only); tiny blob for a baked fixture",
66 )
67 ap.add_argument(
68 "--pixel-format",
69 choices=sorted(_PIXFMT_BY_NAME),
70 default="gray4",
71 help="device profile: raster depth to emit (default gray4 = 4bpp packed, "
72 "half the storage for a grayscale panel; gray8 = lossless 8bpp)",
73 )
74 ap.add_argument(
75 "--chunk-bytes",
76 type=int,
77 default=CONTAINER_CHUNK_BYTES,
78 help="inflated bytes per independently-compressed container chunk "
79 "(must equal the reader's ra8_vmem frame size)",
80 )
81 ap.add_argument(
82 "--selftest",
83 action="store_true",
84 help="compile the fixed-layout fixture and run the #196 self-check, then exit",
85 )
86 return ap
87
88
89def _print_stats(
90 input_path: str,
91 meta: dict[str, str],
92 blob: bytes,
93 container: bytes,
94 bb: BlobBuilder,
95) -> None:
96 """Print the ``--stats`` size/structure summary for one compile.
97
98 Args:
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.
104 """
105 src = Path(input_path).stat().st_size
106 out = len(container)
107 print(f"{meta['title']} -- {meta['author']}")
108 print(
109 f" chapters={len(bb.chapters)} nodes={len(bb.nodes)} "
110 f"attrs={len(bb.attrs)} css={len(bb.stylesheets)} images={len(bb.images)}"
111 )
112 print(
113 f" epub={src // 1024} KB -> rabook={out // 1024} KB "
114 f"({100 * out // max(src, 1)}%); inflated={len(blob) // 1024} KB"
115 )
116
117
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 # noqa: PLC0415 -- selftest path added above
123
124 return selftest()
125
126
127def main() -> int:
128 """Parse the command line, compile, and write the container to disk.
129
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.
135
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.
138
139 Returns:
140 0 on success. Non-zero exits arrive as SystemExit from argparse or the
141 selftest, not through this return.
142 """
143 ap = _build_arg_parser()
144 args = ap.parse_args()
145
146 if args.selftest:
147 return _run_selftest()
148 if not args.input or not args.output:
149 ap.error("input and output are required unless --selftest")
150
151 blob, meta, bb = compile_epub(
152 args.input, args.max_edge, args.no_images, _PIXFMT_BY_NAME[args.pixel_format]
153 )
154 container = wrap_container(blob, args.chunk_bytes)
155 with Path(args.output).open("wb") as fh:
156 fh.write(container)
157
158 if args.stats:
159 _print_stats(args.input, meta, blob, container, bb)
160 return 0
161
162
163if __name__ == "__main__":
164 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298