db9fe5e0fa
- epub2html.py: EPUB -> тот же XHTML, что pdf2html.py, только стандартная библиотека. Уровень глав определяется по книге, а не берётся из <h1>: в EPUB там обычно название и части. - pdf2html.py: листинг узнаётся по флагу моноширинного шрифта PyMuPDF, переносы и отступы внутри <pre> сохраняются, де-дефисация к коду не применяется. - translate.py: <pre> исключён из проверок языка (английский код утягивал долю кириллицы ниже порога приёмки), рабочий каталог привязан к книге, устаревшие главы прошлого прогона чистятся, инлайновый <code> переживает переводчика. - bookhtml.py: общий каркас документа и CSS для обоих входов. - Тесты: test_epub2html.py на собранном в памяти EPUB, четыре новых случая в test_translate.py.
172 lines
5.8 KiB
Python
172 lines
5.8 KiB
Python
#!/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: # внутри <pre> курсив и полужирный только мешают
|
|
return t
|
|
if is_mono(s):
|
|
return "<code>%s</code>" % t
|
|
bold, ital = style(s["font"])
|
|
if ital:
|
|
t = "<i>%s</i>" % t
|
|
if bold:
|
|
t = "<b>%s</b>" % 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"</i>(\s*)<i>", r"\1", txt)
|
|
txt = re.sub(r"</b>(\s*)<b>", 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, '<img src="images/%s"/>' % 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. Пробелы здесь уже не трогаем:
|
|
# внутри <pre> они значимы, а в прозе схлопнуты в block_text.
|
|
doc_html = re.sub(r"</(i|b|code)>(\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"))
|