0320d2d331
Причиной мусорных заголовков у Брикмана оказался не порог кегля, а повёрнутый на 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
100 lines
5.6 KiB
Python
100 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
||
"""Общая сборка XHTML для pdf2html.py и epub2html.py.
|
||
|
||
Формат намеренно жёсткий: один блок — одна строка (кроме <pre>, где переносы
|
||
значимы). На этом держится мост translate.py — он правит блоки по номеру
|
||
строки, а всё, чего не узнал, доносит до результата нетронутым.
|
||
"""
|
||
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; }
|
||
h1 { text-align: center; margin: 2em 0 0.2em; page-break-before: always; }
|
||
h1.title { page-break-before: avoid; }
|
||
h2 { text-align: center; font-style: italic; font-weight: normal;
|
||
font-size: 1.1em; margin: 0.2em 0 1.5em; }
|
||
p { text-indent: 1.2em; margin: 0; text-align: justify; }
|
||
p.note { text-indent: 0; margin: 1em 2em; font-size: 0.9em;
|
||
font-family: sans-serif; }
|
||
p.li { text-indent: 0; margin: 0.3em 0 0.3em 1.5em; text-align: left; }
|
||
p.row { text-indent: 0; margin: 0.3em 0; text-align: left;
|
||
font-size: 0.9em; }
|
||
p.figure { text-indent: 0; text-align: center; margin: 1em 0; }
|
||
img { max-width: 100%; }
|
||
pre { font-family: monospace; font-size: 0.75em; margin: 1em 0;
|
||
white-space: pre-wrap; word-wrap: break-word; text-align: left;
|
||
text-indent: 0; }
|
||
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 = ['<?xml version="1.0" encoding="utf-8"?>',
|
||
'<html xmlns="http://www.w3.org/1999/xhtml"><head>',
|
||
"<title>%s</title>" % html.escape(title),
|
||
"<style>%s</style></head><body>" % CSS]
|
||
for tag, cls, txt in parts:
|
||
if tag == "figure":
|
||
buf.append('<p class="figure">%s</p>' % txt)
|
||
else:
|
||
c = ' class="%s"' % cls if cls else ""
|
||
buf.append("<%s%s>%s</%s>" % (tag, c, txt, tag))
|
||
buf.append("</body></html>")
|
||
return "\n".join(buf)
|