epub - Python library for generating standard-compliant EPUB 3.0 books.

git clone https://benconnors.ca/git-repos/epub

Log | Files | Refs

epub.py (12437B) - raw


      1 #!/usr/bin/env python3
      2 
      3 """Module for generating EPUB v3.0 content."""
      4 
      5 import datetime as dt
      6 import hashlib
      7 import os
      8 import uuid
      9 import zipfile
     10 import mimetypes
     11 
     12 from lxml import etree
     13 
     14 NBSP = "\u00A0"
     15 OPF_NS = {"dc": "http://purl.org/dc/elements/1.1/",
     16           "opf": "http://www.idpf.org/2007/opf"}
     17 OPF = "{%s}"%OPF_NS["opf"]
     18 DC = "{%s}"%OPF_NS["dc"]
     19 EPUB = "{http://www.idpf.org/2007/ops}"
     20 
     21 def hash_file(fname):
     22     """Return the SHA-256 hash of a file."""
     23     sha256 = hashlib.sha256()
     24     with open(fname, "rb") as f:
     25         while True:
     26             data = f.read(1048576)
     27             if not data:
     28                 break
     29             sha256.update(data)
     30     return sha256.digest().hex()
     31 
     32 class Page:
     33     """Class for creating a single page in an EPUB. 
     34 
     35     Note that this is not the conventional, printed concept of a page: a single page should 
     36     be the unit of division of the book, most often an entire chapter (as the actual 
     37     displayed page varies by device and screen size).
     38 
     39     The `content` parameter holds all of the content for the page, stored similarly to 
     40     S-exps. It must be a list of 2-tuples:
     41 
     42         (tag, data)
     43 
     44     The `tag` element may be a string tag name or a 2-tuple of the form:
     45 
     46         (tag_name, attrib)
     47 
     48     Where `attrib` is a dictionary containing the attributes of the tag and `tag_name` is a
     49     string.
     50 
     51     If `tag` is the empty string, `data` is interpreted as a list of elements to be added 
     52     to the current element, in the same format as the `content` list. Otherwise, `data` is 
     53     interpreted as a string to be stored as the tag's text.
     54 
     55 
     56     For example, to create the following:
     57 
     58         <h1>A List</h1>
     59         <div class="container">
     60           <ol>
     61             <li>Element 1</li>
     62             <li>Element 2</li>
     63           </ol>
     64         </div>
     65 
     66     The `content` would be:
     67 
     68         [
     69             ("h1", "A List"),
     70             (
     71                 ("div", {"class": "container"}),
     72                 (
     73                     "ol",
     74                     (
     75                         "",
     76                         (
     77                             ("li", "Element 1"),
     78                             ("li", "Element 2"),
     79                         )
     80                     )
     81                 ),
     82             )
     83         ]
     84     """
     85     TITLE = "title"
     86     TOC = "toc"
     87     CONTENT = "content"
     88 
     89     @staticmethod
     90     def _generate_inner(root, stuff):
     91         static = {}
     92 
     93         if isinstance(stuff, str):
     94             tag, elem = None, stuff
     95         else:
     96             tag, elem = stuff
     97 
     98         if not isinstance(tag, str) and tag is not None:
     99             tag, attrib = tag
    100         else:
    101             attrib = {}
    102 
    103         ## There are three cases we need to handle
    104         ## 1. Text node, in which case we return the text
    105         ## 2. Regular node, in which case we create the node and return
    106         ## 3. "" node, in which case we iterate over the list
    107 
    108         if tag is None: ## Text node
    109             if not isinstance(elem, str):
    110                 raise ValueError("Text node must be most nested tag")
    111             return elem, {}
    112         elif tag: ## Regular node
    113             ## We need to do a bit more work for images/links
    114             if tag == "img" and "src" in attrib: ## Store image src and redirect it
    115                 ext = os.path.splitext(attrib["src"])[1]
    116                 h = hash_file(attrib["src"]) 
    117                 if ext:
    118                     h += ext
    119                 attrib = attrib.copy()
    120                 static["OEBPS/Static/"+h] = attrib["src"]
    121                 attrib["src"] = "../Static/"+h
    122             elif ("src" in attrib and os.path.isfile(attrib["src"])) or ("href" in attrib and not attrib["href"].startswith('#') and os.path.isfile(attrib["href"])):
    123                 raise ValueError("Unknown tag %s for href/src" % tag)
    124 
    125             this = etree.SubElement(root, tag, attrib)
    126 
    127             rest, new_static = Page._generate_inner(this, elem)
    128             static.update(new_static)
    129 
    130             if isinstance(rest, str): ## It was some text
    131                 this.text = rest
    132 
    133             return this, static
    134         else: ## List node
    135             ## For text nodes, we need to keep track of the last element so we can put the text in 
    136             ## its tail, if necessary
    137             prev_iter = None
    138             if root.text is None:
    139                 root.text = ""
    140             for other in elem:
    141                 rest, new_static = Page._generate_inner(root, other)
    142                 static.update(new_static)
    143                 if isinstance(rest, str): ## Text to add
    144                     if prev_iter is not None: ## Add it to the tail
    145                         if prev_iter.tail is None:
    146                             prev_iter.tail = ""
    147                         if prev_iter.tail:
    148                             prev_iter.tail += ' '
    149                         prev_iter.tail += rest
    150                     else:
    151                         if root.text:
    152                             root.text += ' '
    153                         root.text += rest
    154                 else: ## Otherwise, we update the last element to point to this one
    155                     prev_iter = rest
    156 
    157             return root, static
    158 
    159     def generate_xhtml(self):
    160         """Generate the XHTML representation of this page. 
    161     
    162         Returns a 3-tuple:
    163 
    164             (root, kwargs, static)
    165 
    166         Where `root` is the root Element for the page, `kwargs` are the keyword arguments
    167         to be used when converting this page to a string, and `static` is a mapping from 
    168         zip file path to filesystem path for storing static files required by this page. 
    169         The file's zip path is determined from its hash, so collisions need not be handled.
    170         """
    171         chap = etree.Element("html", {}, {None: "http://www.w3.org/1999/xhtml", "epub": "http://www.idpf.org/2007/ops"})
    172 
    173         head = etree.SubElement(chap, "head")
    174         etree.SubElement(head, "title").text = self.title
    175         etree.SubElement(head, "link", {"href": "../Styles/stylesheet.css", "rel": "stylesheet", "type": "text/css"})
    176         etree.SubElement(head, "link", {"href": "../Styles/page-template.xpgt", "rel": "stylesheet", "type": "application/vnd.adobe-page-template+xml"})
    177 
    178         div = etree.SubElement(etree.SubElement(chap, "body"), "div")
    179         _, static = self._generate_inner(div, ("", self.content))
    180 
    181         return (chap, {"doctype": '<!DOCTYPE html>', "standalone": False}, static)
    182 
    183     def __init__(self, title, content, in_toc=True, type_="content", fname=None):
    184         self.content = content
    185         self.title = title
    186         self.in_toc = in_toc
    187         self.type = type_
    188         self.fname = fname
    189 
    190 class BasicTOCPage(Page):
    191     """Page for generating a TOC."""
    192     def add_page(self, p: Page, fname):
    193         """Add a page to the TOC."""
    194         print("toc", p.title, p.in_toc)
    195         if p.in_toc:
    196             if p.type != Page.CONTENT or p.fname is not None:
    197                 self.toc_ol.append(("li", (("a", {"href": fname}), p.title)))
    198             else:
    199                 self.toc_ol.append(("li", (("a", {"href": fname}), "{header}".format(num=self.chapter_count, header=p.title))))
    200                 self.chapter_count += 1
    201 
    202     def clear(self):
    203         """Clear the current table of contents."""
    204         while self.toc_ol:
    205             self.toc_ol.pop(0)
    206 
    207     def __init__(self, in_toc=True, fname="toc.xhtml"):
    208         super().__init__("Table of Contents", [("h1", "Table of Contents")], in_toc, type_=Page.TOC, fname=fname)
    209         self.chapter_count = 1
    210         self.toc_ol = []
    211         self.content.append((("nav", {EPUB+"type": "toc"}), ("ol", ("", self.toc_ol))))
    212 
    213 class BasicTitlePage(Page):
    214     """Basic title page that shows the title and the author."""
    215     def __init__(self, book_title, author, in_toc=True, fname="title.xhtml"):
    216         super().__init__("Title Page", [NBSP, ("h1", book_title), NBSP, ("h2", "By "+author)], in_toc=in_toc, type_=Page.TITLE, fname=fname)
    217 
    218 class Book:
    219     """Class representing an EPUB v3.0 container."""
    220     def generate_epub(self, target="out.epub"):
    221         """Generate the EPUB."""
    222         static = {"OEBPS/Styles/page-template.xpgt" : "/home/ben/Workspace/epub/static/page-template.xpgt",
    223                   "OEBPS/Styles/stylesheet.css": "/home/ben/Workspace/epub/static/stylesheet.css"}
    224         xmlmap = {}
    225 
    226         ## Generate the container file
    227         container = etree.Element("container", {"version": "1.0"}, {None: "urn:oasis:names:tc:opendocument:xmlns:container"})
    228         xmlmap["META-INF/container.xml"] = (container, {}, {})
    229         rf = etree.SubElement(container, "rootfiles")
    230         etree.SubElement(rf, "rootfile", {"full-path": "OEBPS/content.opf", "media-type": "application/oebps-package+xml"})
    231 
    232         ## Generate the content file 
    233         content = etree.Element("package", {"unique-identifier" : "BookID", "version": "3.0"}, {None: OPF_NS["opf"]})
    234         xmlmap["OEBPS/content.opf"] = (content, {}, {})
    235 
    236         metad = etree.SubElement(content, "metadata", {}, OPF_NS)
    237         etree.SubElement(metad, DC+"title").text = self.title 
    238         etree.SubElement(metad, DC+"rights").text = "Public Domain"
    239         etree.SubElement(metad, DC+"language").text = "en-US"
    240         etree.SubElement(metad, DC+"creator", {"id": "author"}).text = self.author
    241         etree.SubElement(metad, "meta", {"refines": "#author", "property": "role", "scheme": "marc:relators", "id": "role"}).text = "aut"
    242         etree.SubElement(metad, "meta", {"property": "dcterms:modified"}).text = dt.datetime.utcnow().isoformat()[:-7]+'Z'
    243         etree.SubElement(metad, DC+"identifier", {"id": "BookID"}).text = str(uuid.uuid3(uuid.NAMESPACE_OID, self.title+'|'+self.author))
    244         manif = etree.SubElement(content, "manifest")
    245         etree.SubElement(manif, "item", {"id": "page-template.xpgt", "href": "Styles/page-template.xpgt", "media-type": "application/vnd.adobe-page-template+xml"})
    246         etree.SubElement(manif, "item", {"id": "stylesheet.css", "href": "Styles/stylesheet.css", "media-type": "text/css"})
    247 
    248         if self.cover is not None:
    249             cover_path = "Static/cover" + os.path.splitext(self.cover)[1]
    250             etree.SubElement(manif, "item", {"id": "cover", "href": cover_path, "media-type": mimetypes.guess_type(cover_path), "properties": "cover-image"})
    251             static["OEBPS/"+cover_path] = self.cover
    252 
    253         spine = etree.SubElement(content, "spine")
    254         
    255         ## Generate pages
    256         self.toc.clear()
    257         form = "page%06d.xhtml"
    258         n = 1
    259         for p in self.pages:
    260             manif_attr = {}
    261 
    262             if p.type == Page.TOC:
    263                 manif_attr["properties"] = "nav"
    264             
    265             if p.fname is None:
    266                 fname = form % n
    267             else:
    268                 fname = p.fname
    269 
    270             ## Add it to the manifest
    271             etree.SubElement(manif, "item", {"id": fname, "href": "Text/"+fname, "media-type": "application/xhtml+xml", **manif_attr})
    272             ## Add it to the spine
    273             etree.SubElement(spine, "itemref", {"idref": fname})
    274             ## Add it to the TOC
    275             self.toc.add_page(p, fname)
    276             ## Generate the page
    277             xml = p.generate_xhtml()
    278             xmlmap["OEBPS/Text/"+fname] = xml
    279             for zpath in xml[2]:
    280                 if zpath.startswith("OEBPS/"):
    281                     zpath = zpath[6:]
    282                 etree.SubElement(manif, "item", {"id": zpath.replace('/', '-'), "href": zpath, "media-type": mimetypes.guess_type(zpath)[0]})
    283             n += 1
    284 
    285         ## Regenerate the TOC
    286         xmlmap["OEBPS/Text/"+self.toc.fname] = self.toc.generate_xhtml()
    287 
    288         epub = zipfile.ZipFile(target, 'w', zipfile.ZIP_DEFLATED)
    289         epub.writestr("mimetype", "application/epub+zip", zipfile.ZIP_STORED)
    290         for zpath, path in static.items():
    291             epub.write(path, zpath)
    292         for zpath, xml in xmlmap.items():
    293             xml, kwargs, static = xml
    294             epub.writestr(zpath, etree.tostring(xml, encoding="utf-8", xml_declaration=True, pretty_print=True, **kwargs))
    295             for szpath, spath in static.items():
    296                 epub.write(spath, szpath)
    297 
    298         return epub
    299 
    300     def __init__(self, title, author, toc_class=BasicTOCPage, title_class=BasicTitlePage, cover=None):
    301         self.title = title
    302         self.author = author
    303 
    304         self.title_page = title_class(title, author)
    305         self.toc = toc_class(in_toc=False)
    306         self.cover = cover
    307 
    308         self.pages = [self.title_page, self.toc]