Мусор в оглавлении сканов: фильтр поворота, проверка заголовков, вход для 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
+44
View File
@@ -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. Пробелы здесь уже не трогаем:
# внутри <pre> они значимы, а в прозе схлопнуты в block_text.