ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
gen_manifest.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"""Generate apps/shared_libs/book/inc/book_library.h from compiled .rabook blobs.
5
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.
11
12@copyright Copyright (c) 2026 Brighton Sikarskie
13SPDX-License-Identifier: MIT
14"""
15
16from __future__ import annotations
17
18import argparse
19import struct
20import unicodedata
21import zlib
22from pathlib import Path
23
24
25def ascii_only(text: str) -> str:
26 """Fold text to 7-bit ASCII that is safe inside a C string literal.
27
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.
34
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 "..." .
38
39 Args:
40 text: Arbitrary Unicode, typically a book title or author.
41
42 Returns:
43 ASCII-only text containing neither a double quote nor a backslash. May
44 be shorter than the input, and may be empty.
45 """
46 norm = unicodedata.normalize("NFKD", text)
47 out = norm.encode("ascii", "ignore").decode("ascii")
48 return out.replace('"', "'").replace("\\", "")
49
50
51def unwrap_container(data: bytes) -> bytes:
52 """Inflate a chunked RBKC .rabook container back to its flat blob.
53
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.
57 """
58 if data[:4] != b"RBKC":
59 msg = "not an RBKC container"
60 raise ValueError(msg)
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"
64 raise ValueError(msg)
65 offsets = struct.unpack_from(f"<{count + 1}Q", data, 24)
66 payload = 24 + 8 * (count + 1)
67 blob = b"".join(
68 zlib.decompress(data[payload + offsets[i] : payload + offsets[i + 1]]) for i in range(count)
69 )
70 if len(blob) != total:
71 msg = "RBKC inflated size mismatch"
72 raise ValueError(msg)
73 return blob
74
75
76def read_meta(path: Path) -> dict[str, object]:
77 """Inflate one .rabook and extract the fields the manifest header needs.
78
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
82 scratch buffer from.
83
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.
87
88 Args:
89 path: Path to a .rabook container.
90
91 Returns:
92 Dict with "title", "author", "language" (decoded with replacement, so
93 never raises on bad UTF-8), "file_size", "inflated_size", "chapters"
94 and "images".
95
96 Raises:
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.
100 """
101 with Path(path).open("rb") as fh:
102 container = fh.read()
103 try:
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])
110 string_off = h[19]
111
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")
116
117 return {
118 "title": s(h[4]),
119 "author": s(h[5]),
120 "language": s(h[6]),
121 "file_size": len(container),
122 "inflated_size": inflated_size,
123 "chapters": h[9],
124 "images": h[17],
125 }
126
127
128#: The fixed half of the generated header: the file doc block and the entry
129#: struct. Nothing here depends on the books, so it is a constant rather than
130#: 30 more lines inside the generator -- what varies and what does not should
131#: be readable apart.
132_HEADER_PREAMBLE = (
133 "/**",
134 " * @file book_library.h",
135 " * @brief Auto-generated index of the compiled e-book library.",
136 " *",
137 " * @details",
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.",
141 " *",
142 " * @copyright Copyright (c) 2026 Brighton Sikarskie",
143 " * SPDX-License-Identifier: MIT",
144 " * @since Version 0.1.0",
145 " */",
146 "#pragma once",
147 "",
148 "#include <stdint.h>",
149 "",
150 "/**",
151 " * @struct book_library_entry_t",
152 " * @brief One bundled book: display metadata plus storage/inflate sizes.",
153 " * @since Version 0.1.0",
154 " */",
155 "typedef struct {",
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;",
162 "",
163)
164
165
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."""
168 return [
169 "/**",
170 " * @enum book_library_count_t",
171 " * @brief Number of books in @ref g_book_library.",
172 " * @since Version 0.1.0",
173 " */",
174 "typedef enum : uint16_t {",
175 f" k_book_library_count = {len(entries)}U, /**< Bundled book count. */",
176 "} book_library_count_t;",
177 "",
178 "/**",
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",
183 " */",
184 "typedef enum : uint32_t {",
185 f" k_book_library_max_inflated = {biggest}U, /**< Worst-case inflated size. */",
186 "} book_library_scratch_t;",
187 "",
188 ]
189
190
191def _generated_table(entries: list[dict[str, object]]) -> list[str]:
192 """The book table itself, one initialiser row per book."""
193 lines = [
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] = {",
197 ]
198 lines.extend(
199 f' {{ "{ascii_only(e["title"])}", "{ascii_only(e["author"])}", '
200 f'"{ascii_only(e["filename"])}", {e["file_size"]}U, {e["inflated_size"]}U }},'
201 for e in entries
202 )
203 lines += ["};", "// NOLINTEND(readability-magic-numbers)", ""]
204 return lines
205
206
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)
210 lines = [
211 *_HEADER_PREAMBLE,
212 *_generated_constants(entries, biggest),
213 *_generated_table(entries),
214 ]
215 return lines, biggest
216
217
218def main() -> int:
219 """Scan a directory of .rabook files and write the generated C header.
220
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.
225
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.
229
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.
233 """
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()
238
239 compiled_dir = Path(args.compiled_dir)
240 files = sorted(f.name for f in compiled_dir.iterdir() if f.name.endswith(".rabook"))
241 entries = []
242 for name in files:
243 meta = read_meta(compiled_dir / name)
244 meta["filename"] = name
245 entries.append(meta)
246
247 lines, biggest = _build_header_lines(entries)
248
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)
252 print(
253 f"wrote {args.output}: {len(entries)} books, "
254 f"{total_disk // 1024 // 1024} MB on disk, max inflate {biggest // 1024} KB"
255 )
256
257
258if __name__ == "__main__":
259 main()
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298