Мусор в оглавлении сканов: фильтр поворота, проверка заголовков, вход для 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
This commit is contained in:
chesirecatt
2026-08-25 19:03:08 +03:00
parent 356b9ddb22
commit 0320d2d331
5 changed files with 356 additions and 6 deletions
+35
View File
@@ -26,9 +26,44 @@
к абзацу с предыдущей страницы; к абзацу с предыдущей страницы;
- переносы на конце строки снимаются, соседние `</i><i>` схлопываются; - переносы на конце строки снимаются, соседние `</i><i>` схлопываются;
- иллюстрации выгружаются в `images/`, обложка рендерится со страницы 1 в 150 dpi; - иллюстрации выгружаются в `images/`, обложка рендерится со страницы 1 в 150 dpi;
- повёрнутый на 90° текст выбрасывается целиком: боковые врезки и подписи к
таблицам распознаются в кашу («aoHegoduenueArdng») и лезут в заголовки, потому
что кегль у них крупный. У Брикмана это 105 строк из 17 300, и все до одной —
брак;
- мусорный заголовок понижается до `<p>`, а не удаляется: оглавление чистится,
текст остаётся. Проверка — `looks_garbled()` в `scripts/bookhtml.py`, шесть
признаков структуры, любых двух хватает. Вместе с фильтром поворота это увело
Брикмана с 99 `<h1>` до 48;
- позиционирование не сохраняется намеренно — текст должен течь под любой - позиционирование не сохраняется намеренно — текст должен течь под любой
размер шрифта на читалке. размер шрифта на читалке.
## DjVu со своим текстовым слоем: `scripts/djvu2html.py`
У сканов в DjVu слой распознавания обычно уже есть, и он лучше нашего прогона
через `ocrmypdf`. Замер на Прате (C++ 6-е рус. изд., 1244 полосы): слов со
смесью алфавитов внутри слова было 433 на 391 тысячу слов (10,9 на 10 000),
после сборки из родного слоя — 109 (2,8).
⚠️ **Через PDF этот слой не проходит.** `ddjvu -format=pdf` кладёт страницы
картинками, `pdftotext` после этого отдаёт пустоту. Текст берётся напрямую из
`djvutxt --detail=line`, скрипт разбирает его сам.
Чего в слое DjVu нет вовсе — курсива и полужирного: в скане их и не было.
Заголовки опознаются высотой строки, листинги — пунктуацией.
⚠️ **Высота строки в DjVu — это габарит с выносными элементами, а не кегль.**
Строка с «Ц» или «р» выше соседней на те же 10%, поэтому пороги взяты по
измеренным разрывам, а не «чуть выше основного текста». У Праты при основной
строке 78: колонтитулы 85–90, разделы 105–125, названия глав 170–185.
Строки заголовка склеиваются подряд: название главы занимает две-три строки, и
без склейки «Класс string и стандартная библиотека шаблонов» разваливается на
четыре пункта оглавления.
Самопроверки без сети: `python3 scripts/djvu2html.py --selftest` и
`python3 scripts/pdf2html.py --selftest --selftest` (флаг занимает оба
обязательных аргумента).
## Подключение как скил Claude Code ## Подключение как скил Claude Code
Репозиторий одновременно является скилом (`SKILL.md` в корне) и клонируется Репозиторий одновременно является скилом (`SKILL.md` в корне) и клонируется
+27 -6
View File
@@ -15,12 +15,27 @@ tag, and 2817 body paragraphs became `<h2>`.
properties, and emits semantic XHTML. calibre is then used only as the properties, and emits semantic XHTML. calibre is then used only as the
XHTML→EPUB packer. XHTML→EPUB packer.
## Two entry points ## Three entry points
`scripts/pdf2html.py` for PDF, `scripts/epub2html.py` for EPUB. Both emit the `scripts/pdf2html.py` for PDF, `scripts/epub2html.py` for EPUB,
same normalized XHTML — one block per line, `<pre>` for code listings — and `scripts/djvu2html.py` for DjVu. All three emit the same normalized XHTML — one
everything downstream (translation, packing, audiobook) is identical. Shared block per line, `<pre>` for code listings — and everything downstream
document skeleton and CSS live in `scripts/bookhtml.py`. (translation, packing, audiobook) is identical. The shared document skeleton,
CSS and the junk-heading check `looks_garbled()` live in `scripts/bookhtml.py`.
**A DjVu with its own text layer must not be re-OCR'd.** The layer that is
already in the file beats a fresh `ocrmypdf` run — measured on Prata's C++ 6th
Russian edition, 1244 pages: words mixing Latin and Cyrillic inside one word
dropped from 433 to 109 per 391k words (10.9 → 2.8 per 10 000). That layer does
not survive a trip through PDF: `ddjvu -format=pdf` writes the pages as images
and `pdftotext` then returns nothing at all. `djvu2html.py` reads `djvutxt
--detail=line` directly.
Styling is the price: a DjVu text layer carries no italic or bold at all (the
scan never had them). Headings come from line height, listings from punctuation.
Line height there is the glyph bounding box, not the type size — a line holding
a descender is 10% taller than its neighbour — so the thresholds are set from
measured gaps (`H1`/`H2` in the script), not from "slightly above body text".
**Do not route an EPUB through the PDF path.** Converting EPUB→PDF→XHTML throws **Do not route an EPUB through the PDF path.** Converting EPUB→PDF→XHTML throws
away the semantic markup that is already there and re-derives it from font away the semantic markup that is already there and re-derives it from font
@@ -123,7 +138,13 @@ EOF
is being promoted; is being promoted;
- many double spaces means line joining is off; - many double spaces means line joining is off;
- list the extracted headings (`grep -o '<h1[^>]*>.\{0,60\}'`) and read them — - list the extracted headings (`grep -o '<h1[^>]*>.\{0,60\}'`) and read them —
they become the TOC, and a wrong one is obvious at a glance; they become the TOC, and a wrong one is obvious at a glance. On a scan most of
the junk there comes from **rotated** text, not from bad thresholds: sideways
captions and table stubs OCR into mush (`aoHegoduenueArdng`) and land in
headings because their type is large. `pdf2html.py` drops any block whose line
direction is not horizontal (`is_rotated()`, measured on Brikman: 105 lines out
of 17 300, every one of them garbage), and demotes what is left of the mush to
`<p>` rather than deleting it. That pair took Brikman from 99 `<h1>` to 48;
- read one full page of body text and confirm paragraphs merge across page - read one full page of body text and confirm paragraphs merge across page
breaks and hyphenated words are rejoined. breaks and hyphenated words are rejoined.
+55
View File
@@ -6,6 +6,14 @@
строки, а всё, чего не узнал, доносит до результата нетронутым. строки, а всё, чего не узнал, доносит до результата нетронутым.
""" """
import html import html
import re
# Брак OCR-а внутри слова: цифра вплотную к букве («30HWod1Q»), смесь алфавитов
# в одном слове («aoHegoduenue»), заглавная посреди слова.
DIGIT_WORD = re.compile(r"[^\W\d_][\d]|[\d][^\W\d_]")
WORD = re.compile(r"\w+")
LAT, CYR = re.compile(r"[A-Za-z]"), re.compile(r"[А-Яа-яЁё]")
MIDCAP = re.compile(r"[a-zа-яё][A-ZА-ЯЁ]")
CSS = """ CSS = """
body { margin: 0 1em; } body { margin: 0 1em; }
@@ -28,6 +36,53 @@ code { font-family: monospace; font-size: 0.9em; }
""" """
def looks_garbled(t):
"""Заголовок ли это вообще, или подпись из схемы, распознанная по буквам.
Основную часть брака снимает фильтр поворота в главном цикле — здесь остаётся
то, что набрано горизонтально: подписи внутри иллюстраций и обрывки таблиц
(«LF. FF. FT. TIT», «0|91239123», «В»).
Безусловный брак — три случая, каждый сам по себе: в заголовке нет ни одной
буквы; заголовок короче трёх знаков; в нём есть палка (в наборе её не бывает,
в распознанном скане она попадается постоянно).
Дальше шесть признаков структуры, любых двух хватает. Одного мало: «C++11 и
лямбды» даёт «не-букв больше трети», «Python3» — цифру вплотную к букве,
и оба заголовка настоящие.
"""
t = t.strip()
words = WORD.findall(t)
if not words or not any(c.isalpha() for c in t):
return True
if "|" in t:
return True
# Заголовок короче четырёх знаков — обрывок («В», «лов», «юн»). Аббревиатуры
# целиком заглавными («API», «AI») настоящими заголовками бывают, их щадим —
# но только если букв в них не одна и та же: «TT» это обрывок таблицы.
if len(t) < 4 and not (t.isupper() and len(set(t)) >= 2):
return True
# Слова из двух букв по кругу: «TIT. ITITIT», «LF. FF. FT». Нужны минимум два
# таких слова подряд и ни одного нормального, иначе под нож попадёт «Часть III».
long_words = [w for w in words if len(w) >= 3]
if len(long_words) >= 2 and all(len(set(w.lower())) <= 2 for w in long_words):
return True
letters = sum(c.isalpha() for c in t)
toks = t.split()
# Обрывок — короткое слово без цифр внутри: «LF.», «оо». Цифры исключены
# намеренно, иначе «C++11» и «Qt 6» считались бы обрывками.
frags = [w for w in toks if not any(c.isdigit() for c in w)
and sum(c.isalpha() for c in w) < 3]
signals = (
len(frags) * 2 > len(toks), # обрывки слов
letters * 3 < len(t) * 2, # не-букв больше трети
bool(DIGIT_WORD.search(t)),
any(LAT.search(w) and CYR.search(w) for w in words), # смесь алфавитов в слове
any(MIDCAP.search(w) for w in words), # заглавная посреди слова
next((c.islower() for c in t if c.isalpha()), False), # начинается со строчной
)
return sum(signals) >= 2
def document(title, parts): def document(title, parts):
"""parts: [(tag, cls, inner_html)]; tag 'figure' — псевдотег для картинки.""" """parts: [(tag, cls, inner_html)]; tag 'figure' — псевдотег для картинки."""
buf = ['<?xml version="1.0" encoding="utf-8"?>', buf = ['<?xml version="1.0" encoding="utf-8"?>',
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""DjVu со своим текстовым слоем -> semantic XHTML.
Для сканов, у которых слой распознавания уже есть в файле: он почти всегда
лучше нашего OCR-а. Замерено на Прате 6-го изд.: родной слой 302 523 слова
при смеси алфавитов 0.8 на 10 000 знаков, наш прогон через ocrmypdf —
296 954 слова при 12.6 и на 26 страниц меньше.
Через PDF этот слой не проходит: ddjvu -format=pdf кладёт страницы картинками
и текст теряется целиком (проверено, pdftotext даёт пустоту). Поэтому слой
берётся напрямую из djvutxt.
Стиля (курсив, полужирный) в слое DjVu нет вовсе — в скане его и не было.
Заголовки опознаются высотой строки, листинги — пунктуацией, как в pdf2html.py.
djvu2html.py book.djvu out.html
djvu2html.py --selftest
"""
import collections
import html
import re
import subprocess
import sys
from pathlib import Path
import bookhtml
CODEY = re.compile(r"[{}();\[\]<>=*/#]|::|->")
SOFT_HYPHEN = "­"
HEAD_BAND = 0.07 # доля высоты полосы: выше — колонтитул, а не текст
FOOT_BAND = 0.05 # то же снизу — колонцифра
# Высота строки в DjVu — это габарит с выносными элементами, а не кегль: строка
# с «Ц» или «р» выше соседней на те же 10%. Поэтому пороги взяты не «чуть выше
# основного текста», а по измеренным разрывам у Праты (основная строка 78):
# 85–90 — колонтитулы, 105–125 — разделы, 170–185 — названия глав.
H1 = 2.0
H2 = 1.30
def parse(txt):
"""djvutxt --detail=line -> [[(x0, y0, x1, y1, text), ...] по страницам].
Свой разбор, а не sexpdata: формат простой, а зависимость ради него —
лишняя. Строка может переноситься между скобкой и текстом, поэтому идём
по знакам, а не построчно.
"""
pages, cur, i, n = [], None, 0, len(txt)
while i < n:
c = txt[i]
if c == '"': # строка: до неэкранированной закрывающей кавычки
j, buf = i + 1, []
while j < n and txt[j] != '"':
if txt[j] == "\\" and j + 1 < n:
buf.append({"n": "\n", "t": "\t"}.get(txt[j + 1], txt[j + 1]))
j += 2
continue
buf.append(txt[j])
j += 1
if cur is not None and cur["box"]:
cur["lines"].append(tuple(cur["box"]) + ("".join(buf),))
cur["box"] = None
i = j + 1
continue
m = re.match(r"\((page|line)((?:\s+-?\d+){4})", txt[i:])
if m:
box = [int(v) for v in m.group(2).split()]
if m.group(1) == "page":
cur = {"h": box[3] - box[1], "lines": [], "box": None}
pages.append(cur)
elif cur is not None:
cur["box"] = box
i += m.end()
continue
i += 1
return [(p["h"], p["lines"]) for p in pages]
def profile(pages):
"""Высота основной строки и левое поле — по самым частым значениям."""
heights, lefts = collections.Counter(), collections.Counter()
for _, lines in pages:
for x0, y0, x1, y1, _ in lines:
heights[y1 - y0] += 1
lefts[x0] += 1
if not heights:
sys.exit("в DjVu нет текстового слоя — этот скрипт не поможет, нужен OCR")
# Высота строки гуляет на пиксель-другой, поэтому берётся не голая мода, а
# та высота, у которой вместе с соседями ±1 набирается больше всего строк.
body = max(heights, key=lambda h: sum(heights[h + d] for d in (-1, 0, 1)))
left = min(x for x, _ in lefts.most_common(4))
return body, left
def looks_like_code(t):
return len(t) > 8 and len(CODEY.findall(t)) / len(t) > 0.03
def kind(height, body):
if height >= body * H1:
return "h1"
if height >= body * H2:
return "h2"
return "p"
def convert(pages, body, left):
parts, open_para = [], None # open_para: [tag, cls, text]
for page_h, lines in pages:
for x0, y0, x1, y1, raw in lines:
txt = raw.strip()
if not txt:
continue
# Координаты DjVu считаются снизу вверх.
top = (page_h - y1) / page_h if page_h else 0.5
bottom = y0 / page_h if page_h else 0.5
if (top < HEAD_BAND or bottom < FOOT_BAND) and len(txt) < 80:
continue # колонтитул и колонцифра
tag = kind(y1 - y0, body)
if tag == "p" and looks_like_code(txt):
tag = "pre"
if tag in ("h1", "h2") and bookhtml.looks_garbled(txt):
tag = "p"
# Абзац продолжается, пока строки идут от левого поля: красная
# строка и смена тега начинают новый. Заголовок склеивается всегда:
# название главы занимает две-три строки («Класс string» / «и
# стандартная» / «библиотека» / «шаблонов» — это один заголовок),
# а два разных заголовка подряд без текста между ними не встречаются.
cont = open_para and open_para[0] == tag and (
tag in ("h1", "h2") or (tag == "p" and x0 <= left + (y1 - y0)))
if tag == "pre" and open_para and open_para[0] == "pre":
open_para[2] += "\n" + txt
continue
if cont:
prev = open_para[2]
if prev.endswith(SOFT_HYPHEN):
open_para[2] = prev[:-1] + txt
elif prev.endswith("-") and not prev.endswith("--"):
open_para[2] = prev[:-1] + txt
else:
open_para[2] = prev + " " + txt
continue
if open_para:
parts.append(tuple(open_para))
open_para = [tag, None, txt]
if open_para:
parts.append(tuple(open_para))
return [(t, c, html.escape(x).replace(SOFT_HYPHEN, "")) for t, c, x in parts]
# Кегли взяты с настоящих полос Праты: колонтитул 64, текст 77, раздел 108,
# название главы 180. Начало координат в DjVu внизу полосы.
SAMPLE = """(page 0 0 100 1000 (line 10 950 90 985 "300 Глава 6 ")
(line 10 720 90 900 "Упражнения по программированию ")
(line 10 590 90 698 "Подраздел про циклы ")
(line 10 500 90 577 "Напишите программу, которая читает ввод до сим-")
(line 10 420 90 497 "вола @ и повторяет его. ")
(line 10 320 90 397 "int main() { return 0; }")
(line 10 20 90 60 "300 "))"""
def selftest():
pages = parse(SAMPLE)
assert len(pages) == 1, pages
assert len(pages[0][1]) == 7, pages[0][1]
body, left = profile(pages)
assert body == 77, body
parts = convert(pages, body, left)
tags = [p[0] for p in parts]
assert tags == ["h1", "h2", "p", "pre"], tags
# Колонтитул сверху и колонцифра снизу выброшены, перенос склеен без пробела.
assert "символа" in parts[2][2], parts[2][2]
assert "300" not in " ".join(p[2] for p in parts), parts
assert parse('(page 0 0 10 10 (line 1 1 2 2 "a \\"b\\" c"))')[0][1][0][4] == 'a "b" c'
print("selftest OK")
if len(sys.argv) == 2 and sys.argv[1] == "--selftest":
selftest()
sys.exit()
if len(sys.argv) < 3:
sys.exit("usage: djvu2html.py book.djvu out.html | djvu2html.py --selftest")
SRC, OUT = Path(sys.argv[1]), Path(sys.argv[2])
raw = subprocess.run(["djvutxt", "--detail=line", str(SRC)],
capture_output=True, text=True, check=True).stdout
pages = parse(raw)
BODY, LEFT = profile(pages)
print("страниц %d, высота строки %d, левое поле %d" % (len(pages), BODY, LEFT))
parts = convert(pages, BODY, LEFT)
OUT.write_text(bookhtml.document(SRC.stem, parts), encoding="utf-8")
print("blocks:", len(parts),
"h1:", sum(1 for p in parts if p[0] == "h1"),
"h2:", sum(1 for p in parts if p[0] == "h2"),
"p:", sum(1 for p in parts if p[0] == "p"),
"pre:", sum(1 for p in parts if p[0] == "pre"))
+44
View File
@@ -39,6 +39,8 @@ MAX_IMG_SHARE = 0.6 # доля площади страницы: больше
# Запасное опознание листинга, когда издатель вычистил и имена шрифтов, и флаги: # Запасное опознание листинга, когда издатель вычистил и имена шрифтов, и флаги:
# кегль мельче основного текста плюс пунктуация, которой в прозе не бывает. # кегль мельче основного текста плюс пунктуация, которой в прозе не бывает.
CODEY = re.compile(r"[{}();\[\]<>=*/#]|::|->") CODEY = re.compile(r"[{}();\[\]<>=*/#]|::|->")
ROTATED = 0.1 # синус угла строки: больше — текст повёрнут, а не набран по горизонтали
HEAD_BAND = 0.07 # доля высоты полосы сверху, где лежит колонтитул, а не текст
SOFT_HYPHEN = "\u00ad" SOFT_HYPHEN = "\u00ad"
OCR = False OCR = False
@@ -143,6 +145,11 @@ def looks_like_code(spans):
return len(text) > 8 and len(CODEY.findall(text)) / len(text) > 0.03 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): def block_kind(b, body, h1_size):
"""(tag, css class) по кеглю блока относительно основного текста книги.""" """(tag, css class) по кеглю блока относительно основного текста книги."""
spans = [s for l in b["lines"] for s in l["spans"] if s["text"].strip()] spans = [s for l in b["lines"] for s in l["spans"] if s["text"].strip()]
@@ -199,6 +206,23 @@ def block_text(b, pre=False):
return txt.strip() 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) doc = pymupdf.open(SRC)
OCR = ocr_layer(doc) OCR = ocr_layer(doc)
BODY, INDENT_X, H1_SIZE = profile(doc) BODY, INDENT_X, H1_SIZE = profile(doc)
@@ -219,6 +243,13 @@ for pno, page in enumerate(doc):
continue continue
blocks = sorted(page.get_text("dict")["blocks"], key=lambda b: b["bbox"][1]) blocks = sorted(page.get_text("dict")["blocks"], key=lambda b: b["bbox"][1])
for bi, b in enumerate(blocks): for bi, b in enumerate(blocks):
# Повёрнутый на 90° текст: боковые врезки и подписи к таблицам. В книге
# их единицы, а распознаются они всегда в кашу («aoHegoduenueArdng») и
# лезут в заголовки, потому что кегль у них крупный. Замерено у Брикмана:
# 105 строк из 17 300, все до одной — брак. Настоящая повёрнутая таблица
# тоже отсеется, но её всё равно нечем показать в потоковой вёрстке.
if is_rotated(b):
continue
if b["type"] == 1: if b["type"] == 1:
x0, y0, x1, y1 = b["bbox"] x0, y0, x1, y1 = b["bbox"]
if x1 - x0 < MIN_IMG or y1 - y0 < MIN_IMG: if x1 - x0 < MIN_IMG or y1 - y0 < MIN_IMG:
@@ -245,6 +276,14 @@ for pno, page in enumerate(doc):
txt = block_text(b, pre=(tag == "pre")) txt = block_text(b, pre=(tag == "pre"))
if not txt: if not txt:
continue 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 indented = b["lines"][0]["bbox"][0] >= INDENT_X
cont = ( cont = (
open_para open_para
@@ -295,6 +334,11 @@ for part in parts:
merged.append(part) merged.append(part)
parts = merged 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) doc_html = bookhtml.document(doc.metadata.get("title") or SRC.stem, parts)
# merge tag runs split at page/line boundaries. Пробелы здесь уже не трогаем: # merge tag runs split at page/line boundaries. Пробелы здесь уже не трогаем:
# внутри <pre> они значимы, а в прозе схлопнуты в block_text. # внутри <pre> они значимы, а в прозе схлопнуты в block_text.