#!/usr/bin/env python3 """PDF (calibre-generated) -> semantic XHTML, preserving italic/bold/headings.""" import collections import html import re import sys from pathlib import Path import pymupdf import bookhtml if len(sys.argv) < 3 or sys.argv[1] in ("-h", "--help"): sys.exit("usage: pdf2html.py book.pdf out.html | pdf2html.py --fonts book.pdf") if sys.argv[1] == "--fonts": # разведка: какие шрифты/кегли в PDF c = collections.Counter() d = pymupdf.open(sys.argv[2]) for p in d: for b in p.get_text("dict")["blocks"]: for l in b.get("lines", []): for s in l["spans"]: c[(s["font"], round(s["size"], 1))] += len(s["text"]) for (f, sz), n in c.most_common(25): print("%-32s %5.1f %8d симв." % (f, sz, n)) sys.exit() SRC = Path(sys.argv[1]) OUT = Path(sys.argv[2]) IMGDIR = OUT.parent / "images" IMGDIR.mkdir(parents=True, exist_ok=True) # Пороги не зашиты: имена шрифтов у многих издательств вычищены до вида # «Fd820825», а кегль основного текста у каждой книги свой. Профиль считается по # самому файлу, увидеть его: pdf2html.py --fonts file.pdf ITALIC, MONO, BOLD = 2, 8, 16 # биты span["flags"] в PyMuPDF MIN_IMG = 24 # pt: всё мельче — линейки, буллеты и прочая вёрстка, не иллюстрации MAX_IMG_SHARE = 0.6 # доля площади страницы: больше — это скан полосы, а не рисунок # Запасное опознание листинга, когда издатель вычистил и имена шрифтов, и флаги: # кегль мельче основного текста плюс пунктуация, которой в прозе не бывает. CODEY = re.compile(r"[{}();\[\]<>=*/#]|::|->") ROTATED = 0.1 # синус угла строки: больше — текст повёрнут, а не набран по горизонтали HEAD_BAND = 0.07 # доля высоты полосы сверху, где лежит колонтитул, а не текст SOFT_HYPHEN = "\u00ad" OCR = False def style(font, flags=0): """Полужирный и курсив: сначала флаги, затем имя — имена бывают пустыми.""" f = font.lower() return (bool(flags & BOLD) or "bold" in f or "semibold" in f, bool(flags & ITALIC) or "-it" in f or "italic" in f) def is_mono(s): """Моноширинный шрифт = листинг кода. Признак из флагов, а не из имени: имена у каждого издательства свои, флаг одинаковый. Исключение — невидимый слой Tesseract: он весь набран GlyphLessFont с выставленным моно-битом, и без этой проверки книга целиком опознаётся как один сплошной листинг (измерено на Ansible после OCR: pre 10498, p 0). В таком слое листинги ловятся кеглем и пунктуацией, см. block_kind(). """ if "glyphless" in s["font"].lower(): return False return bool(s["flags"] & MONO) def ocr_layer(doc, step=23): """Текст сделан OCR-ом: у всего слоя один невидимый шрифт. Кегль в таком слое подгоняется под рамку слова и гуляет (9.0, 9.2, 9.4 в одном абзаце), поэтому дальше он округляется до целого — иначе гистограмма размазывается и основной кегль книги определяется неверно.""" glyphless = total = 0 for pno in range(1, doc.page_count, step): for b in doc[pno].get_text("dict")["blocks"]: for l in b.get("lines", []): for s in l["spans"]: n = len(s["text"]) total += n if "glyphless" in s["font"].lower(): glyphless += n return total > 0 and glyphless / total > 0.9 def size_of(span): """Кегль спана. В OCR-слое округляется до целого: см. ocr_layer().""" return round(span["size"]) if OCR else round(span["size"], 1) def profile(doc, step=7): """(кегль текста, x0 красной строки, кегль глав) по выборке страниц. Кегль глав берётся из гистограммы, а не как «в полтора раза больше текста»: у книги обычно два-три размера заголовков, и если объявить главой каждый из них, оглавление на читалке распухает до трёхсот пунктов вместо двадцати. """ size_chars = collections.Counter() head_blocks = collections.Counter() x0 = collections.Counter() for pno in range(1, doc.page_count, step): for b in doc[pno].get_text("dict")["blocks"]: spans = [sp for l in b.get("lines", []) for sp in l["spans"] if sp["text"].strip()] if not spans: continue for l in b["lines"]: x0[round(l["bbox"][0])] += 1 for sp in spans: size_chars[size_of(sp)] += len(sp["text"]) top = max(spans, key=lambda sp: len(sp["text"])) head_blocks[size_of(top)] += 1 if not size_chars: sys.exit("в PDF нет извлекаемого текста — нужен OCR, этот скрипт не поможет") body = size_chars.most_common(1)[0][0] # кандидаты в главы: крупнее текста и встречаются не единожды (единичный # размер — это титул, а не уровень заголовка) cand = sorted((sz for sz, n in head_blocks.items() if sz >= body * 1.15 and n >= 3), reverse=True) h1 = cand[0] if cand else body * 1.55 left = min(x for x, n in x0.most_common(4)) return body, left + max(4, body * 0.6), h1 def span_html(s, plain=False): t = html.escape(s["text"]) if not t: return "" if plain: # внутри
 курсив и полужирный только мешают
        return t
    if is_mono(s):
        return "%s" % t
    bold, ital = style(s["font"], s["flags"])
    if ital:
        t = "%s" % t
    if bold:
        t = "%s" % t
    return t


