Files
chesirecatt 1bacd38292 Восстановление структуры EPUB по CSS для дампов из PDF
Дамп, собранный конвертером из PDF, теряет всю семантику: ни заголовков, ни
листингов, только абзацы с обфусцированными классами. Такие книги уезжали в
перевод одним куском, а код — вместе с текстом.

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

Проверено на двух настоящих книгах: дамп APoSD — 1833 плоских абзаца стали 29
главами и 58 листингами; Страуструп с собственной разметкой не изменился.
2026-08-20 19:11:51 +03:00

351 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""EPUB -> тот же XHTML, что выдаёт pdf2html.py (один блок = одна строка).
EPUB уже размечен семантически, поэтому здесь не разведка шрифтов, а
нормализация: привести чужую вёрстку к формату, который понимает мост
translate.py, и вычистить всё, что переводчику видеть не нужно.
Листинги остаются в <pre> и не переводятся вовсе: BLOCK-регулярка моста их не
узнаёт, а всё неузнанное доходит до результата нетронутым.
Только стандартная библиотека — ни calibre, ни lxml здесь не нужны.
"""
import argparse
import collections
import html
import posixpath
import re
import sys
import zipfile
from html.parser import HTMLParser
from pathlib import Path
from xml.etree import ElementTree
import bookhtml
CONTAINER = "META-INF/container.xml"
OPF_NS = "{http://www.idpf.org/2007/opf}"
CONT_NS = "{urn:oasis:names:tc:opendocument:xmlns:container}"
# tag источника -> (tag результата, css-класс)
BLOCK_MAP = {
"h1": ("h1", None), "h2": ("h2", None), "h3": ("h3", None),
"h4": ("h4", None), "h5": ("h5", None), "h6": ("h6", None),
"p": ("p", None), "pre": ("pre", None), "li": ("p", "li"),
"tr": ("p", "row"), "figcaption": ("p", "note"),
"dt": ("p", "note"), "dd": ("p", "note"),
}
INLINE_MAP = {"i": "i", "em": "i", "cite": "i", "b": "b", "strong": "b",
"code": "code", "kbd": "code", "samp": "code", "tt": "code",
"var": "code"}
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",
"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):
"""Собирает блоки из одного XHTML-документа книги."""
def __init__(self, on_image, head_cls=None, mono_cls=None):
super().__init__(convert_charrefs=True)
self.on_image = on_image
self.head_cls = head_cls or {}
self.mono_cls = mono_cls or set()
self.blocks = []
self.cur = None # (tag, cls, [куски])
self.open_inline = [] # незакрытые инлайновые теги текущего блока
self.drop = 0
# -- служебное ---------------------------------------------------------
def flush(self):
if not self.cur:
return
tag, cls, parts = self.cur
self.cur = None
for t in reversed(self.open_inline):
parts.append("</%s>" % t) # чужая вёрстка бывает несбалансированной
self.open_inline = []
txt = "".join(parts)
if tag == "pre":
txt = txt.strip("\n").rstrip()
else:
txt = re.sub(r"\s+", " ", txt).strip()
txt = re.sub(r"<(i|b|code)>(\s*)</\1>", r"\2", txt) # пустая разметка
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))
def start(self, tag, cls):
self.flush()
self.cur = (tag, cls, [])
# -- HTMLParser --------------------------------------------------------
def handle_starttag(self, tag, attrs):
if tag in DROP:
self.drop += 1
return
if self.drop:
return
a = dict(attrs)
if tag in ("img", "image"):
src = a.get("src") or a.get("{http://www.w3.org/1999/xlink}href") \
or a.get("xlink:href") or a.get("href")
name = self.on_image(src) if src else None
if name:
self.flush()
self.blocks.append(("figure", None,
'<img src="images/%s"/>' % name))
return
if tag == "br":
if self.cur:
self.cur[2].append("\n" if self.cur[0] == "pre" else " ")
return
if tag in BLOCK_MAP:
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
if tag in ("td", "th") and self.cur and self.cur[0] == "p" \
and self.cur[1] == "row" and self.cur[2]:
self.cur[2].append(" | ")
return
if tag in INLINE_MAP and self.cur and self.cur[0] != "pre":
# Внутри листинга инлайн не нужен: книги размечают код сплошным
# <strong>, и на e-ink это страница жирного текста.
out = INLINE_MAP[tag]
self.open_inline.append(out)
self.cur[2].append("<%s>" % out)
def handle_endtag(self, tag):
if tag in DROP:
self.drop = max(0, self.drop - 1)
return
if self.drop:
return
if tag in BLOCK_MAP:
self.flush()
return
if tag in INLINE_MAP and self.cur and self.cur[0] != "pre":
out = INLINE_MAP[tag]
if out in self.open_inline:
self.open_inline.remove(out)
self.cur[2].append("</%s>" % out)
def handle_data(self, data):
if self.drop:
return
if not self.cur:
if not data.strip():
return
self.start("p", None) # текст вне блочного тега не теряем
self.cur[2].append(html.escape(data, quote=False))
def close(self):
super().close()
self.flush()
def spine_documents(zf):
"""[(путь в архиве, media-type)] в порядке чтения + путь к OPF."""
root = ElementTree.fromstring(zf.read(CONTAINER))
opf_path = root.find(".//%srootfile" % CONT_NS).get("full-path")
opf = ElementTree.fromstring(zf.read(opf_path))
base = posixpath.dirname(opf_path)
items, cover = {}, None
for it in opf.iter("%sitem" % OPF_NS):
href = posixpath.normpath(posixpath.join(base, it.get("href")))
props = it.get("properties") or ""
items[it.get("id")] = (href, it.get("media-type"), props)
if "cover-image" in props:
cover = href
if cover is None: # EPUB 2: обложка объявляется через <meta name="cover">
meta = opf.find(".//%smeta[@name='cover']" % OPF_NS)
if meta is not None and meta.get("content") in items:
cover = items[meta.get("content")][0]
docs = []
for ref in opf.iter("%sitemref" % OPF_NS):
item = items.get(ref.get("idref"))
if not item:
continue
href, mtype, props = item
if "nav" in props or re.search(r"(toc|nav|content)s?\.x?html?$", href, re.I):
continue
docs.append(href)
return docs, items, cover
# Больше этого числа глав дробить незачем: переводчик держит контекст в пределах
# главы, слишком мелкая нарезка его обесценивает.
MAX_CHAPTERS = 150
def chapter_level(parts):
"""Каким уровнем заголовка в этой книге размечены главы.
Мост режет книгу на главы по <h1>, а в EPUB <h1> обычно занят названием
книги и частями: у Страуструпа их 7 на 656 страниц, тогда как главы — это
<h2> (49 штук). Берём самый глубокий уровень, при котором число глав ещё
остаётся вменяемым.
"""
counts = collections.Counter(t for t, _, _ in parts if re.fullmatch(r"h[1-6]", t))
best, total = 1, 0
for lvl in range(1, 7):
total += counts.get("h%d" % lvl, 0)
if total > MAX_CHAPTERS:
break
if total:
best = lvl
return best
def convert(src, out):
imgdir = out.parent / "images"
imgdir.mkdir(parents=True, exist_ok=True)
parts, seen, counter = [], {}, [0]
with zipfile.ZipFile(src) as zf:
names = set(zf.namelist())
docs, items, cover = spine_documents(zf)
def extract(path, stem=None):
if path in seen:
return seen[path]
if path not in names:
return None
ext = posixpath.splitext(path)[1] or ".img"
if stem:
name = stem + ext
else:
counter[0] += 1
name = "img%03d%s" % (counter[0], ext)
(imgdir / name).write_bytes(zf.read(path))
seen[path] = name
return name
if cover:
extract(cover, stem="cover")
def parse_all(head_cls=None, mono_cls=None):
got = []
for doc in docs:
if doc not in names:
print("нет в архиве, пропущен: %s" % doc, file=sys.stderr)
continue
here = posixpath.dirname(doc)
def on_image(src_attr, here=here):
target = posixpath.normpath(
posixpath.join(here, src_attr.split("#")[0]))
return extract(target)
r = Reader(on_image, head_cls, mono_cls)
r.feed(zf.read(doc).decode("utf-8", "replace"))
r.close()
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
lvl = chapter_level(parts)
parts = [(("h1" if int(t[1]) <= lvl else "h2", c, x)
if re.fullmatch(r"h[1-6]", t) else (t, c, x))
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")
print("главы размечены h%d, глав получилось: %d" % (lvl, h1))
if h1 < 3:
print("ВНИМАНИЕ: заголовков-глав всего %d — переводчик будет держать "
"контекст крупными кусками, проверь результат внимательнее" % h1)
out.write_text(bookhtml.document(title, parts), encoding="utf-8")
print("blocks: %d, images: %d" % (len(parts), len(seen)))
print("h1: %d p: %d pre: %d"
% (h1, sum(1 for p in parts if p[0] == "p"),
sum(1 for p in parts if p[0] == "pre")))
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("source", type=Path, help="исходный .epub")
ap.add_argument("output", type=Path, help="куда писать XHTML")
args = ap.parse_args()
if not args.source.exists():
sys.exit("нет файла %s" % args.source)
convert(args.source, args.output)
if __name__ == "__main__":
main()