Мусор в оглавлении сканов: фильтр поворота, проверка заголовков, вход для 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:
@@ -26,9 +26,44 @@
|
|||||||
к абзацу с предыдущей страницы;
|
к абзацу с предыдущей страницы;
|
||||||
- переносы на конце строки снимаются, соседние `</i><i>` схлопываются;
|
- переносы на конце строки снимаются, соседние `</i><i>` схлопываются;
|
||||||
- иллюстрации выгружаются в `images/`, обложка рендерится со страницы 1 в 150 dpi;
|
- иллюстрации выгружаются в `images/`, обложка рендерится со страницы 1 в 150 dpi;
|
||||||
|
- повёрнутый на 90° текст выбрасывается целиком: боковые врезки и подписи к
|
||||||
|
таблицам распознаются в кашу («aoHegoduenueArdng») и лезут в заголовки, потому
|
||||||
|
что кегль у них крупный. У Брикмана это 105 строк из 17 300, и все до одной —
|
||||||
|
брак;
|
||||||
|
- мусорный заголовок понижается до `<p>`, а не удаляется: оглавление чистится,
|
||||||
|
текст остаётся. Проверка — `looks_garbled()` в `scripts/bookhtml.py`, шесть
|
||||||
|
признаков структуры, любых двух хватает. Вместе с фильтром поворота это увело
|
||||||
|
Брикмана с 99 `<h1>` до 48;
|
||||||
- позиционирование не сохраняется намеренно — текст должен течь под любой
|
- позиционирование не сохраняется намеренно — текст должен течь под любой
|
||||||
размер шрифта на читалке.
|
размер шрифта на читалке.
|
||||||
|
|
||||||
|
## DjVu со своим текстовым слоем: `scripts/djvu2html.py`
|
||||||
|
|
||||||
|
У сканов в DjVu слой распознавания обычно уже есть, и он лучше нашего прогона
|
||||||
|
через `ocrmypdf`. Замер на Прате (C++ 6-е рус. изд., 1244 полосы): слов со
|
||||||
|
смесью алфавитов внутри слова было 433 на 391 тысячу слов (10,9 на 10 000),
|
||||||
|
после сборки из родного слоя — 109 (2,8).
|
||||||
|
|
||||||
|
⚠️ **Через PDF этот слой не проходит.** `ddjvu -format=pdf` кладёт страницы
|
||||||
|
картинками, `pdftotext` после этого отдаёт пустоту. Текст берётся напрямую из
|
||||||
|
`djvutxt --detail=line`, скрипт разбирает его сам.
|
||||||
|
|
||||||
|
Чего в слое DjVu нет вовсе — курсива и полужирного: в скане их и не было.
|
||||||
|
Заголовки опознаются высотой строки, листинги — пунктуацией.
|
||||||
|
|
||||||
|
⚠️ **Высота строки в DjVu — это габарит с выносными элементами, а не кегль.**
|
||||||
|
Строка с «Ц» или «р» выше соседней на те же 10%, поэтому пороги взяты по
|
||||||
|
измеренным разрывам, а не «чуть выше основного текста». У Праты при основной
|
||||||
|
строке 78: колонтитулы 85–90, разделы 105–125, названия глав 170–185.
|
||||||
|
|
||||||
|
Строки заголовка склеиваются подряд: название главы занимает две-три строки, и
|
||||||
|
без склейки «Класс string и стандартная библиотека шаблонов» разваливается на
|
||||||
|
четыре пункта оглавления.
|
||||||
|
|
||||||
|
Самопроверки без сети: `python3 scripts/djvu2html.py --selftest` и
|
||||||
|
`python3 scripts/pdf2html.py --selftest --selftest` (флаг занимает оба
|
||||||
|
обязательных аргумента).
|
||||||
|
|
||||||
## Подключение как скил Claude Code
|
## Подключение как скил Claude Code
|
||||||
|
|
||||||
Репозиторий одновременно является скилом (`SKILL.md` в корне) и клонируется
|
Репозиторий одновременно является скилом (`SKILL.md` в корне) и клонируется
|
||||||
|
|||||||
@@ -15,12 +15,27 @@ tag, and 2817 body paragraphs became `<h2>`.
|
|||||||
properties, and emits semantic XHTML. calibre is then used only as the
|
properties, and emits semantic XHTML. calibre is then used only as the
|
||||||
XHTML→EPUB packer.
|
XHTML→EPUB packer.
|
||||||
|
|
||||||
## Two entry points
|
## Three entry points
|
||||||
|
|
||||||
`scripts/pdf2html.py` for PDF, `scripts/epub2html.py` for EPUB. Both emit the
|
`scripts/pdf2html.py` for PDF, `scripts/epub2html.py` for EPUB,
|
||||||
same normalized XHTML — one block per line, `<pre>` for code listings — and
|
`scripts/djvu2html.py` for DjVu. All three emit the same normalized XHTML — one
|
||||||
everything downstream (translation, packing, audiobook) is identical. Shared
|
block per line, `<pre>` for code listings — and everything downstream
|
||||||
document skeleton and CSS live in `scripts/bookhtml.py`.
|
(translation, packing, audiobook) is identical. The shared document skeleton,
|
||||||
|
CSS and the junk-heading check `looks_garbled()` live in `scripts/bookhtml.py`.
|
||||||
|
|
||||||
|
**A DjVu with its own text layer must not be re-OCR'd.** The layer that is
|
||||||
|
already in the file beats a fresh `ocrmypdf` run — measured on Prata's C++ 6th
|
||||||
|
Russian edition, 1244 pages: words mixing Latin and Cyrillic inside one word
|
||||||
|
dropped from 433 to 109 per 391k words (10.9 → 2.8 per 10 000). That layer does
|
||||||
|
not survive a trip through PDF: `ddjvu -format=pdf` writes the pages as images
|
||||||
|
and `pdftotext` then returns nothing at all. `djvu2html.py` reads `djvutxt
|
||||||
|
--detail=line` directly.
|
||||||
|
|
||||||
|
Styling is the price: a DjVu text layer carries no italic or bold at all (the
|
||||||
|
scan never had them). Headings come from line height, listings from punctuation.
|
||||||
|
Line height there is the glyph bounding box, not the type size — a line holding
|
||||||
|
a descender is 10% taller than its neighbour — so the thresholds are set from
|
||||||
|
measured gaps (`H1`/`H2` in the script), not from "slightly above body text".
|
||||||
|
|
||||||
**Do not route an EPUB through the PDF path.** Converting EPUB→PDF→XHTML throws
|
**Do not route an EPUB through the PDF path.** Converting EPUB→PDF→XHTML throws
|
||||||
away the semantic markup that is already there and re-derives it from font
|
away the semantic markup that is already there and re-derives it from font
|
||||||
@@ -123,7 +138,13 @@ EOF
|
|||||||
is being promoted;
|
is being promoted;
|
||||||
- many double spaces means line joining is off;
|
- many double spaces means line joining is off;
|
||||||
- list the extracted headings (`grep -o '<h1[^>]*>.\{0,60\}'`) and read them —
|
- list the extracted headings (`grep -o '<h1[^>]*>.\{0,60\}'`) and read them —
|
||||||
they become the TOC, and a wrong one is obvious at a glance;
|
they become the TOC, and a wrong one is obvious at a glance. On a scan most of
|
||||||
|
the junk there comes from **rotated** text, not from bad thresholds: sideways
|
||||||
|
captions and table stubs OCR into mush (`aoHegoduenueArdng`) and land in
|
||||||
|
headings because their type is large. `pdf2html.py` drops any block whose line
|
||||||
|
direction is not horizontal (`is_rotated()`, measured on Brikman: 105 lines out
|
||||||
|
of 17 300, every one of them garbage), and demotes what is left of the mush to
|
||||||
|
`<p>` rather than deleting it. That pair took Brikman from 99 `<h1>` to 48;
|
||||||
- read one full page of body text and confirm paragraphs merge across page
|
- read one full page of body text and confirm paragraphs merge across page
|
||||||
breaks and hyphenated words are rejoined.
|
breaks and hyphenated words are rejoined.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
строки, а всё, чего не узнал, доносит до результата нетронутым.
|
строки, а всё, чего не узнал, доносит до результата нетронутым.
|
||||||
"""
|
"""
|
||||||
import html
|
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 = """
|
CSS = """
|
||||||
body { margin: 0 1em; }
|
body { margin: 0 1em; }
|
||||||
@@ -28,6 +36,53 @@ 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):
|
def document(title, parts):
|
||||||
"""parts: [(tag, cls, inner_html)]; tag 'figure' — псевдотег для картинки."""
|
"""parts: [(tag, cls, inner_html)]; tag 'figure' — псевдотег для картинки."""
|
||||||
buf = ['<?xml version="1.0" encoding="utf-8"?>',
|
buf = ['<?xml version="1.0" encoding="utf-8"?>',
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -39,6 +39,8 @@ MAX_IMG_SHARE = 0.6 # доля площади страницы: больше
|
|||||||
# Запасное опознание листинга, когда издатель вычистил и имена шрифтов, и флаги:
|
# Запасное опознание листинга, когда издатель вычистил и имена шрифтов, и флаги:
|
||||||
# кегль мельче основного текста плюс пунктуация, которой в прозе не бывает.
|
# кегль мельче основного текста плюс пунктуация, которой в прозе не бывает.
|
||||||
CODEY = re.compile(r"[{}();\[\]<>=*/#]|::|->")
|
CODEY = re.compile(r"[{}();\[\]<>=*/#]|::|->")
|
||||||
|
ROTATED = 0.1 # синус угла строки: больше — текст повёрнут, а не набран по горизонтали
|
||||||
|
HEAD_BAND = 0.07 # доля высоты полосы сверху, где лежит колонтитул, а не текст
|
||||||
SOFT_HYPHEN = "\u00ad"
|
SOFT_HYPHEN = "\u00ad"
|
||||||
OCR = False
|
OCR = False
|
||||||
|
|
||||||
@@ -143,6 +145,11 @@ def looks_like_code(spans):
|
|||||||
return len(text) > 8 and len(CODEY.findall(text)) / len(text) > 0.03
|
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):
|
def block_kind(b, body, h1_size):
|
||||||
"""(tag, css class) по кеглю блока относительно основного текста книги."""
|
"""(tag, css class) по кеглю блока относительно основного текста книги."""
|
||||||
spans = [s for l in b["lines"] for s in l["spans"] if s["text"].strip()]
|
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()
|
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)
|
doc = pymupdf.open(SRC)
|
||||||
OCR = ocr_layer(doc)
|
OCR = ocr_layer(doc)
|
||||||
BODY, INDENT_X, H1_SIZE = profile(doc)
|
BODY, INDENT_X, H1_SIZE = profile(doc)
|
||||||
@@ -219,6 +243,13 @@ for pno, page in enumerate(doc):
|
|||||||
continue
|
continue
|
||||||
blocks = sorted(page.get_text("dict")["blocks"], key=lambda b: b["bbox"][1])
|
blocks = sorted(page.get_text("dict")["blocks"], key=lambda b: b["bbox"][1])
|
||||||
for bi, b in enumerate(blocks):
|
for bi, b in enumerate(blocks):
|
||||||
|
# Повёрнутый на 90° текст: боковые врезки и подписи к таблицам. В книге
|
||||||
|
# их единицы, а распознаются они всегда в кашу («aoHegoduenueArdng») и
|
||||||
|
# лезут в заголовки, потому что кегль у них крупный. Замерено у Брикмана:
|
||||||
|
# 105 строк из 17 300, все до одной — брак. Настоящая повёрнутая таблица
|
||||||
|
# тоже отсеется, но её всё равно нечем показать в потоковой вёрстке.
|
||||||
|
if is_rotated(b):
|
||||||
|
continue
|
||||||
if b["type"] == 1:
|
if b["type"] == 1:
|
||||||
x0, y0, x1, y1 = b["bbox"]
|
x0, y0, x1, y1 = b["bbox"]
|
||||||
if x1 - x0 < MIN_IMG or y1 - y0 < MIN_IMG:
|
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"))
|
txt = block_text(b, pre=(tag == "pre"))
|
||||||
if not txt:
|
if not txt:
|
||||||
continue
|
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
|
indented = b["lines"][0]["bbox"][0] >= INDENT_X
|
||||||
cont = (
|
cont = (
|
||||||
open_para
|
open_para
|
||||||
@@ -295,6 +334,11 @@ for part in parts:
|
|||||||
merged.append(part)
|
merged.append(part)
|
||||||
parts = merged
|
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)
|
doc_html = bookhtml.document(doc.metadata.get("title") or SRC.stem, parts)
|
||||||
# merge tag runs split at page/line boundaries. Пробелы здесь уже не трогаем:
|
# merge tag runs split at page/line boundaries. Пробелы здесь уже не трогаем:
|
||||||
# внутри <pre> они значимы, а в прозе схлопнуты в block_text.
|
# внутри <pre> они значимы, а в прозе схлопнуты в block_text.
|
||||||
|
|||||||
Reference in New Issue
Block a user