Пять правок по итогам четырёх книг из to_read

Извлечение (pdf2html.py), каждая правка вызвана конкретной книгой:

* колонтитул опознаётся по повтору текста в верхнем поле на многих полосах, а
  не по одной позиции. Позиционный признак рубит и настоящие заголовки: у
  Вернона («DDD Distilled») глава начинается ровно с верха полосы и короче 80
  знаков, так пропали 14 заголовков из 15. Это ровно тот второй проход, о
  котором говорил ponytail-комментарий на прежнем фильтре;
* блок режется, если шапка набрана крупнее следующего за ней текста. У Вернона
  85 подзаголовков лежали в одном блоке с первым абзацем раздела: оглавление
  теряло второй уровень, а абзац начинался с заголовка без точки;
* порог раздела снижен с 1.15 до 1.10 кегля текста. У Вернона подзаголовок
  17.2 при тексте 15.0 — ровно на пять сотых ниже прежнего порога, и все 85
  разделов уезжали в прозу. Тот же множитель, что у разреза блока, иначе
  разрез и классификация расходятся;
* кандидатом в главы не может быть кегль, у которого в блоках нет букв. У
  Нейгарда («Release it!») номер главы набран кеглем 100 отдельным блоком
  («1», «2», …), и главой объявлялся он, а настоящие заголовки в 30 пунктов
  уезжали в разделы — оглавление выходило пустым. Отбор по наличию букв, а не
  по длине: «Preface» — семь знаков, порог по длине отсекал бы и его.

Упаковка (pack_epub.py): XML 1.0 запрещает управляющие символы, а из PDF они
приезжают регулярно. У Бейера («Site Reliability Engineering») 33 главы из 45
не разбирались как XML из-за одного такого знака в начале абзаца, и читалка
спотыкается на таком файле молча. Чистим на упаковке, а не надеемся на
извлечение. Тест на это добавлен и проверен: с обезвреженной правкой он падает.

SKILL.md приведён в соответствие с репозиторием. Шаг упаковки с 25.08 делает
pack_epub.py, а текст всё ещё требовал calibre на PATH — из-за этого проверка
инструментов давала ложный отказ. Замерено 05.09.2026: ebook-convert
отсутствует на всех трёх здешних машинах, и все четыре книги собрались без
него. За calibre остался только .azw3 для Kindle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCiPWRi3Jqy4saD2LwU1Dp
This commit is contained in:
chesirecatt
2026-09-05 18:23:05 +03:00
parent af5c243687
commit 33845bd47a
4 changed files with 133 additions and 17 deletions
+77 -3
View File
@@ -97,6 +97,7 @@ def profile(doc, step=7):
"""
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"]:
@@ -110,13 +111,25 @@ def profile(doc, step=7):
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), reverse=True)
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
@@ -165,7 +178,11 @@ def block_kind(b, body, h1_size):
return ("h1", "title")
if sz >= h1_size * 0.97:
return ("h1", None)
if sz >= body * 1.15:
# 1.10, а не 1.15: у Вернона подзаголовок набран 17.2 при тексте 15.0, то
# есть ровно на пять сотых ниже прежнего порога — и все 85 разделов книги
# уезжали в прозу. Тот же множитель, что у split_by_size, иначе разрез и
# классификация расходятся.
if sz >= body * 1.10:
return ("h2", None)
return ("p", None)
@@ -237,11 +254,65 @@ open_para = None # (tag, cls, text) still collecting
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») и
@@ -280,7 +351,10 @@ for pno, page in enumerate(doc):
# отсеивается 21 блок крупного кегля из 511, все до одного — шапки.
# ponytail: признак позиционный. Надёжнее — текст, повторяющийся на
# десятках полос, но за это платить вторым проходом по документу.
if b["bbox"][1] < page.rect.height * HEAD_BAND and len(txt) < 80:
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