ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
rabook_blob.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""BlobBuilder: everything the compiler has parsed, laid out as one flat blob.
4
5The device executes this in place -- it never unzips, never parses XHTML, and
6never allocates to walk a chapter. So the writer's whole job is to turn a tree
7of Python objects into arrays of fixed-width records joined by indices, in the
8exact layout apps/shared_libs/book/inc/book.h reads back.
9
10Raster images are the one thing whose FORM changes: the panel is physically
114bpp grayscale, so images are transcoded here. Resolution is preserved by
12default; downscaling is opt-in via --max-edge (issue #210).
13
14@copyright Copyright (c) 2026 Brighton Sikarskie
15SPDX-License-Identifier: MIT
16"""
17
18from __future__ import annotations
19
20import io
21import itertools
22import struct
23import zlib
24
25from epub_dom import DomNode, StringPool
26from gray4_kernel import gray4_downscale, gray4_encode, gray4_output_dims, stb_compute_y
27from PIL import Image
28from rabook_format import (
29 FORMAT_VERSION,
30 IMG_GRAY4,
31 IMG_SVG,
32 MAGIC,
33 NIL,
34 NODE_ELEMENT,
35 NODE_TEXT,
36 PIXFMT_GRAY4,
37 PIXFMT_GRAY8,
38)
39
40# PIL modes stb_image decodes as one 16-bit gray channel; it reduces them to
41# 8-bit by keeping the high byte (v >> 8), matching stbi__convert_16_to_8.
42_PIL_MODES_16 = ("I", "I;16", "I;16L", "I;16B", "I;16N")
43# PIL modes that are already an 8-bit gray (optionally with alpha); stb keeps the
44# grey channel when reducing 1/2 channels to 1, so convert("L") matches.
45_PIL_MODES_GREY8 = ("L", "LA", "La")
46_STB_16_TO_8_SHIFT = 8 # stbi__convert_16_to_8: high byte is the 16->8 reduction.
47_BYTE_MASK = 0xFF # (uint8_t) mask after the 16->8 reduction.
48_RGB_STRIDE = 3 # Bytes per pixel in a PIL "RGB" buffer.
49
50
51def _stb_gray8(im: Image.Image) -> tuple[int, int, bytes]:
52 """Decode a PIL image to 8-bit gray exactly as the device stb_image would.
53
54 The on-device pipeline calls ``stbi_load_from_memory(..., req_comp=1)``, so
55 every raster is reduced to a single 8-bit gray channel before the gray4/gray8
56 encode. Mirroring that reduction here is what makes the host emit the same
57 bytes (issue #337):
58
59 * a 16-bit single channel keeps its high byte (``v >> 8``), like
60 ``stbi__convert_16_to_8``;
61 * an 8-bit gray (or gray+alpha) channel passes through unchanged, like stb
62 keeping ``src[0]`` when it folds 1/2 channels down to 1;
63 * anything with colour is expanded to RGB and folded with
64 :func:`gray4_kernel.stb_compute_y`, like stb's compute_y on a 3/4-channel
65 source.
66
67 Args:
68 im: An open ``PIL.Image`` in any mode.
69
70 Returns:
71 ``(width, height, gray8_bytes)``, row-major, one byte per pixel.
72 """
73 width, height = im.size
74 if im.mode in _PIL_MODES_16:
75 gray = bytes((v >> _STB_16_TO_8_SHIFT) & _BYTE_MASK for v in im.getdata())
76 return (width, height, gray)
77 if im.mode in _PIL_MODES_GREY8:
78 return (width, height, im.convert("L").tobytes())
79 rgb = im.convert("RGB").tobytes()
80 gray = bytes(
81 stb_compute_y(rgb[i], rgb[i + 1], rgb[i + 2]) for i in range(0, len(rgb), _RGB_STRIDE)
82 )
83 return (width, height, gray)
84
85
86# The SE source files are trusted local input; some cover scans exceed Pillow's
87# default decompression-bomb threshold, so lift it rather than warn.
88Image.MAX_IMAGE_PIXELS = None
89
90# noise defeats DEFLATE (the renderer can dither at draw time if desired).
91MAX_IMAGE_EDGE = 0
92# When true, drop all images (text-only). Yields a tiny inflated blob that fits
93# in MRAM as a baked fixture -- used by the on-device reader demo.
94SKIP_IMAGES = False
95
96
97class BlobBuilder:
98 """Accumulates the node/attr/chapter/image tables and emits the blob."""
99
100 def __init__(self) -> None:
101 """Create empty tables with no cover and no feature flags set.
102
103 `cover_index` starts at NIL rather than 0 because 0 is a valid image
104 index; a book whose cover is never resolved must serialize as "no
105 cover", not as "the first image". `flags` starts clear because the
106 firmware validator rejects any bit outside the mask it knows, so bits
107 are only ever set deliberately by a caller.
108 """
109 self.sp = StringPool()
110 self.nodes = [] # list of dicts (see _emit_node)
111 self.attrs = [] # list of (name_off, value_off)
112 self.chapters = [] # list of (title_off, href_off, root_node)
113 self.stylesheets = [] # list of (source_off, scope_chapter)
114 self.images = [] # list of (id_off, w, h, fmt, data, raw_size, pixel_format)
115 self.cover_index = NIL
116 self.flags = 0 # book_flag_t bits (e.g. FLAG_RTL); 0 for EPUB text
117
118 # -- DOM serialization ----------------------------------------------------
119 def add_text(self, text: str) -> int:
120 """Append a text node and return its index in the node table.
121
122 The node is emitted with no children, no attributes and no sibling
123 link: `add_element` owns stitching `next_sibling` once it knows the full
124 child list. Node indices are dense and assigned in creation order, and a
125 node's index is final once returned.
126
127 Args:
128 text: Character data; interned, so repeated runs cost one pool slot.
129
130 Returns:
131 Index of the new node in `self.nodes`.
132 """
133 idx = len(self.nodes)
134 self.nodes.append(
135 {
136 "kind": NODE_TEXT,
137 "name_off": 0,
138 "text_off": self.sp.intern(text),
139 "attr_count": 0,
140 "first_attr": NIL,
141 "first_child": NIL,
142 "next_sibling": NIL,
143 }
144 )
145 return idx
146
147 def add_element(self, elem: DomNode) -> int:
148 """Flatten a DomBuilder subtree into the node/attr tables, depth first.
149
150 The element's own record is reserved before its children are walked, so
151 a parent always has a lower index than its descendants -- the device
152 reader relies on that ordering to walk the tree without a stack.
153 Attributes of one element are written contiguously, which is why the
154 record stores only `first_attr` plus a count.
155
156 Recursion depth tracks markup nesting depth, so `compile_epub` raises
157 the interpreter recursion limit before calling this; a deeply nested
158 document would otherwise die with RecursionError rather than a
159 diagnostic.
160
161 Args:
162 elem: DomBuilder node dict with "tag", "attrs" and "children".
163
164 Returns:
165 Index of the element's own node record.
166 """
167 first_attr = NIL
168 acount = 0
169 if elem["attrs"]:
170 first_attr = len(self.attrs)
171 for name, value in elem["attrs"]:
172 self.attrs.append((self.sp.intern(name), self.sp.intern(value)))
173 acount += 1
174 idx = len(self.nodes)
175 self.nodes.append(
176 {
177 "kind": NODE_ELEMENT,
178 "name_off": self.sp.intern(elem["tag"]),
179 "text_off": 0,
180 "attr_count": acount,
181 "first_attr": first_attr,
182 "first_child": NIL,
183 "next_sibling": NIL,
184 }
185 )
186 kids = []
187 for child in elem["children"]:
188 if "text" in child:
189 kids.append(self.add_text(child["text"]))
190 else:
191 kids.append(self.add_element(child))
192 if kids:
193 self.nodes[idx]["first_child"] = kids[0]
194 for cur, nxt in itertools.pairwise(kids):
195 self.nodes[cur]["next_sibling"] = nxt
196 return idx
197
198 def add_chapter(self, root_elem: DomNode, title: str, href: str) -> None:
199 """Flatten one spine document and register it as the next chapter.
200
201 Chapters are stored in call order, and that order IS the reading order
202 the device presents -- callers must therefore iterate the OPF spine, not
203 the manifest (whose order is arbitrary).
204
205 Args:
206 root_elem: Subtree to flatten, normally the document's `<body>`.
207 title: TOC label, or "" when the TOC has no entry for this
208 document. An untitled chapter is legitimate and displays as
209 blank rather than being skipped.
210 href: Manifest-relative href, kept so the reader can resolve
211 intra-book links back to a chapter index.
212 """
213 root_idx = self.add_element(root_elem)
214 self.chapters.append((self.sp.intern(title), self.sp.intern(href), root_idx))
215
216 # -- assets ---------------------------------------------------------------
217 def add_stylesheet(self, css_text: str) -> None:
218 """Store a stylesheet verbatim, scoped to the whole book.
219
220 The source text is kept uninterpreted -- no minification, no parsing,
221 no dropping of rules the device renderer does not implement yet. That
222 is the fidelity rule: a rule the renderer learns later must not need the
223 book recompiled.
224
225 The scope field is written as NIL (book-wide) rather than a chapter
226 index. EPUB per-document `<link rel="stylesheet">` scoping is not
227 modelled; every sheet applies everywhere, so two documents with
228 conflicting sheets will both see both.
229
230 Args:
231 css_text: Stylesheet source, already decoded to text.
232 """
233 self.stylesheets.append((self.sp.intern(css_text), NIL))
234
235 def add_raster_image(
236 self,
237 href: str,
238 data: bytes,
239 max_image_edge: int = MAX_IMAGE_EDGE,
240 pixel_format: int = PIXFMT_GRAY4,
241 ) -> int:
242 """Transcode an encoded raster to panel-native grayscale and store it.
243
244 This is the one place content changes form. Colour is flattened to
245 luminance with the SAME integer luma the device's stb_image applies
246 (`gray4_kernel.stb_compute_y`, mirroring `stbi__compute_y`), so the host
247 tool and the on-device compiler emit byte-identical output for one source
248 (issue #337). The reduced gray8 is then either kept verbatim
249 (`pixel_format` PIXFMT_GRAY8, one byte per pixel) or quantized and packed
250 two-pixels-per-byte to 4bpp (PIXFMT_GRAY4, the default) via the exact
251 integer kernel `ra8_rabook_gray4_*` runs on device. Dithering stays OFF
252 (dithered noise survives neither the panel's own dynamics nor a later
253 downscale).
254
255 BOTH the default no-downscale path and the opt-in `--max-edge` downscale
256 path go through `gray4_kernel`, so there is exactly ONE luma and ONE
257 quantiser shared with the firmware -- no PIL palette-snap, no LANCZOS,
258 nothing that drifts across Pillow versions (issue #337 closes the
259 no-downscale gap that #213 left on the default path).
260
261 Pixels are stored uncompressed. The container DEFLATEs the whole blob
262 once, so per-image compression would only double-compress, and after the
263 single inflate-on-open these bytes are handed to the panel with no
264 decode step at all.
265
266 Args:
267 href: Manifest href, interned as the lookup id the DOM's `<img
268 src>` is matched against.
269 data: Encoded source bytes in any format Pillow opens.
270 max_image_edge: Opt-in long-edge cap in pixels (issue #210). 0 --
271 the default -- preserves source resolution, which is what makes
272 the manga zoom loupe possible.
273 pixel_format: Device-profile raster depth (issue #343). PIXFMT_GRAY4
274 (the default, 4bpp packed -- half the storage, and exactly right
275 for a 16-level e-ink panel) or PIXFMT_GRAY8 (8bpp, lossless for a
276 deeper panel).
277
278 Returns:
279 Index of the image in `self.images`, for `cover_index` or a DOM ref.
280
281 Raises:
282 OSError: `data` is not a decodable image.
283 ValueError: Pillow rejected the decoded image, or `pixel_format` is
284 not a known depth.
285 """
286 if pixel_format not in (PIXFMT_GRAY4, PIXFMT_GRAY8):
287 msg = f"unknown pixel_format {pixel_format}"
288 raise ValueError(msg)
289 w, h, gray = _stb_gray8(Image.open(io.BytesIO(data)))
290 if max_image_edge:
291 ow, oh = gray4_output_dims(w, h, max_image_edge)
292 if (ow, oh) != (w, h):
293 gray = gray4_downscale(gray, w, h, ow, oh)
294 w, h = ow, oh
295 # Stored raw; the whole blob is DEFLATE-wrapped as one stream on disk, so
296 # per-image compression would just double-compress for no gain. After the
297 # single inflate-on-open these bytes are panel-ready, no decode.
298 raw = gray if pixel_format == PIXFMT_GRAY8 else gray4_encode(gray, w, h)
299 self.images.append((self.sp.intern(href), w, h, IMG_GRAY4, raw, len(raw), pixel_format))
300 return len(self.images) - 1
301
302 def add_svg_image(self, href: str, data: bytes) -> int:
303 """Store SVG source unchanged, as vector data the device rasterizes.
304
305 Unlike `add_raster_image` this does not transcode: SVG stays vector so
306 it can be rendered at whatever size the reflowed layout gives it. The
307 bytes are not parsed or validated here either, so a malformed SVG
308 reaches the device and is rejected there rather than at compile time.
309
310 Width and height are written as 0 because an SVG has no fixed pixel
311 size; the renderer takes its dimensions from the document's own
312 viewBox. Consumers must branch on the IMG_SVG format tag before trusting
313 the dimension fields.
314
315 Args:
316 href: Manifest href, interned as the image's lookup id.
317 data: Raw SVG bytes, stored verbatim.
318
319 Returns:
320 Index of the image in `self.images`.
321 """
322 # pixel_format is unused for a vector entry; store PIXFMT_GRAY4 (0), the
323 # value every pre-field blob carried, so an SVG record stays all-zeros
324 # in that byte.
325 self.images.append((self.sp.intern(href), 0, 0, IMG_SVG, data, len(data), PIXFMT_GRAY4))
326 return len(self.images) - 1
327
328 # -- serialization --------------------------------------------------------
329 def _pack_tables(self) -> tuple[bytes, bytes, bytes, bytes]:
330 """Pack the four fixed-width record tables: chapters, nodes, attrs, styles."""
331 chap = b"".join(struct.pack("<3I", *c) for c in self.chapters)
332 node = b"".join(
333 struct.pack(
334 "<BBHIIIII",
335 n["kind"],
336 0,
337 n["attr_count"],
338 n["name_off"],
339 n["text_off"],
340 n["first_attr"],
341 n["first_child"],
342 n["next_sibling"],
343 )
344 for n in self.nodes
345 )
346 attr = b"".join(struct.pack("<2I", *a) for a in self.attrs)
347 style = b"".join(struct.pack("<2I", *s) for s in self.stylesheets)
348 return chap, node, attr, style
349
350 def _pack_images(self) -> tuple[bytes, bytes]:
351 """Pack the image table and its payload pool.
352
353 The pool is built first so every image record can carry a resolved
354 `data_off` into it; a record written before its payload is placed
355 would point at an offset that does not exist yet.
356 """
357 pool = bytearray()
358 records = []
359 for id_off, w, h, fmt, data, raw, pixfmt in self.images:
360 data_off = len(pool)
361 pool += data
362 # The second B is book_image_t.pixel_format (issue #343); the H
363 # after it is the still-reserved padding (0).
364 records.append(
365 struct.pack("<IHHBBHIII", id_off, w, h, fmt, pixfmt, 0, data_off, len(data), raw)
366 )
367 return b"".join(records), pool
368
369 def serialize(self, meta: dict[str, str]) -> bytes:
370 """Pack every table into the final RABOOK1 blob and return its bytes.
371
372 Sections are laid out in a fixed order -- chapters, nodes, attrs,
373 styles, images, string pool, image pool -- each section's offset
374 computed from the running total, and all of it described by a 100-byte
375 header. The image pool is built before the header so the image records
376 can carry resolved payload offsets. The trailing CRC32 covers the body
377 only, never the header, since the header holds the CRC field itself.
378
379 Order matters in one non-obvious way: the four metadata strings are
380 interned BEFORE `self.sp.buf` is snapshotted. They frequently appear
381 nowhere in the DOM, so interning them later -- during the header pack --
382 would append past the captured copy and leave the header pointing at
383 offsets that do not exist in the emitted blob.
384
385 The result is the inflated blob. `wrap_container` applies the chunked
386 RBKC compression that actually goes on disk.
387
388 Args:
389 meta: Dict with "title", "author", "language" and "identifier";
390 all four must be present, and "" is the valid "unknown" value.
391
392 Returns:
393 Complete little-endian blob: 100-byte header followed by the body.
394 """
395 chap, node, attr, style = self._pack_tables()
396 image, pool = self._pack_images()
397
398 # Intern metadata strings BEFORE snapshotting the pool. They may not
399 # appear anywhere in the DOM, so interning them later (during the header
400 # pack) would append past the captured `strings` and dangle the offsets.
401 title_off = self.sp.intern(meta["title"])
402 author_off = self.sp.intern(meta["author"])
403 language_off = self.sp.intern(meta["language"])
404 identifier_off = self.sp.intern(meta["identifier"])
405 strings = bytes(self.sp.buf)
406
407 header_size = 100
408 off_chap = header_size
409 off_node = off_chap + len(chap)
410 off_attr = off_node + len(node)
411 off_style = off_attr + len(attr)
412 off_image = off_style + len(style)
413 off_string = off_image + len(image)
414 off_pool = off_string + len(strings)
415 total = off_pool + len(pool)
416
417 body = chap + node + attr + style + image + strings + bytes(pool)
418 crc = zlib.crc32(body) & 0xFFFFFFFF
419
420 header = struct.pack(
421 "<8s23I",
422 MAGIC,
423 FORMAT_VERSION,
424 total,
425 self.flags,
426 title_off,
427 author_off,
428 language_off,
429 identifier_off,
430 self.cover_index,
431 len(self.chapters),
432 off_chap,
433 len(self.nodes),
434 off_node,
435 len(self.attrs),
436 off_attr,
437 len(self.stylesheets),
438 off_style,
439 len(self.images),
440 off_image,
441 off_string,
442 len(strings),
443 off_pool,
444 len(pool),
445 crc,
446 )
447 assert len(header) == header_size # noqa: S101 # structural invariant: mismatched header_size is a coding error
448 return header + body