ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
epub_package.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Reading the EPUB package: the OPF manifest/spine and the navigation document.
4
5Namespace-tolerant on purpose. Real EPUBs in the wild bind the OPF and NCX
6namespaces inconsistently (and some omit them), so every element is matched on
7its LOCAL name -- a book that will not open because of a namespace prefix is a
8bug in the tool, not in the book.
9
10@copyright Copyright (c) 2026 Brighton Sikarskie
11SPDX-License-Identifier: MIT
12"""
13
14from __future__ import annotations
15
16import posixpath
17from typing import TYPE_CHECKING
18from xml.etree import ElementTree as ET
19
20if TYPE_CHECKING:
21 from zipfile import ZipFile
22
23_XML_DECLARATION_ENCODINGS = ("ascii", "utf-16-le", "utf-16-be", "utf-32-le", "utf-32-be")
24_FORBIDDEN_XML_DECLARATIONS = tuple(
25 declaration.encode(encoding)
26 for declaration in ("<!DOCTYPE", "<!ENTITY")
27 for encoding in _XML_DECLARATION_ENCODINGS
28)
29
30
31def _parse_xml(data: bytes) -> ET.Element:
32 """Parse EPUB XML after rejecting every entity-declaration encoding Expat accepts."""
33 upper = data.upper()
34 if any(declaration in upper for declaration in _FORBIDDEN_XML_DECLARATIONS):
35 msg = "DTD and entity declarations are forbidden in EPUB metadata"
36 raise ET.ParseError(msg)
37 return ET.fromstring(data) # noqa: S314 -- DTD/entity declarations rejected above
38
39
40# --- EPUB unpacking -----------------------------------------------------------
41def opf_localname(tag: str | None) -> str | None:
42 """Strip the `{namespace}` prefix ElementTree prepends to a tag name.
43
44 OPF, NCX and XHTML documents all declare namespaces, and publishers use
45 different prefixes and even different namespace URIs for the same vocabulary.
46 Matching on the local name alone is what lets the parsers below accept real
47 EPUBs instead of only spec-perfect ones.
48
49 Args:
50 tag: An ElementTree tag, namespaced or not. None and "" pass through.
51
52 Returns:
53 The local name, or `tag` unchanged when it carries no namespace.
54 """
55 return tag.split("}", 1)[1] if tag and tag[0] == "{" else tag
56
57
58def parse_opf(
59 zf: ZipFile,
60) -> tuple[str, dict[str, str], dict[str, tuple[str, str, str]], list[str], str | None]:
61 """Read container.xml and the OPF package it points at.
62
63 Walks the OPF once with `iter()` rather than following its schema, so
64 elements in unexpected parents are still found. Metadata fields take the
65 FIRST value seen and ignore later ones, which is how a book with several
66 `<dc:creator>` entries yields one author.
67
68 Cover resolution follows the device's precedence exactly: an EPUB3 manifest
69 item whose `properties` contains "cover-image" wins, and the legacy EPUB2
70 `<meta name="cover">` is only the fallback. The substring test mirrors
71 `epub_xml_shim.cpp` `find_cover_by_properties()` byte for byte, so the
72 host `.rabook` and an on-device compile agree on the cover -- which matters
73 for EPUB3-only fixed-layout comics that ship no legacy meta (issue #196).
74
75 Args:
76 zf: Open ZipFile for the EPUB.
77
78 Returns:
79 Tuple of (opf_dir, meta, manifest, spine, cover_id): the OPF's directory
80 for resolving relative hrefs, the four metadata strings (each "" if
81 absent), manifest id -> (href, media_type, properties), spine idrefs in
82 reading order, and the cover's manifest id or None.
83
84 Raises:
85 KeyError: The EPUB has no META-INF/container.xml.
86 xml.etree.ElementTree.ParseError: container.xml or the OPF is not
87 well-formed XML.
88 """
89 container = _parse_xml(zf.read("META-INF/container.xml"))
90 rootfile = None
91 for el in container.iter():
92 if opf_localname(el.tag) == "rootfile":
93 rootfile = el.get("full-path")
94 break
95 opf = _parse_xml(zf.read(rootfile))
96 opf_dir = posixpath.dirname(rootfile)
97
98 meta = {"title": "", "author": "", "language": "", "identifier": ""}
99 manifest = {} # id -> (href, media_type, properties)
100 spine = [] # list of idref
101 cover_id_meta = None # legacy EPUB2 <meta name="cover" content="ID">
102 cover_id_props = None # EPUB3 manifest <item properties="cover-image">
103 for el in opf.iter():
104 tag = opf_localname(el.tag)
105 if tag == "title" and not meta["title"]:
106 meta["title"] = (el.text or "").strip()
107 elif tag == "creator" and not meta["author"]:
108 meta["author"] = (el.text or "").strip()
109 elif tag == "language" and not meta["language"]:
110 meta["language"] = (el.text or "").strip()
111 elif tag == "identifier" and not meta["identifier"]:
112 meta["identifier"] = (el.text or "").strip()
113 elif tag == "meta" and el.get("name") == "cover":
114 cover_id_meta = el.get("content")
115 elif tag == "item":
116 props = el.get("properties", "") or ""
117 manifest[el.get("id")] = (el.get("href"), el.get("media-type", ""), props)
118 # First manifest item carrying the EPUB3 cover-image property. The
119 # substring test mirrors epub_xml_shim.cpp find_cover_by_properties()
120 # (a strstr over the space-separated properties list) byte-for-byte, so
121 # an EPUB3-only book -- no legacy meta, how modern fixed-layout comics
122 # ship (issue #196) -- resolves the same cover host-side and on-device.
123 if (cover_id_props is None) and ("cover-image" in props):
124 cover_id_props = el.get("id")
125 elif tag == "itemref":
126 spine.append(el.get("idref"))
127 # EPUB3 properties="cover-image" wins; the legacy <meta name="cover"> is the
128 # fallback -- the exact precedence epub_xml_shim.cpp uses on device
129 # (find_cover_by_properties() then find_cover_by_meta()), so the desktop
130 # .rabook and the on-device compile agree on the cover image index.
131 cover_id = cover_id_props if cover_id_props is not None else cover_id_meta
132 return opf_dir, meta, manifest, spine, cover_id
133
134
135def parse_toc(
136 zf: ZipFile, opf_dir: str, manifest: dict[str, tuple[str, str, str]]
137) -> dict[str, str]:
138 """Map a normalized doc path -> TOC label, from the EPUB3 nav or EPUB2 NCX."""
139 labels = {}
140
141 def resolve(href: str) -> str:
142 return posixpath.normpath(posixpath.join(opf_dir, href.split("#", 1)[0]))
143
144 nav_href = next((h for (h, _m, p) in manifest.values() if "nav" in p), None)
145 ncx_href = next(
146 (h for (h, m, _p) in manifest.values() if m == "application/x-dtbncx+xml"), None
147 )
148 try:
149 if nav_href:
150 tree = _parse_xml(zf.read(resolve(nav_href)))
151 base = posixpath.dirname(resolve(nav_href))
152 for a in tree.iter():
153 if opf_localname(a.tag) == "a" and a.get("href"):
154 tgt = posixpath.normpath(posixpath.join(base, a.get("href").split("#", 1)[0]))
155 labels.setdefault(tgt, "".join(a.itertext()).strip())
156 elif ncx_href:
157 tree = _parse_xml(zf.read(resolve(ncx_href)))
158 base = posixpath.dirname(resolve(ncx_href))
159 for nav in tree.iter():
160 if opf_localname(nav.tag) != "navPoint":
161 continue
162 label = ""
163 href = None
164 for sub in nav.iter():
165 ln = opf_localname(sub.tag)
166 if ln == "text" and not label:
167 label = (sub.text or "").strip()
168 elif ln == "content":
169 href = sub.get("src")
170 if href:
171 tgt = posixpath.normpath(posixpath.join(base, href.split("#", 1)[0]))
172 labels.setdefault(tgt, label)
173 except (ET.ParseError, KeyError):
174 pass
175 return labels