commit 7226e10464fa5d9fe7cb5899d21b9f5d261444a3 Author: Илья Поляков Date: Mon Aug 3 07:28:14 2026 +0300 pdf2epub: конвертер PDF->EPUB с сохранением курсива и структуры глав diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76f4bc2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +out/ +*.epub +*.azw3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..537c77e --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# pdf2epub + +Конвертация PDF, собранного из электронной книги (Producer: calibre), в EPUB/AZW3 +для Kindle **с сохранением форматирования**: курсив, полужирный, заголовки глав, +врезки, иллюстрации, склейка абзацев через границы страниц. + +## Зачем не `ebook-convert` напрямую + +Прямая конвертация `ebook-convert book.pdf book.epub` теряет курсив: poppler +определяет начертание по имени шрифта и не опознаёт сокращённые имена вроде +`MinionPro-It` (без подстроки `Italic`). На «The Unicorn Project» получилось +**435 курсивных фрагментов в PDF против 1 тега `` в EPUB**. Плюс calibre +размечает основной текст как `

` (2817 штук), потому что определяет заголовки +по кеглю относительно самого частого. + +`pdf2html.py` извлекает текст через PyMuPDF, где начертание берётся из +свойств span'а, и сам раскладывает блоки по семантике. + +## Что делает скрипт + +- курсив/полужирный — по имени шрифта span'а (`-It`, `Italic`, `Bold`, `Semibold`); +- заголовки — по кеглю: `>= 28` или декоративный шрифт — `h1.title`, + `>= 24` — `h1` (главы и части), `>= 17` — `h2` (подзаголовок с датой); +- врезки (дневник, чаты, слайды) — отдельный шрифт семейства → `p.note`; +- абзац = блок PDF; блок без красной строки в начале страницы приклеивается + к абзацу с предыдущей страницы; +- переносы на конце строки снимаются, соседние `` схлопываются; +- иллюстрации выгружаются в `images/`, обложка рендерится со страницы 1 в 150 dpi; +- позиционирование не сохраняется намеренно — текст должен течь под любой + размер шрифта на читалке. + +## Требования + +PyMuPDF и calibre: + +```bash +python3 -m venv ~/.venvs/pdf2epub +~/.venvs/pdf2epub/bin/pip install pymupdf +sudo dnf install calibre # если ebook-convert ещё нет +``` + +## Использование + +Разведка — какие в PDF шрифты и кегли (пороги в скрипте подобраны под вёрстку +15pt/letter, для другой книги их правят по этому выводу): + +```bash +~/.venvs/pdf2epub/bin/python pdf2html.py --fonts book.pdf +``` + +Конвертация: + +```bash +~/.venvs/pdf2epub/bin/python pdf2html.py book.pdf out/book.html + +ebook-convert out/book.html "Название.epub" \ + --title="Название" \ + --authors="Автор" \ + --language=en \ + --cover=out/images/cover.jpg \ + --level1-toc='//h:h1' --level2-toc='//h:h2' \ + --page-breaks-before='//h:h1' \ + --no-default-epub-cover + +ebook-convert "Название.epub" "Название.azw3" +``` + +`--level1-toc`/`--level2-toc` обязательны: без них calibre строит оглавление +своей эвристикой и ломает разбивку по главам. + +## Куда класть на Kindle + +- `.epub` — через Send to Kindle (веб или почта), Amazon конвертирует на своей стороне; +- `.azw3` — по USB прямо в `documents/` на устройстве. + +## Проверка результата + +```bash +python3 - <<'EOF' +import re +t = open('out/book.html').read() +print('курсив:', t.count(''), 'полужирный:', t.count('')) +print('заголовки:', len(re.findall(r']+>', '', t).count(' ')) +EOF +``` + +Двойных пробелов должно быть единицы, курсива — сотни. Ноль курсива означает, +что имена шрифтов в PDF не попали под правило в `style()`. + +## Ограничения + +- рассчитан на PDF с текстовым слоем; сканы требуют OCR и сюда не годятся; +- пороги кеглей и семейство шрифта для врезок — константы под конкретную вёрстку, + правятся руками по выводу `--fonts`; +- колонтитулы и номера страниц не вырезаются: в PDF от calibre их нет. + Для типографского PDF понадобится отбрасывать блоки по координате `y`. + +## Проверено на + +`The Unicorn Project` (Gene Kim), 399 страниц: 40 заголовков, оглавление на +62 пункта, 430 курсивных фрагментов, 178 врезок, 5 иллюстраций, ~733 тыс. знаков. diff --git a/pdf2html.py b/pdf2html.py new file mode 100644 index 0000000..850eefa --- /dev/null +++ b/pdf2html.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""PDF (calibre-generated) -> semantic XHTML, preserving italic/bold/headings.""" +import html +import re +import sys +from pathlib import Path + +import pymupdf + +if sys.argv[1] == "--fonts": # разведка: какие шрифты/кегли в PDF + import collections + + c = collections.Counter() + d = pymupdf.open(sys.argv[2]) + for p in d: + for b in p.get_text("dict")["blocks"]: + for l in b.get("lines", []): + for s in l["spans"]: + c[(s["font"], round(s["size"], 1))] += len(s["text"]) + for (f, sz), n in c.most_common(25): + print("%-32s %5.1f %8d симв." % (f, sz, n)) + sys.exit() + +SRC = Path(sys.argv[1]) +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 первой строки: больше — абзац с красной строки + + +def style(font): + f = font.lower() + return ("bold" in f or "semibold" in f, "-it" in f or "italic" in f) + + +def span_html(s): + t = html.escape(s["text"]) + if not t: + return "" + bold, ital = style(s["font"]) + if ital: + t = "%s" % t + if bold: + t = "%s" % t + return t + + +def block_kind(b): + """(tag, css class) from dominant span font/size.""" + 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) + if sz >= 17: + return ("h2", None) + if f.startswith("MyriadPro"): + return ("p", "note") # Maxine's journal / chat / slides + return ("p", None) + + +def block_text(b): + out = [] + for i, l in enumerate(b["lines"]): + line = "".join(span_html(s) for s in l["spans"]) + if not line: + continue + if out: + prev = out[-1] + if prev.endswith("-") and not prev.endswith("--"): + out[-1] = prev[:-1] # de-hyphenate + else: + out.append(" ") + out.append(line) + txt = "".join(out) + txt = re.sub(r"(\s*)", r"\1", txt) + txt = re.sub(r"(\s*)", r"\1", txt) + return txt.strip() + + +doc = pymupdf.open(SRC) +parts = [] +open_para = None # (tag, cls, text) still collecting + +# cover +pix = doc[0].get_pixmap(dpi=150) +pix.save(IMGDIR / "cover.jpg") + +img_n = 0 +for pno, page in enumerate(doc): + if pno == 0: + continue + blocks = sorted(page.get_text("dict")["blocks"], key=lambda b: b["bbox"][1]) + for bi, b in enumerate(blocks): + if b["type"] == 1: + 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)) + continue + kind = block_kind(b) + if not kind: + continue + tag, cls = kind + txt = block_text(b) + if not txt: + continue + indented = b["lines"][0]["bbox"][0] >= INDENT_X + cont = ( + open_para + and tag == "p" + and open_para[0] == "p" + and open_para[1] == cls + and bi == 0 + and not indented + ) + if cont: + join = "" if open_para[2].endswith("-") else " " + open_para = (tag, cls, open_para[2] + join + txt) + continue + if open_para: + parts.append(open_para) + open_para = (tag, cls, txt) +if open_para: + parts.append(open_para) + +CSS = """ +body { margin: 0 1em; } +h1 { text-align: center; margin: 2em 0 0.2em; page-break-before: always; } +h1.title { page-break-before: avoid; } +h2 { text-align: center; font-style: italic; font-weight: normal; + font-size: 1.1em; margin: 0.2em 0 1.5em; } +p { text-indent: 1.2em; margin: 0; text-align: justify; } +p.note { text-indent: 0; margin: 1em 2em; font-size: 0.9em; + font-family: sans-serif; } +p.figure { text-indent: 0; text-align: center; margin: 1em 0; } +img { max-width: 100%; } +""" + +buf = ['', + '', + "%s" % html.escape(doc.metadata.get("title") or SRC.stem), + "" % CSS] +for tag, cls, txt in parts: + if tag == "figure": + buf.append('

%s

' % txt) + else: + c = ' class="%s"' % cls if cls else "" + buf.append("<%s%s>%s" % (tag, c, txt, tag)) +buf.append("") +doc_html = "\n".join(buf) +# merge tag runs split at page/line boundaries, collapse doubled spaces +doc_html = re.sub(r"(\s*)<\1>", r"\2", doc_html) +doc_html = re.sub(r"(?<=\S) {2,}(?=\S)", " ", doc_html) +OUT.write_text(doc_html, encoding="utf-8") + +print("blocks:", len(parts), "images:", img_n) +print("h1:", sum(1 for p in parts if p[0] == "h1"), + "p:", sum(1 for p in parts if p[0] == "p"))