#!/usr/bin/env python3 """PDF (calibre-generated) -> semantic XHTML, preserving italic/bold/headings.""" 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 import collections 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) # Пороги подобраны под вёрстку 15pt/letter. Для другой книги сначала # посмотреть реальные шрифты и кегли: pdf2html.py --fonts file.pdf INDENT_X = 88 # x0 первой строки: больше — абзац с красной строки MONO = 8 # бит моноширинного шрифта в span["flags"] (PyMuPDF) def style(font): f = font.lower() return ("bold" in f or "semibold" in f, "-it" in f or "italic" in f) def is_mono(s): """Моноширинный шрифт = листинг кода. Признак берётся из флагов PyMuPDF, а не из имени шрифта: имена у каждого издательства свои, флаг одинаковый.""" return bool(s["flags"] & MONO) 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"])
    if ital:
        t = "%s" % t
    if bold:
        t = "%s" % t
    return t


def block_kind(b):
    """(tag, css class) from dominant span font/size."""
    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"]))
    f, sz = top["font"], top["size"]
    if sz >= 28 or f.startswith("Arvo") or f.startswith("CloisterBlack"):
        return ("h1", "title")
    if sz >= 24:
        return ("h1", None)
    if all(is_mono(sp) for sp in spans):
        return ("pre", None)
    if sz >= 17:
        return ("h2", None)
    if f.startswith("MyriadPro"):
        return ("p", "note")  # Maxine's journal / chat / slides
    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).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]
            if 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)
    return txt.strip()


doc = pymupdf.open(SRC)
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):
        if b["type"] == 1:
            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))
            continue
        kind = block_kind(b)
        if not kind:
            continue
        tag, cls = kind
        txt = block_text(b, pre=(tag == "pre"))
        if not txt:
            continue
        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)
            continue
        if open_para:
            parts.append(open_para)
        open_para = (tag, cls, txt)
if open_para:
    parts.append(open_para)

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"))