3"""The compile pipeline: one EPUB in, one .rabook blob out.
5Apart from the CLI so the selftest can drive the real pipeline rather than a
6re-implementation of it -- a fixture suite that exercises its own copy of the
7compiler proves nothing about the compiler that ships.
9@copyright Copyright (c) 2026 Brighton Sikarskie
10SPDX-License-Identifier: MIT
13from __future__
import annotations
17from pathlib
import Path
18from zipfile
import ZipFile
20from epub_dom
import DomBuilder, find_first
21from epub_package
import parse_opf, parse_toc
22from rabook_blob
import MAX_IMAGE_EDGE, SKIP_IMAGES, BlobBuilder
23from rabook_format
import PIXFMT_GRAY4
28 max_image_edge: int = MAX_IMAGE_EDGE,
29 skip_images: bool = SKIP_IMAGES,
30 pixel_format: int = PIXFMT_GRAY4,
31) -> tuple[bytes, dict[str, str], BlobBuilder]:
32 """Compile one EPUB into an inflated .rabook blob.
34 Assembles in dependency order -- stylesheets, then images (so the cover
35 index is known), then spine chapters -- and takes each chapter's `<body>`
36 subtree, falling back to the whole document when there is none.
38 The pass is deliberately lenient, because a book that fails to open is worse
39 than a book missing one asset. A manifest entry absent from the zip is
40 skipped; an image Pillow cannot decode is skipped; a spine idref with no
41 manifest entry is skipped. All four are silent. The consequence worth
42 knowing at 2am: a corrupt EPUB compiles successfully into a blob with
43 missing content rather than reporting an error.
45 Recursion limit: `add_element` recurses per level of markup nesting, so the
46 limit is raised to 100000 here. This mutates interpreter global state and is
50 path: Path to the source .epub.
51 max_image_edge: Long-edge downscale cap in pixels; 0 preserves source
52 resolution. See `BlobBuilder.add_raster_image` for why the two
53 paths are not byte-equivalent.
54 skip_images: Drop every image, producing a text-only blob small enough
55 to bake into MRAM as a fixture. The cover is dropped too.
56 pixel_format: Device-profile raster depth (issue #343): PIXFMT_GRAY4
57 (the default 4bpp packing) or PIXFMT_GRAY8 (lossless 8bpp). Only the
58 raster image arm reads it; SVG is unaffected.
61 Tuple of (blob, meta, bb): the serialized inflated blob, the metadata
62 dict, and the BlobBuilder itself so callers can report table sizes.
65 KeyError: Malformed EPUB with no container.xml.
66 xml.etree.ElementTree.ParseError: container.xml or the OPF is not
67 well-formed. A broken TOC is tolerated; a broken OPF is not.
68 zipfile.BadZipFile: `path` is not a zip archive.
70 sys.setrecursionlimit(100000)
72 with ZipFile(path)
as zf:
73 opf_dir, meta, manifest, spine, cover_id = parse_opf(zf)
74 labels = parse_toc(zf, opf_dir, manifest)
75 names = set(zf.namelist())
77 def resolve(href: str) -> str:
78 return posixpath.normpath(posixpath.join(opf_dir, href))
81 for href, media, _props
in manifest.values():
82 if media ==
"text/css":
85 bb.add_stylesheet(zf.read(full).decode(
"utf-8",
"replace"))
89 for mid, (href, media, _props)
in ()
if skip_images
else manifest.items():
94 if media ==
"image/svg+xml":
95 id_to_image[mid] = bb.add_svg_image(href, zf.read(full))
96 elif media.startswith(
"image/"):
97 id_to_image[mid] = bb.add_raster_image(
98 href, zf.read(full), max_image_edge, pixel_format
100 except (OSError, ValueError):
102 if cover_id
and cover_id
in id_to_image:
103 bb.cover_index = id_to_image[cover_id]
106 href_by_id = {mid: h
for mid, (h, _m, _p)
in manifest.items()}
108 href = href_by_id.get(idref)
112 if full
not in names:
115 dom.feed(zf.read(full).decode(
"utf-8",
"replace"))
116 body = find_first(dom.root,
"body")
or dom.root
117 title = labels.get(full,
"")
118 bb.add_chapter(body, title, href)
120 return bb.serialize(meta), meta, bb