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.
268 lines
10 KiB
Python
268 lines
10 KiB
Python
#!/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"}
|
||
IMG_EXT = {"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif",
|
||
"image/svg+xml": ".svg", "image/webp": ".webp"}
|
||
|
||
|
||
class Reader(HTMLParser):
|
||
"""Собирает блоки из одного XHTML-документа книги."""
|
||
|
||
def __init__(self, on_image):
|
||
super().__init__(convert_charrefs=True)
|
||
self.on_image = on_image
|
||
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 txt.strip():
|
||
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:
|
||
self.start(*BLOCK_MAP[tag])
|
||
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")
|
||
|
||
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)
|
||
r.feed(zf.read(doc).decode("utf-8", "replace"))
|
||
r.close()
|
||
parts.extend(r.blocks)
|
||
|
||
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]
|
||
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()
|