4"""Compile a CBZ (a ZIP of comic/manga page images) into a .rabook blob.
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
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.
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.
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
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.
45@copyright Copyright (c) 2026 Brighton Sikarskie
46SPDX-License-Identifier: MIT
49from __future__
import annotations
58from collections.abc
import Callable
59from pathlib
import Path
60from typing
import IO, NoReturn
61from zipfile
import ZipFile
64from rabook_blob
import BlobBuilder
65from rabook_format
import (
66 CONTAINER_CHUNK_BYTES,
74IMAGE_EXTS = frozenset({
".png",
".jpg",
".jpeg",
".webp",
".bmp"})
86IMAGE_FMT =
"<IHHBBHIII"
95def natural_key(name: str) -> list[int | str]:
96 """Sort key ordering embedded integers numerically (page2 < page10).
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.
102 return [int(tok)
if tok.isdigit()
else tok.lower()
for tok
in re.split(
r"(\d+)", name)]
105def is_page_entry(name: str) -> bool:
106 """Whether an archive entry is a readable page image.
108 Rejects directory entries, hidden/AppleDouble files (``.foo``, ``._foo``),
109 anything under ``__MACOSX/``, and non-image extensions.
111 if name.endswith(
"/"):
113 if "__MACOSX" in name.split(
"/"):
115 base = posixpath.basename(name)
116 if not base
or base.startswith(
"."):
118 return posixpath.splitext(base)[1].lower()
in IMAGE_EXTS
121def page_dom(name: str) -> dict[str, object]:
122 """One page's synthetic chapter DOM: ``<body><img src=NAME alt=BASE/></body>``.
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.
130 "attrs": [(
"src", name), (
"alt", posixpath.basename(name))],
133 return {
"tag":
"body",
"attrs": [],
"children": [img]}
137 src: str | Path | IO[bytes],
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.
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.
153 with ZipFile(src)
as zf:
154 pages = sorted((n
for n
in zf.namelist()
if is_page_entry(n)), key=natural_key)
156 msg =
"no image pages found in the CBZ"
157 raise ValueError(msg)
158 for num, name
in enumerate(pages, start=1):
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
166 bb.add_chapter(page_dom(name), f
"Page {num}", name)
167 stem = Path(src).stem
if isinstance(src, (str, Path))
else ""
169 "title": title
or stem,
172 "identifier": stem
or (title
or ""),
174 return bb.serialize(meta), meta, bb
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")
184def _require(cond: bool, what: str) ->
None:
185 """Exit non-zero unless ``cond`` holds (a checkable assert for the selftest)."""
187 _fail(f
"selftest FAILED: {what}")
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)
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)
215 "format_version": fields[1],
216 "total_size": fields[2],
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],
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")
235 struct.unpack_from(CHAPTER_FMT, blob, hdr[
"chapter_off"] + (i * CHAPTER_BYTES))
236 for i
in range(hdr[
"chapter_count"])
239 struct.unpack_from(IMAGE_FMT, blob, hdr[
"image_off"] + (i * IMAGE_BYTES))
240 for i
in range(hdr[
"image_count"])
242 return hdr, chapters, images, string_at
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))
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)}
254 with ZipFile(buf,
"w")
as zf:
255 for name
in (
"vol1/p10.png",
"vol1/p2.jpeg",
"vol1/p1.png"):
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)])
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
"")
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}]")
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}]")
295 struct.unpack_from(ATTR_FMT, blob, hdr[
"attr_off"] + ((child[5] + k) * ATTR_BYTES))
296 for k
in range(child[2])
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}]")
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")
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")
323 "cbz_compile.py selftest: PASS -- page order, RTL flag, cover, dims, DOM shape OK.\n"
329 """Parse the command line and write the RBKC-wrapped .rabook to disk.
331 `--selftest` short-circuits everything else and ignores the positional
332 arguments. Otherwise both are required.
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.
341 0 on success; the selftest's own status when `--selftest` is given.
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)")
353 help=
"opt-in: downscale page long edge to at most this many pixels "
354 "(default 0 = preserve source resolution)",
359 default=CONTAINER_CHUNK_BYTES,
360 help=
"inflated bytes per independently-compressed container chunk "
361 "(must equal the reader's ra8_vmem frame size)",
363 ap.add_argument(
"--stats", action=
"store_true", help=
"print size/structure stats")
365 "--selftest", action=
"store_true", help=
"run the built-in round-trip self-check and exit"
367 args = ap.parse_args()
371 if not args.input
or not args.output:
372 ap.error(
"input and output are required unless --selftest")
374 blob, meta, bb = compile_cbz(
379 max_image_edge=args.max_edge,
381 container = wrap_container(blob, args.chunk_bytes)
382 with Path(args.output).open(
"wb")
as fh:
386 src_size = Path(args.input).stat().st_size
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)}")
392 f
" cbz={src_size // 1024} KB -> rabook={out // 1024} KB "
393 f
"({100 * out // max(src_size, 1)}%); inflated={len(blob) // 1024} KB"
398if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.