4"""Generate apps/shared_libs/book/inc/book_library.h from compiled .rabook blobs.
6Scans a directory of .rabook files, reads each one's title/author/language and
7its on-disk and inflated sizes, and emits a C header exposing a static table
8(`g_book_library[]`) so firmware can list the bundled library and size the
9inflate scratch buffer with a single #include. ASCII-only output (titles are
10transliterated) to satisfy the repository encoding policy.
12@copyright Copyright (c) 2026 Brighton Sikarskie
13SPDX-License-Identifier: MIT
16from __future__
import annotations
22from pathlib
import Path
25def ascii_only(text: str) -> str:
26 """Fold text to 7-bit ASCII that is safe inside a C string literal.
28 NFKD decomposition first, so an accented letter splits into a plain letter
29 plus a combining mark and the letter survives the ASCII encode; encoding
30 without it would drop the whole character. Anything still non-ASCII after
31 that -- CJK, emoji, typographic dashes -- is dropped silently, so a title in
32 a non-Latin script can come back empty. That is accepted: the repository
33 encoding policy requires pure 7-bit ASCII in generated sources.
35 Double quotes become apostrophes and backslashes are deleted, which is what
36 keeps the result from terminating or escaping the surrounding C literal.
37 Callers must still not wrap the result in anything but a plain "..." .
40 text: Arbitrary Unicode, typically a book title or author.
43 ASCII-only text containing neither a double quote nor a backslash. May
44 be shorter than the input, and may be empty.
46 norm = unicodedata.normalize(
"NFKD", text)
47 out = norm.encode(
"ascii",
"ignore").decode(
"ascii")
48 return out.replace(
'"',
"'").replace(
"\\",
"")
51def unwrap_container(data: bytes) -> bytes:
52 """Inflate a chunked RBKC .rabook container back to its flat blob.
54 Keep in sync with book_container_t in apps/shared_libs/book/inc/book.h:
55 "RBKC" + <I chunk_bytes + <Q total + <I count + <I reserved(0), a
56 (count + 1)-entry <Q offset table, then count concatenated zlib streams.
58 if data[:4] != b
"RBKC":
59 msg =
"not an RBKC container"
61 chunk_bytes, total, count, reserved = struct.unpack_from(
"<IQII", data, 4)
62 if reserved != 0
or chunk_bytes == 0
or count != (total + chunk_bytes - 1) // chunk_bytes:
63 msg =
"malformed RBKC header"
65 offsets = struct.unpack_from(f
"<{count + 1}Q", data, 24)
66 payload = 24 + 8 * (count + 1)
68 zlib.decompress(data[payload + offsets[i] : payload + offsets[i + 1]])
for i
in range(count)
70 if len(blob) != total:
71 msg =
"RBKC inflated size mismatch"
76def read_meta(path: Path) -> dict[str, object]:
77 """Inflate one .rabook and extract the fields the manifest header needs.
79 Reads the whole container into memory and inflates all of it just to reach
80 the header and a few pool strings -- wasteful per file, but it is the only
81 way to learn the inflated size, which is the number the firmware sizes its
84 Note the two different sizes returned and do not confuse them: `file_size`
85 is the compressed bytes on the SD card, `inflated_size` is the RAM the
86 device must have free to open the book.
89 path: Path to a .rabook container.
92 Dict with "title", "author", "language" (decoded with replacement, so
93 never raises on bad UTF-8), "file_size", "inflated_size", "chapters"
97 ValueError: Not a valid RBKC container, prefixed with the file path so
98 the caller's loop reports which book failed.
99 OSError: `path` cannot be read.
101 with Path(path).open(
"rb")
as fh:
102 container = fh.read()
104 flat = unwrap_container(container)
105 except ValueError
as exc:
106 msg = f
"{path}: {exc}"
107 raise ValueError(msg)
from exc
108 inflated_size = len(flat)
109 h = struct.unpack(
"<8s23I", flat[:100])
112 def s(off: int) -> str:
113 start = string_off + off
114 end = flat.index(b
"\x00", start)
115 return flat[start:end].decode(
"utf-8",
"replace")
121 "file_size": len(container),
122 "inflated_size": inflated_size,
134 " * @file book_library.h",
135 " * @brief Auto-generated index of the compiled e-book library.",
138 " * Generated by tools/epub_compile/src/gen_manifest.py from the .rabook blobs in",
139 " * the e-reader content tree. DO NOT EDIT BY HAND -- re-run the generator instead. Each",
140 " * entry names a book and the scratch size its `book_open()` needs.",
142 " * @copyright Copyright (c) 2026 Brighton Sikarskie",
143 " * SPDX-License-Identifier: MIT",
144 " * @since Version 0.1.0",
148 "#include <stdint.h>",
151 " * @struct book_library_entry_t",
152 " * @brief One bundled book: display metadata plus storage/inflate sizes.",
153 " * @since Version 0.1.0",
156 " const char* title; /**< Book title (transliterated to ASCII). */",
157 " const char* author; /**< Author (transliterated to ASCII). */",
158 " const char* filename; /**< `.rabook` file name on storage. */",
159 " uint32_t file_size; /**< Compressed size on disk, bytes. */",
160 " uint32_t inflated_size; /**< Scratch bytes book_open() needs. */",
161 "} book_library_entry_t;",
166def _generated_constants(entries: list[dict[str, object]], biggest: int) -> list[str]:
167 """The two C23 typed enums whose values come from the scanned books."""
170 " * @enum book_library_count_t",
171 " * @brief Number of books in @ref g_book_library.",
172 " * @since Version 0.1.0",
174 "typedef enum : uint16_t {",
175 f
" k_book_library_count = {len(entries)}U, /**< Bundled book count. */",
176 "} book_library_count_t;",
179 " * @enum book_library_scratch_t",
180 " * @brief Largest inflate scratch any bundled book needs, bytes.",
181 " * @details Size a shared open() buffer to this and every book fits.",
182 " * @since Version 0.1.0",
184 "typedef enum : uint32_t {",
185 f
" k_book_library_max_inflated = {biggest}U, /**< Worst-case inflated size. */",
186 "} book_library_scratch_t;",
191def _generated_table(entries: list[dict[str, object]]) -> list[str]:
192 """The book table itself, one initialiser row per book."""
194 "/** @brief The bundled book index. Generated; data table, magic numbers ok. */",
195 "// NOLINTBEGIN(readability-magic-numbers)",
196 "static const book_library_entry_t g_book_library[k_book_library_count] = {",
199 f
' {{ "{ascii_only(e["title"])}", "{ascii_only(e["author"])}", '
200 f
'"{ascii_only(e["filename"])}", {e["file_size"]}U, {e["inflated_size"]}U }},'
203 lines += [
"};",
"// NOLINTEND(readability-magic-numbers)",
""]
207def _build_header_lines(entries: list[dict[str, object]]) -> tuple[list[str], int]:
208 """Return the C header lines for the given book entries, and the worst-case size."""
209 biggest = max((e[
"inflated_size"]
for e
in entries), default=0)
212 *_generated_constants(entries, biggest),
213 *_generated_table(entries),
215 return lines, biggest
219 """Scan a directory of .rabook files and write the generated C header.
221 Books are ordered by filename, and that order fixes the indices in
222 `g_book_library[]`. Anything that persists a book index -- a saved
223 reading position, a baked fixture -- is invalidated by adding or renaming a
224 file in the directory.
226 The header is written with `encoding="ascii"`, so a title that survived
227 `ascii_only` but still holds a non-ASCII byte raises here rather than
228 producing a source file the encoding gate would later reject.
230 Non-.rabook files in the directory are ignored; an unreadable or corrupt
231 .rabook aborts the whole run rather than being skipped, because a manifest
232 that silently omits a book would under-size the inflate scratch buffer.
234 ap = argparse.ArgumentParser(description=
"Generate the book library manifest header.")
235 ap.add_argument(
"compiled_dir", help=
"directory of .rabook files")
236 ap.add_argument(
"output", help=
"path to the generated header")
237 args = ap.parse_args()
239 compiled_dir = Path(args.compiled_dir)
240 files = sorted(f.name
for f
in compiled_dir.iterdir()
if f.name.endswith(
".rabook"))
243 meta = read_meta(compiled_dir / name)
244 meta[
"filename"] = name
247 lines, biggest = _build_header_lines(entries)
249 with Path(args.output).open(
"w", encoding=
"ascii")
as fh:
250 fh.write(
"\n".join(lines))
251 total_disk = sum(e[
"file_size"]
for e
in entries)
253 f
"wrote {args.output}: {len(entries)} books, "
254 f
"{total_disk // 1024 // 1024} MB on disk, max inflate {biggest // 1024} KB"
258if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.