33845bd47a
Извлечение (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
179 lines
9.0 KiB
Python
179 lines
9.0 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
|
||
|
||
# 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()
|