From a21a4d713bb2ff2b3b994449abf9f760f3492379 Mon Sep 17 00:00:00 2001 From: chesirecatt Date: Thu, 20 Aug 2026 20:11:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D1=80=D1=83=D1=87?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20+=20=D1=83=D0=BF=D0=B0=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20EPUB=20=D0=B1=D0=B5=D0=B7=20calibre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ручная настройка 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 первым файлом, экранирование разметки в метаданных, сохранение переносов в листинге и то, что преамбула до первой главы не теряется. --- scripts/pack_epub.py | 145 ++++++++++++++++++++++++++++++++++++++ scripts/pdf2html.py | 134 ++++++++++++++++++++++++++++------- scripts/test_pack_epub.py | 75 ++++++++++++++++++++ 3 files changed, 329 insertions(+), 25 deletions(-) create mode 100644 scripts/pack_epub.py create mode 100644 scripts/test_pack_epub.py diff --git a/scripts/pack_epub.py b/scripts/pack_epub.py new file mode 100644 index 0000000..b0e93dc --- /dev/null +++ b/scripts/pack_epub.py @@ -0,0 +1,145 @@ +#!/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 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 = [], [] + 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", + '\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) + if meta["cover"] and (imgdir / meta["cover"]).exists(): + z.write(imgdir / meta["cover"], "OEBPS/images/" + meta["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 + ([meta["cover"]] if meta["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 +""" % (html.escape(meta["id"]), html.escape(meta["title"]), + html.escape(meta["author"]), meta["lang"], + "\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/") + 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() diff --git a/scripts/pdf2html.py b/scripts/pdf2html.py index f1d1417..d5763e4 100644 --- a/scripts/pdf2html.py +++ b/scripts/pdf2html.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """PDF (calibre-generated) -> semantic XHTML, preserving italic/bold/headings.""" +import collections import html import re import sys @@ -13,8 +14,6 @@ if len(sys.argv) < 3 or sys.argv[1] in ("-h", "--help"): sys.exit("usage: pdf2html.py book.pdf out.html | pdf2html.py --fonts book.pdf") if sys.argv[1] == "--fonts": # разведка: какие шрифты/кегли в PDF - import collections - c = collections.Counter() d = pymupdf.open(sys.argv[2]) for p in d: @@ -31,23 +30,64 @@ OUT = Path(sys.argv[2]) IMGDIR = OUT.parent / "images" IMGDIR.mkdir(parents=True, exist_ok=True) -# Пороги подобраны под вёрстку 15pt/letter. Для другой книги сначала -# посмотреть реальные шрифты и кегли: pdf2html.py --fonts file.pdf -INDENT_X = 88 # x0 первой строки: больше — абзац с красной строки -MONO = 8 # бит моноширинного шрифта в span["flags"] (PyMuPDF) +# Пороги не зашиты: имена шрифтов у многих издательств вычищены до вида +# «Fd820825», а кегль основного текста у каждой книги свой. Профиль считается по +# самому файлу, увидеть его: pdf2html.py --fonts file.pdf +ITALIC, MONO, BOLD = 2, 8, 16 # биты span["flags"] в PyMuPDF +MIN_IMG = 24 # pt: всё мельче — линейки, буллеты и прочая вёрстка, не иллюстрации +MAX_IMG_SHARE = 0.6 # доля площади страницы: больше — это скан полосы, а не рисунок +# Запасное опознание листинга, когда издатель вычистил и имена шрифтов, и флаги: +# кегль мельче основного текста плюс пунктуация, которой в прозе не бывает. +CODEY = re.compile(r"[{}();\[\]<>=*/#]|::|->") -def style(font): +def style(font, flags=0): + """Полужирный и курсив: сначала флаги, затем имя — имена бывают пустыми.""" f = font.lower() - return ("bold" in f or "semibold" in f, "-it" in f or "italic" in f) + return (bool(flags & BOLD) or "bold" in f or "semibold" in f, + bool(flags & ITALIC) or "-it" in f or "italic" in f) def is_mono(s): - """Моноширинный шрифт = листинг кода. Признак берётся из флагов PyMuPDF, а - не из имени шрифта: имена у каждого издательства свои, флаг одинаковый.""" + """Моноширинный шрифт = листинг кода. Признак из флагов, а не из имени: + имена у каждого издательства свои, флаг одинаковый.""" return bool(s["flags"] & MONO) +def profile(doc, step=7): + """(кегль текста, x0 красной строки, кегль глав) по выборке страниц. + + Кегль глав берётся из гистограммы, а не как «в полтора раза больше текста»: + у книги обычно два-три размера заголовков, и если объявить главой каждый из + них, оглавление на читалке распухает до трёхсот пунктов вместо двадцати. + """ + size_chars = collections.Counter() + head_blocks = collections.Counter() + x0 = collections.Counter() + for pno in range(1, doc.page_count, step): + for b in doc[pno].get_text("dict")["blocks"]: + spans = [sp for l in b.get("lines", []) for sp in l["spans"] + if sp["text"].strip()] + if not spans: + continue + for l in b["lines"]: + x0[round(l["bbox"][0])] += 1 + for sp in spans: + size_chars[round(sp["size"], 1)] += len(sp["text"]) + top = max(spans, key=lambda sp: len(sp["text"])) + head_blocks[round(top["size"], 1)] += 1 + if not size_chars: + sys.exit("в PDF нет извлекаемого текста — нужен OCR, этот скрипт не поможет") + body = size_chars.most_common(1)[0][0] + # кандидаты в главы: крупнее текста и встречаются не единожды (единичный + # размер — это титул, а не уровень заголовка) + cand = sorted((sz for sz, n in head_blocks.items() + if sz >= body * 1.15 and n >= 3), reverse=True) + h1 = cand[0] if cand else body * 1.55 + left = min(x for x, n in x0.most_common(4)) + return body, left + max(4, body * 0.6), h1 + + def span_html(s, plain=False): t = html.escape(s["text"]) if not t: @@ -56,7 +96,7 @@ def span_html(s, plain=False): return t if is_mono(s): return "%s" % t - bold, ital = style(s["font"]) + bold, ital = style(s["font"], s["flags"]) if ital: t = "%s" % t if bold: @@ -64,23 +104,30 @@ def span_html(s, plain=False): return t -def block_kind(b): - """(tag, css class) from dominant span font/size.""" +def looks_like_code(spans): + """Доля кодовой пунктуации. Порог низкий: в прозе на 200 знаков не набирается + и десятка скобок со звёздочками, в листинге — набирается всегда.""" + text = "".join(s["text"] for s in spans) + return len(text) > 8 and len(CODEY.findall(text)) / len(text) > 0.03 + + +def block_kind(b, body, h1_size): + """(tag, css class) по кеглю блока относительно основного текста книги.""" spans = [s for l in b["lines"] for s in l["spans"] if s["text"].strip()] if not spans: return None top = max(spans, key=lambda s: len(s["text"])) - f, sz = top["font"], top["size"] - if sz >= 28 or f.startswith("Arvo") or f.startswith("CloisterBlack"): - return ("h1", "title") - if sz >= 24: - return ("h1", None) + sz = top["size"] if all(is_mono(sp) for sp in spans): return ("pre", None) - if sz >= 17: + if sz <= body * 0.93 and looks_like_code(spans): + return ("pre", None) + if sz >= h1_size * 1.35: + return ("h1", "title") + if sz >= h1_size * 0.97: + return ("h1", None) + if sz >= body * 1.15: return ("h2", None) - if f.startswith("MyriadPro"): - return ("p", "note") # Maxine's journal / chat / slides return ("p", None) @@ -111,6 +158,9 @@ def block_text(b, pre=False): doc = pymupdf.open(SRC) +BODY, INDENT_X, H1_SIZE = profile(doc) +print("кегль текста %.1f, глав %.1f, красная строка от x0=%.0f" + % (BODY, H1_SIZE, INDENT_X)) parts = [] open_para = None # (tag, cls, text) still collecting @@ -125,18 +175,28 @@ for pno, page in enumerate(doc): blocks = sorted(page.get_text("dict")["blocks"], key=lambda b: b["bbox"][1]) for bi, b in enumerate(blocks): if b["type"] == 1: + x0, y0, x1, y1 = b["bbox"] + if x1 - x0 < MIN_IMG or y1 - y0 < MIN_IMG: + continue # линейка или буллет, а не иллюстрация + page_area = page.rect.width * page.rect.height + if page_area and (x1 - x0) * (y1 - y0) / page_area > MAX_IMG_SHARE: + continue # подложка отсканированной полосы: текст берём из OCR-слоя img_n += 1 name = "img%02d.png" % img_n (IMGDIR / name).write_bytes(b["image"]) if open_para: parts.append(open_para) open_para = None - parts.append(("figure", None, '' % name)) + parts.append(("figure", None, '' % name, False)) continue - kind = block_kind(b) + kind = block_kind(b, BODY, H1_SIZE) if not kind: continue tag, cls = kind + # Глава открывает полосу, раздел встречается по тексту. Признак — + # верхняя треть страницы, а не «первый блок»: сверху нередко идёт + # колонтитул или плашка, и тогда первым заголовок не бывает никогда. + at_top = b["bbox"][1] < page.rect.height * 0.35 txt = block_text(b, pre=(tag == "pre")) if not txt: continue @@ -151,14 +211,38 @@ for pno, page in enumerate(doc): ) if cont: join = "" if open_para[2].endswith("-") else " " - open_para = (tag, cls, open_para[2] + join + txt) + open_para = (tag, cls, open_para[2] + join + txt, open_para[3]) continue if open_para: parts.append(open_para) - open_para = (tag, cls, txt) + open_para = (tag, cls, txt, at_top) if open_para: parts.append(open_para) +# Понижаем заголовки не с верха полосы до раздела — но только если глав после +# этого остаётся достаточно: у части книг главы начинаются в середине страницы, +# и тогда правило обнулило бы оглавление целиком. +top_h1 = sum(1 for t, c, x, top in parts if t == "h1" and c is None and top) +if top_h1 >= 5: + parts = [(("h2" if (t == "h1" and c is None and not top) else t), c, x, top) + for t, c, x, top in parts] +parts = [(t, c, x) for t, c, x, _ in parts] + +# «1» и «Списки» приезжают отдельными блоками: номер главы набран крупно, её +# название — отдельной строкой. В оглавлении читалки нужен один пункт. +merged = [] +for part in parts: + if (part[0] == "h1" and merged and merged[-1][0] == "h1" + and len(merged[-1][2]) < 60): + merged[-1] = ("h1", merged[-1][1], merged[-1][2] + ". " + part[2]) + continue + if (part[0] == "h2" and merged and merged[-1][0] == "h1" + and len(merged[-1][2]) < 12): + merged[-1] = ("h1", merged[-1][1], merged[-1][2] + ". " + part[2]) + continue + merged.append(part) +parts = merged + doc_html = bookhtml.document(doc.metadata.get("title") or SRC.stem, parts) # merge tag runs split at page/line boundaries. Пробелы здесь уже не трогаем: # внутри
 они значимы, а в прозе схлопнуты в block_text.
