af5c243687
У zipfile.ZipFile умолчание ZIP_STORED, компрессия нигде не задавалась — книги весили втрое больше при том же тексте (Брикман 1,42 МБ против 0,38). Теперь ZIP_DEFLATED; mimetype по-прежнему пишется несжатым, этого требует формат EPUB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCiPWRi3Jqy4saD2LwU1Dp
173 lines
8.4 KiB
Python
173 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""XHTML от pdf2html.py/epub2html.py -> EPUB без сторонних зависимостей.
|
|
|
|
calibre для упаковки не нужен: EPUB — это zip с манифестом, а установка calibre
|
|
требует root, которого на чужой машине может не быть. Формат намеренно
|
|
консервативный (EPUB 2 + NCX): его одинаково понимают KOReader, штатные читалки
|
|
PocketBook и calibre, если он всё-таки понадобится.
|
|
|
|
Книга режется по <h1>: одна глава — один файл. Так читалка не разбирает
|
|
мегабайтный документ на каждом перелистывании и получает оглавление даром.
|
|
"""
|
|
import argparse
|
|
import html
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
H1 = re.compile(r"^<h1[^>]*>(.*?)</h1>$")
|
|
IMG = re.compile(r'<img src="images/([^"]+)"')
|
|
NS = 'xmlns="http://www.w3.org/1999/xhtml"'
|
|
|
|
CHAPTER = """<?xml version="1.0" encoding="utf-8"?>
|
|
<!DOCTYPE html>
|
|
<html %s><head><meta charset="utf-8"/><title>%s</title>
|
|
<link rel="stylesheet" type="text/css" href="style.css"/></head>
|
|
<body>
|
|
%s
|
|
</body></html>
|
|
"""
|
|
|
|
|
|
def split_chapters(lines):
|
|
"""[(заголовок, [строки])] — режем по <h1>, преамбулу оставляем главой."""
|
|
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 <meta name="calibre:series" content="%s"/>' % html.escape(name)]
|
|
if idx:
|
|
out.append('<meta name="calibre:series_index" content="%s"/>' % html.escape(str(idx)))
|
|
out.append('<meta property="belongs-to-collection" id="c01">%s</meta>' % html.escape(name))
|
|
out.append('<meta refines="#c01" property="collection-type">series</meta>')
|
|
if idx:
|
|
out.append('<meta refines="#c01" property="group-position">%s</meta>' % html.escape(str(idx)))
|
|
return "\n ".join(out)
|
|
|
|
|
|
def build(src, out, meta):
|
|
text = src.read_text(encoding="utf-8")
|
|
css = re.search(r"<style>(.*?)</style>", text, re.S)
|
|
css = css.group(1) if css else ""
|
|
body = re.search(r"<body>(.*)</body>", 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",
|
|
'<?xml version="1.0"?>\n<container version="1.0" '
|
|
'xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
|
|
'<rootfiles><rootfile full-path="OEBPS/content.opf" '
|
|
'media-type="application/oebps-package+xml"/></rootfiles></container>')
|
|
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 = ['<item id="css" href="style.css" media-type="text/css"/>',
|
|
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>']
|
|
spine = []
|
|
for i, (name, _) in enumerate(files):
|
|
items.append('<item id="c%d" href="%s" media-type="application/xhtml+xml"/>'
|
|
% (i, name))
|
|
spine.append('<itemref idref="c%d"/>' % 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('<item id="i%d" href="images/%s" media-type="%s"%s/>'
|
|
% (i, img, mime.get(Path(img).suffix.lower(), "image/jpeg"),
|
|
' properties="cover-image"' if img == meta["cover"] else ""))
|
|
z.writestr("OEBPS/content.opf", """<?xml version="1.0" encoding="utf-8"?>
|
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bid">
|
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
<dc:identifier id="bid">%s</dc:identifier>
|
|
<dc:title>%s</dc:title>
|
|
<dc:creator>%s</dc:creator>
|
|
<dc:language>%s</dc:language>%s
|
|
</metadata>
|
|
<manifest>%s</manifest>
|
|
<spine toc="ncx">%s</spine>
|
|
</package>""" % (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(
|
|
'<navPoint id="n%d" playOrder="%d"><navLabel><text>%s</text></navLabel>'
|
|
'<content src="%s"/></navPoint>' % (i, i + 1, html.escape(t), n)
|
|
for i, (n, t) in enumerate(files))
|
|
z.writestr("OEBPS/toc.ncx", """<?xml version="1.0" encoding="utf-8"?>
|
|
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
|
<head><meta name="dtb:uid" content="%s"/></head>
|
|
<docTitle><text>%s</text></docTitle>
|
|
<navMap>%s</navMap>
|
|
</ncx>""" % (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()
|