Files
pdf2epub/scripts/bookhtml.py
T
chesirecatt 0320d2d331 Мусор в оглавлении сканов: фильтр поворота, проверка заголовков, вход для 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
2026-08-25 19:03:08 +03:00

100 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)