diff --git a/scripts/test_pack_epub.py b/scripts/test_pack_epub.py
new file mode 100644
index 0000000..270ba93
--- /dev/null
+++ b/scripts/test_pack_epub.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""Самопроверка упаковщика без сети: python3 test_pack_epub.py"""
+import re
+import tempfile
+import zipfile
+from pathlib import Path
+
+import pack_epub
+
+BOOK = """
+T
+
+

Предисловие до первой главы.

+

Глава 1. Начало

+

Текст первой главы.

+
def f():
+    return 1
+

+

Глава 2. Продолжение

+

Текст второй главы.

+""" + + +def test_pack(tmp: Path): + src = tmp / "book.html" + src.write_text(BOOK, encoding="utf-8") + (tmp / "images").mkdir() + (tmp / "images" / "fig.png").write_bytes(b"\x89PNG\r\n\x1a\n") + (tmp / "images" / "cover.jpg").write_bytes(b"\xff\xd8\xff") + out = tmp / "book.epub" + chapters, imgs = pack_epub.build(src, out, { + "title": "Книга & книга", "author": "Автор", "lang": "ru", + "cover": "cover.jpg", "id": "urn:uuid:test"}) + + # преамбула до первой главы не теряется, поэтому глав три, а не две + assert (chapters, imgs) == (3, 1), (chapters, imgs) + + z = zipfile.ZipFile(out) + names = z.namelist() + # без этого часть читалок не опознаёт файл как EPUB + assert names[0] == "mimetype" + assert z.getinfo("mimetype").compress_type == zipfile.ZIP_STORED + assert "OEBPS/images/fig.png" in names and "OEBPS/images/cover.jpg" in names + + opf = z.read("OEBPS/content.opf").decode("utf-8") + assert "Книга & книга" in opf, "разметку надо экранировать" + assert opf.count("([^<]*)", ncx)[1:] == [ + "Начало", "Глава 1. Начало", "Глава 2. Продолжение"] + + ch1 = z.read("OEBPS/ch001.xhtml").decode("utf-8") + assert "
def f():\n    return 1
" in ch1, "переносы в листинге значимы" + assert 'href="style.css"' in ch1 + assert "Текст второй главы" not in ch1, "главы не должны склеиваться" + + +def test_no_content(tmp: Path): + src = tmp / "empty.html" + src.write_text("\n", encoding="utf-8") + try: + pack_epub.build(src, tmp / "x.epub", {"title": "t", "author": "", "lang": "ru", + "cover": "", "id": "i"}) + except SystemExit: + return + raise AssertionError("пустой источник должен останавливать упаковку") + + +if __name__ == "__main__": + for case in (test_pack, test_no_content): + with tempfile.TemporaryDirectory() as d: + case(Path(d)) + print("OK")