Files
pdf2epub/scripts/pdf2html.py
T
chesirecatt 0320d2d331 Мусор в оглавлении сканов: фильтр поворота, проверка заголовков, вход для DjVu
Причиной мусорных заголовков у Брикмана оказался не порог кегля, а повёрнутый
на 90° текст: боковые врезки и подписи к таблицам распознаются в кашу
(«aoHegoduenueArdng») и попадают в h1, потому что кегль у них крупный. Замер:
105 строк из 17 300, все до одной брак. Блоки с неgоризонтальным направлением
строки выбрасываются целиком (is_rotated).

Остаток — подписи внутри иллюстраций — понижается до <p>, а не удаляется:
looks_garbled() в bookhtml.py, шесть признаков структуры, любых двух хватает.
Проверка повторяется после склейки соседних заголовков: по отдельности «LF»,
«FF» и «TIT» проходят как аббревиатуры, склеенные — обрывок таблицы.
Вместе: 99 h1 у Брикмана против 48.

djvu2html.py — третий вход конвейера. Родной текстовый слой DjVu чище нашего
OCR (у Праты 10,9 против 2,8 слов со смесью алфавитов на 10 000), а через PDF
он не проходит: ddjvu -format=pdf кладёт страницы картинками.

Самопроверки: djvu2html.py --selftest, pdf2html.py --selftest --selftest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCiPWRi3Jqy4saD2LwU1Dp
2026-08-25 19:03:08 +03:00

