pdf2epub: конвертер PDF->EPUB с сохранением курсива и структуры глав
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
out/
|
||||||
|
*.epub
|
||||||
|
*.azw3
|
||||||
@@ -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 тега `<i>` в EPUB**. Плюс calibre
|
||||||
|
размечает основной текст как `<h2>` (2817 штук), потому что определяет заголовки
|
||||||
|
по кеглю относительно самого частого.
|
||||||
|
|
||||||
|
`pdf2html.py` извлекает текст через PyMuPDF, где начертание берётся из
|
||||||
|
свойств span'а, и сам раскладывает блоки по семантике.
|
||||||
|
|
||||||
|
## Что делает скрипт
|
||||||
|
|
||||||
|
- курсив/полужирный — по имени шрифта span'а (`-It`, `Italic`, `Bold`, `Semibold`);
|
||||||
|
- заголовки — по кеглю: `>= 28` или декоративный шрифт — `h1.title`,
|
||||||
|
`>= 24` — `h1` (главы и части), `>= 17` — `h2` (подзаголовок с датой);
|
||||||
|
- врезки (дневник, чаты, слайды) — отдельный шрифт семейства → `p.note`;
|
||||||
|
- абзац = блок PDF; блок без красной строки в начале страницы приклеивается
|
||||||
|
к абзацу с предыдущей страницы;
|
||||||
|
- переносы на конце строки снимаются, соседние `</i><i>` схлопываются;
|
||||||
|
- иллюстрации выгружаются в `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('<i>'), 'полужирный:', t.count('<b>'))
|
||||||
|
print('заголовки:', len(re.findall(r'<h1', t)))
|
||||||
|
print('двойные пробелы:', re.sub('<[^>]+>', '', t).count(' '))
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Двойных пробелов должно быть единицы, курсива — сотни. Ноль курсива означает,
|
||||||
|
что имена шрифтов в PDF не попали под правило в `style()`.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- рассчитан на PDF с текстовым слоем; сканы требуют OCR и сюда не годятся;
|
||||||
|
- пороги кеглей и семейство шрифта для врезок — константы под конкретную вёрстку,
|
||||||
|
правятся руками по выводу `--fonts`;
|
||||||
|
- колонтитулы и номера страниц не вырезаются: в PDF от calibre их нет.
|
||||||
|
Для типографского PDF понадобится отбрасывать блоки по координате `y`.
|
||||||
|
|
||||||
|
## Проверено на
|
||||||
|
|
||||||
|
`The Unicorn Project` (Gene Kim), 399 страниц: 40 заголовков, оглавление на
|
||||||
|
62 пункта, 430 курсивных фрагментов, 178 врезок, 5 иллюстраций, ~733 тыс. знаков.
|
||||||
+169
@@ -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 = "<i>%s</i>" % t
|
||||||
|
if bold:
|
||||||
|
t = "<b>%s</b>" % 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"</i>(\s*)<i>", r"\1", txt)
|
||||||
|
txt = re.sub(r"</b>(\s*)<b>", 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, '<img src="images/%s"/>' % 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 = ['<?xml version="1.0" encoding="utf-8"?>',
|
||||||
|
'<html xmlns="http://www.w3.org/1999/xhtml"><head>',
|
||||||
|
"<title>%s</title>" % html.escape(doc.metadata.get("title") or SRC.stem),
|
||||||
|
"<style>%s</style></head><body>" % CSS]
|
||||||
|
for tag, cls, txt in parts:
|
||||||
|
if tag == "figure":
|
||||||
|
buf.append('<p class="figure">%s</p>' % txt)
|
||||||
|
else:
|
||||||
|
c = ' class="%s"' % cls if cls else ""
|
||||||
|
buf.append("<%s%s>%s</%s>" % (tag, c, txt, tag))
|
||||||
|
buf.append("</body></html>")
|
||||||
|
doc_html = "\n".join(buf)
|
||||||
|
# merge tag runs split at page/line boundaries, collapse doubled spaces
|
||||||
|
doc_html = re.sub(r"</(i|b)>(\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"))
|
||||||
Reference in New Issue
Block a user