ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
cbz_compile.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"""Compile a CBZ (a ZIP of comic/manga page images) into a .rabook blob.
5
6CBZ is the manga interchange format: page order is the filename sort of the
7archive entries, and there is no OPF/spine/TOC/CSS -- the images ARE the book.
8This tool maps that onto the exact RABOOK1 content model the EPUB compiler
9emits for fixed-layout books (issue #196 calls that shape "CBZ-in-EPUB-
10clothing"): one spine chapter per page whose DOM is a single full-page
11``<img>`` element referencing one manifest image, so the device render path
12(``reflow``'s existing image handling) is shared with EPUB books instead of
13growing a second reader. Nothing here parses XHTML; the chapters are
14synthesized.
15
16Behavior (issue #212):
17
18* Pages are the archive's image entries (png/jpg/jpeg/webp/bmp via Pillow),
19 natural-sorted so ``p2`` orders before ``p10``. Directories, hidden files,
20 ``__MACOSX/`` resource forks, and non-image entries are ignored.
21* Raster pages are transcoded to panel-native 4bpp grayscale at SOURCE
22 resolution -- no downscale by default (issue #210; manga text must survive
23 the zoom loupe). ``--max-edge`` remains the opt-in clamp.
24* The first page doubles as the shelf cover (``cover_image_index``).
25* ``--rtl`` stamps the right-to-left reading-order flag
26 (``k_book_flag_rtl`` in apps/shared_libs/book/inc/book.h) into the header;
27 mirrored page-turn zones on device are issue #211.
28* The flat blob is wrapped in the chunked RBKC container, same as EPUB books,
29 so full-resolution volumes ride the demand-paged open.
30
31The heavy lifting (gray4 quantize + packing, string interning, table
32serialization, RBKC wrapping) is imported from ``epub_compile.py`` so the two
33compilers can never drift apart on the wire format.
34
35Usage:
36 cbz_compile.py INPUT.cbz OUTPUT.rabook [--rtl] [--title T] [--author A]
37 [--max-edge N] [--chunk-bytes N] [--stats]
38 cbz_compile.py --selftest
39
40``--selftest`` builds a tiny synthetic CBZ in memory (scrambled entry order,
41natural-sort-hostile names, junk entries), compiles it both LTR and RTL, then
42re-parses the emitted container byte-for-byte and checks page order, the RTL
43flag, the cover index, preserved dimensions, and the chapter DOM shape.
44
45@copyright Copyright (c) 2026 Brighton Sikarskie
46SPDX-License-Identifier: MIT
47"""
48
49from __future__ import annotations
50
51import argparse
52import io
53import posixpath
54import re
55import struct
56import sys
57import zlib
58from collections.abc import Callable
59from pathlib import Path
60from typing import IO, NoReturn
61from zipfile import ZipFile
62
63from PIL import Image
64from rabook_blob import BlobBuilder
65from rabook_format import (
66 CONTAINER_CHUNK_BYTES,
67 FLAG_RTL,
68 IMG_GRAY4,
69 MAGIC,
70 wrap_container,
71)
72
73# Page entries accepted from the archive (decoded via Pillow).
74IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".bmp"})
75
76# --- wire-layout mirrors used only by the selftest parser ---------------------
77# (kept in lockstep with apps/shared_libs/book/inc/book.h and BlobBuilder.serialize)
78HEADER_FMT = "<8s23I"
79HEADER_BYTES = 100
80CHAPTER_FMT = "<3I"
81CHAPTER_BYTES = 12
82NODE_FMT = "<BBHIIIII"
83NODE_BYTES = 24
84ATTR_FMT = "<2I"
85ATTR_BYTES = 8
86IMAGE_FMT = "<IHHBBHIII"
87IMAGE_BYTES = 24
88CONT_HDR_FMT = "<IQII"
89CONT_MAGIC = b"RBKC"
90CONT_MAGIC_LEN = 4
91CONT_TABLE_OFF = 24
92CONT_ENTRY_BYTES = 8
93
94
95def natural_key(name: str) -> list[int | str]:
96 """Sort key ordering embedded integers numerically (page2 < page10).
97
98 ``re.split`` with a capturing digit group always alternates non-digit and
99 digit tokens starting with a (possibly empty) non-digit token, so compared
100 keys never pit an int against a str at the same position.
101 """
102 return [int(tok) if tok.isdigit() else tok.lower() for tok in re.split(r"(\d+)", name)]
103
104
105def is_page_entry(name: str) -> bool:
106 """Whether an archive entry is a readable page image.
107
108 Rejects directory entries, hidden/AppleDouble files (``.foo``, ``._foo``),
109 anything under ``__MACOSX/``, and non-image extensions.
110 """
111 if name.endswith("/"):
112 return False
113 if "__MACOSX" in name.split("/"):
114 return False
115 base = posixpath.basename(name)
116 if not base or base.startswith("."):
117 return False
118 return posixpath.splitext(base)[1].lower() in IMAGE_EXTS
119
120
121def page_dom(name: str) -> dict[str, object]:
122 """One page's synthetic chapter DOM: ``<body><img src=NAME alt=BASE/></body>``.
123
124 Mirrors the fixed-layout EPUB shape so ``reflow``'s existing image path
125 lays the page out; ``src`` equals the manifest image id exactly, so href
126 resolution is an exact string match.
127 """
128 img = {
129 "tag": "img",
130 "attrs": [("src", name), ("alt", posixpath.basename(name))],
131 "children": [],
132 }
133 return {"tag": "body", "attrs": [], "children": [img]}
134
135
136def compile_cbz(
137 src: str | Path | IO[bytes],
138 *,
139 title: str = "",
140 author: str = "",
141 rtl: bool = False,
142 max_image_edge: int = 0,
143) -> tuple[bytes, dict[str, str], BlobBuilder]:
144 """Compile a CBZ (path or file object) to a flat RABOOK1 blob.
145
146 Returns ``(blob, meta, builder)`` like ``epub_compile.compile_epub``.
147 A page that fails to decode aborts the compile: unlike an EPUB's
148 decorative figures, CBZ pages ARE the content, so a hole is corrupt input.
149 """
150 bb = BlobBuilder()
151 if rtl:
152 bb.flags |= FLAG_RTL
153 with ZipFile(src) as zf:
154 pages = sorted((n for n in zf.namelist() if is_page_entry(n)), key=natural_key)
155 if not pages:
156 msg = "no image pages found in the CBZ"
157 raise ValueError(msg)
158 for num, name in enumerate(pages, start=1):
159 try:
160 idx = bb.add_raster_image(name, zf.read(name), max_image_edge)
161 except (OSError, ValueError) as exc:
162 msg = f"page {name!r} failed to decode"
163 raise ValueError(msg) from exc
164 if num == 1:
165 bb.cover_index = idx
166 bb.add_chapter(page_dom(name), f"Page {num}", name)
167 stem = Path(src).stem if isinstance(src, (str, Path)) else ""
168 meta = {
169 "title": title or stem,
170 "author": author,
171 "language": "",
172 "identifier": stem or (title or ""),
173 }
174 return bb.serialize(meta), meta, bb
175
176
177# --- selftest ------------------------------------------------------------------
178def _fail(message: str) -> NoReturn:
179 """Print ``message`` to stderr and exit non-zero (checkable, no traceback)."""
180 sys.stderr.write(f"cbz_compile.py: {message}\n")
181 raise SystemExit(1)
182
183
184def _require(cond: bool, what: str) -> None:
185 """Exit non-zero unless ``cond`` holds (a checkable assert for the selftest)."""
186 if not cond:
187 _fail(f"selftest FAILED: {what}")
188
189
190def unwrap_container(data: bytes) -> bytes:
191 """Inverse of ``wrap_container``: inflate an RBKC file back to the flat blob."""
192 if data[:CONT_MAGIC_LEN] != CONT_MAGIC:
193 msg = "bad container magic"
194 raise ValueError(msg)
195 chunk_bytes, total, count, reserved = struct.unpack_from(CONT_HDR_FMT, data, CONT_MAGIC_LEN)
196 if reserved != 0 or chunk_bytes <= 0:
197 msg = "bad container header"
198 raise ValueError(msg)
199 offs = struct.unpack_from(f"<{count + 1}Q", data, CONT_TABLE_OFF)
200 payload = data[CONT_TABLE_OFF + ((count + 1) * CONT_ENTRY_BYTES) :]
201 blob = b"".join(zlib.decompress(payload[offs[i] : offs[i + 1]]) for i in range(count))
202 if len(blob) != total:
203 msg = "container inflated to the wrong length"
204 raise ValueError(msg)
205 return blob
206
207
208def _parse_blob(
209 blob: bytes,
210) -> tuple[dict[str, int], list[tuple], list[tuple], Callable[[int], str]]:
211 """Decode the header + chapter/node/attr/image tables for the selftest."""
212 fields = struct.unpack_from(HEADER_FMT, blob, 0)
213 hdr = {
214 "magic": fields[0],
215 "format_version": fields[1],
216 "total_size": fields[2],
217 "flags": fields[3],
218 "title_off": fields[4],
219 "cover_image_index": fields[8],
220 "chapter_count": fields[9],
221 "chapter_off": fields[10],
222 "node_off": fields[12],
223 "attr_off": fields[14],
224 "image_count": fields[17],
225 "image_off": fields[18],
226 "string_off": fields[19],
227 "crc32": fields[23],
228 }
229
230 def string_at(off: int) -> str:
231 start = hdr["string_off"] + off
232 return blob[start : blob.index(b"\x00", start)].decode("utf-8")
233
234 chapters = [
235 struct.unpack_from(CHAPTER_FMT, blob, hdr["chapter_off"] + (i * CHAPTER_BYTES))
236 for i in range(hdr["chapter_count"])
237 ]
238 images = [
239 struct.unpack_from(IMAGE_FMT, blob, hdr["image_off"] + (i * IMAGE_BYTES))
240 for i in range(hdr["image_count"])
241 ]
242 return hdr, chapters, images, string_at
243
244
245def _node_at(blob: bytes, node_off: int, idx: int) -> tuple:
246 """Unpack DOM node ``idx`` from the node table."""
247 return struct.unpack_from(NODE_FMT, blob, node_off + (idx * NODE_BYTES))
248
249
250def _selftest_build_cbz() -> tuple[io.BytesIO, dict[str, tuple[int, int]]]:
251 """Build the in-memory fixture CBZ: 3 pages + entries the filter must drop."""
252 dims = {"vol1/p1.png": (7, 5), "vol1/p2.jpeg": (4, 9), "vol1/p10.png": (3, 3)}
253 buf = io.BytesIO()
254 with ZipFile(buf, "w") as zf:
255 for name in ("vol1/p10.png", "vol1/p2.jpeg", "vol1/p1.png"): # scrambled order
256 w, h = dims[name]
257 img = Image.new("L", (w, h))
258 img.putdata([(x * 37 + y * 11) % 256 for y in range(h) for x in range(w)])
259 out = io.BytesIO()
260 img.save(out, "PNG" if name.endswith(".png") else "JPEG")
261 zf.writestr(name, out.getvalue())
262 zf.writestr("vol1/notes.txt", b"not a page")
263 zf.writestr("__MACOSX/vol1/._p1.png", b"resource fork")
264 zf.writestr("vol1/._p2.jpeg", b"appledouble")
265 zf.writestr("vol1/art/", b"")
266 buf.seek(0)
267 return buf, dims
268
269
270def _selftest_check_pages(blob: bytes, dims: dict[str, tuple[int, int]]) -> None:
271 """Check page order, cover, dims, packing, and the per-page DOM shape."""
272 hdr, chapters, images, string_at = _parse_blob(blob)
273 want = ["vol1/p1.png", "vol1/p2.jpeg", "vol1/p10.png"]
274 _require(hdr["magic"] == MAGIC, "blob magic")
275 _require(hdr["total_size"] == len(blob), "blob total_size")
276 _require(zlib.crc32(blob[HEADER_BYTES:]) == hdr["crc32"], "body crc32")
277 _require(hdr["chapter_count"] == len(want), "one chapter per page")
278 _require(hdr["image_count"] == len(want), "one image per page")
279 _require(hdr["cover_image_index"] == 0, "first page is the cover")
280 hrefs = [string_at(href_off) for (_t, href_off, _r) in chapters]
281 _require(hrefs == want, f"natural page order (got {hrefs})")
282 for i, name in enumerate(want):
283 id_off, w, h, fmt, _r1, _r2, _doff, dsize, rsize = images[i]
284 _require(string_at(id_off) == name, f"image id [{i}]")
285 _require((w, h) == dims[name], f"source dims preserved [{name}]")
286 _require(fmt == IMG_GRAY4, f"gray4 transcode [{name}]")
287 _require(dsize == rsize == ((w * h) + 1) // 2, f"4bpp packed size [{name}]")
288 # DOM shape: chapter root is <body> whose only child is a full-page
289 # <img> whose src equals the manifest image id exactly.
290 root = _node_at(blob, hdr["node_off"], chapters[i][2])
291 _require(string_at(root[3]) == "body", f"chapter root is body [{i}]")
292 child = _node_at(blob, hdr["node_off"], root[6])
293 _require(string_at(child[3]) == "img", f"page child is img [{i}]")
294 attrs = [
295 struct.unpack_from(ATTR_FMT, blob, hdr["attr_off"] + ((child[5] + k) * ATTR_BYTES))
296 for k in range(child[2])
297 ]
298 pairs = {string_at(n): string_at(v) for (n, v) in attrs}
299 _require(pairs.get("src") == name, f"img src == image id [{i}]")
300
301
302def _selftest() -> int:
303 """Round-trip self-check; exits non-zero on the first failed check."""
304 fixture, dims = _selftest_build_cbz()
305 blob, meta, bb = compile_cbz(fixture, title="SelfTest Manga", rtl=True)
306 container = wrap_container(blob)
307 inflated = unwrap_container(container)
308 _require(inflated == blob, "container round-trip")
309 hdr, _c, _i, string_at = _parse_blob(inflated)
310 _require(hdr["flags"] == FLAG_RTL, "--rtl sets exactly the RTL flag bit")
311 _require(string_at(hdr["title_off"]) == "SelfTest Manga", "title interned")
312 _selftest_check_pages(inflated, dims)
313 _require(bb.cover_index == 0, "builder cover index")
314 _require(meta["title"] == "SelfTest Manga", "meta title")
315
316 fixture.seek(0)
317 blob_ltr, _meta2, _bb2 = compile_cbz(fixture, title="SelfTest Manga", rtl=False)
318 hdr_ltr, _c2, _i2, _s2 = _parse_blob(blob_ltr)
319 _require(hdr_ltr["flags"] == 0, "default compile leaves flags 0")
320 _require(blob_ltr[HEADER_BYTES:] == blob[HEADER_BYTES:], "rtl flips only the header flag")
321
322 sys.stdout.write(
323 "cbz_compile.py selftest: PASS -- page order, RTL flag, cover, dims, DOM shape OK.\n"
324 )
325 return 0
326
327
328def main() -> int:
329 """Parse the command line and write the RBKC-wrapped .rabook to disk.
330
331 `--selftest` short-circuits everything else and ignores the positional
332 arguments. Otherwise both are required.
333
334 Two flags have consequences past this process. `--rtl` sets a header bit
335 the device reads as manga page order, and it is the ONLY difference in the
336 output -- the page rasters are byte-identical either way, which the selftest
337 asserts. `--chunk-bytes` must equal the reader's `ra8_vmem` frame size; a
338 mismatch produces a container the firmware cannot demand-page.
339
340 Returns:
341 0 on success; the selftest's own status when `--selftest` is given.
342 """
343 ap = argparse.ArgumentParser(description="Compile a CBZ into a .rabook blob.")
344 ap.add_argument("input", nargs="?", help="source .cbz (a ZIP of page images)")
345 ap.add_argument("output", nargs="?", help="destination .rabook")
346 ap.add_argument("--rtl", action="store_true", help="right-to-left reading order (manga)")
347 ap.add_argument("--title", default="", help="book title (default: input filename stem)")
348 ap.add_argument("--author", default="", help="book author (default: empty)")
349 ap.add_argument(
350 "--max-edge",
351 type=int,
352 default=0,
353 help="opt-in: downscale page long edge to at most this many pixels "
354 "(default 0 = preserve source resolution)",
355 )
356 ap.add_argument(
357 "--chunk-bytes",
358 type=int,
359 default=CONTAINER_CHUNK_BYTES,
360 help="inflated bytes per independently-compressed container chunk "
361 "(must equal the reader's ra8_vmem frame size)",
362 )
363 ap.add_argument("--stats", action="store_true", help="print size/structure stats")
364 ap.add_argument(
365 "--selftest", action="store_true", help="run the built-in round-trip self-check and exit"
366 )
367 args = ap.parse_args()
368
369 if args.selftest:
370 return _selftest()
371 if not args.input or not args.output:
372 ap.error("input and output are required unless --selftest")
373
374 blob, meta, bb = compile_cbz(
375 args.input,
376 title=args.title,
377 author=args.author,
378 rtl=args.rtl,
379 max_image_edge=args.max_edge,
380 )
381 container = wrap_container(blob, args.chunk_bytes)
382 with Path(args.output).open("wb") as fh:
383 fh.write(container)
384
385 if args.stats:
386 src_size = Path(args.input).stat().st_size
387 out = len(container)
388 direction = "rtl" if args.rtl else "ltr"
389 print(f"{meta['title']} -- {meta['author'] or '(no author)'} [{direction}]")
390 print(f" pages={len(bb.chapters)} images={len(bb.images)} nodes={len(bb.nodes)}")
391 print(
392 f" cbz={src_size // 1024} KB -> rabook={out // 1024} KB "
393 f"({100 * out // max(src_size, 1)}%); inflated={len(blob) // 1024} KB"
394 )
395 return 0
396
397
398if __name__ == "__main__":
399 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298