Source code for ebooklib.writer

# 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/>.

"""EpubWriter - serialises an EpubBook to an EPUB file."""

import datetime
import logging
import os
import os.path
import posixpath as zip_path
import zipfile
from typing import IO, Any, Protocol, TypeAlias, cast

from lxml import etree

import ebooklib
from ebooklib.book import EpubBook
from ebooklib.consts import CONTAINER_PATH, CONTAINER_XML, NAMESPACES
from ebooklib.items import EpubCover, EpubHtml, EpubItem, EpubNav, EpubNcx
from ebooklib.toc import Link, Section
from ebooklib.utils import get_pages_for_items, parse_string

logger = logging.getLogger(__name__)


class _WritableFile(Protocol):
    """Minimal structural interface required by zipfile.ZipFile for writing."""

    def write(self, data: bytes, /) -> object: ...

    def seek(self, offset: int, whence: int = ..., /) -> object: ...

    def tell(self) -> int: ...


# Anything accepted by zipfile.ZipFile: a path or a writable binary file-like object.
EpubTarget: TypeAlias = str | os.PathLike[str] | _WritableFile


[docs] class EpubWriter: DEFAULT_OPTIONS: dict[str, Any] = { "epub2_guide": True, "epub3_landmark": True, "epub3_pages": True, "landmark_title": "Guide", "pages_title": "Pages", "spine_direction": True, "package_direction": False, "play_order": {"enabled": False, "start_from": 1}, "raise_exceptions": True, "compresslevel": 6, }
[docs] @classmethod def get_default_options(cls) -> dict[str, Any]: default = dict(cls.DEFAULT_OPTIONS) default["mtime"] = datetime.datetime.now(datetime.timezone.utc) return default
[docs] @classmethod def datetime_to_zipinfo_datetime(cls, dt: datetime.datetime) -> tuple[int, int, int, int, int, int]: """ Converts a datetime object to a tuple format compatible with zipfile.ZipInfo. :Args: - dt: datetime.datetime object :Returns: Tuple of (year, month, day, hour, minute, second) for use in ZipInfo """ # ZIP timestamps carry no timezone information and are conventionally # interpreted as local time, so convert aware datetimes to local time first. if dt.tzinfo is not None: dt = dt.astimezone() return (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second)
[docs] def zipinfo(self, name: str) -> zipfile.ZipInfo: return zipfile.ZipInfo(name, self.datetime_to_zipinfo_datetime(self.options["mtime"]))
def __init__(self, name: EpubTarget, book: EpubBook, options: dict[str, Any] | None = None) -> None: self.file_name = name self.book = book self.options = self.get_default_options() if options: self.options.update(options) self._init_play_order() def _init_play_order(self) -> None: self._play_order = {"enabled": False, "start_from": 1} try: self._play_order["enabled"] = self.options["play_order"]["enabled"] self._play_order["start_from"] = self.options["play_order"]["start_from"] except KeyError: pass
[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, "before_write"): plg.before_write(self.book) for item in self.book.get_items(): if isinstance(item, EpubHtml): for plg in self.options.get("plugins", []): if hasattr(plg, "html_before_write"): plg.html_before_write(self.book, item)
def _write_container(self) -> None: container_xml = CONTAINER_XML % {"folder_name": self.book.FOLDER_NAME} self.out.writestr(self.zipinfo(CONTAINER_PATH), container_xml) def _write_opf_metadata(self, root: etree._Element) -> None: nsmap = {"dc": NAMESPACES["DC"], "opf": NAMESPACES["OPF"]} nsmap.update(self.book.namespaces) metadata = etree.SubElement(root, "metadata", nsmap=nsmap) el = etree.SubElement(metadata, "meta", {"property": "dcterms:modified"}) # The EPUB specification requires dcterms:modified to be a UTC timestamp. # astimezone() treats a naive datetime as local time before converting. mtime_utc = self.options["mtime"].astimezone(datetime.timezone.utc) el.text = mtime_utc.strftime("%Y-%m-%dT%H:%M:%SZ") for ns_name, values in self.book.metadata.items(): if ns_name == NAMESPACES["OPF"]: for values2 in values.values(): for v in values2: if "property" in v[1] and v[1]["property"] == "dcterms:modified": continue try: el = etree.SubElement(metadata, "meta", v[1]) if v[0]: el.text = v[0] except ValueError: logging.error("Could not create metadata.") else: for name, values2 in values.items(): for v in values2: try: if ns_name: el = etree.SubElement(metadata, f"{{{ns_name}}}{name}", v[1]) else: el = etree.SubElement(metadata, name, v[1]) el.text = v[0] except ValueError: logging.info(f'Could not create metadata "{name}".') def _write_opf_manifest(self, root: etree._Element) -> str | None: manifest = etree.SubElement(root, "manifest") _ncx_id = None # mathml, scripted, svg, remote-resources, and switch # nav # cover-image for item in self.book.get_items(): if not item.manifest: continue item_id = item.id or "" if isinstance(item, EpubNav): etree.SubElement( manifest, "item", {"href": item.get_name(), "id": item_id, "media-type": item.media_type, "properties": "nav"}, ) elif isinstance(item, EpubNcx): _ncx_id = item.id etree.SubElement( manifest, "item", {"href": item.file_name, "id": item_id, "media-type": item.media_type} ) elif isinstance(item, EpubCover): etree.SubElement( manifest, "item", {"href": item.file_name, "id": item_id, "media-type": item.media_type, "properties": "cover-image"}, ) else: opts = {"href": item.file_name, "id": item_id, "media-type": item.media_type} properties = getattr(item, "properties", None) if properties: opts["properties"] = " ".join(properties) media_overlay = getattr(item, "media_overlay", None) if media_overlay is not None: opts["media-overlay"] = media_overlay media_duration = getattr(item, "media_duration", None) if media_duration is not None: opts["duration"] = media_duration etree.SubElement(manifest, "item", opts) return _ncx_id def _write_opf_spine(self, root: etree._Element, ncx_id: str | None) -> None: spine_attributes = {"toc": ncx_id or "ncx"} if self.book.direction and self.options["spine_direction"]: spine_attributes["page-progression-direction"] = self.book.direction spine = etree.SubElement(root, "spine", spine_attributes) for _item in self.book.spine: # this is for now # later we should be able to fetch things from tuple is_linear = True if isinstance(_item, tuple): item = _item[0] if len(_item) > 1: if _item[1] == "no": is_linear = False else: item = _item if isinstance(item, EpubHtml): opts = {"idref": item.get_id() or ""} if not item.is_linear or not is_linear: opts["linear"] = "no" elif isinstance(item, EpubItem): opts = {"idref": item.get_id() or ""} if not item.is_linear or not is_linear: opts["linear"] = "no" else: opts = {"idref": item} itm = self.book.get_item_with_id(item) if itm is not None and (not itm.is_linear or not is_linear): opts["linear"] = "no" etree.SubElement(spine, "itemref", opts) def _write_opf_guide(self, root: etree._Element) -> None: # - http://www.idpf.org/epub/20/spec/OPF_2.0.1_draft.htm#Section2.6 if len(self.book.guide) > 0 and self.options.get("epub2_guide"): guide = etree.SubElement(root, "guide", {}) for item in self.book.guide: chap = item.get("item") if chap is not None: _href = chap.file_name _title = chap.title else: _href = item.get("href", "") _title = item.get("title", "") _ref = etree.SubElement( guide, "reference", {"type": item.get("type", ""), "title": _title or "", "href": _href or ""} ) def _write_opf_bindings(self, root: etree._Element) -> None: if len(self.book.bindings) > 0: bindings = etree.SubElement(root, "bindings", {}) for item in self.book.bindings: etree.SubElement(bindings, "mediaType", item) def _write_opf_file(self, root: etree._Element) -> None: tree_str = etree.tostring(root, pretty_print=True, encoding="utf-8", xml_declaration=True) self.out.writestr(self.zipinfo(f"{self.book.FOLDER_NAME}/content.opf"), tree_str) def _write_opf(self) -> None: package_attributes = { "xmlns": NAMESPACES["OPF"], "unique-identifier": self.book.IDENTIFIER_ID, "version": "3.0", } if self.book.direction and self.options["package_direction"]: package_attributes["dir"] = self.book.direction root = etree.Element("package", package_attributes) prefixes = ["rendition: http://www.idpf.org/vocab/rendition/#"] + self.book.prefixes root.attrib["prefix"] = " ".join(prefixes) # METADATA self._write_opf_metadata(root) # MANIFEST _ncx_id = self._write_opf_manifest(root) # SPINE self._write_opf_spine(root, _ncx_id) # GUIDE self._write_opf_guide(root) # BINDINGS self._write_opf_bindings(root) # WRITE FILE self._write_opf_file(root) def _get_nav(self, item: EpubNav) -> bytes: # just a basic navigation for now nav_xml = parse_string(self.book.get_template("nav") or b"") root = nav_xml.getroot() root.set("lang", self.book.language) root.attrib[f"{{{NAMESPACES['XML']}}}lang"] = self.book.language nav_dir_name = os.path.dirname(item.file_name) head = etree.SubElement(root, "head") title = etree.SubElement(head, "title") title.text = item.title or self.book.title # for now this just handles css files and ignores others for _link in item.links: _lnk = etree.SubElement( head, "link", {"href": _link.get("href", ""), "rel": "stylesheet", "type": "text/css"} ) body = etree.SubElement(root, "body") if item.direction: body.set("dir", item.direction) epub_type_attr = f"{{{NAMESPACES['EPUB']}}}type" nav = etree.SubElement( body, "nav", { epub_type_attr: "toc", "id": "id", "role": "doc-toc", }, ) content_title = etree.SubElement(nav, "h2") content_title.text = item.title or self.book.title def _create_section(itm, items): ol = etree.SubElement(itm, "ol") for item in items: if isinstance(item, tuple | list): li = etree.SubElement(ol, "li") if isinstance(item[0], EpubHtml): a = etree.SubElement(li, "a", {"href": zip_path.relpath(item[0].file_name, nav_dir_name)}) elif isinstance(item[0], Section) and item[0].href != "": a = etree.SubElement(li, "a", {"href": zip_path.relpath(item[0].href, nav_dir_name)}) elif isinstance(item[0], Link): a = etree.SubElement(li, "a", {"href": zip_path.relpath(item[0].href, nav_dir_name)}) else: a = etree.SubElement(li, "span") a.text = item[0].title _create_section(li, item[1]) elif isinstance(item, Link): li = etree.SubElement(ol, "li") a = etree.SubElement(li, "a", {"href": zip_path.relpath(item.href, nav_dir_name)}) a.text = item.title elif isinstance(item, EpubHtml): li = etree.SubElement(ol, "li") a = etree.SubElement(li, "a", {"href": zip_path.relpath(item.file_name, nav_dir_name)}) a.text = item.title _create_section(nav, self.book.toc) # LANDMARKS / GUIDE # - http://www.idpf.org/epub/30/spec/epub30-contentdocs.html#sec-xhtml-nav-def-types-landmarks if len(self.book.guide) > 0 and self.options.get("epub3_landmark"): # Epub2 guide types do not map completely to epub3 landmark types. guide_to_landscape_map = {"notes": "rearnotes", "text": "bodymatter"} guide_nav = etree.SubElement(body, "nav", {epub_type_attr: "landmarks"}) guide_content_title = etree.SubElement(guide_nav, "h2") guide_content_title.text = self.options.get("landmark_title", "Guide") guild_ol = etree.SubElement(guide_nav, "ol") for elem in self.book.guide: chap = elem.get("item") if chap is not None: _href = chap.file_name _title = chap.title else: _href = elem.get("href", "") _title = elem.get("title", "") if not _href: # a landmark entry without a target can not be represented in the nav document continue li_item = etree.SubElement(guild_ol, "li") guide_type = elem.get("type") or "" a_item = etree.SubElement( li_item, "a", { epub_type_attr: guide_to_landscape_map.get(guide_type, guide_type), "href": zip_path.relpath(_href, nav_dir_name), }, ) a_item.text = _title # PAGE-LIST if self.options.get("epub3_pages"): inserted_pages = get_pages_for_items( [item for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT) if not isinstance(item, EpubNav)] ) if len(inserted_pages) > 0: pagelist_nav = etree.SubElement( body, "nav", { epub_type_attr: "page-list", "id": "pages", "hidden": "hidden", }, ) pagelist_content_title = etree.SubElement(pagelist_nav, "h2") pagelist_content_title.text = self.options.get("pages_title", "Pages") pages_ol = etree.SubElement(pagelist_nav, "ol") for filename, pageref, label in inserted_pages: li_item = etree.SubElement(pages_ol, "li") _href = f"{filename}#{pageref}" _title = label a_item = etree.SubElement( li_item, "a", { "href": zip_path.relpath(_href, nav_dir_name), }, ) a_item.text = _title tree_str = etree.tostring(nav_xml, pretty_print=True, encoding="utf-8", xml_declaration=True) return tree_str def _get_ncx(self) -> bytes: # we should be able to setup language for NCX as also ncx = parse_string(self.book.get_template("ncx") or b"") root = ncx.getroot() head = etree.SubElement(root, "head") # get this id _uid = etree.SubElement(head, "meta", {"content": self.book.uid, "name": "dtb:uid"}) _uid = etree.SubElement(head, "meta", {"content": "0", "name": "dtb:depth"}) _uid = etree.SubElement(head, "meta", {"content": "0", "name": "dtb:totalPageCount"}) _uid = etree.SubElement(head, "meta", {"content": "0", "name": "dtb:maxPageNumber"}) doc_title = etree.SubElement(root, "docTitle") title = etree.SubElement(doc_title, "text") title.text = self.book.title # For now just make a very simple navMap nav_map = etree.SubElement(root, "navMap") def _add_play_order(nav_point): nav_point.set("playOrder", str(self._play_order["start_from"])) self._play_order["start_from"] += 1 def _create_section(itm, items, uid): for item in items: if isinstance(item, tuple | list): section, subsection = item[0], item[1] np = etree.SubElement( itm, "navPoint", {"id": (section.get_id() or "") if isinstance(section, EpubHtml) else f"sep_{uid}"}, ) if self._play_order["enabled"]: _add_play_order(np) nl = etree.SubElement(np, "navLabel") nt = etree.SubElement(nl, "text") nt.text = section.title # CAN NOT HAVE EMPTY SRC HERE href = "" if isinstance(section, EpubHtml): href = section.file_name elif isinstance(section, Section) and section.href != "": href = section.href elif isinstance(section, Link): href = section.href _nc = etree.SubElement(np, "content", {"src": href}) uid = _create_section(np, subsection, uid + 1) elif isinstance(item, Link): _parent = itm _content = _parent.find("content") if _content is not None: if _content.get("src") == "": _content.set("src", item.href) np = etree.SubElement(itm, "navPoint", {"id": item.uid or ""}) if self._play_order["enabled"]: _add_play_order(np) nl = etree.SubElement(np, "navLabel") nt = etree.SubElement(nl, "text") nt.text = item.title _nc = etree.SubElement(np, "content", {"src": item.href}) elif isinstance(item, EpubHtml): _parent = itm _content = _parent.find("content") if _content is not None: if _content.get("src") == "": _content.set("src", item.file_name) np = etree.SubElement(itm, "navPoint", {"id": item.get_id() or ""}) if self._play_order["enabled"]: _add_play_order(np) nl = etree.SubElement(np, "navLabel") nt = etree.SubElement(nl, "text") nt.text = item.title _nc = etree.SubElement(np, "content", {"src": item.file_name}) return uid _create_section(nav_map, self.book.toc, 0) tree_str = etree.tostring(root, pretty_print=True, encoding="utf-8", xml_declaration=True) return tree_str def _write_items(self) -> None: for item in self.book.get_items(): if isinstance(item, EpubNcx): self.out.writestr(self.zipinfo(f"{self.book.FOLDER_NAME}/{item.file_name}"), self._get_ncx()) elif isinstance(item, EpubNav): self.out.writestr(self.zipinfo(f"{self.book.FOLDER_NAME}/{item.file_name}"), self._get_nav(item)) elif item.manifest: self.out.writestr(self.zipinfo(f"{self.book.FOLDER_NAME}/{item.file_name}"), item.get_content()) else: self.out.writestr(self.zipinfo(item.file_name), item.get_content())
[docs] def write(self) -> None: # zipfile stubs expect IO[bytes], but any object with write/seek/tell works target = cast("str | os.PathLike[str] | IO[bytes]", self.file_name) self.out = zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED, compresslevel=self.options["compresslevel"]) try: self.out.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED) self._write_container() self._write_opf() self._write_items() finally: self.out.close()
[docs] def write_epub(name: EpubTarget, book: EpubBook, options: dict[str, Any] | None = None) -> bool: """ Creates epub file with the content defined in EpubBook. >>> ebooklib.write_epub('book.epub', book) :Args: - name: file name for the output file - book: instance of EpubBook - options: extra opions as dictionary (optional) """ epub = EpubWriter(name, book, options) epub.process() try: epub.write() except Exception as e: if options and options.get("raise_exceptions"): raise e else: return False return True