352 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""PDF (calibre-generated) -> semantic XHTML, preserving italic/bold/headings."""
import collections
import html
import re
import sys
from pathlib import Path
import pymupdf
import bookhtml
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
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)
# Пороги не зашиты: имена шрифтов у многих издательств вычищены до вида
# «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"[{}();\[\]<>=*/#]|::|->")
ROTATED = 0.1 # синус угла строки: больше — текст повёрнут, а не набран по горизонтали
HEAD_BAND = 0.07 # доля высоты полосы сверху, где лежит колонтитул, а не текст
SOFT_HYPHEN = "\u00ad"
OCR = False
def style(font, flags=0):
"""Полужирный и курсив: сначала флаги, затем имя — имена бывают пустыми."""
f = font.lower()
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):
"""Моноширинный шрифт = листинг кода. Признак из флагов, а не из имени:
имена у каждого издательства свои, флаг одинаковый.
Исключение — невидимый слой Tesseract: он весь набран GlyphLessFont с
выставленным моно-битом, и без этой проверки книга целиком опознаётся как
один сплошной листинг (измерено на Ansible после OCR: pre 10498, p 0).
В таком слое листинги ловятся кеглем и пунктуацией, см. block_kind().
"""
if "glyphless" in s["font"].lower():
return False
return bool(s["flags"] & MONO)
def ocr_layer(doc, step=23):
"""Текст сделан OCR-ом: у всего слоя один невидимый шрифт. Кегль в таком
слое подгоняется под рамку слова и гуляет (9.0, 9.2, 9.4 в одном абзаце),
поэтому дальше он округляется до целого — иначе гистограмма размазывается
и основной кегль книги определяется неверно."""
glyphless = total = 0
for pno in range(1, doc.page_count, step):
for b in doc[pno].get_text("dict")["blocks"]:
for l in b.get("lines", []):
for s in l["spans"]:
n = len(s["text"])
total += n
if "glyphless" in s["font"].lower():
glyphless += n
return total > 0 and glyphless / total > 0.9
def size_of(span):
"""Кегль спана. В OCR-слое округляется до целого: см. ocr_layer()."""
return round(span["size"]) if OCR else round(span["size"], 1)
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[size_of(sp)] += len(sp["text"])
top = max(spans, key=lambda sp: len(sp["text"]))
head_blocks[size_of(top)] += 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:
return ""
if plain: # внутри <pre> курсив и полужирный только мешают
return t
if is_mono(s):
return "<code>%s</code>" % t
bold, ital = style(s["font"], s["flags"])
if ital:
t = "<i>%s</i>" % t
if bold:
t = "<b>%s</b>" % t
return t
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 is_rotated(b):
"""Строки блока набраны не по горизонтали (боковая врезка, подпись к таблице)."""
return bool(b.get("lines")) and any(abs(l["dir"][1]) > ROTATED for l in b["lines"])
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"]))
sz = size_of(top)
if all(is_mono(sp) for sp in spans):
return ("pre", None)
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 ("p", None)
def block_text(b, pre=False):
if pre:
# Перенос строки в коде значим, висящий дефис — это минус, а не перенос
# слова. Ни склейки строк, ни де-дефисации здесь быть не должно.
rows = ["".join(span_html(s, plain=True) for s in l["spans"])
for l in b["lines"]]
# В настоящем коде мягкого переноса не бывает: если он тут есть, блок
# опознан как листинг ошибочно (обычно это таблица опций), и слово надо
# склеить, а не оставить разорванным переводом строки.
return "\n".join(rows).replace(SOFT_HYPHEN + "\n", "").rstrip()
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]
# Русские издания переносят мягким дефисом U+00AD, а не обычным:
# без этой ветки строки склеиваются через пробел и слово рвётся
# пополам («введен ные»). Измерено на Шоттсе (Питер, 2020).
if prev.endswith(SOFT_HYPHEN):
out[-1] = prev[:-1]
elif 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)
txt = re.sub(r"(?<=\S) {2,}(?=\S)", " ", txt)
# Мягкие переносы внутри строки читалке не нужны и ломают поиск по тексту.
txt = txt.replace(SOFT_HYPHEN, "")
return txt.strip()
if SRC.name == "--selftest": # python3 pdf2html.py --selftest --selftest
# Повёрнутый текст ловится не по буквам, а по направлению строки: у
# «aoHegoduenueArdng» из боковой врезки Брикмана структура слова приличная.
assert is_rotated({"lines": [{"dir": (0.0, 1.0)}]})
assert is_rotated({"lines": [{"dir": (0.0, -1.0)}]})
assert not is_rotated({"lines": [{"dir": (1.0, 0.0)}, {"dir": (1.0, 0.01)}]})
for bad in ("30HWod1Q", "o|qisuy", "1 2 3 4", "|", "0Q1 v9 8|", "В",
"9120905", "LF. FF. FT. TIT", "ee. лов. о", "лов", "TIT. ITITIT", "TT",
"‘ая. ®No TERRAFORM", "юн"):
assert bookhtml.looks_garbled(bad), bad
for good in ("Глава 1. Зачем нужен Terraform", "C++11 и лямбды", "Python3",
"Модули", "Часть II. Основы", "Что дальше?", "Часть III", "API",
"AI", "Резюме", "06 авторе", "systemd в деталях"):
assert not bookhtml.looks_garbled(good), good
print("selftest OK")
sys.exit()
doc = pymupdf.open(SRC)
OCR = ocr_layer(doc)
BODY, INDENT_X, H1_SIZE = profile(doc)
if OCR:
print("слой распознан OCR-ом: моно-признак отключён, кегль округляется")
print("кегль текста %.1f, глав %.1f, красная строка от x0=%.0f"
% (BODY, H1_SIZE, INDENT_X))
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):
# Повёрнутый на 90° текст: боковые врезки и подписи к таблицам. В книге
# их единицы, а распознаются они всегда в кашу («aoHegoduenueArdng») и
# лезут в заголовки, потому что кегль у них крупный. Замерено у Брикмана:
# 105 строк из 17 300, все до одной — брак. Настоящая повёрнутая таблица
# тоже отсеется, но её всё равно нечем показать в потоковой вёрстке.
if is_rotated(b):
continue
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, False))
continue
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
# Колонтитул: верхние 7% полосы — поле, а не текст. У Брикмана так
# отсеивается 21 блок крупного кегля из 511, все до одного — шапки.
# ponytail: признак позиционный. Надёжнее — текст, повторяющийся на
# десятках полос, но за это платить вторым проходом по документу.
if b["bbox"][1] < page.rect.height * HEAD_BAND and len(txt) < 80:
continue
if tag in ("h1", "h2") and bookhtml.looks_garbled(txt):
tag, cls = "p", None
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, open_para[3])
continue
# Соседние листинги склеиваются в один блок: OCR-слой отдаёт каждую
# строку кода отдельным блоком, и без склейки листинг рассыпается на
# десяток однострочных <pre> подряд. Строки внутри блока значимы,
# поэтому соединяются переводом строки, а не пробелом.
if OCR and tag == "pre" and open_para and open_para[0] == "pre":
open_para = (tag, cls, open_para[2] + "\n" + txt, open_para[3])
continue
if open_para:
parts.append(open_para)
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
# Проверка повторяется после склейки: по отдельности «LF», «FF» и «TIT» проходят
# как аббревиатуры, а склеенные в один заголовок — это обрывок таблицы.
parts = [(("p", None, x) if t in ("h1", "h2") and bookhtml.looks_garbled(x)
else (t, c, x)) for t, c, x in parts]
doc_html = bookhtml.document(doc.metadata.get("title") or SRC.stem, parts)
# merge tag runs split at page/line boundaries. Пробелы здесь уже не трогаем:
# внутри <pre> они значимы, а в прозе схлопнуты в block_text.
doc_html = re.sub(r"</(i|b|code)>(\s*)<\1>", r"\2", 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"),
"pre:", sum(1 for p in parts if p[0] == "pre"))