From 0320d2d331a9f5aac54c8ee7157d8d610d4a4a2b Mon Sep 17 00:00:00 2001 From: chesirecatt Date: Tue, 25 Aug 2026 19:03:08 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D1=83=D1=81=D0=BE=D1=80=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D0=B3=D0=BB=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=D0=BD=D0=BE=D0=B2:=20=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=20=D0=BF=D0=BE=D0=B2=D0=BE=D1=80=D0=BE=D1=82?= =?UTF-8?q?=D0=B0,=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D0=BE=D0=B2,?= =?UTF-8?q?=20=D0=B2=D1=85=D0=BE=D0=B4=20=D0=B4=D0=BB=D1=8F=20DjVu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Причиной мусорных заголовков у Брикмана оказался не порог кегля, а повёрнутый на 90° текст: боковые врезки и подписи к таблицам распознаются в кашу («aoHegoduenueArdng») и попадают в h1, потому что кегль у них крупный. Замер: 105 строк из 17 300, все до одной брак. Блоки с неgоризонтальным направлением строки выбрасываются целиком (is_rotated). Остаток — подписи внутри иллюстраций — понижается до

, а не удаляется: 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 Claude-Session: https://claude.ai/code/session_01XCiPWRi3Jqy4saD2LwU1Dp --- README.md | 35 ++++++++ SKILL.md | 33 ++++++-- scripts/bookhtml.py | 55 ++++++++++++ scripts/djvu2html.py | 195 +++++++++++++++++++++++++++++++++++++++++++ scripts/pdf2html.py | 44 ++++++++++ 5 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 scripts/djvu2html.py diff --git a/README.md b/README.md index b01cf13..d9d94ae 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,44 @@ к абзацу с предыдущей страницы; - переносы на конце строки снимаются, соседние `` схлопываются; - иллюстрации выгружаются в `images/`, обложка рендерится со страницы 1 в 150 dpi; +- повёрнутый на 90° текст выбрасывается целиком: боковые врезки и подписи к + таблицам распознаются в кашу («aoHegoduenueArdng») и лезут в заголовки, потому + что кегль у них крупный. У Брикмана это 105 строк из 17 300, и все до одной — + брак; +- мусорный заголовок понижается до `

`, а не удаляется: оглавление чистится, + текст остаётся. Проверка — `looks_garbled()` в `scripts/bookhtml.py`, шесть + признаков структуры, любых двух хватает. Вместе с фильтром поворота это увело + Брикмана с 99 `

` до 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 Репозиторий одновременно является скилом (`SKILL.md` в корне) и клонируется diff --git a/SKILL.md b/SKILL.md index ec39beb..73ac0c5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -15,12 +15,27 @@ tag, and 2817 body paragraphs became `

`. properties, and emits semantic XHTML. calibre is then used only as the XHTML→EPUB packer. -## Two entry points +## Three entry points -`scripts/pdf2html.py` for PDF, `scripts/epub2html.py` for EPUB. Both emit the -same normalized XHTML — one block per line, `
` for code listings — and
-everything downstream (translation, packing, audiobook) is identical. Shared
-document skeleton and CSS live in `scripts/bookhtml.py`.
+`scripts/pdf2html.py` for PDF, `scripts/epub2html.py` for EPUB,
+`scripts/djvu2html.py` for DjVu. All three emit the same normalized XHTML — one
+block per line, `
` for code listings — and everything downstream
+(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
 away the semantic markup that is already there and re-derives it from font
@@ -123,7 +138,13 @@ EOF
   is being promoted;
 - many double spaces means line joining is off;
 - list the extracted headings (`grep -o ']*>.\{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
+  `

` rather than deleting it. That pair took Brikman from 99 `

` to 48; - read one full page of body text and confirm paragraphs merge across page breaks and hyphenated words are rejoined. diff --git a/scripts/bookhtml.py b/scripts/bookhtml.py index a178367..c2c3c61 100644 --- a/scripts/bookhtml.py +++ b/scripts/bookhtml.py @@ -6,6 +6,14 @@ строки, а всё, чего не узнал, доносит до результата нетронутым. """ 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 = """ 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): """parts: [(tag, cls, inner_html)]; tag 'figure' — псевдотег для картинки.""" buf = ['', diff --git a/scripts/djvu2html.py b/scripts/djvu2html.py new file mode 100644 index 0000000..71837c9 --- /dev/null +++ b/scripts/djvu2html.py @@ -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")) diff --git a/scripts/pdf2html.py b/scripts/pdf2html.py index b140e73..9555e2c 100644 --- a/scripts/pdf2html.py +++ b/scripts/pdf2html.py @@ -39,6 +39,8 @@ MAX_IMG_SHARE = 0.6 # доля площади страницы: больше # Запасное опознание листинга, когда издатель вычистил и имена шрифтов, и флаги: # кегль мельче основного текста плюс пунктуация, которой в прозе не бывает. CODEY = re.compile(r"[{}();\[\]<>=*/#]|::|->") +ROTATED = 0.1 # синус угла строки: больше — текст повёрнут, а не набран по горизонтали +HEAD_BAND = 0.07 # доля высоты полосы сверху, где лежит колонтитул, а не текст SOFT_HYPHEN = "\u00ad" OCR = False @@ -143,6 +145,11 @@ def looks_like_code(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()] @@ -199,6 +206,23 @@ def block_text(b, pre=False): 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) @@ -219,6 +243,13 @@ for pno, page in enumerate(doc): 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: @@ -245,6 +276,14 @@ for pno, page in enumerate(doc): 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 @@ -295,6 +334,11 @@ for part in parts: 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. Пробелы здесь уже не трогаем: # внутри
 они значимы, а в прозе схлопнуты в block_text.