Files
pdf2epub/scripts/pack_epub.py
T
chesirecatt 33845bd47a Пять правок по итогам четырёх книг из to_read
Извлечение (pdf2html.py), каждая правка вызвана конкретной книгой:

* колонтитул опознаётся по повтору текста в верхнем поле на многих полосах, а
  не по одной позиции. Позиционный признак рубит и настоящие заголовки: у
  Вернона («DDD Distilled») глава начинается ровно с верха полосы и короче 80
  знаков, так пропали 14 заголовков из 15. Это ровно тот второй проход, о
  котором говорил ponytail-комментарий на прежнем фильтре;
* блок режется, если шапка набрана крупнее следующего за ней текста. У Вернона
  85 подзаголовков лежали в одном блоке с первым абзацем раздела: оглавление
  теряло второй уровень, а абзац начинался с заголовка без точки;
* порог раздела снижен с 1.15 до 1.10 кегля текста. У Вернона подзаголовок
  17.2 при тексте 15.0 — ровно на пять сотых ниже прежнего порога, и все 85
  разделов уезжали в прозу. Тот же множитель, что у разреза блока, иначе
  разрез и классификация расходятся;
* кандидатом в главы не может быть кегль, у которого в блоках нет букв. У
  Нейгарда («Release it!») номер главы набран кеглем 100 отдельным блоком
  («1», «2», …), и главой объявлялся он, а настоящие заголовки в 30 пунктов
  уезжали в разделы — оглавление выходило пустым. Отбор по наличию букв, а не
  по длине: «Preface» — семь знаков, порог по длине отсекал бы и его.

Упаковка (pack_epub.py): XML 1.0 запрещает управляющие символы, а из PDF они
приезжают регулярно. У Бейера («Site Reliability Engineering») 33 главы из 45
не разбирались как XML из-за одного такого знака в начале абзаца, и читалка
спотыкается на таком файле молча. Чистим на упаковке, а не надеемся на
извлечение. Тест на это добавлен и проверен: с обезвреженной правкой он падает.

SKILL.md приведён в соответствие с репозиторием. Шаг упаковки с 25.08 делает
pack_epub.py, а текст всё ещё требовал calibre на PATH — из-за этого проверка
инструментов давала ложный отказ. Замерено 05.09.2026: ebook-convert
отсутствует на всех трёх здешних машинах, и все четыре книги собрались без
него. За calibre остался только .azw3 для Kindle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCiPWRi3Jqy4saD2LwU1Dp
2026-09-05 18:23:05 +03:00

179 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
# XML 1.0 запрещает управляющие символы, а из PDF они приезжают регулярно:
# у Бейера («Site Reliability Engineering») 33 главы из 45 не разбирались как
# XML из-за одного такого знака в начале абзаца. Читалка спотыкается молча,
# поэтому чистим на упаковке, а не надеемся на извлечение.
BAD_XML = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
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 = BAD_XML.sub("", 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()