356b9ddb22
Невидимый слой tesseract набран GlyphLessFont с выставленным моно-битом, и is_mono() опознавала книгу как один сплошной листинг: измерено на Ansible после OCR — pre 10498, p 0. Кегль в таком слое подгоняется под рамку слова и гуляет внутри абзаца (9.0, 9.2, 9.4), из-за чего гистограмма размазывалась и основной кегль книги определялся неверно; в OCR-слое он округляется до целого. Каждая строка кода приходит отдельным блоком — соседние pre склеиваются переводом строки, иначе листинг рассыпается на десяток однострочных. Признак OCR-слоя считается по выборке страниц, обычные PDF идут прежним путём. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
308 lines
15 KiB
Python
308 lines
15 KiB
Python
#!/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"[{}();\[\]<>=*/#]|::|->")
|
||
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 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()
|
||
|
||
|
||
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):
|
||
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
|
||
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
|
||
|
||
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"))
|