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