3"""Reading the EPUB package: the OPF manifest/spine and the navigation document.
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.
10@copyright Copyright (c) 2026 Brighton Sikarskie
11SPDX-License-Identifier: MIT
14from __future__
import annotations
17from typing
import TYPE_CHECKING
18from xml.etree
import ElementTree
as ET
21 from zipfile
import ZipFile
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
31def _parse_xml(data: bytes) -> ET.Element:
32 """Parse EPUB XML after rejecting every entity-declaration encoding Expat accepts."""
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)
41def opf_localname(tag: str |
None) -> str |
None:
42 """Strip the `{namespace}` prefix ElementTree prepends to a tag name.
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.
50 tag: An ElementTree tag, namespaced or not. None and "" pass through.
53 The local name, or `tag` unchanged when it carries no namespace.
55 return tag.split(
"}", 1)[1]
if tag
and tag[0] ==
"{" else tag
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.
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.
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).
76 zf: Open ZipFile for the EPUB.
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.
85 KeyError: The EPUB has no META-INF/container.xml.
86 xml.etree.ElementTree.ParseError: container.xml or the OPF is not
89 container = _parse_xml(zf.read(
"META-INF/container.xml"))
91 for el
in container.iter():
92 if opf_localname(el.tag) ==
"rootfile":
93 rootfile = el.get(
"full-path")
95 opf = _parse_xml(zf.read(rootfile))
96 opf_dir = posixpath.dirname(rootfile)
98 meta = {
"title":
"",
"author":
"",
"language":
"",
"identifier":
""}
102 cover_id_props =
None
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")
116 props = el.get(
"properties",
"")
or ""
117 manifest[el.get(
"id")] = (el.get(
"href"), el.get(
"media-type",
""), props)
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"))
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
136 zf: ZipFile, opf_dir: str, manifest: dict[str, tuple[str, str, str]]
138 """Map a normalized doc path -> TOC label, from the EPUB3 nav or EPUB2 NCX."""
141 def resolve(href: str) -> str:
142 return posixpath.normpath(posixpath.join(opf_dir, href.split(
"#", 1)[0]))
144 nav_href = next((h
for (h, _m, p)
in manifest.values()
if "nav" in p),
None)
146 (h
for (h, m, _p)
in manifest.values()
if m ==
"application/x-dtbncx+xml"),
None
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())
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":
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")
171 tgt = posixpath.normpath(posixpath.join(base, href.split(
"#", 1)[0]))
172 labels.setdefault(tgt, label)
173 except (ET.ParseError, KeyError):