Files
pdf2epub/scripts/pack_epub.py
T
chesirecatt a21a4d713b Автоматический подбор порогов вместо ручного + упаковка EPUB без calibre
Ручная настройка block_kind() под каждую книгу оказалась неприменима: у части
изданий имена шрифтов вычищены до «Fd820825», а флаги стилей у всех спанов
равны 4, то есть ни курсива, ни моноширинности по ним не видно. Пороги теперь
считаются из самого файла:

- кегль основного текста — по гистограмме символов;
- кегль глав — по частоте блочных кеглей, а не множителем от текста: иначе
  подзаголовки разделов попадают в главы и оглавление пухнет до трёхсот пунктов;
- глава отличается от раздела положением в верхней трети полосы, и правило
  применяется, только если глав так набирается хотя бы пять;
- красная строка — по распределению x0.

Листинг опознаётся флагом моноширинности, а при его отсутствии — кеглем мельче
основного текста плюс плотностью кодовой пунктуации.

Отсев картинок: мельче 24 pt (линейки и буллеты) и крупнее 60% площади полосы
(подложка отсканированной страницы). На «Программисте-прагматике» второе
правило убрало 370 сканов полос и уменьшило EPUB со 124 МБ до 17 МБ.

pack_epub.py собирает EPUB на стандартной библиотеке: calibre для упаковки не
нужен, а его установка требует root. Книга режется по h1, оглавление NCX
строится само. Тест test_pack_epub.py проверяет несжатый mimetype первым файлом,
экранирование разметки в метаданных, сохранение переносов в листинге и то, что
преамбула до первой главы не теряется.
2026-08-20 20:11:03 +03:00

146 lines
6.7 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 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 = [], []
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",
'<?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)
if meta["cover"] and (imgdir / meta["cover"]).exists():
z.write(imgdir / meta["cover"], "OEBPS/images/" + meta["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 + ([meta["cover"]] if meta["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>
</metadata>
<manifest>%s</manifest>
<spine toc="ncx">%s</spine>
</package>""" % (html.escape(meta["id"]), html.escape(meta["title"]),
html.escape(meta["author"]), meta["lang"],
"\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/")
args = ap.parse_args()
meta = {"title": args.title, "author": args.author, "lang": args.lang,
"cover": args.cover, "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()