Compare commits
2 Commits
db9fe5e0fa
...
a21a4d713b
| Author | SHA1 | Date | |
|---|---|---|---|
| a21a4d713b | |||
| 1bacd38292 |
@@ -32,6 +32,15 @@ needed, not PyMuPDF) and convert with:
|
|||||||
|
|
||||||
`python scripts/epub2html.py book.epub out/book.html`
|
`python scripts/epub2html.py book.epub out/book.html`
|
||||||
|
|
||||||
|
**Dumps converted from PDF carry no semantics at all** — no headings, no `<pre>`,
|
||||||
|
just `<p class="class_s1e2">` with obfuscated names. The script detects this
|
||||||
|
(zero headings or zero listings) and rebuilds the structure from the book's own
|
||||||
|
stylesheet: the three largest font sizes become heading levels, a typewriter or
|
||||||
|
monospace family becomes `<pre>`, and adjacent listing paragraphs merge back into
|
||||||
|
one block. A book that carries its own markup never enters this path. Measured on
|
||||||
|
the dokumen.pub dump of Ousterhout's APoSD: 1833 flat paragraphs became 29
|
||||||
|
chapters and 58 listings.
|
||||||
|
|
||||||
It reports which heading level turned out to be the chapter level. In most
|
It reports which heading level turned out to be the chapter level. In most
|
||||||
EPUBs `<h1>` is the book title and the parts, while chapters are `<h2>` — the
|
EPUBs `<h1>` is the book title and the parts, while chapters are `<h2>` — the
|
||||||
script picks the deepest level that still gives a sane chapter count, because
|
script picks the deepest level that still gives a sane chapter count, because
|
||||||
|
|||||||
+89
-6
@@ -39,16 +39,57 @@ INLINE_MAP = {"i": "i", "em": "i", "cite": "i", "b": "b", "strong": "b",
|
|||||||
"code": "code", "kbd": "code", "samp": "code", "tt": "code",
|
"code": "code", "kbd": "code", "samp": "code", "tt": "code",
|
||||||
"var": "code"}
|
"var": "code"}
|
||||||
DROP = {"script", "style", "head", "title", "nav"}
|
DROP = {"script", "style", "head", "title", "nav"}
|
||||||
|
# Дампы, собранные конвертером из PDF, теряют всю семантику: ни заголовков, ни
|
||||||
|
# <pre>, только <p class="class_s1e2"> с обфусцированными именами. Разметку
|
||||||
|
# приходится восстанавливать из таблицы стилей — по кеглю и по гарнитуре.
|
||||||
|
MONO_FONT = re.compile(r"mono|courier|consol|typewriter|menlo|inconsolata|"
|
||||||
|
r"liberationmono|andalemono|lucidasanstype", re.I)
|
||||||
|
CSS_RULE = re.compile(r"\.([\w-]+)\s*\{([^}]*)\}")
|
||||||
IMG_EXT = {"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif",
|
IMG_EXT = {"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif",
|
||||||
"image/svg+xml": ".svg", "image/webp": ".webp"}
|
"image/svg+xml": ".svg", "image/webp": ".webp"}
|
||||||
|
|
||||||
|
|
||||||
|
def css_styles(zf):
|
||||||
|
"""class -> (кегль в em, моноширинный ли) из всех таблиц стилей книги."""
|
||||||
|
out = {}
|
||||||
|
for name in zf.namelist():
|
||||||
|
if not name.lower().endswith(".css"):
|
||||||
|
continue
|
||||||
|
for cls, body in CSS_RULE.findall(zf.read(name).decode("utf-8", "replace")):
|
||||||
|
size = out.get(cls, (0.0, False))[0]
|
||||||
|
m = re.search(r"font-size:\s*([\d.]+)\s*(em|rem|pt|px|%)", body)
|
||||||
|
if m:
|
||||||
|
v = float(m.group(1))
|
||||||
|
size = {"em": v, "rem": v, "pt": v / 12, "px": v / 16,
|
||||||
|
"%": v / 100}[m.group(2)]
|
||||||
|
fam = re.search(r"font-family:\s*([^;]+)", body)
|
||||||
|
mono = bool(fam and MONO_FONT.search(fam.group(1)))
|
||||||
|
was = out.get(cls, (0.0, False))
|
||||||
|
out[cls] = (size or was[0], mono or was[1])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def derive_from_css(styles):
|
||||||
|
"""(класс -> уровень заголовка, множество моноширинных классов).
|
||||||
|
|
||||||
|
Заголовки — три самых крупных кегля выше основного текста. Больше трёх
|
||||||
|
уровней брать нельзя: дальше начинаются подписи и колонтитулы.
|
||||||
|
"""
|
||||||
|
sizes = sorted({s for s, _ in styles.values() if s > 1.05}, reverse=True)[:3]
|
||||||
|
level = {s: i + 1 for i, s in enumerate(sizes)}
|
||||||
|
head = {c: level[s] for c, (s, _) in styles.items() if s in level}
|
||||||
|
mono = {c for c, (_, m) in styles.items() if m}
|
||||||
|
return head, mono
|
||||||
|
|
||||||
|
|
||||||
class Reader(HTMLParser):
|
class Reader(HTMLParser):
|
||||||
"""Собирает блоки из одного XHTML-документа книги."""
|
"""Собирает блоки из одного XHTML-документа книги."""
|
||||||
|
|
||||||
def __init__(self, on_image):
|
def __init__(self, on_image, head_cls=None, mono_cls=None):
|
||||||
super().__init__(convert_charrefs=True)
|
super().__init__(convert_charrefs=True)
|
||||||
self.on_image = on_image
|
self.on_image = on_image
|
||||||
|
self.head_cls = head_cls or {}
|
||||||
|
self.mono_cls = mono_cls or set()
|
||||||
self.blocks = []
|
self.blocks = []
|
||||||
self.cur = None # (tag, cls, [куски])
|
self.cur = None # (tag, cls, [куски])
|
||||||
self.open_inline = [] # незакрытые инлайновые теги текущего блока
|
self.open_inline = [] # незакрытые инлайновые теги текущего блока
|
||||||
@@ -69,7 +110,12 @@ class Reader(HTMLParser):
|
|||||||
else:
|
else:
|
||||||
txt = re.sub(r"\s+", " ", txt).strip()
|
txt = re.sub(r"\s+", " ", txt).strip()
|
||||||
txt = re.sub(r"<(i|b|code)>(\s*)</\1>", r"\2", txt) # пустая разметка
|
txt = re.sub(r"<(i|b|code)>(\s*)</\1>", r"\2", txt) # пустая разметка
|
||||||
if txt.strip():
|
if not txt.strip():
|
||||||
|
return
|
||||||
|
if tag == "pre" and self.blocks and self.blocks[-1][0] == "pre":
|
||||||
|
prev = self.blocks[-1]
|
||||||
|
self.blocks[-1] = (prev[0], prev[1], prev[2] + "\n" + txt)
|
||||||
|
return
|
||||||
self.blocks.append((tag, cls, txt))
|
self.blocks.append((tag, cls, txt))
|
||||||
|
|
||||||
def start(self, tag, cls):
|
def start(self, tag, cls):
|
||||||
@@ -98,7 +144,16 @@ class Reader(HTMLParser):
|
|||||||
self.cur[2].append("\n" if self.cur[0] == "pre" else " ")
|
self.cur[2].append("\n" if self.cur[0] == "pre" else " ")
|
||||||
return
|
return
|
||||||
if tag in BLOCK_MAP:
|
if tag in BLOCK_MAP:
|
||||||
self.start(*BLOCK_MAP[tag])
|
out_tag, out_cls = BLOCK_MAP[tag]
|
||||||
|
if out_tag in ("p", "pre"):
|
||||||
|
for c in (a.get("class") or "").split():
|
||||||
|
if c in self.head_cls:
|
||||||
|
out_tag, out_cls = "h%d" % self.head_cls[c], None
|
||||||
|
break
|
||||||
|
if c in self.mono_cls:
|
||||||
|
out_tag, out_cls = "pre", None
|
||||||
|
break
|
||||||
|
self.start(out_tag, out_cls)
|
||||||
return
|
return
|
||||||
if tag in ("td", "th") and self.cur and self.cur[0] == "p" \
|
if tag in ("td", "th") and self.cur and self.cur[0] == "p" \
|
||||||
and self.cur[1] == "row" and self.cur[2]:
|
and self.cur[1] == "row" and self.cur[2]:
|
||||||
@@ -220,6 +275,8 @@ def convert(src, out):
|
|||||||
if cover:
|
if cover:
|
||||||
extract(cover, stem="cover")
|
extract(cover, stem="cover")
|
||||||
|
|
||||||
|
def parse_all(head_cls=None, mono_cls=None):
|
||||||
|
got = []
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
if doc not in names:
|
if doc not in names:
|
||||||
print("нет в архиве, пропущен: %s" % doc, file=sys.stderr)
|
print("нет в архиве, пропущен: %s" % doc, file=sys.stderr)
|
||||||
@@ -227,19 +284,45 @@ def convert(src, out):
|
|||||||
here = posixpath.dirname(doc)
|
here = posixpath.dirname(doc)
|
||||||
|
|
||||||
def on_image(src_attr, here=here):
|
def on_image(src_attr, here=here):
|
||||||
target = posixpath.normpath(posixpath.join(here, src_attr.split("#")[0]))
|
target = posixpath.normpath(
|
||||||
|
posixpath.join(here, src_attr.split("#")[0]))
|
||||||
return extract(target)
|
return extract(target)
|
||||||
|
|
||||||
r = Reader(on_image)
|
r = Reader(on_image, head_cls, mono_cls)
|
||||||
r.feed(zf.read(doc).decode("utf-8", "replace"))
|
r.feed(zf.read(doc).decode("utf-8", "replace"))
|
||||||
r.close()
|
r.close()
|
||||||
parts.extend(r.blocks)
|
got.extend(r.blocks)
|
||||||
|
return got
|
||||||
|
|
||||||
|
parts = parse_all()
|
||||||
|
has_head = any(re.fullmatch(r"h[1-6]", t) for t, _, _ in parts)
|
||||||
|
has_pre = any(t == "pre" for t, _, _ in parts)
|
||||||
|
if not (has_head and has_pre):
|
||||||
|
head_cls, mono_cls = derive_from_css(css_styles(zf))
|
||||||
|
if (not has_head and head_cls) or (not has_pre and mono_cls):
|
||||||
|
print("семантики в книге нет (заголовки: %s, листинги: %s) — "
|
||||||
|
"восстанавливаю по CSS" % (has_head, has_pre))
|
||||||
|
seen.clear()
|
||||||
|
counter[0] = 0
|
||||||
|
parts = parse_all(head_cls if not has_head else None,
|
||||||
|
mono_cls if not has_pre else None)
|
||||||
|
|
||||||
title = out.stem
|
title = out.stem
|
||||||
lvl = chapter_level(parts)
|
lvl = chapter_level(parts)
|
||||||
parts = [(("h1" if int(t[1]) <= lvl else "h2", c, x)
|
parts = [(("h1" if int(t[1]) <= lvl else "h2", c, x)
|
||||||
if re.fullmatch(r"h[1-6]", t) else (t, c, x))
|
if re.fullmatch(r"h[1-6]", t) else (t, c, x))
|
||||||
for t, c, x in parts]
|
for t, c, x in parts]
|
||||||
|
# «Chapter 1» и «Introduction» приезжают отдельными заголовками: в дампе это
|
||||||
|
# две строки. Иначе каждая вторая глава состоит из одного блока.
|
||||||
|
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", None, merged[-1][2] + ". " + part[2])
|
||||||
|
continue
|
||||||
|
merged.append(part)
|
||||||
|
parts = merged
|
||||||
|
|
||||||
h1 = sum(1 for p in parts if p[0] == "h1")
|
h1 = sum(1 for p in parts if p[0] == "h1")
|
||||||
print("главы размечены h%d, глав получилось: %d" % (lvl, h1))
|
print("главы размечены h%d, глав получилось: %d" % (lvl, h1))
|
||||||
if h1 < 3:
|
if h1 < 3:
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""XHTML от pdf2html.py/epub2html.py -> EPUB без сторонних зависимостей.
|
||||||
|
|
||||||
|
calibre для упаковки не нужен: EPUB — это zip с манифестом, а установка calibre
|
||||||
|
требует root, которого на чужой машине может не быть. Формат намеренно
|
||||||
|
консервативный (EPUB 2 + NCX): его одинаково понимают KOReader, штатные читалки
|
||||||
|
PocketBook и calibre, если он всё-таки понадобится.
|
||||||
|
|
||||||
|
Книга режется по <h1>: одна глава — один файл. Так читалка не разбирает
|
||||||
|
мегабайтный документ на каждом перелистывании и получает оглавление даром.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
H1 = re.compile(r"^<h1[^>]*>(.*?)</h1>$")
|
||||||
|
IMG = re.compile(r'<img src="images/([^"]+)"')
|
||||||
|
NS = 'xmlns="http://www.w3.org/1999/xhtml"'
|
||||||
|
|
||||||
|
CHAPTER = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html %s><head><meta charset="utf-8"/><title>%s</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="style.css"/></head>
|
||||||
|
<body>
|
||||||
|
%s
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def split_chapters(lines):
|
||||||
|
"""[(заголовок, [строки])] — режем по <h1>, преамбулу оставляем главой."""
|
||||||
|
chapters, title, buf = [], "Начало", []
|
||||||
|
for line in lines:
|
||||||
|
m = H1.match(line.strip())
|
||||||
|
if m and buf:
|
||||||
|
chapters.append((title, buf))
|
||||||
|
title, buf = re.sub("<[^>]+>", "", m.group(1)).strip() or "Глава", [line]
|
||||||
|
continue
|
||||||
|
if m:
|
||||||
|
title = re.sub("<[^>]+>", "", m.group(1)).strip() or "Глава"
|
||||||
|
buf.append(line)
|
||||||
|
if buf:
|
||||||
|
chapters.append((title, buf))
|
||||||
|
return [(t, b) for t, b in chapters if any(x.strip() for x in b)]
|
||||||
|
|
||||||
|
|
||||||
|
def build(src, out, meta):
|
||||||
|
text = src.read_text(encoding="utf-8")
|
||||||
|
css = re.search(r"<style>(.*?)</style>", text, re.S)
|
||||||
|
css = css.group(1) if css else ""
|
||||||
|
body = re.search(r"<body>(.*)</body>", text, re.S)
|
||||||
|
lines = (body.group(1) if body else text).splitlines()
|
||||||
|
chapters = split_chapters(lines)
|
||||||
|
if not chapters:
|
||||||
|
sys.exit("в %s нет содержимого" % src)
|
||||||
|
|
||||||
|
imgdir = src.parent / "images"
|
||||||
|
used, files = [], []
|
||||||
|
with zipfile.ZipFile(out, "w") as z:
|
||||||
|
# mimetype обязан идти первым и без сжатия — иначе часть читалок
|
||||||
|
# не опознаёт архив как EPUB
|
||||||
|
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip",
|
||||||
|
compress_type=zipfile.ZIP_STORED)
|
||||||
|
z.writestr("META-INF/container.xml",
|
||||||
|
'<?xml version="1.0"?>\n<container version="1.0" '
|
||||||
|
'xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
|
||||||
|
'<rootfiles><rootfile full-path="OEBPS/content.opf" '
|
||||||
|
'media-type="application/oebps-package+xml"/></rootfiles></container>')
|
||||||
|
z.writestr("OEBPS/style.css", css)
|
||||||
|
for n, (title, buf) in enumerate(chapters):
|
||||||
|
name = "ch%03d.xhtml" % n
|
||||||
|
files.append((name, title))
|
||||||
|
chunk = "\n".join(buf)
|
||||||
|
for img in IMG.findall(chunk):
|
||||||
|
if img not in used and (imgdir / img).exists():
|
||||||
|
used.append(img)
|
||||||
|
z.writestr("OEBPS/" + name,
|
||||||
|
CHAPTER % (NS, html.escape(title), chunk))
|
||||||
|
for img in used:
|
||||||
|
z.write(imgdir / img, "OEBPS/images/" + img)
|
||||||
|
if meta["cover"] and (imgdir / meta["cover"]).exists():
|
||||||
|
z.write(imgdir / meta["cover"], "OEBPS/images/" + meta["cover"])
|
||||||
|
|
||||||
|
items = ['<item id="css" href="style.css" media-type="text/css"/>',
|
||||||
|
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>']
|
||||||
|
spine = []
|
||||||
|
for i, (name, _) in enumerate(files):
|
||||||
|
items.append('<item id="c%d" href="%s" media-type="application/xhtml+xml"/>'
|
||||||
|
% (i, name))
|
||||||
|
spine.append('<itemref idref="c%d"/>' % i)
|
||||||
|
mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif", ".svg": "image/svg+xml"}
|
||||||
|
for i, img in enumerate(used + ([meta["cover"]] if meta["cover"] else [])):
|
||||||
|
items.append('<item id="i%d" href="images/%s" media-type="%s"%s/>'
|
||||||
|
% (i, img, mime.get(Path(img).suffix.lower(), "image/jpeg"),
|
||||||
|
' properties="cover-image"' if img == meta["cover"] else ""))
|
||||||
|
z.writestr("OEBPS/content.opf", """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bid">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:identifier id="bid">%s</dc:identifier>
|
||||||
|
<dc:title>%s</dc:title>
|
||||||
|
<dc:creator>%s</dc:creator>
|
||||||
|
<dc:language>%s</dc:language>
|
||||||
|
</metadata>
|
||||||
|
<manifest>%s</manifest>
|
||||||
|
<spine toc="ncx">%s</spine>
|
||||||
|
</package>""" % (html.escape(meta["id"]), html.escape(meta["title"]),
|
||||||
|
html.escape(meta["author"]), meta["lang"],
|
||||||
|
"\n ".join(items), "\n ".join(spine)))
|
||||||
|
|
||||||
|
nav = "\n".join(
|
||||||
|
'<navPoint id="n%d" playOrder="%d"><navLabel><text>%s</text></navLabel>'
|
||||||
|
'<content src="%s"/></navPoint>' % (i, i + 1, html.escape(t), n)
|
||||||
|
for i, (n, t) in enumerate(files))
|
||||||
|
z.writestr("OEBPS/toc.ncx", """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||||
|
<head><meta name="dtb:uid" content="%s"/></head>
|
||||||
|
<docTitle><text>%s</text></docTitle>
|
||||||
|
<navMap>%s</navMap>
|
||||||
|
</ncx>""" % (html.escape(meta["id"]), html.escape(meta["title"]), nav))
|
||||||
|
return len(chapters), len(used)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("source", type=Path, help="XHTML от pdf2html.py/epub2html.py")
|
||||||
|
ap.add_argument("output", type=Path, help="куда писать .epub")
|
||||||
|
ap.add_argument("--title", required=True)
|
||||||
|
ap.add_argument("--author", default="")
|
||||||
|
ap.add_argument("--lang", default="ru")
|
||||||
|
ap.add_argument("--cover", default="cover.jpg", help="имя файла в images/")
|
||||||
|
args = ap.parse_args()
|
||||||
|
meta = {"title": args.title, "author": args.author, "lang": args.lang,
|
||||||
|
"cover": args.cover, "id": "urn:uuid:" + args.output.stem.replace(" ", "-")}
|
||||||
|
n, imgs = build(args.source, args.output, meta)
|
||||||
|
size = args.output.stat().st_size / 2**20
|
||||||
|
print("%s: глав %d, картинок %d, %.1f МБ" % (args.output.name, n, imgs, size))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+109
-25
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""PDF (calibre-generated) -> semantic XHTML, preserving italic/bold/headings."""
|
"""PDF (calibre-generated) -> semantic XHTML, preserving italic/bold/headings."""
|
||||||
|
import collections
|
||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
@@ -13,8 +14,6 @@ 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")
|
sys.exit("usage: pdf2html.py book.pdf out.html | pdf2html.py --fonts book.pdf")
|
||||||
|
|
||||||
if sys.argv[1] == "--fonts": # разведка: какие шрифты/кегли в PDF
|
if sys.argv[1] == "--fonts": # разведка: какие шрифты/кегли в PDF
|
||||||
import collections
|
|
||||||
|
|
||||||
c = collections.Counter()
|
c = collections.Counter()
|
||||||
d = pymupdf.open(sys.argv[2])
|
d = pymupdf.open(sys.argv[2])
|
||||||
for p in d:
|
for p in d:
|
||||||
@@ -31,23 +30,64 @@ OUT = Path(sys.argv[2])
|
|||||||
IMGDIR = OUT.parent / "images"
|
IMGDIR = OUT.parent / "images"
|
||||||
IMGDIR.mkdir(parents=True, exist_ok=True)
|
IMGDIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Пороги подобраны под вёрстку 15pt/letter. Для другой книги сначала
|
# Пороги не зашиты: имена шрифтов у многих издательств вычищены до вида
|
||||||
# посмотреть реальные шрифты и кегли: pdf2html.py --fonts file.pdf
|
# «Fd820825», а кегль основного текста у каждой книги свой. Профиль считается по
|
||||||
INDENT_X = 88 # x0 первой строки: больше — абзац с красной строки
|
# самому файлу, увидеть его: pdf2html.py --fonts file.pdf
|
||||||
MONO = 8 # бит моноширинного шрифта в span["flags"] (PyMuPDF)
|
ITALIC, MONO, BOLD = 2, 8, 16 # биты span["flags"] в PyMuPDF
|
||||||
|
MIN_IMG = 24 # pt: всё мельче — линейки, буллеты и прочая вёрстка, не иллюстрации
|
||||||
|
MAX_IMG_SHARE = 0.6 # доля площади страницы: больше — это скан полосы, а не рисунок
|
||||||
|
# Запасное опознание листинга, когда издатель вычистил и имена шрифтов, и флаги:
|
||||||
|
# кегль мельче основного текста плюс пунктуация, которой в прозе не бывает.
|
||||||
|
CODEY = re.compile(r"[{}();\[\]<>=*/#]|::|->")
|
||||||
|
|
||||||
|
|
||||||
def style(font):
|
def style(font, flags=0):
|
||||||
|
"""Полужирный и курсив: сначала флаги, затем имя — имена бывают пустыми."""
|
||||||
f = font.lower()
|
f = font.lower()
|
||||||
return ("bold" in f or "semibold" in f, "-it" in f or "italic" in f)
|
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):
|
def is_mono(s):
|
||||||
"""Моноширинный шрифт = листинг кода. Признак берётся из флагов PyMuPDF, а
|
"""Моноширинный шрифт = листинг кода. Признак из флагов, а не из имени:
|
||||||
не из имени шрифта: имена у каждого издательства свои, флаг одинаковый."""
|
имена у каждого издательства свои, флаг одинаковый."""
|
||||||
return bool(s["flags"] & MONO)
|
return bool(s["flags"] & MONO)
|
||||||
|
|
||||||
|
|
||||||
|
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[round(sp["size"], 1)] += len(sp["text"])
|
||||||
|
top = max(spans, key=lambda sp: len(sp["text"]))
|
||||||
|
head_blocks[round(top["size"], 1)] += 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):
|
def span_html(s, plain=False):
|
||||||
t = html.escape(s["text"])
|
t = html.escape(s["text"])
|
||||||
if not t:
|
if not t:
|
||||||
@@ -56,7 +96,7 @@ def span_html(s, plain=False):
|
|||||||
return t
|
return t
|
||||||
if is_mono(s):
|
if is_mono(s):
|
||||||
return "<code>%s</code>" % t
|
return "<code>%s</code>" % t
|
||||||
bold, ital = style(s["font"])
|
bold, ital = style(s["font"], s["flags"])
|
||||||
if ital:
|
if ital:
|
||||||
t = "<i>%s</i>" % t
|
t = "<i>%s</i>" % t
|
||||||
if bold:
|
if bold:
|
||||||
@@ -64,23 +104,30 @@ def span_html(s, plain=False):
|
|||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
def block_kind(b):
|
def looks_like_code(spans):
|
||||||
"""(tag, css class) from dominant span font/size."""
|
"""Доля кодовой пунктуации. Порог низкий: в прозе на 200 знаков не набирается
|
||||||
|
и десятка скобок со звёздочками, в листинге — набирается всегда."""
|
||||||
|
text = "".join(s["text"] for s in spans)
|
||||||
|
return len(text) > 8 and len(CODEY.findall(text)) / len(text) > 0.03
|
||||||
|
|
||||||
|
|
||||||
|
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()]
|
spans = [s for l in b["lines"] for s in l["spans"] if s["text"].strip()]
|
||||||
if not spans:
|
if not spans:
|
||||||
return None
|
return None
|
||||||
top = max(spans, key=lambda s: len(s["text"]))
|
top = max(spans, key=lambda s: len(s["text"]))
|
||||||
f, sz = top["font"], top["size"]
|
sz = 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):
|
if all(is_mono(sp) for sp in spans):
|
||||||
return ("pre", None)
|
return ("pre", None)
|
||||||
if sz >= 17:
|
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 ("h2", None)
|
||||||
if f.startswith("MyriadPro"):
|
|
||||||
return ("p", "note") # Maxine's journal / chat / slides
|
|
||||||
return ("p", None)
|
return ("p", None)
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +158,9 @@ def block_text(b, pre=False):
|
|||||||
|
|
||||||
|
|
||||||
doc = pymupdf.open(SRC)
|
doc = pymupdf.open(SRC)
|
||||||
|
BODY, INDENT_X, H1_SIZE = profile(doc)
|
||||||
|
print("кегль текста %.1f, глав %.1f, красная строка от x0=%.0f"
|
||||||
|
% (BODY, H1_SIZE, INDENT_X))
|
||||||
parts = []
|
parts = []
|
||||||
open_para = None # (tag, cls, text) still collecting
|
open_para = None # (tag, cls, text) still collecting
|
||||||
|
|
||||||
@@ -125,18 +175,28 @@ for pno, page in enumerate(doc):
|
|||||||
blocks = sorted(page.get_text("dict")["blocks"], key=lambda b: b["bbox"][1])
|
blocks = sorted(page.get_text("dict")["blocks"], key=lambda b: b["bbox"][1])
|
||||||
for bi, b in enumerate(blocks):
|
for bi, b in enumerate(blocks):
|
||||||
if b["type"] == 1:
|
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
|
img_n += 1
|
||||||
name = "img%02d.png" % img_n
|
name = "img%02d.png" % img_n
|
||||||
(IMGDIR / name).write_bytes(b["image"])
|
(IMGDIR / name).write_bytes(b["image"])
|
||||||
if open_para:
|
if open_para:
|
||||||
parts.append(open_para)
|
parts.append(open_para)
|
||||||
open_para = None
|
open_para = None
|
||||||
parts.append(("figure", None, '<img src="images/%s"/>' % name))
|
parts.append(("figure", None, '<img src="images/%s"/>' % name, False))
|
||||||
continue
|
continue
|
||||||
kind = block_kind(b)
|
kind = block_kind(b, BODY, H1_SIZE)
|
||||||
if not kind:
|
if not kind:
|
||||||
continue
|
continue
|
||||||
tag, cls = kind
|
tag, cls = kind
|
||||||
|
# Глава открывает полосу, раздел встречается по тексту. Признак —
|
||||||
|
# верхняя треть страницы, а не «первый блок»: сверху нередко идёт
|
||||||
|
# колонтитул или плашка, и тогда первым заголовок не бывает никогда.
|
||||||
|
at_top = b["bbox"][1] < page.rect.height * 0.35
|
||||||
txt = block_text(b, pre=(tag == "pre"))
|
txt = block_text(b, pre=(tag == "pre"))
|
||||||
if not txt:
|
if not txt:
|
||||||
continue
|
continue
|
||||||
@@ -151,14 +211,38 @@ for pno, page in enumerate(doc):
|
|||||||
)
|
)
|
||||||
if cont:
|
if cont:
|
||||||
join = "" if open_para[2].endswith("-") else " "
|
join = "" if open_para[2].endswith("-") else " "
|
||||||
open_para = (tag, cls, open_para[2] + join + txt)
|
open_para = (tag, cls, open_para[2] + join + txt, open_para[3])
|
||||||
continue
|
continue
|
||||||
if open_para:
|
if open_para:
|
||||||
parts.append(open_para)
|
parts.append(open_para)
|
||||||
open_para = (tag, cls, txt)
|
open_para = (tag, cls, txt, at_top)
|
||||||
if open_para:
|
if open_para:
|
||||||
parts.append(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
|
||||||
|
|
||||||
doc_html = bookhtml.document(doc.metadata.get("title") or SRC.stem, parts)
|
doc_html = bookhtml.document(doc.metadata.get("title") or SRC.stem, parts)
|
||||||
# merge tag runs split at page/line boundaries. Пробелы здесь уже не трогаем:
|
# merge tag runs split at page/line boundaries. Пробелы здесь уже не трогаем:
|
||||||
# внутри <pre> они значимы, а в прозе схлопнуты в block_text.
|
# внутри <pre> они значимы, а в прозе схлопнуты в block_text.
|
||||||
|
|||||||
@@ -113,7 +113,78 @@ def test_convert(tmp: Path):
|
|||||||
assert text.count("<h1>") == 3 and "<h2>" not in text
|
assert text.count("<h1>") == 3 and "<h2>" not in text
|
||||||
|
|
||||||
|
|
||||||
|
# Дамп, собранный конвертером из PDF: ни одного заголовка, ни одного <pre>,
|
||||||
|
# только <p> с обфусцированными классами и таблица стилей.
|
||||||
|
FLAT_CSS = """
|
||||||
|
.cls_head { font-size: 1.66667em; font-family: Reg; }
|
||||||
|
.cls_body { font-size: 1em; font-family: Reg; }
|
||||||
|
.cls_code { font-size: 0.9em; font-family: lucidasanstypewriter; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
FLAT_OPF = """<?xml version="1.0"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||||
|
<metadata/>
|
||||||
|
<manifest>
|
||||||
|
<item id="s" href="style.css" media-type="text/css"/>
|
||||||
|
<item id="a" href="p1.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="b" href="p2.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="c" href="p3.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
</manifest>
|
||||||
|
<spine><itemref idref="a"/><itemref idref="b"/><itemref idref="c"/></spine>
|
||||||
|
</package>"""
|
||||||
|
|
||||||
|
|
||||||
|
def flat_doc(n):
|
||||||
|
return """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml"><body>
|
||||||
|
<p class="cls_head">Chapter %d</p>
|
||||||
|
<p class="cls_head">Title Of Chapter %d</p>
|
||||||
|
<p class="cls_body">Body text of the chapter, long enough to be a paragraph.</p>
|
||||||
|
<p class="cls_code">def f(x):</p>
|
||||||
|
<p class="cls_code"> return x + 1</p>
|
||||||
|
<p class="cls_body">Closing paragraph of the chapter goes here.</p>
|
||||||
|
</body></html>""" % (n, n)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flat_dump_recovered(tmp: Path):
|
||||||
|
src = tmp / "flat.epub"
|
||||||
|
with zipfile.ZipFile(src, "w") as z:
|
||||||
|
z.writestr("mimetype", "application/epub+zip")
|
||||||
|
z.writestr("META-INF/container.xml", CONTAINER)
|
||||||
|
z.writestr("OEBPS/content.opf", FLAT_OPF)
|
||||||
|
z.writestr("OEBPS/style.css", FLAT_CSS)
|
||||||
|
for i in (1, 2, 3):
|
||||||
|
z.writestr("OEBPS/p%d.xhtml" % i, flat_doc(i))
|
||||||
|
out = tmp / "flat.html"
|
||||||
|
epub2html.convert(src, out)
|
||||||
|
text = out.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# заголовки восстановлены по кеглю и склеены с номером главы
|
||||||
|
assert "<h1>Chapter 2. Title Of Chapter 2</h1>" in text, text
|
||||||
|
assert text.count("<h1>") == 3
|
||||||
|
|
||||||
|
# листинг восстановлен по гарнитуре, соседние строки склеены в один блок
|
||||||
|
assert "<pre>def f(x):\n return x + 1</pre>" in text, text
|
||||||
|
assert text.count("<pre>") == 3
|
||||||
|
|
||||||
|
# обычный текст остался абзацем
|
||||||
|
assert "<p>Body text of the chapter" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_markup_wins_over_css(tmp: Path):
|
||||||
|
"""Книга со своей разметкой не должна уезжать в запасной путь."""
|
||||||
|
src = tmp / "book.epub"
|
||||||
|
build_epub(src)
|
||||||
|
out = tmp / "book.html"
|
||||||
|
epub2html.convert(src, out)
|
||||||
|
text = out.read_text(encoding="utf-8")
|
||||||
|
assert text.count("<h1>") == 3, "заголовки книги должны остаться её собственными"
|
||||||
|
assert "def main():" in text
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
for case in (test_convert, test_flat_dump_recovered,
|
||||||
|
test_real_markup_wins_over_css):
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
test_convert(Path(d))
|
case(Path(d))
|
||||||
print("OK")
|
print("OK")
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Самопроверка упаковщика без сети: python3 test_pack_epub.py"""
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pack_epub
|
||||||
|
|
||||||
|
BOOK = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>T</title>
|
||||||
|
<style>p { margin: 0; }</style></head><body>
|
||||||
|
<p>Предисловие до первой главы.</p>
|
||||||
|
<h1>Глава 1. Начало</h1>
|
||||||
|
<p>Текст первой главы.</p>
|
||||||
|
<pre>def f():
|
||||||
|
return 1</pre>
|
||||||
|
<p class="figure"><img src="images/fig.png"/></p>
|
||||||
|
<h1>Глава 2. Продолжение</h1>
|
||||||
|
<p>Текст второй главы.</p>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack(tmp: Path):
|
||||||
|
src = tmp / "book.html"
|
||||||
|
src.write_text(BOOK, encoding="utf-8")
|
||||||
|
(tmp / "images").mkdir()
|
||||||
|
(tmp / "images" / "fig.png").write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||||
|
(tmp / "images" / "cover.jpg").write_bytes(b"\xff\xd8\xff")
|
||||||
|
out = tmp / "book.epub"
|
||||||
|
chapters, imgs = pack_epub.build(src, out, {
|
||||||
|
"title": "Книга & книга", "author": "Автор", "lang": "ru",
|
||||||
|
"cover": "cover.jpg", "id": "urn:uuid:test"})
|
||||||
|
|
||||||
|
# преамбула до первой главы не теряется, поэтому глав три, а не две
|
||||||
|
assert (chapters, imgs) == (3, 1), (chapters, imgs)
|
||||||
|
|
||||||
|
z = zipfile.ZipFile(out)
|
||||||
|
names = z.namelist()
|
||||||
|
# без этого часть читалок не опознаёт файл как EPUB
|
||||||
|
assert names[0] == "mimetype"
|
||||||
|
assert z.getinfo("mimetype").compress_type == zipfile.ZIP_STORED
|
||||||
|
assert "OEBPS/images/fig.png" in names and "OEBPS/images/cover.jpg" in names
|
||||||
|
|
||||||
|
opf = z.read("OEBPS/content.opf").decode("utf-8")
|
||||||
|
assert "<dc:title>Книга & книга</dc:title>" in opf, "разметку надо экранировать"
|
||||||
|
assert opf.count("<itemref") == 3
|
||||||
|
assert 'properties="cover-image"' in opf
|
||||||
|
|
||||||
|
ncx = z.read("OEBPS/toc.ncx").decode("utf-8")
|
||||||
|
assert re.findall(r"<text>([^<]*)</text>", ncx)[1:] == [
|
||||||
|
"Начало", "Глава 1. Начало", "Глава 2. Продолжение"]
|
||||||
|
|
||||||
|
ch1 = z.read("OEBPS/ch001.xhtml").decode("utf-8")
|
||||||
|
assert "<pre>def f():\n return 1</pre>" in ch1, "переносы в листинге значимы"
|
||||||
|
assert 'href="style.css"' in ch1
|
||||||
|
assert "Текст второй главы" not in ch1, "главы не должны склеиваться"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_content(tmp: Path):
|
||||||
|
src = tmp / "empty.html"
|
||||||
|
src.write_text("<html><body>\n</body></html>", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
pack_epub.build(src, tmp / "x.epub", {"title": "t", "author": "", "lang": "ru",
|
||||||
|
"cover": "", "id": "i"})
|
||||||
|
except SystemExit:
|
||||||
|
return
|
||||||
|
raise AssertionError("пустой источник должен останавливать упаковку")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for case in (test_pack, test_no_content):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
case(Path(d))
|
||||||
|
print("OK")
|
||||||
Reference in New Issue
Block a user