3"""BlobBuilder: everything the compiler has parsed, laid out as one flat blob.
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.
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).
14@copyright Copyright (c) 2026 Brighton Sikarskie
15SPDX-License-Identifier: MIT
18from __future__
import annotations
25from epub_dom
import DomNode, StringPool
26from gray4_kernel
import gray4_downscale, gray4_encode, gray4_output_dims, stb_compute_y
28from rabook_format
import (
42_PIL_MODES_16 = (
"I",
"I;16",
"I;16L",
"I;16B",
"I;16N")
45_PIL_MODES_GREY8 = (
"L",
"LA",
"La")
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.
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
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
68 im: An open ``PIL.Image`` in any mode.
71 ``(width, height, gray8_bytes)``, row-major, one byte per pixel.
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()
81 stb_compute_y(rgb[i], rgb[i + 1], rgb[i + 2])
for i
in range(0, len(rgb), _RGB_STRIDE)
83 return (width, height, gray)
88Image.MAX_IMAGE_PIXELS =
None
98 """Accumulates the node/attr/chapter/image tables and emits the blob."""
100 def __init__(self) -> None:
101 """Create empty tables with no cover and no feature flags set.
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.
109 self.sp = StringPool()
113 self.stylesheets = []
115 self.cover_index = NIL
119 def add_text(self, text: str) -> int:
120 """Append a text node and return its index in the node table.
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.
128 text: Character data; interned, so repeated runs cost one pool slot.
131 Index of the new node in `self.nodes`.
133 idx = len(self.nodes)
138 "text_off": self.sp.intern(text),
147 def add_element(self, elem: DomNode) -> int:
148 """Flatten a DomBuilder subtree into the node/attr tables, depth first.
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.
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
162 elem: DomBuilder node dict with "tag", "attrs" and "children".
165 Index of the element's own node record.
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)))
174 idx = len(self.nodes)
177 "kind": NODE_ELEMENT,
178 "name_off": self.sp.intern(elem[
"tag"]),
180 "attr_count": acount,
181 "first_attr": first_attr,
187 for child
in elem[
"children"]:
189 kids.append(self.add_text(child[
"text"]))
191 kids.append(self.add_element(child))
193 self.nodes[idx][
"first_child"] = kids[0]
194 for cur, nxt
in itertools.pairwise(kids):
195 self.nodes[cur][
"next_sibling"] = nxt
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.
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).
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.
213 root_idx = self.add_element(root_elem)
214 self.chapters.append((self.sp.intern(title), self.sp.intern(href), root_idx))
217 def add_stylesheet(self, css_text: str) ->
None:
218 """Store a stylesheet verbatim, scoped to the whole book.
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
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.
231 css_text: Stylesheet source, already decoded to text.
233 self.stylesheets.append((self.sp.intern(css_text), NIL))
235 def add_raster_image(
239 max_image_edge: int = MAX_IMAGE_EDGE,
240 pixel_format: int = PIXFMT_GRAY4,
242 """Transcode an encoded raster to panel-native grayscale and store it.
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
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).
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
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
279 Index of the image in `self.images`, for `cover_index` or a DOM ref.
282 OSError: `data` is not a decodable image.
283 ValueError: Pillow rejected the decoded image, or `pixel_format` is
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)))
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)
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
302 def add_svg_image(self, href: str, data: bytes) -> int:
303 """Store SVG source unchanged, as vector data the device rasterizes.
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.
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.
316 href: Manifest href, interned as the image's lookup id.
317 data: Raw SVG bytes, stored verbatim.
320 Index of the image in `self.images`.
325 self.images.append((self.sp.intern(href), 0, 0, IMG_SVG, data, len(data), PIXFMT_GRAY4))
326 return len(self.images) - 1
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)
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
350 def _pack_images(self) -> tuple[bytes, bytes]:
351 """Pack the image table and its payload pool.
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.
359 for id_off, w, h, fmt, data, raw, pixfmt
in self.images:
365 struct.pack(
"<IHHBBHIII", id_off, w, h, fmt, pixfmt, 0, data_off, len(data), raw)
367 return b
"".join(records), pool
369 def serialize(self, meta: dict[str, str]) -> bytes:
370 """Pack every table into the final RABOOK1 blob and return its bytes.
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.
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.
385 The result is the inflated blob. `wrap_container` applies the chunked
386 RBKC compression that actually goes on disk.
389 meta: Dict with "title", "author", "language" and "identifier";
390 all four must be present, and "" is the valid "unknown" value.
393 Complete little-endian blob: 100-byte header followed by the body.
395 chap, node, attr, style = self._pack_tables()
396 image, pool = self._pack_images()
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)
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)
417 body = chap + node + attr + style + image + strings + bytes(pool)
418 crc = zlib.crc32(body) & 0xFFFFFFFF
420 header = struct.pack(
437 len(self.stylesheets),
447 assert len(header) == header_size