ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
epub_pipeline.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""The compile pipeline: one EPUB in, one .rabook blob out.
4
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.
8
9@copyright Copyright (c) 2026 Brighton Sikarskie
10SPDX-License-Identifier: MIT
11"""
12
13from __future__ import annotations
14
15import posixpath
16import sys
17from pathlib import Path
18from zipfile import ZipFile
19
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
24
25
26def compile_epub(
27 path: str | Path,
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.
33
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.
37
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.
44
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
47 not restored.
48
49 Args:
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.
59
60 Returns:
61 Tuple of (blob, meta, bb): the serialized inflated blob, the metadata
62 dict, and the BlobBuilder itself so callers can report table sizes.
63
64 Raises:
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.
69 """
70 sys.setrecursionlimit(100000)
71 bb = BlobBuilder()
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())
76
77 def resolve(href: str) -> str:
78 return posixpath.normpath(posixpath.join(opf_dir, href))
79
80 # stylesheets (verbatim)
81 for href, media, _props in manifest.values():
82 if media == "text/css":
83 full = resolve(href)
84 if full in names:
85 bb.add_stylesheet(zf.read(full).decode("utf-8", "replace"))
86
87 # images (raster -> 4bpp, svg -> vector); remember manifest-id -> image index
88 id_to_image = {}
89 for mid, (href, media, _props) in () if skip_images else manifest.items():
90 full = resolve(href)
91 if full not in names:
92 continue
93 try:
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
99 )
100 except (OSError, ValueError):
101 pass
102 if cover_id and cover_id in id_to_image:
103 bb.cover_index = id_to_image[cover_id]
104
105 # spine chapters -> faithful DOM (body subtree)
106 href_by_id = {mid: h for mid, (h, _m, _p) in manifest.items()}
107 for idref in spine:
108 href = href_by_id.get(idref)
109 if not href:
110 continue
111 full = resolve(href)
112 if full not in names:
113 continue
114 dom = DomBuilder()
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)
119
120 return bb.serialize(meta), meta, bb