# This file is part of EbookLib.
# Copyright (c) 2013 Aleksandar Erkalovic <aerkalov@gmail.com>
#
# EbookLib is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# EbookLib is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with EbookLib. If not, see <http://www.gnu.org/licenses/>.
"""EpubReader - loads an EPUB file into an EpubBook."""
import os
import posixpath as zip_path
import warnings
import zipfile
from typing import Any
from urllib.parse import unquote
from lxml import etree
from ebooklib.book import EpubBook
from ebooklib.consts import IMAGE_MEDIA_TYPES, NAMESPACES
from ebooklib.exceptions import EpubException
from ebooklib.items import EpubCover, EpubCoverHtml, EpubHtml, EpubImage, EpubItem, EpubNav, EpubNcx, EpubSMIL
from ebooklib.toc import Link, Section
from ebooklib.utils import Directory, parse_html_string, parse_string
[docs]
class EpubReader:
DEFAULT_OPTIONS: dict[str, Any] = {"ignore_ncx": True}
def __init__(self, epub_file_name, options: dict[str, Any] | None = None) -> None:
self.file_name = epub_file_name
self.book = EpubBook()
self.zf: zipfile.ZipFile | Directory | None = None
self.opf_file = ""
self.opf_dir = ""
self.options = dict(self.DEFAULT_OPTIONS)
if options:
self.options.update(options)
self._check_deprecated()
def _check_deprecated(self) -> None:
if self.options.get("ignore_ncx") is None:
warnings.warn("In the future version we will turn default option ignore_ncx to True.", stacklevel=2)
[docs]
def process(self) -> None:
# should cache this html parsing so we don't do it for every plugin
for plg in self.options.get("plugins", []):
if hasattr(plg, "after_read"):
plg.after_read(self.book)
for item in self.book.get_items():
if isinstance(item, EpubHtml):
for plg in self.options.get("plugins", []):
if hasattr(plg, "html_after_read"):
plg.html_after_read(self.book, item)
[docs]
def load(self) -> EpubBook:
self._load()
return self.book
[docs]
def read_file(self, name: str) -> bytes:
# Raises KeyError for missing items in an archive, OSError for directory sources
if self.zf is None:
raise EpubException(-1, "EPUB file is not opened")
name = zip_path.normpath(name)
return self.zf.read(name)
def _load_container(self) -> None:
meta_inf = self.read_file("META-INF/container.xml")
tree = parse_string(meta_inf)
for root_file in tree.findall(
".//xmlns:rootfile[@media-type]", namespaces={"xmlns": NAMESPACES["CONTAINERNS"]}
):
if root_file.get("media-type") == "application/oebps-package+xml":
self.opf_file = root_file.get("full-path") or ""
self.opf_dir = zip_path.dirname(self.opf_file)
def _load_metadata(self) -> None:
container_root = self.container.getroot()
# get epub version
self.book.version = container_root.get("version", None)
# get unique-identifier
unique_identifier = container_root.get("unique-identifier", None)
if unique_identifier:
self.book.IDENTIFIER_ID = unique_identifier
# get xml:lang
# get metadata
metadata = self.container.find(f"{{{NAMESPACES['OPF']}}}metadata")
if metadata is None:
raise EpubException(-1, "Can not find metadata element")
nsmap = metadata.nsmap
default_ns = nsmap.get(None, "")
nsdict: dict[str | None, dict[str, list[Any]]] = {v: {} for v in nsmap.values()}
# ids of all dc:identifier elements, in document order
identifier_ids: list[str] = []
def add_item(ns, tag, value, extra):
if ns not in nsdict:
nsdict[ns] = {}
values = nsdict[ns].setdefault(tag, [])
values.append((value, extra))
for t in metadata:
if not etree.iselement(t) or t.tag is etree.Comment:
continue
if t.tag == default_ns + "meta":
name = t.get("name")
others = dict(t.items())
if name and ":" in name:
prefix, name = name.split(":", 1)
else:
prefix = None
add_item(t.nsmap.get(prefix, prefix), name, t.text, others)
else:
tag = t.tag[t.tag.rfind("}") + 1 :]
if (t.prefix and t.prefix.lower() == "dc") and tag == "identifier":
_id = t.get("id", None)
if _id:
identifier_ids.append(_id)
others = dict(t.items())
add_item(t.nsmap[t.prefix], tag, t.text, others)
self.book.metadata = nsdict
# The package "unique-identifier" names the identifier of the publication, so it wins over
# any other dc:identifier (for example an ISBN listed after it). Without that attribute, or when
# it does not match any identifier (invalid package), the last dc:identifier with an id is used.
if identifier_ids and unique_identifier not in identifier_ids:
self.book.IDENTIFIER_ID = identifier_ids[-1]
titles = self.book.get_metadata("DC", "title")
if len(titles) > 0:
self.book.title = titles[0][0]
for value, others in self.book.get_metadata("DC", "identifier"):
if others.get("id") == self.book.IDENTIFIER_ID:
self.book.uid = value
def _load_manifest(self) -> None:
manifest = self.container.find(f"{{{NAMESPACES['OPF']}}}manifest")
if manifest is None:
raise EpubException(-1, "Can not find manifest element")
for r in manifest:
if r is not None and r.tag != f"{{{NAMESPACES['OPF']}}}item":
continue
uid = r.get("id")
href = unquote(r.get("href") or "")
media_type = r.get("media-type") or ""
_properties = r.get("properties", "")
if _properties:
properties = _properties.split(" ")
else:
properties = []
# people use wrong content types
if media_type == "image/jpg":
media_type = "image/jpeg"
ei: EpubItem
match media_type:
case "application/x-dtbncx+xml":
ei = EpubNcx(uid=uid, file_name=href)
ei.content = self.read_file(zip_path.join(self.opf_dir, ei.file_name))
case "application/smil+xml":
ei = EpubSMIL(uid=uid, file_name=href)
ei.content = self.read_file(zip_path.join(self.opf_dir, ei.file_name))
case "application/xhtml+xml" if "nav" in properties:
ei = EpubNav(uid=uid, file_name=href)
ei.content = self.read_file(zip_path.join(self.opf_dir, ei.file_name))
case "application/xhtml+xml" if "cover" in properties:
ei = EpubCoverHtml(uid=uid or "cover", file_name=href)
ei.media_type = media_type
ei.content = self.read_file(zip_path.join(self.opf_dir, href))
case "application/xhtml+xml":
ei = EpubHtml()
ei.id = uid
ei.file_name = href
ei.media_type = media_type
ei.media_overlay = r.get("media-overlay", None)
ei.media_duration = r.get("duration", None)
ei.content = self.read_file(zip_path.join(self.opf_dir, ei.get_name()))
ei.properties = properties
case _ if media_type in IMAGE_MEDIA_TYPES and "cover-image" in properties:
ei = EpubCover(uid=uid, file_name=href)
ei.media_type = media_type
ei.content = self.read_file(zip_path.join(self.opf_dir, ei.get_name()))
case _ if media_type in IMAGE_MEDIA_TYPES:
ei = EpubImage()
ei.id = uid
ei.file_name = href
ei.media_type = media_type
ei.content = self.read_file(zip_path.join(self.opf_dir, ei.get_name()))
case _:
# different types
ei = EpubItem()
ei.id = uid
ei.file_name = href
ei.media_type = media_type
ei.content = self.read_file(zip_path.join(self.opf_dir, ei.get_name()))
self.book.add_item(ei)
def _parse_ncx(self, data: bytes) -> None:
"""Parse an NCX (EPUB 2 table of contents) into ``self.book.toc``.
``book.toc`` is always a list. An NCX with an empty ``navMap`` (or without one) gives
an empty list, so that the book can still be written back and the EPUB 3 navigation
document is used when the NCX has no entries.
:Args:
- data: content of the NCX file
"""
tree = parse_string(data)
tree_root = tree.getroot()
nav_map = tree_root.find(f"{{{NAMESPACES['DAISY']}}}navMap")
if nav_map is None:
# A malformed NCX without navMap has no entries; keep the TOC a (possibly empty) list.
self.book.toc = []
return
def _get_children(elems, n, nid):
label, content = "", ""
children = []
for a in elems:
if a.tag == f"{{{NAMESPACES['DAISY']}}}navLabel":
# navLabel without a text child (malformed NCX) gives an empty label
label = a[0].text if len(a) else ""
if a.tag == f"{{{NAMESPACES['DAISY']}}}content":
content = a.get("src", "")
if a.tag == f"{{{NAMESPACES['DAISY']}}}navPoint":
children.append(_get_children(a, n + 1, a.get("id", "")))
if n == 0:
return children
if len(children) > 0:
return (Section(label, href=content), children)
else:
return Link(content, label, nid)
self.book.toc = _get_children(nav_map, 0, "")
def _parse_nav(self, data: str | bytes, base_path: str, navtype: str = "toc") -> None:
"""Parse the EPUB 3 navigation document into the book's TOC or page list.
The ``nav`` element is selected in this order:
1. **Semantic match (EPUB 3 conformant).** The XHTML ``nav`` whose ``epub:type``
whitespace-separated token list contains the requested type (``toc`` or
``page-list``). The document is parsed as strict XML with entity resolution
and network access disabled, and the attribute is matched by namespace
(``http://www.idpf.org/2007/ops``), so any prefix works.
2. **Legacy match (non-conformant books).** Used when the document is not
well-formed XML or has no semantic match: the first ``nav`` with any
attribute whose value is exactly the requested type (e.g. ``type="toc"``).
Note that this can pick an unrelated ``<nav class="toc">`` menu, so it is
only tried after the semantic lookup found nothing.
3. **Single ``nav`` (table of contents only).** If there is still no match
and the document contains exactly one ``nav`` element, it is assumed to be
the table of contents. A page list is never guessed this way.
If nothing matches, a missing page list is silently ignored (it is optional in
EPUB 3), while a missing table of contents raises ``IndexError`` as before.
:Args:
- data: content of the navigation document
- base_path: directory of the navigation document, used to resolve links
- navtype: ``"toc"`` for the table of contents, anything else for the page list
"""
nav_type = "toc" if navtype == "toc" else "page-list"
nav_node = None
try:
parser = etree.XMLParser(resolve_entities=False, no_network=True, recover=False)
xml_node = etree.fromstring(data.encode("utf-8") if isinstance(data, str) else data, parser)
found = xml_node.xpath(
"//h:nav[contains(concat(' ', normalize-space(@e:type), ' '), concat(' ', $t, ' '))]",
namespaces={"h": NAMESPACES["XHTML"], "e": NAMESPACES["EPUB"]},
t=nav_type,
)
if isinstance(found, list) and found:
nav_node = found[0]
except etree.XMLSyntaxError:
pass
if nav_node is None:
# Keep the historical HTML tolerance when semantic XML navigation is
# absent, including malformed books and unqualified attributes.
html_node = parse_html_string(data)
nav_nodes = [node for node in html_node.iter("nav") if nav_type in node.attrib.values()]
if not nav_nodes and navtype == "toc":
# Last resort: a lone nav element can only be the table of contents.
all_navs = list(html_node.iter("nav"))
if len(all_navs) == 1:
nav_nodes = all_navs
if navtype != "toc" and not nav_nodes:
return
nav_node = nav_nodes[0]
def child(node, name):
# Match by local name so both HTML (``li``) and XHTML (``{ns}li``) trees work.
for sub in node.iterchildren(etree.Element):
if etree.QName(sub).localname == name:
return sub
return None
def children_named(node, name):
return [sub for sub in node.iterchildren(etree.Element) if etree.QName(sub).localname == name]
def text_of(node):
# Like ``text_content()`` but also keeps unresolved entity references
# (e.g. ``&name;``) as text, since entity resolution is disabled.
parts = [node.text or ""]
for sub in node:
if sub.tag is etree.Entity:
parts.append(sub.text or "")
elif isinstance(sub.tag, str):
parts.append(text_of(sub))
parts.append(sub.tail or "")
return "".join(parts)
def parse_list(list_node):
items = []
for item_node in children_named(list_node, "li"):
sublist_node = child(item_node, "ol")
link_node = child(item_node, "a")
if sublist_node is not None:
title = text_of(item_node[0])
children = parse_list(sublist_node)
if link_node is not None and link_node.get("href"):
href = zip_path.normpath(zip_path.join(base_path, link_node.get("href")))
items.append((Section(title, href=href), children))
else:
items.append((Section(title), children))
elif link_node is not None and link_node.get("href"):
title = text_of(link_node)
href = zip_path.normpath(zip_path.join(base_path, link_node.get("href")))
items.append(Link(href, title))
return items
if navtype == "toc":
self.book.toc = parse_list(child(nav_node, "ol"))
elif nav_node is not None:
# generate the pages list if there is one
self.book.pages = parse_list(child(nav_node, "ol"))
# generate the per-file pages lists
# because of the order of parsing the files, this can't be done
# when building the EpubHtml objects
htmlfiles = {}
for htmlfile in self.book.items:
if isinstance(htmlfile, EpubHtml):
htmlfiles[htmlfile.file_name] = htmlfile
for page in self.book.pages:
try:
(filename, idref) = page.href.split("#")
except ValueError:
filename = page.href
if filename in htmlfiles:
htmlfiles[filename].pages.append(page)
def _load_spine(self) -> None:
spine = self.container.find(f"{{{NAMESPACES['OPF']}}}spine")
if spine is None:
raise EpubException(-1, "Can not find spine element")
self.book.spine = [(t.get("idref"), t.get("linear", "yes")) for t in spine]
toc = spine.get("toc", "")
self.book.set_direction(spine.get("page-progression-direction", None))
# should read ncx or nav file
nav_item = next((item for item in self.book.items if isinstance(item, EpubNav)), None)
if toc:
if not self.options.get("ignore_ncx") or not nav_item:
ncx_item = self.book.get_item_with_id(toc)
if ncx_item is None:
raise EpubException(-1, "Can not find ncx file.")
try:
ncxFile = self.read_file(zip_path.join(self.opf_dir, ncx_item.get_name()))
except KeyError:
raise EpubException(-1, "Can not find ncx file.") from None
self._parse_ncx(ncxFile)
def _load_guide(self) -> None:
guide = self.container.find(f"{{{NAMESPACES['OPF']}}}guide")
if guide is not None:
self.book.guide = [{"href": t.get("href"), "title": t.get("title"), "type": t.get("type")} for t in guide]
def _load_opf_file(self) -> None:
try:
s = self.read_file(self.opf_file)
except KeyError:
raise EpubException(-1, "Can not find container file") from None
self.container = parse_string(s)
self._load_metadata()
self._load_manifest()
self._load_spine()
self._load_guide()
# read nav file if found
nav_item = next((item for item in self.book.items if isinstance(item, EpubNav)), None)
if nav_item:
if self.options.get("ignore_ncx") or not self.book.toc:
self._parse_nav(nav_item.content, zip_path.dirname(nav_item.file_name), navtype="toc")
self._parse_nav(nav_item.content, zip_path.dirname(nav_item.file_name), navtype="pages")
def _load(self) -> None:
self.zf = None
file_name = self.file_name
if isinstance(file_name, bytes):
# bytes filesystem paths are decoded so both branches below get str
file_name = os.fsdecode(file_name)
if isinstance(file_name, str | os.PathLike):
if os.path.isdir(file_name):
self.zf = Directory(file_name)
if self.zf is None:
try:
self.zf = zipfile.ZipFile(file_name, "r", compression=zipfile.ZIP_DEFLATED, allowZip64=True)
except zipfile.BadZipfile:
raise EpubException(0, "Bad Zip file") from None
except zipfile.LargeZipFile:
raise EpubException(1, "Large Zip file") from None
# 1st check metadata
self._load_container()
self._load_opf_file()
self.zf.close()
[docs]
def read_epub(name, options: dict[str, Any] | None = None) -> EpubBook:
"""
Creates new instance of EpubBook with the content defined in the input file.
>>> book = ebooklib.read_epub('book.epub')
:Args:
- name: full path to the input file
- options: extra options as dictionary (optional)
:Returns:
Instance of EpubBook.
"""
reader = EpubReader(name, options)
book = reader.load()
reader.process()
return book