EPUB как второй вход в конвейер; листинги кода не переводятся и не ломают приёмку
- 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.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user