#!/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"))