Оформить репозиторий как скил pdf-to-kindle: SKILL.md, скрипт в scripts/, установка симлинком

This commit is contained in:
Илья Поляков
2026-08-03 07:32:11 +03:00
parent 7226e10464
commit 495d80f326
3 changed files with 127 additions and 3 deletions
+169
View File
@@ -0,0 +1,169 @@
#!/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
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 первой строки: больше — абзац с красной строки
def style(font):
f = font.lower()
return ("bold" in f or "semibold" in f, "-it" in f or "italic" in f)
def span_html(s):
t = html.escape(s["text"])
if not t:
return ""
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 sz >= 17:
return ("h2", None)
if f.startswith("MyriadPro"):
return ("p", "note") # Maxine's journal / chat / slides
return ("p", None)
def block_text(b):
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)
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)
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)
CSS = """
body { margin: 0 1em; }
h1 { text-align: center; margin: 2em 0 0.2em; page-break-before: always; }
h1.title { page-break-before: avoid; }
h2 { text-align: center; font-style: italic; font-weight: normal;
font-size: 1.1em; margin: 0.2em 0 1.5em; }
p { text-indent: 1.2em; margin: 0; text-align: justify; }
p.note { text-indent: 0; margin: 1em 2em; font-size: 0.9em;
font-family: sans-serif; }
p.figure { text-indent: 0; text-align: center; margin: 1em 0; }
img { max-width: 100%; }
"""
buf = ['<?xml version="1.0" encoding="utf-8"?>',
'<html xmlns="http://www.w3.org/1999/xhtml"><head>',
"<title>%s</title>" % html.escape(doc.metadata.get("title") or SRC.stem),
"<style>%s</style></head><body>" % CSS]
for tag, cls, txt in parts:
if tag == "figure":
buf.append('<p class="figure">%s</p>' % txt)
else:
c = ' class="%s"' % cls if cls else ""
buf.append("<%s%s>%s</%s>" % (tag, c, txt, tag))
buf.append("</body></html>")
doc_html = "\n".join(buf)
# merge tag runs split at page/line boundaries, collapse doubled spaces
doc_html = re.sub(r"</(i|b)>(\s*)<\1>", r"\2", doc_html)
doc_html = re.sub(r"(?<=\S) {2,}(?=\S)", " ", 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"))