Автоматический подбор порогов вместо ручного + упаковка 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 первым файлом,
экранирование разметки в метаданных, сохранение переносов в листинге и то, что
преамбула до первой главы не теряется.
This commit is contained in:
chesirecatt
2026-08-20 20:11:03 +03:00
parent 1bacd38292
commit a21a4d713b
3 changed files with 329 additions and 25 deletions
+145
View File
@@ -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, если он всё-таки понадобится.
Книга режется по <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()
+109 -25
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""PDF (calibre-generated) -> semantic XHTML, preserving italic/bold/headings.""" """PDF (calibre-generated) -> semantic XHTML, preserving italic/bold/headings."""
import collections
import html import html
import re import re
import sys 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") sys.exit("usage: pdf2html.py book.pdf out.html | pdf2html.py --fonts book.pdf")
if sys.argv[1] == "--fonts": # разведка: какие шрифты/кегли в PDF if sys.argv[1] == "--fonts": # разведка: какие шрифты/кегли в PDF
import collections
c = collections.Counter() c = collections.Counter()
d = pymupdf.open(sys.argv[2]) d = pymupdf.open(sys.argv[2])
for p in d: for p in d:
@@ -31,23 +30,64 @@ OUT = Path(sys.argv[2])
IMGDIR = OUT.parent / "images" IMGDIR = OUT.parent / "images"
IMGDIR.mkdir(parents=True, exist_ok=True) IMGDIR.mkdir(parents=True, exist_ok=True)
# Пороги подобраны под вёрстку 15pt/letter. Для другой книги сначала # Пороги не зашиты: имена шрифтов у многих издательств вычищены до вида
# посмотреть реальные шрифты и кегли: pdf2html.py --fonts file.pdf # «Fd820825», а кегль основного текста у каждой книги свой. Профиль считается по
INDENT_X = 88 # x0 первой строки: больше — абзац с красной строки # самому файлу, увидеть его: pdf2html.py --fonts file.pdf
MONO = 8 # бит моноширинного шрифта в span["flags"] (PyMuPDF) 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() 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): def is_mono(s):
"""Моноширинный шрифт = листинг кода. Признак берётся из флагов PyMuPDF, а """Моноширинный шрифт = листинг кода. Признак из флагов, а не из имени:
не из имени шрифта: имена у каждого издательства свои, флаг одинаковый.""" имена у каждого издательства свои, флаг одинаковый."""
return bool(s["flags"] & MONO) 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): def span_html(s, plain=False):
t = html.escape(s["text"]) t = html.escape(s["text"])
if not t: if not t:
@@ -56,7 +96,7 @@ def span_html(s, plain=False):
return t return t
if is_mono(s): if is_mono(s):
return "<code>%s</code>" % t return "<code>%s</code>" % t
bold, ital = style(s["font"]) bold, ital = style(s["font"], s["flags"])
if ital: if ital:
t = "<i>%s</i>" % t t = "<i>%s</i>" % t
if bold: if bold:
@@ -64,23 +104,30 @@ def span_html(s, plain=False):
return t return t
def block_kind(b): def looks_like_code(spans):
"""(tag, css class) from dominant span font/size.""" """Доля кодовой пунктуации. Порог низкий: в прозе на 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()] spans = [s for l in b["lines"] for s in l["spans"] if s["text"].strip()]
if not spans: if not spans:
return None return None
top = max(spans, key=lambda s: len(s["text"])) top = max(spans, key=lambda s: len(s["text"]))
f, sz = top["font"], top["size"] sz = top["size"]
if sz >= 28 or f.startswith("Arvo") or f.startswith("CloisterBlack"):
return ("h1", "title")
if sz >= 24:
return ("h1", None)
if all(is_mono(sp) for sp in spans): if all(is_mono(sp) for sp in spans):
return ("pre", None) 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) return ("h2", None)
if f.startswith("MyriadPro"):
return ("p", "note") # Maxine's journal / chat / slides
return ("p", None) return ("p", None)
@@ -111,6 +158,9 @@ def block_text(b, pre=False):
doc = pymupdf.open(SRC) doc = pymupdf.open(SRC)
BODY, INDENT_X, H1_SIZE = profile(doc)
print("кегль текста %.1f, глав %.1f, красная строка от x0=%.0f"
% (BODY, H1_SIZE, INDENT_X))
parts = [] parts = []
open_para = None # (tag, cls, text) still collecting 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]) blocks = sorted(page.get_text("dict")["blocks"], key=lambda b: b["bbox"][1])
for bi, b in enumerate(blocks): for bi, b in enumerate(blocks):
if b["type"] == 1: 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 img_n += 1
name = "img%02d.png" % img_n name = "img%02d.png" % img_n
(IMGDIR / name).write_bytes(b["image"]) (IMGDIR / name).write_bytes(b["image"])
if open_para: if open_para:
parts.append(open_para) parts.append(open_para)
open_para = None open_para = None
parts.append(("figure", None, '<img src="images/%s"/>' % name)) parts.append(("figure", None, '<img src="images/%s"/>' % name, False))
continue continue
kind = block_kind(b) kind = block_kind(b, BODY, H1_SIZE)
if not kind: if not kind:
continue continue
tag, cls = kind tag, cls = kind
# Глава открывает полосу, раздел встречается по тексту. Признак —
# верхняя треть страницы, а не «первый блок»: сверху нередко идёт
# колонтитул или плашка, и тогда первым заголовок не бывает никогда.
at_top = b["bbox"][1] < page.rect.height * 0.35
txt = block_text(b, pre=(tag == "pre")) txt = block_text(b, pre=(tag == "pre"))
if not txt: if not txt:
continue continue
@@ -151,14 +211,38 @@ for pno, page in enumerate(doc):
) )
if cont: if cont:
join = "" if open_para[2].endswith("-") else " " 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 continue
if open_para: if open_para:
parts.append(open_para) parts.append(open_para)
open_para = (tag, cls, txt) open_para = (tag, cls, txt, at_top)
if open_para: if open_para:
parts.append(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) doc_html = bookhtml.document(doc.metadata.get("title") or SRC.stem, parts)
# merge tag runs split at page/line boundaries. Пробелы здесь уже не трогаем: # merge tag runs split at page/line boundaries. Пробелы здесь уже не трогаем:
# внутри <pre> они значимы, а в прозе схлопнуты в block_text. # внутри <pre> они значимы, а в прозе схлопнуты в block_text.
+75
View File
@@ -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 = """<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>T</title>
<style>p { margin: 0; }</style></head><body>
<p>Предисловие до первой главы.</p>
<h1>Глава 1. Начало</h1>
<p>Текст первой главы.</p>
<pre>def f():
return 1</pre>
<p class="figure"><img src="images/fig.png"/></p>
<h1>Глава 2. Продолжение</h1>
<p>Текст второй главы.</p>
</body></html>"""
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 "<dc:title>Книга &amp; книга</dc:title>" in opf, "разметку надо экранировать"
assert opf.count("<itemref") == 3
assert 'properties="cover-image"' in opf
ncx = z.read("OEBPS/toc.ncx").decode("utf-8")
assert re.findall(r"<text>([^<]*)</text>", ncx)[1:] == [
"Начало", "Глава 1. Начало", "Глава 2. Продолжение"]
ch1 = z.read("OEBPS/ch001.xhtml").decode("utf-8")
assert "<pre>def f():\n return 1</pre>" 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("<html><body>\n</body></html>", 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")