#!/usr/bin/env python3
"""XHTML от pdf2html.py/epub2html.py -> EPUB без сторонних зависимостей.
calibre для упаковки не нужен: EPUB — это zip с манифестом, а установка calibre
требует root, которого на чужой машине может не быть. Формат намеренно
консервативный (EPUB 2 + NCX): его одинаково понимают KOReader, штатные читалки
PocketBook и calibre, если он всё-таки понадобится.
Книга режется по
: одна глава — один файл. Так читалка не разбирает
мегабайтный документ на каждом перелистывании и получает оглавление даром.
"""
import argparse
import html
import re
import shutil
import sys
import zipfile
from pathlib import Path
H1 = re.compile(r"^
, преамбулу оставляем главой."""
chapters, title, buf = [], "Начало", []
for line in lines:
m = H1.match(line.strip())
if m and buf:
chapters.append((title, buf))
title, buf = re.sub("<[^>]+>", "", m.group(1)).strip() or "Глава", [line]
continue
if m:
title = re.sub("<[^>]+>", "", m.group(1)).strip() or "Глава"
buf.append(line)
if buf:
chapters.append((title, buf))
return [(t, b) for t, b in chapters if any(x.strip() for x in b)]
def series_meta(meta):
"""Цикл и номер в нём. Пишем в двух видах: calibre-совместимый meta читают
почти все читалки, belongs-to-collection — требование EPUB 3."""
name = meta.get("series")
if not name:
return ""
idx = meta.get("series_index") or ""
out = ['\n ' % html.escape(name)]
if idx:
out.append('' % html.escape(str(idx)))
out.append('%s' % html.escape(name))
out.append('series')
if idx:
out.append('%s' % html.escape(str(idx)))
return "\n ".join(out)
def build(src, out, meta):
text = src.read_text(encoding="utf-8")
css = re.search(r"", text, re.S)
css = css.group(1) if css else ""
body = re.search(r"(.*)", text, re.S)
lines = (body.group(1) if body else text).splitlines()
chapters = split_chapters(lines)
if not chapters:
sys.exit("в %s нет содержимого" % src)
imgdir = src.parent / "images"
used, files = [], []
with zipfile.ZipFile(out, "w") as z:
# mimetype обязан идти первым и без сжатия — иначе часть читалок
# не опознаёт архив как EPUB
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip",
compress_type=zipfile.ZIP_STORED)
z.writestr("META-INF/container.xml",
'\n'
'')
z.writestr("OEBPS/style.css", css)
for n, (title, buf) in enumerate(chapters):
name = "ch%03d.xhtml" % n
files.append((name, title))
chunk = "\n".join(buf)
for img in IMG.findall(chunk):
if img not in used and (imgdir / img).exists():
used.append(img)
z.writestr("OEBPS/" + name,
CHAPTER % (NS, html.escape(title), chunk))
for img in used:
z.write(imgdir / img, "OEBPS/images/" + img)
# Обложка бывает и среди картинок текста — второй раз её класть нельзя,
# zip примет дубликат имени, а читалки на такой архив ругаются.
cover = meta["cover"] if meta["cover"] not in used else ""
if cover and (imgdir / cover).exists():
z.write(imgdir / cover, "OEBPS/images/" + cover)
items = ['',
'']
spine = []
for i, (name, _) in enumerate(files):
items.append(''
% (i, name))
spine.append('' % i)
mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".svg": "image/svg+xml"}
for i, img in enumerate(used + ([cover] if cover else [])):
items.append(''
% (i, img, mime.get(Path(img).suffix.lower(), "image/jpeg"),
' properties="cover-image"' if img == meta["cover"] else ""))
z.writestr("OEBPS/content.opf", """
%s%s%s%s%s
%s%s""" % (html.escape(meta["id"]), html.escape(meta["title"]),
html.escape(meta["author"]), meta["lang"], series_meta(meta),
"\n ".join(items), "\n ".join(spine)))
nav = "\n".join(
'%s'
'' % (i, i + 1, html.escape(t), n)
for i, (n, t) in enumerate(files))
z.writestr("OEBPS/toc.ncx", """
%s%s""" % (html.escape(meta["id"]), html.escape(meta["title"]), nav))
return len(chapters), len(used)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("source", type=Path, help="XHTML от pdf2html.py/epub2html.py")
ap.add_argument("output", type=Path, help="куда писать .epub")
ap.add_argument("--title", required=True)
ap.add_argument("--author", default="")
ap.add_argument("--lang", default="ru")
ap.add_argument("--cover", default="cover.jpg", help="имя файла в images/")
ap.add_argument("--series", default="", help="название цикла")
ap.add_argument("--series-index", default="", help="номер в цикле")
args = ap.parse_args()
meta = {"title": args.title, "author": args.author, "lang": args.lang,
"cover": args.cover, "series": args.series,
"series_index": args.series_index,
"id": "urn:uuid:" + args.output.stem.replace(" ", "-")}
n, imgs = build(args.source, args.output, meta)
size = args.output.stat().st_size / 2**20
print("%s: глав %d, картинок %d, %.1f МБ" % (args.output.name, n, imgs, size))
if __name__ == "__main__":
main()