def looks_like_code(spans):
    """Доля кодовой пунктуации. Порог низкий: в прозе на 200 знаков не набирается
    и десятка скобок со звёздочками, в листинге — набирается всегда."""
    text = "".join(s["text"] for s in spans)
    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):
    """(tag, css class) по кеглю блока относительно основного текста книги."""
    spans = [s for l in b["lines"] for s in l["spans"] if s["text"].strip()]
    if not spans:
        return None
    top = max(spans, key=lambda s: len(s["text"]))
    sz = size_of(top)
    if all(is_mono(sp) for sp in spans):
        return ("pre", None)
    if sz <= body * 0.93 and looks_like_code(spans):
        return ("pre", None)
    if sz >= h1_size * 1.35:
        return ("h1", "title")
    if sz >= h1_size * 0.97:
        return ("h1", None)
    if sz >= body * 1.15:
        return ("h2", None)
    return ("p", None)


def block_text(b, pre=False):
    if pre:
        # Перенос строки в коде значим, висящий дефис — это минус, а не перенос
        # слова. Ни склейки строк, ни де-дефисации здесь быть не должно.
        rows = ["".join(span_html(s, plain=True) for s in l["spans"])
                for l in b["lines"]]
        # В настоящем коде мягкого переноса не бывает: если он тут есть, блок
        # опознан как листинг ошибочно (обычно это таблица опций), и слово надо
        # склеить, а не оставить разорванным переводом строки.
        return "\n".join(rows).replace(SOFT_HYPHEN + "\n", "").rstrip()
    out = []
    for i, l in enumerate(b["lines"]):
        line = "".join(span_html(s) for s in l["spans"])
        if not line:
            continue
        if out:
            prev = out[-1]
            # Русские издания переносят мягким дефисом U+00AD, а не обычным:
            # без этой ветки строки склеиваются через пробел и слово рвётся
            # пополам («введен ные»). Измерено на Шоттсе (Питер, 2020).
            if prev.endswith(SOFT_HYPHEN):
                out[-1] = prev[:-1]
            elif prev.endswith("-") and not prev.endswith("--"):
                out[-1] = prev[:-1]  # de-hyphenate
            else:
                out.append(" ")
        out.append(line)
    txt = "".join(out)
    txt = re.sub(r"(\s*)", r"\1", txt)
    txt = re.sub(r"(\s*)", r"\1", txt)
    txt = re.sub(r"(?<=\S) {2,}(?=\S)", " ", txt)
    # Мягкие переносы внутри строки читалке не нужны и ломают поиск по тексту.
    txt = txt.replace(SOFT_HYPHEN, "")
    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)
OCR = ocr_layer(doc)
BODY, INDENT_X, H1_SIZE = profile(doc)
if OCR:
    print("слой распознан OCR-ом: моно-признак отключён, кегль округляется")
print("кегль текста %.1f, глав %.1f, красная строка от x0=%.0f"
      % (BODY, H1_SIZE, INDENT_X))
parts = []
open_para = None  # (tag, cls, text) still collecting

# cover
pix = doc[0].get_pixmap(dpi=150)
pix.save(IMGDIR / "cover.jpg")

