Files
pdf2epub/scripts/pdf2html.py
T
chesirecatt 5d957e7be9 pdf2html: CMYK-блоки с ext=png на деле JPEG — декодировать через Pixmap
На «Запускаем Ansible» 3-го изд. 10 из 593 картинок — блоки с
b["ext"]=="png" и colorspace=4, но реальные байты — CMYK JPEG (FFD8,
подтверждено file). Запись b["image"] как есть под именем .png давала файл,
который pngquant и часть читалок отказывались декодировать.

_image_png() декодирует сырые байты через pymupdf.Pixmap (MuPDF определяет
формат по содержимому, не по чужому ext), при colorspace не Gray/RGB
конвертирует в RGB, и всегда отдаёт настоящий PNG. На отказе декодирования —
откат к исходным байтам, не падать.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 12:01:05 +03:00

446 lines
24 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 _image_png(raw):
"""Байты картинки блока -> настоящий PNG.
b["ext"] врёт: на «Запускаем Ansible» 3-го изд. блоки с ext="png" и
colorspace=4 на деле — CMYK JPEG (magic FFD8, подтверждено `file`), и
PNG-инструменты (pngquant) отказываются их декодировать с именем .png.
Вместо доверия заявленному формату декодируем через Pixmap (MuPDF сам
распознаёт реальный формат по данным) и всегда отдаём RGB PNG — тогда
имя файла (.png) не расходится с содержимым и CMYK не ловит инверсию
цвета в читалках, которые не ждут Adobe-JPEG с четырьмя каналами.
"""
try:
pix = pymupdf.Pixmap(raw)
if pix.colorspace is not None and pix.colorspace.n not in (1, 3):
pix = pymupdf.Pixmap(pymupdf.csRGB, pix)
return pix.tobytes("png")
except Exception:
return raw # не смогли декодировать — пишем как есть, лучше так, чем ничего
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()
head_len = {}
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
head_len.setdefault(size_of(top), []).append(
"".join(sp["text"] for sp in spans).strip())
if not size_chars:
sys.exit("в PDF нет извлекаемого текста — нужен OCR, этот скрипт не поможет")
body = size_chars.most_common(1)[0][0]
# кандидаты в главы: крупнее текста и встречаются не единожды (единичный
# размер — это титул, а не уровень заголовка)
# Ещё условие: у кандидата должны быть слова, а не цифры. У Нейгарда номер
# главы набран кеглем 100 отдельным блоком («1», «2», …), и без проверки
# главой объявлялся он, а настоящие заголовки в 30 пунктов уезжали в
# разделы — оглавление выходило пустым. Отбор идёт по наличию букв, а не по
# длине: «Preface» — семь знаков, и порог по длине отсекал бы и его.
def _wordy(sz):
txts = head_len.get(sz) or []
letters = sum(1 for t in txts if re.search(r"[^\W\d_]", t))
return bool(txts) and letters * 2 >= len(txts)
cand = sorted((sz for sz, n in head_blocks.items()
if sz >= body * 1.15 and n >= 3 and _wordy(sz)), 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)
# 1.10, а не 1.15: у Вернона подзаголовок набран 17.2 при тексте 15.0, то
# есть ровно на пять сотых ниже прежнего порога — и все 85 разделов книги
# уезжали в прозу. Тот же множитель, что у split_by_size, иначе разрез и
# классификация расходятся.
if sz >= body * 1.10:
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")
def running_heads(doc, band, limit=80, min_pages=5):
"""Тексты, повторяющиеся в верхнем поле на многих полосах.
Позиционный признак в одиночку рубит и настоящие заголовки: у книг,
свёрстанных calibre, глава начинается ровно с верха полосы и короче 80
знаков. У Вернона так пропали 14 заголовков из 15. Повтор по десяткам
страниц — то, чем колонтитул отличается от заголовка.
"""
seen = collections.Counter()
for page in doc:
for b in page.get_text("dict")["blocks"]:
if b.get("type") == 1 or "lines" not in b:
continue
if b["bbox"][1] >= page.rect.height * band:
continue
txt = "".join(sp["text"] for l in b["lines"] for sp in l["spans"])
txt = re.sub(r"\d+", "", txt).strip().lower()
if txt and len(txt) < limit:
seen[txt] += 1
return {t for t, n in seen.items() if n >= min_pages}
def split_by_size(b, body):
"""Разрезать блок, где шапка набрана крупнее следующего за ней текста.
У Вернона 85 подзаголовков лежат в одном блоке с первым абзацем раздела:
строка 17.2 и сразу за ней 15.0. Без разреза они становятся частью абзаца,
оглавление теряет второй уровень, а текст начинается с заголовка без точки.
"""
lines = [l for l in b.get("lines", [])
if any(sp["text"].strip() for sp in l["spans"])]
if len(lines) < 2:
return [b]
big = [max(size_of(sp) for sp in l["spans"] if sp["text"].strip())
> body * 1.12 for l in lines]
if not big[0] or all(big):
return [b]
cut = big.index(False)
if not any(big[:cut]) or any(big[cut:]):
return [b] # разрез только когда крупное строго сверху
head = dict(b, lines=lines[:cut],
bbox=(b["bbox"][0], b["bbox"][1], b["bbox"][2],
lines[cut - 1]["bbox"][3]))
rest = dict(b, lines=lines[cut:],
bbox=(b["bbox"][0], lines[cut]["bbox"][1], b["bbox"][2],
b["bbox"][3]))
return [head, rest]
HEADS = running_heads(doc, HEAD_BAND)
print("колонтитулов опознано по повтору:", len(HEADS))
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])
blocks = [part for b in blocks
for part in (split_by_size(b, BODY) if b.get("type") != 1 else [b])]
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(_image_png(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
and re.sub(r"\d+", "", bookhtml.strip_tags(txt)
if hasattr(bookhtml, "strip_tags")
else re.sub(r"<[^>]+>", "", txt)).strip().lower() in HEADS):
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"))