Compare commits

..

2 Commits

Author SHA1 Message Date
chesirecatt a21a4d713b Автоматический подбор порогов вместо ручного + упаковка EPUB без calibre
Ручная настройка block_kind() под каждую книгу оказалась неприменима: у части
изданий имена шрифтов вычищены до «Fd820825», а флаги стилей у всех спанов
равны 4, то есть ни курсива, ни моноширинности по ним не видно. Пороги теперь
считаются из самого файла:

- кегль основного текста — по гистограмме символов;
- кегль глав — по частоте блочных кеглей, а не множителем от текста: иначе
  подзаголовки разделов попадают в главы и оглавление пухнет до трёхсот пунктов;
- глава отличается от раздела положением в верхней трети полосы, и правило
  применяется, только если глав так набирается хотя бы пять;
- красная строка — по распределению x0.

Листинг опознаётся флагом моноширинности, а при его отсутствии — кеглем мельче
основного текста плюс плотностью кодовой пунктуации.

Отсев картинок: мельче 24 pt (линейки и буллеты) и крупнее 60% площади полосы
(подложка отсканированной страницы). На «Программисте-прагматике» второе
правило убрало 370 сканов полос и уменьшило EPUB со 124 МБ до 17 МБ.

pack_epub.py собирает EPUB на стандартной библиотеке: calibre для упаковки не
нужен, а его установка требует root. Книга режется по h1, оглавление NCX
строится само. Тест test_pack_epub.py проверяет несжатый mimetype первым файлом,
экранирование разметки в метаданных, сохранение переносов в листинге и то, что
преамбула до первой главы не теряется.
2026-08-20 20:11:03 +03:00
chesirecatt 1bacd38292 Восстановление структуры EPUB по CSS для дампов из PDF
Дамп, собранный конвертером из PDF, теряет всю семантику: ни заголовков, ни
листингов, только абзацы с обфусцированными классами. Такие книги уезжали в
перевод одним куском, а код — вместе с текстом.

Теперь при нулевом числе заголовков или листингов разметка восстанавливается из
таблицы стилей книги: три самых крупных кегля становятся уровнями заголовков,
моноширинная гарнитура — листингом, соседние строки листинга склеиваются в один
блок. Книга со своей разметкой в этот путь не попадает.

Проверено на двух настоящих книгах: дамп APoSD — 1833 плоских абзаца стали 29
главами и 58 листингами; Страуструп с собственной разметкой не изменился.
2026-08-20 19:11:51 +03:00
6 changed files with 510 additions and 43 deletions
+9
View File
@@ -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
+99 -16
View File
@@ -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,8 +110,13 @@ 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():
self.blocks.append((tag, cls, txt)) 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))
def start(self, tag, cls): def start(self, tag, cls):
self.flush() self.flush()
@@ -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,26 +275,54 @@ def convert(src, out):
if cover: if cover:
extract(cover, stem="cover") extract(cover, stem="cover")
for doc in docs: def parse_all(head_cls=None, mono_cls=None):
if doc not in names: got = []
print("нет в архиве, пропущен: %s" % doc, file=sys.stderr) for doc in docs:
continue if doc not in names:
here = posixpath.dirname(doc) print("нет в архиве, пропущен: %s" % doc, file=sys.stderr)
continue
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(
return extract(target) posixpath.join(here, src_attr.split("#")[0]))
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:
+145
View File
@@ -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
View File
@@ -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.
+73 -2
View File
@@ -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__":
with tempfile.TemporaryDirectory() as d: for case in (test_convert, test_flat_dump_recovered,
test_convert(Path(d)) test_real_markup_wins_over_css):
with tempfile.TemporaryDirectory() as d:
case(Path(d))
print("OK") print("OK")
+75
View File
@@ -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>Книга &amp; книга</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")