| epub - Python library for generating standard-compliant EPUB 3.0 books.
git clone https://benconnors.ca/git-repos/epub |
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]