Мусор в оглавлении сканов: фильтр поворота, проверка заголовков, вход для 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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user