4"""Bake compiled .rabook files into a C header of MRAM-resident byte arrays.
6Each blob is embedded as the chunked RBKC container tools/epub_compile emits --
7still compressed. The firmware inflates one into SDRAM only when
8`book_open()` is called, which is what lets several full books (covers and
9inline images included) sit alongside the firmware in MRAM at all.
11Cover thumbnails are the reason this tool does more than embed bytes. Each
12book's cover is pre-decoded here into a gray8 thumbnail, matching the firmware's
13`sh_image_decode_gray8` exactly, so boot can draw the whole shelf without
14inflating a single book. If the two decoders ever diverge, the shelf renders
15differently before and after a book is opened.
17The emitted thumbnail bytes are architecture-dependent to regenerate, so
18re-baking moves the framebuffer golden -- see scripts/builders/books.sh, and re-pin
19the golden in the same change.
22 bake_library.py <out.h> <rabook>|<title>|<author> [<rabook>|<title>|<author> ...]
25from __future__
import annotations
30from pathlib
import Path
37COVER_ABSENT = 0xFFFFFFFF
44def unwrap_container(data: bytes) -> bytes:
45 """Inflate a chunked RBKC .rabook container back to its flat blob.
47 Keep in sync with book_container_t in apps/shared_libs/book/inc/book.h:
48 "RBKC" + <I chunk_bytes + <Q total + <I count + <I reserved(0), a
49 (count + 1)-entry <Q offset table, then count concatenated zlib streams.
51 if data[:4] != b
"RBKC":
52 msg =
"not an RBKC container"
54 chunk_bytes, total, count, reserved = struct.unpack_from(
"<IQII", data, 4)
55 if reserved != 0
or chunk_bytes == 0
or count != (total + chunk_bytes - 1) // chunk_bytes:
56 msg =
"malformed RBKC header"
58 offsets = struct.unpack_from(f
"<{count + 1}Q", data, 24)
59 payload = 24 + 8 * (count + 1)
61 zlib.decompress(data[payload + offsets[i] : payload + offsets[i + 1]])
for i
in range(count)
63 if len(blob) != total:
64 msg =
"RBKC inflated size mismatch"
69def decode_cover_thumb(blob: bytes) -> tuple[bytes, int, int] |
None:
70 """Decode the book cover into a (gray8 bytes, w, h) thumbnail, or None.
72 Mirrors sh_image_decode_gray8 / sh_fit_box / sh_gray4_at byte-for-byte so the
73 baked thumbnail is identical to a runtime decode (keeps the render hash stable).
76 inflated = unwrap_container(blob)
80 def u32(o: int) -> int:
81 return struct.unpack_from(
"<I", inflated, o)[0]
83 def u16(o: int) -> int:
84 return struct.unpack_from(
"<H", inflated, o)[0]
87 if cover == COVER_ABSENT:
89 img = u32(76) + (cover * 24)
90 src_w, src_h, fmt, data_off = u16(img + 4), u16(img + 6), inflated[img + 8], u32(img + 12)
91 if fmt != 0
or src_w == 0
or src_h == 0:
93 data = inflated[u32(88) + data_off :]
95 fit_h = (THUMB_W * src_h) // src_w
98 fit_w = (THUMB_H * src_w) // src_h
100 fit_h = max(1, fit_h)
101 out = bytearray(fit_w * fit_h)
102 for dy
in range(fit_h):
103 sy = (dy * src_h) // fit_h
105 for dx
in range(fit_w):
106 flat = row + ((dx * src_w) // fit_w)
107 byte = data[flat >> 1]
108 nib = (byte & 0x0F)
if (flat & 1)
else (byte >> 4)
109 out[(dy * fit_w) + dx] = (nib << 4) | nib
110 return bytes(out), fit_w, fit_h
113def emit_array(name: str, data: bytes) -> str:
114 """Render bytes as a 4-byte-aligned `static const uint8_t` C array.
116 The `alignas(4)` is not cosmetic: the firmware casts into these blobs to
117 read the container's 32-bit header fields, and an unaligned load faults on
118 the target. Every byte is emitted with a `U` suffix and lines are wrapped
119 near ARRAY_LINE_LIMIT characters -- a soft limit, checked after appending,
120 so a line may overrun by one element's width.
122 Output is deterministic for identical input, which is what lets the
123 generated header be committed and diffed.
126 name: C identifier for the array; used unquoted and unvalidated.
127 data: Bytes to emit. Empty input still yields a well-formed (if
128 zero-length, and therefore not valid C) array.
131 The declaration as a newline-joined string, no trailing newline.
133 out = [f
"alignas(4) static const uint8_t {name}[{len(data)}U] = {{"]
137 if len(line) >= ARRAY_LINE_LIMIT:
143 return "\n".join(out)
146def _load_books(specs: list[str]) -> list[tuple[bytes, str, str, tuple |
None]]:
147 """Read every `<path>|<title>|<author>` spec into a blob + metadata tuple.
149 The `|` separator is unescaped, so a title containing a pipe splits into
150 the wrong fields and raises rather than silently baking a mangled shelf.
154 path, title, author = spec.split(
"|")
155 with Path(path).open(
"rb")
as f:
157 books.append((blob, title, author, decode_cover_thumb(blob)))
161def _header_preamble() -> list[str]:
162 """The generated-file banner, include guard and includes."""
165 " * @file library.h",
166 " * @generated by tools/epub_compile/src/bake_library.py -- do not edit by hand.",
167 " * @brief Baked full .rabook blobs + pre-decoded cover thumbnails (generated).",
168 " * @details Each entry is the chunked RBKC container (book_open inflates it",
169 " * on demand) plus a gray8 cover thumbnail the shelf blits without any",
170 " * boot-time inflation. Regenerate with tools/epub_compile/src/",
171 " * bake_library.py (the",
172 " * thumbnail bytes are architecture-dependent to regenerate; see",
173 " * scripts/builders/books.sh -- re-pin the fb golden when re-baking).",
175 " * @copyright Copyright (c) 2026 Brighton Sikarskie",
176 " * SPDX-License-Identifier: MIT",
177 " * @since Version 0.1.0",
181 "#include <stdint.h>",
183 "// NOLINTBEGIN(readability-magic-numbers)",
187def _emit_book_arrays(
188 books: list[tuple[bytes, str, str, tuple |
None]],
189) -> tuple[list[str], list[tuple]]:
190 """Emit one byte array per blob (and per thumbnail), in shelf order.
192 Returns ``(lines, names)``; ``names`` feeds the table below and carries the
193 symbol names just emitted, so the two cannot disagree about what exists.
195 parts: list[str] = []
196 names: list[tuple] = []
197 for i, (data, title, author, thumb)
in enumerate(books):
198 blob_name = f
"k_lib_blob{i:02d}"
199 parts.append(f
'/** @brief "{title}" by {author} -- {len(data)} bytes. */')
200 parts.append(emit_array(blob_name, data))
201 thumb_name, tw, th =
"nullptr", 0, 0
202 if thumb
is not None:
203 tbytes, tw, th = thumb
204 thumb_name = f
"k_lib_thumb{i:02d}"
205 parts.append(f
"/** @brief Cover thumbnail for {blob_name} ({tw}x{th} gray8). */")
206 parts.append(emit_array(thumb_name, tbytes))
207 names.append((blob_name, len(data), title, author, thumb_name, tw, th))
212def _emit_library_table(count: int, names: list[tuple]) -> list[str]:
213 """Emit the library_book_t struct, the count enum and the shelf table.
215 Command-line order is shelf order, and this preserves it.
218 "/** @brief One openable baked book: compressed blob + cover thumbnail + metadata. */",
220 " const uint8_t* blob; /**< RBKC container start. */",
221 " uint32_t len; /**< Container length in bytes. */",
222 " const uint8_t* thumb; /**< gray8 cover thumbnail, or NULL. */",
223 " uint16_t thumb_w; /**< Thumbnail width in pixels. */",
224 " uint16_t thumb_h; /**< Thumbnail height in pixels. */",
225 " const char* title; /**< Display title. */",
226 " const char* author; /**< Display author. */",
229 "typedef enum : uint16_t {",
230 f
" k_library_count = {count}U, /**< Number of entries in k_library. */",
231 "} library_count_t;",
233 "static const library_book_t k_library[k_library_count] = {",
235 for blob_name, n, title, author, thumb_name, tw, th
in names:
236 t = title.replace(
'"',
'\\"')
237 a = author.replace(
'"',
'\\"')
238 parts.append(f
' {{ {blob_name}, {n}U, {thumb_name}, {tw}U, {th}U, "{t}", "{a}" }},')
239 parts.extend([
"};",
"// NOLINTEND(readability-magic-numbers)",
""])
243def main(argv: list[str]) -> int:
244 """Bake every `<path>|<title>|<author>` spec into the output header.
246 Title and author are passed on the command line rather than read from the
247 blob because the baked shelf shows them before any book is inflated -- the
248 metadata inside the container is not reachable without inflating it.
250 The `|` separator is unescaped, so a title containing a pipe splits into the
251 wrong fields and raises. Books appear in the header in command-line order,
252 and that order is the shelf order.
255 argv: Full argument vector INCLUDING the program name at index 0.
258 0 on success, 2 on a usage error.
261 ValueError: A spec did not split into exactly three fields.
262 OSError: A .rabook path could not be read, or the header not written.
264 if len(argv) < MIN_ARGV_COUNT:
265 sys.stderr.write(
"usage: bake_library.py <out.h> <rabook>|<title>|<author> ...\n")
268 books = _load_books(argv[2:])
269 arrays, names = _emit_book_arrays(books)
270 parts = [*_header_preamble(), *arrays, *_emit_library_table(len(books), names)]
271 with Path(out_path).open(
"w")
as f:
272 f.write(
"\n".join(parts))
274 f
"bake_library: wrote {out_path} ({len(books)} books, "
275 f
"{sum(len(d) for d, _, _, _ in books)} blob bytes + thumbnails)\n"
280if __name__ ==
"__main__":
281 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.