img_n = 0
for pno, page in enumerate(doc):
    if pno == 0:
        continue
    blocks = sorted(page.get_text("dict")["blocks"], key=lambda b: b["bbox"][1])
    for bi, b in enumerate(blocks):
        # Повёрнутый на 90° текст: боковые врезки и подписи к таблицам. В книге
        # их единицы, а распознаются они всегда в кашу («aoHegoduenueArdng») и
        # лезут в заголовки, потому что кегль у них крупный. Замерено у Брикмана:
        # 105 строк из 17 300, все до одной — брак. Настоящая повёрнутая таблица
        # тоже отсеется, но её всё равно нечем показать в потоковой вёрстке.
        if is_rotated(b):
            continue
        if b["type"] == 1:
            x0, y0, x1, y1 = b["bbox"]
            if x1 - x0 < MIN_IMG or y1 - y0 < MIN_IMG:
                continue  # линейка или буллет, а не иллюстрация
            page_area = page.rect.width * page.rect.height
            if page_area and (x1 - x0) * (y1 - y0) / page_area > MAX_IMG_SHARE:
                continue  # подложка отсканированной полосы: текст берём из OCR-слоя
            img_n += 1
            name = "img%02d.png" % img_n
            (IMGDIR / name).write_bytes(b["image"])
            if open_para:
                parts.append(open_para)
                open_para = None
            parts.append(("figure", None, '' % name, False))
            continue
        kind = block_kind(b, BODY, H1_SIZE)
        if not kind:
            continue
        tag, cls = kind
        # Глава открывает полосу, раздел встречается по тексту. Признак —
        # верхняя треть страницы, а не «первый блок»: сверху нередко идёт
        # колонтитул или плашка, и тогда первым заголовок не бывает никогда.
        at_top = b["bbox"][1] < page.rect.height * 0.35
        txt = block_text(b, pre=(tag == "pre"))
        if not txt:
            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
        cont = (
            open_para
            and tag == "p"
            and open_para[0] == "p"
            and open_para[1] == cls
            and bi == 0
            and not indented
        )
        if cont:
            join = "" if open_para[2].endswith("-") else " "
            open_para = (tag, cls, open_para[2] + join + txt, open_para[3])
            continue
        # Соседние листинги склеиваются в один блок: OCR-слой отдаёт каждую
        # строку кода отдельным блоком, и без склейки листинг рассыпается на
        # десяток однострочных 
 подряд. Строки внутри блока значимы,
        # поэтому соединяются переводом строки, а не пробелом.
        if OCR and tag == "pre" and open_para and open_para[0] == "pre":
            open_para = (tag, cls, open_para[2] + "\n" + txt, open_para[3])
            continue
        if open_para:
            parts.append(open_para)
        open_para = (tag, cls, txt, at_top)
if open_para:
    parts.append(open_para)

# Понижаем заголовки не с верха полосы до раздела — но только если глав после
# этого остаётся достаточно: у части книг главы начинаются в середине страницы,
# и тогда правило обнулило бы оглавление целиком.
top_h1 = sum(1 for t, c, x, top in parts if t == "h1" and c is None and top)
if top_h1 >= 5:
    parts = [(("h2" if (t == "h1" and c is None and not top) else t), c, x, top)
             for t, c, x, top in parts]
parts = [(t, c, x) for t, c, x, _ in parts]

# «1» и «Списки» приезжают отдельными блоками: номер главы набран крупно, её
# название — отдельной строкой. В оглавлении читалки нужен один пункт.
merged = []
for part in parts:
    if (part[0] == "h1" and merged and merged[-1][0] == "h1"
            and len(merged[-1][2]) < 60):
        merged[-1] = ("h1", merged[-1][1], merged[-1][2] + ". " + part[2])
        continue
    if (part[0] == "h2" and merged and merged[-1][0] == "h1"
            and len(merged[-1][2]) < 12):
        merged[-1] = ("h1", merged[-1][1], merged[-1][2] + ". " + part[2])
        continue
    merged.append(part)
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)
# merge tag runs split at page/line boundaries. Пробелы здесь уже не трогаем:
# внутри 
 они значимы, а в прозе схлопнуты в block_text.
doc_html = re.sub(r"(\s*)<\1>", r"\2", doc_html)
OUT.write_text(doc_html, encoding="utf-8")

print("blocks:", len(parts), "images:", img_n)
print("h1:", sum(1 for p in parts if p[0] == "h1"),
      "p:", sum(1 for p in parts if p[0] == "p"),
      "pre:", sum(1 for p in parts if p[0] == "pre"))