#!/usr/bin/env python3 """EPUB -> тот же XHTML, что выдаёт pdf2html.py (один блок = одна строка). EPUB уже размечен семантически, поэтому здесь не разведка шрифтов, а нормализация: привести чужую вёрстку к формату, который понимает мост translate.py, и вычистить всё, что переводчику видеть не нужно. Листинги остаются в
 и не переводятся вовсе: 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("" % 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*)", 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,
                                    '' % 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":
            # Внутри листинга инлайн не нужен: книги размечают код сплошным
            # , и на 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("" % 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 = 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):
    """Каким уровнем заголовка в этой книге размечены главы.

    Мост режет книгу на главы по 

, а в EPUB

обычно занят названием книги и частями: у Страуструпа их 7 на 656 страниц, тогда как главы — это

(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()