Автоматический подбор порогов вместо ручного + упаковка 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:
+109
-25
@@ -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 "<code>%s</code>" % t
|
||||
bold, ital = style(s["font"])
|
||||
bold, ital = style(s["font"], s["flags"])
|
||||
if ital:
|
||||
t = "<i>%s</i>" % 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, '<img src="images/%s"/>' % name))
|
||||
parts.append(("figure", None, '<img src="images/%s"/>' % 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. Пробелы здесь уже не трогаем:
|
||||
# внутри <pre> они значимы, а в прозе схлопнуты в block_text.
|
||||
|
||||
Reference in New Issue
Block a user