#!/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"^]*>(.*?)

$") IMG = re.compile(r' %s %s """ def split_chapters(lines): """[(заголовок, [строки])] — режем по

, преамбулу оставляем главой.""" 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 = [], [] # ZIP_DEFLATED обязателен явно: у zipfile умолчание — ZIP_STORED, и книга # выходит втрое толще (у Брикмана 1.42 МБ против 0.43 при том же тексте). # Первым файлом всё равно идёт несжатый mimetype — этого требует формат. with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) 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()