"""Recursive XHTML -> Markdown renderer. Rendering works on "pieces": ("t", text) is escaped prose whose whitespace may be collapsed, ("r", raw) is emitted verbatim, ("br", "") is a hard line break and ("blk", block) is a complete block (e.g. display math) that splits the paragraph. """ from __future__ import annotations import html import re from collections import Counter from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Any from urllib.parse import unquote from lxml import etree from .chapters import IdCarrier, Marker, heading_level from .css import StyleSheet, emphasis, has_highlight, inline_style, style_props from .codeblocks import ( code_text, detect_language, detect_language_from_content, fence_for, inline_code, is_block_code_container, join_wrap_markers, is_inline_code, ) from .diagnostics import Diagnostics from .errors import ContainerError from .footnotes import NoteIndex from .mathml import Unsupported, is_display_tex, looks_like_tex, mathml_to_tex, strip_tex_delimiters from .media import MediaRegistry from .models import Chapter, Note from .normalize import PAGE_ATTR from .references import LinkResolver from .utils import ( MATHML_NS, SVG_NS, XLINK_NS, classes, epub_types, is_element, iter_elements, local, norm_ws, rel_link, remove_invisible, resolve_href, roles, text_content, url_path, ) Piece = tuple[str, str] BLOCK_TAGS = { "p", "div", "section", "article", "header", "footer", "aside", "main", "nav", "blockquote", "ul", "ol", "dl", "dt", "dd", "li", "table", "pre", "figure", "figcaption", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "address", "center", "details", "summary", "body", "hgroup", "caption", "audio", "video", "form", "fieldset", "legend", "object", "iframe", "embed", "html", "menu", "dir", "listing", "xmp", "plaintext", "canvas", "noscript", } TRANSPARENT_INLINE = {"span", "small", "big", "font", "label", "time", "bdi", "bdo", "data", "abbr", "acronym", "nobr", "wbr", "ruby", "rb", "rt", "rp", "output"} FLOW_BLOCKS = {"p", "div", "section", "article", "header", "footer", "aside", "main", "nav", "address", "center", "details", "summary", "body", "hgroup", "caption", "form", "fieldset", "legend", "html", "dt", "dd", "li", "figcaption", "menu", "dir", "noscript", "canvas"} KNOWN_INLINE = TRANSPARENT_INLINE | { "a", "em", "i", "strong", "b", "u", "ins", "del", "s", "strike", "sup", "sub", "q", "cite", "kbd", "mark", "code", "samp", "tt", "var", "dfn", "img", "svg", "math", "br", "image", } SIMPLE_CELL_BLOCKERS = {"pre", "ul", "ol", "dl", "table", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "figure"} FORMULA_CLASS = re.compile(r"equation|formula|math|katex|mathjax|displaymath|eqn") INLINE_FORMULA_CLASS = re.compile(r"inlineequation|inline-?math|inlineformula|math-inline|inline") LABEL_CLASS = re.compile(r"number$|item-number|label|marker") HTML_KEEP_TAGS = { "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", "p", "br", "em", "strong", "i", "b", "u", "s", "del", "ins", "sup", "sub", "code", "pre", "kbd", "samp", "var", "a", "img", "ul", "ol", "li", "dl", "dt", "dd", "div", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "figure", "figcaption", "abbr", "mark", "q", "cite", "small", "hr", "span", } HTML_KEEP_ATTRS = {"colspan", "rowspan", "alt", "title", "start", "scope", "headers", "lang", "dir", "align", "valign", "reversed", "type", "abbr"} WS = re.compile(r"[ \t\r\n\f]+") @dataclass class RenderOptions: format: str = "gfm" footnotes: str = "end-of-chapter" math: str = "auto" extract_media: bool = True keep_html: bool = False language: str = "de" code_language: str = "" # --code-language: label for blocks without a detected language keep_wrap_markers: bool = False # --keep-wrap-markers: do not join ↪-continued code lines keep_invisible_chars: bool = False # --keep-invisible-chars: keep zero-width chars and soft hyphens boxes: str = "callout" # --boxes {callout,quote,html,plain} styles: str = "semantic" # --styles {semantic,html} page_markers: str = "none" # --page-markers {none,visible,comment}: print page numbers css_emphasis: bool = True # --no-css-emphasis: ignore bold/italic/… that only the stylesheet expresses def md_escape(text: str, table: bool = False) -> str: text = text.replace("\\", "\\\\") text = re.sub(r"([`*_\[\]$])", r"\\\1", text) text = re.sub(r"<(?=[A-Za-z/!?])", r"\\<", text) text = re.sub(r"&(?=#?\w+;)", r"\\&", text) text = text.replace("~~", "\\~\\~") if table: text = text.replace("|", "\\|") return text LINE_START = re.compile(r"^(\s*)([#>+=-]|\d{1,9}[.)])(\s|$)") def escape_line_starts(text: str) -> str: out = [] for line in text.split("\n"): m = LINE_START.match(line) if m: token = m.group(2) pos = m.start(2) + (len(token) - 1 if token[0].isdigit() else 0) line = line[:pos] + "\\" + line[pos:] elif re.fullmatch(r"\s*(-+|=+)\s*", line): # thematic break / setext underline pos = len(line) - len(line.lstrip()) line = line[:pos] + "\\" + line[pos:] out.append(line) return "\n".join(out) EMPH_SEP = "\x1f" # separates open marker, content and close marker inside an ("e", …) piece def join_inline(pieces: list[Piece], hard_break: str = "\\\n") -> str: out = "" last_e: tuple[int, str, str, str, int] | None = None # (start, open, core, close, end) of the last emphasis for kind, s in pieces: if kind == "e": open_, core, close = s.split(EMPH_SEP) if last_e and last_e[4] == len(out) and last_e[1] == open_ and last_e[3] == close: # directly adjacent identical emphasis (xy): merge, "*x**y*" would break out = out[: last_e[0]] core = last_e[2] + core start = len(out) out += open_ + core + close last_e = (start, open_, core, close, len(out)) continue if kind == "t": s = WS.sub(" ", s) if not out or out.endswith((" ", "\n")): s = s.lstrip(" ") out += s elif kind == "br": out = out.rstrip(" ") if out and not out.endswith(hard_break): out += hard_break elif kind == "blk": out += " " + WS.sub(" ", s) + " " else: out += s out = out.strip(" ") while out.endswith(hard_break): out = out[: -len(hard_break)].rstrip(" ") while out.startswith(hard_break): out = out[len(hard_break) :].lstrip(" ") return out def indent_block(text: str, first: str, rest: str) -> str: lines = text.split("\n") out = [first + lines[0]] for line in lines[1:]: out.append(rest + line if line.strip() else "") return "\n".join(out) def _collapse_blank_lines(text: str) -> str: """Reduce runs of blank lines to one, except inside fenced code blocks (also indented/quoted ones).""" out: list[str] = [] fence = "" for line in text.split("\n"): bare = re.sub(r"^[ \t]*(?:>[ \t]?)*[ \t]*", "", line) if fence: if bare.rstrip() and set(bare.rstrip()) == {fence[0]} and len(bare.rstrip()) >= len(fence): fence = "" out.append(line) continue m = re.match(r"(`{3,}|~{3,})", bare) if m: fence = m.group(1) elif not line.strip() and out and not out[-1].strip(): continue out.append(line) return "\n".join(out) CONSUMED = "data-e2m-consumed" BOX_TAGS = {"div", "section", "aside", "p"} BOX_CLASS = re.compile(r"(?:^|[-_])(box[a-z]?|kasten|infobox|infokasten|tipps?|tip|hint|hinweis|notiz|warn|warning|" r"warnung|achtung|caution|vorsicht|important|wichtig|merke|merksatz|exkurs|sidebar|callout|" r"alert)(?:[-_\d]|$)") CALLOUT = {"note": "NOTE", "tip": "TIP", "important": "IMPORTANT", "warning": "WARNING", "caution": "CAUTION"} TYPE_WORDS = {"tipp", "tip", "tipps", "hinweis", "note", "notiz", "info", "achtung", "warnung", "warning", "wichtig", "important", "vorsicht", "caution", "gefahr", "merke"} def _box_type(hint: str) -> str: """Callout type from class names, epub:type, a title or the first words of the box.""" h = hint.lower() if re.search(r"vorsicht|caution|gefahr|danger", h): return "caution" if re.search(r"warn|achtung|attention", h): return "warning" if re.search(r"wichtig|important|merke", h): return "important" if re.search(r"tipp|\btip|hint", h): return "tip" if re.search(r"hinweis|\bnote|notiz|anmerkung|\binfo", h): return "note" return "" ORNAMENT = re.compile(r"(?:[*∗⁂❖✻✱✳◆◇•·~][ \t]*){1,9}") POEM_CLASS = re.compile(r"(?:^|[-_])(poem|poetry|verse|vers|lyrics|stanza|gedicht|lied|song)(?:$|[-_\d])") ITALIC_TAGS = {"em", "i", "cite", "dfn", "var"} BOLD_TAGS = {"strong", "b"} CSS_SKIP_TAGS = {"br", "wbr", "img", "svg", "image", "math", "code", "samp", "tt", "kbd", "pre"} def _style_attr(props: list[tuple[str, str]]) -> str: return html.escape("; ".join(f"{k}: {v}" for k, v in props), quote=False).replace('"', "'") def _wrap_text(text: str, marker: str) -> str: """Wrap a finished paragraph in emphasis markers (whitespace stays outside).""" core = text.strip() return f"{marker}{core}{marker}" if core else text _BLOCK_SYNTAX = ("```", "~~~", "|", " ", "#", "$$", "\\[", "* * *") def _is_block_syntax(block: str) -> bool: return block.startswith(_BLOCK_SYNTAX) or re.match(r"\d+\. ", block) is not None def _labeled_note(label: str, blocks: list[str], back: str) -> str: """Anchor-style note: label before and backlinks after the body, without gluing them onto a code fence, table or list line (which would break that block).""" body = "\n\n".join(blocks) text = f"{label}\n\n{body}" if _is_block_syntax(blocks[0]) else f"{label} {body}" if back: text += f"\n\n{back}" if _is_block_syntax(blocks[-1]) else f" {back}" return text def _ancestors(el: Any, depth: int = 5) -> list[Any]: out: list[Any] = [] node = el.getparent() while node is not None and len(out) < depth: out.append(node) node = node.getparent() return out def serialize_mathml(math: Any) -> str: def build(src: Any, parent: Any | None) -> Any: name = src.tag.rsplit("}", 1)[-1] if parent is None: el = etree.Element(f"{{{MATHML_NS}}}{name}", nsmap={None: MATHML_NS}) else: el = etree.SubElement(parent, f"{{{MATHML_NS}}}{name}") for k, v in src.attrib.items(): key = k.rsplit("}", 1)[-1] if key.startswith("on") or key in ("style", "class"): continue el.set(key, v) el.text = src.text for child in src: if is_element(child): sub = build(child, el) sub.tail = child.tail return el tree = build(math, None) text = etree.tostring(tree, encoding="unicode", with_tail=False) return WS.sub(" ", text).replace("> <", "><").strip() class Renderer: def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry, notes: NoteIndex, diag: Diagnostics, zip_names: set[str], entry_problem: Callable[[str], str | None] | None = None, stylesheet: StyleSheet | None = None, base_style: dict[str, str] | None = None) -> None: self.base_style = base_style or {} self.entry_problem = entry_problem or (lambda path: None) self.css = stylesheet self.scene_breaks = 0 self.page_markers = 0 self.marker_sink: list[str] | None = None # collects page markers instead of emitting them inline self.poems = 0 self.boxes = 0 self.in_box = False self.css_applied: Counter[str] = Counter() self.hidden_elements = 0 self.invisible_removed = 0 self.opt = options self.zip_names = zip_names self.in_table_html = False self.resolver = resolver self.media = media self.notes = notes self.diag = diag self.note_counter = 0 self.book_notes: list[Note] = [] self.note_refs: dict[str, list[tuple[str, str]]] = {} # label -> [(chapter file, ref anchor)] self.consumed_formula_images: set[str] = set() self.formulas: list[dict[str, str]] = [] self.code_blocks: list[dict[str, Any]] = [] self.endnotes_file = "" # per chapter state self.ch: Chapter | None = None self.file = "" self.doc = "" self.min_level = 1 self.em = 0 self.strong = 0 self.in_heading = False self.in_table = False self.in_note = False self.figure_caption = "" self.chapter_notes: list[Note] = [] self.md_ref_labels: set[str] = set() # ================================================================== chapter def render_chapter(self, ch: Chapter) -> str: self.ch = ch self.file = ch.filename self.doc = ch.docs[0] if ch.docs else "" self.chapter_notes = [] self.md_ref_labels = set() has_headings = any( is_element(it) and any(heading_level(e) for e in iter_elements(it)) for _, it in ch.items ) blocks: list[str] = [] if ch.title_el is None and has_headings: blocks.append("# " + md_escape(ch.title)) self.min_level = 2 if (ch.title_el is not None or has_headings) else 1 blocks.extend(self.render_items(ch.items)) blocks.extend(self._chapter_note_definitions()) text = "\n\n".join(b for b in blocks if b.strip()) return _collapse_blank_lines(text).strip() + "\n" def render_items(self, items: Sequence[tuple[str | None, Any]]) -> list[str]: blocks: list[str] = [] buf: list[Piece] = [] def flush() -> None: text = join_inline(buf) buf.clear() if text.strip(): blocks.append(escape_line_starts(text)) for doc, item in items: if doc: self.doc = doc if isinstance(item, str): buf.append(("t", self._text(item))) elif isinstance(item, IdCarrier): anchor = self.resolver.anchor_for(self.doc, item.element) if anchor: buf.append(("r", self._anchor(anchor))) elif isinstance(item, Marker) or not is_element(item) or item.get(CONSUMED): continue # markers, comments, siblings already rendered as part of a box elif self.is_block(item): flush() blocks.extend(self.block(item)) else: for piece in self.inline(item): if piece[0] == "blk": flush() blocks.append(piece[1]) else: buf.append(piece) flush() return blocks def children(self, el: Any) -> list[tuple[str | None, Any]]: items: list[tuple[str | None, Any]] = [] if el.text: items.append((None, el.text)) for child in el: if is_element(child): items.append((None, child)) if child.tail: items.append((None, child.tail)) return items def flow(self, el: Any) -> list[str]: return self.render_items(self.children(el)) # ================================================================== helpers def _anchor(self, name: str) -> str: return f'' def page_marker(self, el: Any) -> str: """Visible '[S. 12]' or '' for a print page-break element (--page-markers).""" page = el.get(PAGE_ATTR) if not page or self.opt.page_markers == "none": return "" self.page_markers += 1 page = html.escape(page, quote=False).replace("--", "") marker = f"" if self.opt.page_markers == "comment" else f"\\[S. {md_escape(page)}\\]" if self.marker_sink is not None: self.marker_sink.append(marker) return "" return marker def anchors_of(self, el: Any, subtree: bool = False) -> str: nodes = iter_elements(el) if subtree else [el] names = [] for node in nodes: name = self.resolver.anchor_for(self.doc, node) if name and name not in names: names.append(name) return "".join(self._anchor(n) for n in names) def is_block(self, el: Any) -> bool: name = local(el.tag) if name == "math": return el.get("display") == "block" if name in BLOCK_TAGS: return True if is_inline_code(el): return False if name in KNOWN_INLINE or name not in BLOCK_TAGS: return any(local(e.tag) in BLOCK_TAGS - {"caption"} for e in iter_elements(el) if e is not el) return False def _source(self, el: Any) -> str: el_id = el.get("id") return f"{self.doc}#{el_id}" if el_id else self.doc # ================================================================== blocks def block(self, el: Any) -> list[str]: name = local(el.tag) if self._hidden(el): return [] if name in ("p", "div") and not self.in_table: if self._is_ornament(el) or self._is_scene_gap(el): self.scene_breaks += 1 anchor = self.anchors_of(el, subtree=True) return ([anchor] if anchor else []) + ["* * *"] if name in ("div", "section", "blockquote") and self._is_poem(el): return self.poem(el) if self.opt.boxes != "plain" and not self.in_box and not self.in_table and name in BOX_TAGS: kind = self._box_kind(el) if kind is not None: return self.box(el, kind) ch = self.ch if ch is not None and el is ch.title_el and not heading_level(el): return self.heading(el, forced_level=1) if heading_level(el): return self.heading(el) if name == "pre" or is_block_code_container(el): return self.code_block(el) if name == "blockquote": return self.blockquote(el) if name in ("ul", "ol", "menu", "dir"): return self.list_block(el) if name == "dl": return self.definition_list(el) if name == "table": return self.table(el) if name == "figure": return self.figure(el) if name == "hr": return ["* * *"] if name == "math": return [self.math(el, display=True)[0][1]] if name in ("audio", "video", "object", "iframe", "embed"): return self.media_block(el) if name in ("listing", "xmp", "plaintext"): return self.code_block(el) if name in FLOW_BLOCKS: return self._flow_with_anchor(el) # unknown element or inline element containing blocks if name not in KNOWN_INLINE: self.diag.unsupported_element(name, self._source(el)) if self.opt.keep_html: return [self.clean_html(el, keep_root=True)] return self._flow_with_anchor(el) # ------------------------------------------------------------------ scene breaks and verse (P4) @staticmethod def _is_empty(el: Any) -> bool: return not text_content(el).replace("\xa0", " ").strip() and \ not any(local(e.tag) in ("img", "svg", "math", "image", "hr") for e in iter_elements(el)) def _is_blank_p(self, el: Any) -> bool: """

with nothing but (non-breaking) spaces or
. Empty

s are layout helpers (Springer's div.ClearBoth) and paragraphs holding only anchors are index targets – neither is a scene break.""" return local(el.tag) == "p" and self._is_empty(el) and \ all(local(c.tag) == "br" for c in el if is_element(c)) def _is_prose(self, el: Any) -> bool: return local(el.tag) in ("p", "div") and not self._is_empty(el) and not self._is_ornament(el) \ and not self._is_poem(el) @staticmethod def _element_siblings(el: Any, forward: bool) -> Any: node = el.getnext() if forward else el.getprevious() while node is not None: if is_element(node): yield node node = node.getnext() if forward else node.getprevious() def _is_ornament(self, el: Any) -> bool: """Paragraph that only contains a typographic separator such as *** or ⁂.""" return bool(ORNAMENT.fullmatch(text_content(el).replace("\xa0", " ").strip())) def _is_scene_gap(self, el: Any) -> bool: """Empty paragraph between two prose paragraphs (first of a run of empties) → scene break.""" if not self._is_blank_p(el): return False prev = next(self._element_siblings(el, forward=False), None) if prev is None or not self._is_prose(prev): return False for nxt in self._element_siblings(el, forward=True): if self._is_blank_p(nxt): continue # further empty paragraphs of the same gap return self._is_prose(nxt) return False def _is_poem(self, el: Any) -> bool: types = set(epub_types(el)) return bool(types & {"z3998:poem", "z3998:verse", "z3998:song", "z3998:lyrics"}) or \ any(POEM_CLASS.search(c) for c in classes(el)) def poem(self, el: Any) -> list[str]: """Verse: one quoted line per verse line, stanzas separated by an empty quote line.""" anchor = self.anchors_of(el) stanzas: list[list[str]] = [[]] def new_stanza() -> None: if stanzas[-1]: stanzas.append([]) def walk(container: Any) -> None: for child in container: if not is_element(child) or self._hidden(child): continue if local(child.tag) in ("div", "section") and any( is_element(c) and local(c.tag) in ("p", "div", "span") for c in child): new_stanza() # stanza container walk(child) new_stanza() elif self._is_empty(child): new_stanza() else: line = join_inline([("r", self.anchors_of(child))] + self.inline_children(child)) stanzas[-1].append(escape_line_starts(line).replace("\n", "\n> ")) walk(el) stanzas = [s for s in stanzas if s] if not stanzas: return self._flow_with_anchor(el) self.poems += 1 text = "\n>\n".join("\\\n".join(f"> {line}" for line in stanza) for stanza in stanzas) return ([anchor] if anchor else []) + [text] # ------------------------------------------------------------------ info boxes (P2) def _box_kind(self, el: Any) -> str | None: """'' for a box without type hint, a type keyword, or None if ``el`` is no box.""" name = local(el.tag) types = set(epub_types(el)) | {r.removeprefix("doc-") for r in roles(el)} hint = " ".join(sorted(types)) + " " + " ".join(classes(el)) semantic = types & {"sidebar", "tip", "notice", "warning", "caution", "important", "help", "example"} by_class = any(BOX_CLASS.search(c) for c in classes(el)) if name == "p": return _box_type(hint) if by_class and not self._is_empty(el) else None if not (semantic or by_class or self._css_framed(el)): return None if not text_content(el).strip() or any(local(e.tag) == "pre" for e in iter_elements(el)) \ and not any(local(e.tag) == "p" and text_content(e).strip() for e in iter_elements(el)): return None # bordered code listings or image frames are no info boxes if self.ch is not None and any(e is self.ch.title_el for e in iter_elements(el)): return None # never swallow the chapter heading return _box_type(hint) def _css_framed(self, el: Any) -> bool: """Block with a visible border or background in the book's CSS, holding short prose.""" if self.css is None or local(el.tag) not in ("div", "section", "aside"): return False style = self.css.computed(el) border = any(k.startswith("border") and not re.search(r"\bnone\b|^0(px)?\b|hidden", v) for k, v in style.items() if k in ("border", "border-top", "border-left", "border-style", "border-width", "border-bottom", "border-right")) bg = style.get("background-color") or style.get("background") or "" background = bool(bg) and not re.search(r"transparent|none|inherit|initial|^#?fff(fff)?$|white", bg.lower()) if not (border or background): return False text = norm_ws(text_content(el)) return 0 < len(text) <= 3000 and any(local(e.tag) == "p" for e in iter_elements(el)) \ and not any(heading_level(e) in (1, 2) for e in iter_elements(el)) def box(self, el: Any, kind: str) -> list[str]: anchor = self.anchors_of(el) members = [el] if local(el.tag) == "p": # paragraph-style boxes: group following siblings of the same kind for sib in self._element_siblings(el, forward=True): if local(sib.tag) == "p" and any(BOX_CLASS.search(c) for c in classes(sib)) \ and _box_type(" ".join(classes(sib))) == kind: members.append(sib) else: break title_el = self._box_title(members) title = "" markers: list[str] = [] # page breaks in the title go before the box, like for headings if title_el is not None: pieces: list[Piece] = [("t", self._text(title_el.text))] if title_el.text else [] self.marker_sink = markers try: for child in title_el: if is_element(child) and not self._title_symbol(child): pieces.extend(self.inline(child)) if child.tail: pieces.append(("t", self._text(child.tail))) finally: self.marker_sink = None title = join_inline(pieces, hard_break=" ").strip() kind = kind or _box_type(norm_ws(text_content(title_el))) saved = self.in_box self.in_box = True try: blocks: list[str] = [] for member in members: if member is title_el: continue if local(member.tag) == "p": blocks.extend(self.flow(member)) else: blocks.extend(self.render_items( [(None, x) for x in self._items_without(member, title_el) if not self._box_icon(x)])) finally: self.in_box = saved for sib in members[1:]: sib.set(CONSUMED, "1") if not kind and blocks: kind = _box_type(norm_ws(re.sub(r"[*_\\]", "", blocks[0]))[:30]) kind = kind or "note" if title and norm_ws(re.sub(r"[*_\\:]", "", title)).lower() in TYPE_WORDS: title = "" # 'Tipp' as title of a TIP box adds nothing self.boxes += 1 out = ["".join(markers)] if markers else [] if anchor: out.append(anchor) head = [f"**{title}**"] if title else [] if self.opt.boxes == "html": out.append(f'
\n\n' + "\n\n".join(head + blocks) + "\n\n
") return out body = "\n\n".join(head + blocks) if self.opt.boxes == "callout": body = f"[!{CALLOUT[kind]}]\n" + body out.append("\n".join("> " + line if line.strip() else ">" for line in body.split("\n"))) return out def _box_title(self, members: list[Any]) -> Any: first = members[0] if local(first.tag) == "p": text = norm_ws(text_content(first)) return first if len(members) > 1 and 0 < len(text) <= 40 and not text.endswith((".", "!", "?")) else None for child in first: if not is_element(child) or self._box_icon(child): continue if heading_level(child) or any(re.search(r"heading|title|head$|ueberschrift", c) for c in classes(child)): return child return None return None @staticmethod def _title_symbol(node: Any) -> bool: """Decorative symbol in a box title, e.g. [»].""" text = norm_ws(text_content(node)) return any(re.search(r"icon|symbol", c) for c in classes(node)) and len(text) <= 5 \ and not re.search(r"\w", text) @staticmethod def _box_icon(node: Any) -> bool: """Decorative icon container inside a box (e.g.
Image
).""" if not is_element(node) or text_content(node).strip(): return False imgs = [e for e in iter_elements(node) if local(e.tag) == "img"] return bool(imgs) and (any(re.search(r"icon|box_?img|symbol", c) for c in classes(node)) or all((i.get("alt") or "").strip().lower() in ("", "image", "icon", "bild") for i in imgs)) def _flow_with_anchor(self, el: Any) -> list[str]: anchor = self.anchors_of(el) if self.ch is not None and {"index", "index-persons", "glossary"} & set(self.ch.types): special = self._index_block(el, anchor) if special is not None: return special italic = bold = False if self.opt.css_emphasis and local(el.tag) in ("p", "div") and not self.in_heading \ and not any(local(d.tag) in BLOCK_TAGS for d in iter_elements(el) if d is not el) \ and text_content(el).strip(): e = self._emphasis(el) italic, bold = e["italic"] and not self.em, e["bold"] and not self.strong self.em += italic self.strong += bold try: blocks = self.flow(el) finally: self.em -= italic self.strong -= bold if (italic or bold) and len(blocks) == 1 and not _is_block_syntax(blocks[0]) and not blocks[0].startswith("<"): for flag, marker, key in ((italic, "*", "italic"), (bold, "**", "bold")): if flag: blocks[0] = _wrap_text(blocks[0], marker) self.css_applied[key] += 1 blocks = self._block_styles(el, blocks) if anchor: if blocks and not blocks[0].startswith(("#", "`", "|", " ", "- ", "$$", "\\[", "* * *")) \ and not re.match(r"\d+\. ", blocks[0]): blocks[0] = anchor + blocks[0] else: blocks.insert(0, anchor) return blocks def _index_block(self, el: Any, anchor: str) -> list[str] | None: """Register/glossary layout: letter groups become headings, sub-entries keep their depth.""" cls = classes(el) if any(c in ("heading", "indexheading", "letter", "groupletter", "index-letter") for c in cls): text = norm_ws(text_content(el)) if 0 < len(text) <= 3: return ([anchor] if anchor else []) + [f"## {md_escape(text)}"] depth = 0 for c in cls: if re.search(r"(tertiary|index-?3|level-?3|ie3|subsubentry)", c): depth = 2 elif re.search(r"(secondary|index-?2|level-?2|ie2|subentry|sub-entry)", c): depth = max(depth, 1) if not depth: return None blocks = self.flow(el) if blocks: blocks[0] = "– " * depth + anchor + blocks[0] return blocks def heading(self, el: Any, forced_level: int = 0) -> list[str]: ch = self.ch if forced_level: level = forced_level elif ch is not None and el is ch.title_el: level = 1 else: level = heading_level(el) - (ch.shift if ch else 0) level = max(self.min_level, min(6, level)) anchors = self.anchors_of(el, subtree=True) markers = "".join(self.page_marker(e) for e in iter_elements(el)) self.in_heading = True try: text = join_inline(self.inline_children(el), hard_break=" ") finally: self.in_heading = False text = re.sub(r"", "", text).strip() out = [markers] if markers else [] if anchors: out.append(anchors) if not text: if any(local(e.tag) in ("img", "svg") for e in iter_elements(el)): out.extend(self.flow(el)) return out text = re.sub(r"\s*#+\s*$", lambda m: m.group(0).replace("#", "\\#"), text) out.append("#" * level + " " + text) return out def code_block(self, el: Any) -> list[str]: code = code_text(el) anchors = self.anchors_of(el, subtree=True) out = [anchors] if anchors else [] if not code.strip(): return out joined = 0 if not self.opt.keep_wrap_markers: code, joined = join_wrap_markers(code) lang, how = detect_language(el) if not lang: lang = detect_language_from_content(code) how = "content" if lang else "" if not lang and self.opt.code_language: lang, how = self.opt.code_language, "option" if self.ch: self.ch.stats.code_blocks += 1 if not lang: self.ch.stats.code_blocks_unlabeled += 1 self.code_blocks.append({"source": self._source(el), "chapter": self.file, "language": lang, "detected_by": how, "lines": code.count("\n") + 1, "wrap_lines_joined": joined}) fence = fence_for(code) out.append(f"{fence}{lang}\n{code}\n{fence}") return out def blockquote(self, el: Any) -> list[str]: anchor = self.anchors_of(el) inner = "\n\n".join(self.flow(el)) if not inner.strip(): return [anchor] if anchor else [] quoted = "\n".join("> " + line if line.strip() else ">" for line in inner.split("\n")) return ([anchor] if anchor else []) + [quoted] def list_block(self, el: Any) -> list[str]: anchor = self.anchors_of(el) ordered = local(el.tag) == "ol" try: number = int(el.get("start", "1")) except ValueError: number = 1 items = [c for c in el if is_element(c) and local(c.tag) == "li"] labeled = bool(items) and all(self._list_label(li) is not None for li in items) style = (el.get("style") or "").replace(" ", "").lower() if "list-style-type:none" in style or "list-style:none" in style: labeled = labeled or True rendered: list[str] = [] loose = False for li in items: label_el = self._list_label(li) if labeled else None if label_el is not None: label = norm_ws(text_content(label_el)) rest = [(None, x) for x in self._items_after(li, label_el)] blocks = self.render_items(rest) if blocks: blocks[0] = md_escape(label) + " " + blocks[0] else: blocks = [md_escape(label)] else: blocks = self.flow(li) li_anchor = self.anchors_of(li) if li_anchor: blocks = [li_anchor + (blocks[0] if blocks else "")] + blocks[1:] if len(blocks) > 1: loose = True body = "\n\n".join(blocks) if blocks else "" if ordered and not labeled: marker = f"{number}." number += 1 else: marker = "-" rendered.append(indent_block(body, marker + " ", " " * (len(marker) + 1))) if not rendered: return [anchor] if anchor else [] joined = ("\n\n" if loose else "\n").join(rendered) return ([anchor] if anchor else []) + [joined] def _list_label(self, li: Any) -> Any: first = next((c for c in li if is_element(c)), None) if first is None or (li.text and li.text.strip()): return None if any(LABEL_CLASS.search(c) for c in classes(first)): return first return None def _items_after(self, li: Any, label_el: Any) -> list[Any]: out: list[Any] = [] seen = False for child in li: if child is label_el: seen = True if child.tail: out.append(child.tail) continue if seen: if is_element(child): out.append(child) if child.tail: out.append(child.tail) return out def definition_list(self, el: Any) -> list[str]: anchor = self.anchors_of(el) out: list[str] = [anchor] if anchor else [] terms: list[str] = [] term_anchors = "" # anchors of
elements go in front of the bold term, not inside it for child in el: if not is_element(child): continue name = local(child.tag) if name == "dt": terms.append(join_inline(self.inline_children(child), hard_break=" ")) term_anchors += self.anchors_of(child) elif name == "dd": blocks = self.flow(child) a = self.anchors_of(child) term = " / ".join(terms) if terms else "" ta, terms, term_anchors = term_anchors, [], "" if term and len(blocks) == 1 and "\n" not in blocks[0] and not blocks[0].startswith(("```", "|", "<")): out.append(f"{ta}**{term}:** {a}{blocks[0]}") else: if term: out.append(f"{ta}**{term}**") elif ta: out.append(ta) if a: out.append(a) out.extend(blocks) elif name == "div": # HTML5 allows
grouping inside
out.extend(self.definition_list(child)) if terms: out.append(term_anchors + " / ".join(f"**{t}**" for t in terms)) return out def figure(self, el: Any) -> list[str]: anchor = self.anchors_of(el) caption_el = next((c for c in el if is_element(c) and local(c.tag) == "figcaption"), None) caption = "" if caption_el is not None: caption = join_inline(self.inline_children(caption_el), hard_break=" ") old = self.figure_caption self.figure_caption = norm_ws(text_content(caption_el)) if caption_el is not None else "" items = [(None, x) for x in self._items_without(el, caption_el)] try: blocks = self.render_items(items) finally: self.figure_caption = old out = ([anchor] if anchor else []) + blocks if caption: cap_anchor = self.anchors_of(caption_el, subtree=False) if caption_el is not None else "" out.append(f"{cap_anchor}*{caption}*" if not caption.startswith("*") else cap_anchor + caption) return out def _items_without(self, el: Any, skip: Any) -> list[Any]: out: list[Any] = [] if el.text: out.append(el.text) for child in el: if child is not skip and is_element(child): out.append(child) if child.tail: out.append(child.tail) return out def media_block(self, el: Any) -> list[str]: name = local(el.tag) srcs = [el.get("src") or el.get("data") or ""] srcs += [s.get("src", "") for s in el if is_element(s) and local(s.tag) == "source"] out = [] for src in [s for s in srcs if s]: if self.resolver.is_external(src): out.append(f"[{name.capitalize()}: {md_escape(src)}]({self._url(src)})") continue path = resolve_href(self.doc, unquote(src.split("#")[0])) if path and self._exists(path): if self.opt.extract_media: mf = self.media.register(path, role=name) label = {"audio": "Audio", "video": "Video"}.get(name, "Medium") out.append(f"[{label}: {md_escape(path.rsplit('/', 1)[-1])}]({url_path(rel_link(self.file, mf.output))})") else: out.append(f"*[{name}: {md_escape(path)}]*") self.diag.info("media-link", f"{name}-Element als Link erhalten (in Markdown nicht direkt abspielbar).", self._source(el)) else: self.diag.warn("media-missing", f"Medienquelle {src} nicht im EPUB gefunden.", self._source(el)) if name in ("object", "iframe", "embed"): self.diag.unsupported_element(name, self._source(el)) if not out: out = self.flow(el) return out # ================================================================== tables def table(self, el: Any) -> list[str]: anchor = self.anchors_of(el) rows = self._rows(el) if self.ch: self.ch.stats.tables += 1 cells_total = sum(len(r) for r in rows) if cells_total <= 1 and not self._has_nested_table(el): # single-cell layout table: keep its content as normal flow cell = rows[0][0] if rows and rows[0] else None return ([anchor] if anchor else []) + (self.flow(cell) if cell is not None else []) reason = self._complex_reason(el, rows) caption_el = next((c for c in el if is_element(c) and local(c.tag) == "caption"), None) if reason: if self.ch: self.ch.stats.tables_html += 1 self.diag.warn("table-html", f"Tabelle enthält {reason}; als HTML erhalten.", self._source(el), self.file) return [self.clean_html(el)] # clean_html keeps the id on itself out = [anchor] if anchor else [] if caption_el is not None: cap = join_inline(self.inline_children(caption_el), hard_break=" ") if cap: out.append(f"*{cap}*") width = max(len(r) for r in rows) rendered_rows = [] self.in_table = True try: for r in rows: cells = [] for cell in r: blocks = self.flow(cell) text = "
".join(b.replace("\\\n", "
").replace("\n", " ") for b in blocks) a = self.anchors_of(cell) cells.append((a + text).strip()) cells += [""] * (width - len(cells)) rendered_rows.append(cells) finally: self.in_table = False header_is_th = all(local(c.tag) == "th" for c in rows[0]) or self._in_thead(rows[0][0]) if header_is_th: header, body = rendered_rows[0], rendered_rows[1:] else: header, body = [""] * width, rendered_rows lines = ["| " + " | ".join(header) + " |", "|" + "|".join([" --- "] * width) + "|"] lines += ["| " + " | ".join(r) + " |" for r in body] out.append("\n".join(lines)) return out def _has_nested_table(self, el: Any) -> bool: return any(local(e.tag) == "table" for e in iter_elements(el) if e is not el) def _rows(self, table: Any) -> list[list[Any]]: rows = [] for e in iter_elements(table): if local(e.tag) == "tr" and self._owner_table(e) is table: cells = [c for c in e if is_element(c) and local(c.tag) in ("td", "th")] if cells: rows.append(cells) return rows def _owner_table(self, el: Any) -> Any: node = el.getparent() while node is not None and local(node.tag) != "table": node = node.getparent() return node def _in_thead(self, cell: Any) -> bool: tr = cell.getparent() return tr is not None and tr.getparent() is not None and local(tr.getparent().tag) == "thead" def _complex_reason(self, el: Any, rows: list[list[Any]]) -> str: if self.opt.format == "commonmark": return "keine Tabellen-Syntax in CommonMark" for r in rows: for c in r: for attr in ("colspan", "rowspan"): try: if int(c.get(attr, "1")) > 1: return attr except ValueError: pass if self._has_nested_table(el): return "verschachtelte Tabellen" for r in rows: for c in r: for e in iter_elements(c): if e is not c and (local(e.tag) in SIMPLE_CELL_BLOCKERS or is_block_code_container(e)): return f"Blockelemente ({local(e.tag)}) in Zellen" if local(e.tag) == "math" and e.get("display") == "block": return "abgesetzte Formeln in Zellen" if self._raw_pipe(e): return "senkrechte Striche (|) in Code, Formel oder Link" return "" def _raw_pipe(self, e: Any) -> bool: """'|' in content that is emitted unescaped (code span, math, link target, image) would split a GFM row.""" name = local(e.tag) if is_inline_code(e) or name in ("kbd", "math"): return "|" in text_content(e) if name in ("a", "img", "image", "svg"): return any("|" in v for v in e.attrib.values()) return False # ================================================================== inline def inline_children(self, el: Any) -> list[Piece]: pieces: list[Piece] = [] if el.text: pieces.append(("t", self._text(el.text))) for child in el: if is_element(child): pieces.extend(self.inline(child)) if child.tail: pieces.append(("t", self._text(child.tail))) return pieces def _emphasis(self, el: Any) -> dict[str, bool]: style = self.css.computed(el) if self.css is not None else inline_style(el) return emphasis(style) def _hidden(self, el: Any) -> bool: """display:none in the book's CSS: readers do not show the element, so neither do we.""" if not self.opt.css_emphasis or not is_element(el) or not self._emphasis(el)["hidden"]: return False self.hidden_elements += 1 return True def inline(self, el: Any) -> list[Piece]: """Inline rendering plus formatting that only the stylesheet expresses (P1, P3, P7).""" name = local(el.tag) if name in CSS_SKIP_TAGS: return self._inline_core(el) if self._hidden(el): return [] if self.in_heading or is_inline_code(el) or not text_content(el).strip(): return self._inline_core(el) pieces = self._inline_emphasis(el) if self.opt.css_emphasis else self._inline_core(el) return self._inline_styles(el, pieces) def _inline_styles(self, el: Any, pieces: list[Piece]) -> list[Piece]: """--styles semantic: background highlight → ; --styles html: deviating visual properties and foreign-language spans as /.""" if self.css is None or (pieces and pieces[0][0] == "r" and pieces[0][1].startswith("`")): return pieces # no stylesheet, or rendered as inline code style = self.css.computed(el) if self.opt.styles == "html": props = style_props(style, self.base_style) if props: pieces = self._wrap(pieces, f'', "") self.css_applied["style"] += 1 lang = self._foreign_lang(el) if lang: pieces = self._wrap(pieces, f'', "") self.css_applied["lang"] += 1 elif has_highlight(style): pieces = self._wrap(pieces, "", "") self.css_applied["mark"] += 1 return pieces def _foreign_lang(self, el: Any) -> str: lang = (el.get("lang") or el.get("{http://www.w3.org/XML/1998/namespace}lang") or "").strip() if lang and re.fullmatch(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*", lang) \ and lang.split("-")[0].lower() != self.opt.language.split("-")[0].lower(): return lang return "" def _block_styles(self, el: Any, blocks: list[str]) -> list[str]: """--styles html: paragraph alignment, colour, size, font and language as a wrapping
.""" if self.opt.styles != "html" or self.css is None or self.in_table or not blocks or local(el.tag) not in ("p", "div") \ or any(local(d.tag) in BLOCK_TAGS for d in iter_elements(el) if d is not el): return blocks if all(b.startswith(("$$", "\\[", "![", ""] + blocks + ["
"] def _inline_emphasis(self, el: Any) -> list[Piece]: name = local(el.tag) e = self._emphasis(el) if e["mono"] and name != "a" and re.fullmatch(r"\S+", norm_ws(text_content(el))) \ and not any(local(d.tag) in ("img", "svg", "math", "a") for d in iter_elements(el) if d is not el): self.css_applied["code"] += 1 anchor = self.anchors_of(el) return ([("r", anchor)] if anchor else []) + [("r", inline_code(norm_ws(text_content(el))))] has_link = name == "a" or any(local(d.tag) == "a" for d in iter_elements(el)) italic = e["italic"] and name not in ITALIC_TAGS and not self.em bold = e["bold"] and name not in BOLD_TAGS and not self.strong strike_marker = ("~~", "~~") if self.opt.format == "gfm" else ("", "") wrappers = [ (e["sup"] and name != "sup", "", "", "sup"), (e["sub"] and name != "sub", "", "", "sub"), (e["underline"] and name not in ("u", "ins") and not has_link, "", "", "underline"), (e["strike"] and name not in ("del", "s", "strike"), strike_marker[0], strike_marker[1], "strike"), (italic, "*", "*", "italic"), (bold, "**", "**", "bold"), ] self.em += italic self.strong += bold try: pieces = self._inline_core(el) finally: self.em -= italic self.strong -= bold for flag, open_, close, key in wrappers: if flag: pieces = self._wrap(pieces, open_, close) self.css_applied[key] += 1 return pieces def _inline_core(self, el: Any) -> list[Piece]: name = local(el.tag) anchor = self.anchors_of(el) if name not in ("a",) else "" pre: list[Piece] = [("r", anchor)] if anchor and not self.in_heading else [] marker = self.page_marker(el) if not self.in_heading else "" if marker: pre.insert(0, ("r", marker)) if name == "br": return pre + ([("t", " ")] if self.in_heading else [("br", "")]) if name == "wbr": return pre if name == "a": return pre[:1] + self.link(el) if marker else self.link(el) if name == "img": return pre + self.image(el) if name == "image": href = el.get(f"{{{XLINK_NS}}}href") or el.get("href") or "" return pre + self.image(el, src=href) if name == "svg": return pre + self.svg(el) if name == "math": return pre + self.math(el, display=el.get("display") == "block") if is_inline_code(el): text = code_text(el).replace("\n", " ") if local(el.tag) != "span" else text_content(el) code = inline_code(text) return pre + ([("r", code)] if code else []) if name in ("em", "i", "cite", "dfn", "var"): if self.em: return pre + self.inline_children(el) self.em += 1 try: return pre + self._wrap(self.inline_children(el), "*") finally: self.em -= 1 if name in ("strong", "b"): if self.strong: return pre + self.inline_children(el) self.strong += 1 try: return pre + self._wrap(self.inline_children(el), "**") finally: self.strong -= 1 if name in ("del", "s", "strike"): marker = ("~~", "~~") if self.opt.format == "gfm" else ("", "") return pre + self._wrap(self.inline_children(el), marker[0], marker[1]) if name in ("u", "ins", "mark", "kbd", "sup", "sub"): if name in ("sup", "sub"): elems = [c for c in el if is_element(c)] if len(elems) == 1 and self.notes.note_for_ref(elems[0]) is not None and self.notes.extracted \ and not (el.text or "").strip() and not (elems[0].tail or "").strip(): return pre + self.inline(elems[0]) if name == "u": tag = "u" elif name == "kbd": return pre + [("r", f"{html.escape(norm_ws(text_content(el)), quote=False)}")] else: tag = name return pre + self._wrap(self.inline_children(el), f"<{tag}>", f"") if name == "q": o, c = ("„", "“") if self.opt.language.startswith("de") else ("“", "”") return pre + [("t", o)] + self.inline_children(el) + [("t", c)] if name in BLOCK_TAGS: # block element nested in inline context (e.g.
inside ) return pre + [("t", " ")] + self.inline_children(el) + [("t", " ")] if name not in KNOWN_INLINE: self.diag.unsupported_element(name, self._source(el)) if self.opt.keep_html: return pre + [("r", self.clean_html(el, keep_root=True))] return pre + self.inline_children(el) def _wrap(self, pieces: list[Piece], open_: str, close: str | None = None) -> list[Piece]: close = open_ if close is None else close hard = " " if self.in_heading else "\\\n" s = join_inline(pieces, hard_break=hard) if pieces else "" raw = "".join(p[1] for p in pieces if p[0] == "t") lead = " " if raw[:1].isspace() or s[:1].isspace() else "" trail = " " if raw[-1:].isspace() or s[-1:].isspace() else "" core = s.strip() while core.endswith(hard) and hard.strip(): core = core[: -len(hard)].rstrip() if not core: return [("t", lead or trail)] if (lead or trail) else [] return [("t", lead), ("e", f"{open_}{EMPH_SEP}{core}{EMPH_SEP}{close}"), ("t", trail)] # ------------------------------------------------------------------ links def _url(self, url: str) -> str: url = url.replace(" ", "%20") if "(" in url or ")" in url or "<" in url or ">" in url: return "<" + url.replace("<", "%3C").replace(">", "%3E") + ">" return url def link(self, el: Any) -> list[Piece]: anchor = self.anchors_of(el) pre: list[Piece] = [("r", anchor)] if anchor and not self.in_heading else [] note = self.notes.note_for_ref(el) if self.notes.extracted else None if note is not None: return pre + self.note_ref(note, el) href = (el.get("href") or "").strip() inner = self.inline_children(el) if not href: return pre + inner text = join_inline(inner, hard_break=" ") if href.startswith("javascript:"): return pre + inner if self.resolver.is_external(href): if not text: return pre + [("r", f"<{href}>")] if re.match(r"https?://\S+$", href) else pre return pre + [("r", f"[{text}]({self._url(href)})")] target = self.resolver.split(self.doc, href) if target and target[0] != self.doc and self._is_media_target(target[0]): if not self.opt.extract_media: return pre + inner mf = self.media.register(target[0], role="linked") return pre + [("r", f"[{text or md_escape(target[0].rsplit('/', 1)[-1])}]({url_path(rel_link(self.file, mf.output))})")] url, status = self.resolver.resolve(self.file, self.doc, href) if url is None: if status in ("missing", "unsafe"): self.diag.unresolved(self.doc, href, self.file) elif status == "note": self.diag.info("link-to-note", f"Verweis {href} zeigt auf eine verschobene Anmerkung; als Text belassen.", self.doc, self.file) return pre + inner if status == "anchor-missing": self.diag.warn("anchor-missing", f"Anker in {href} nicht gefunden; Link zeigt auf den Kapitelanfang.", self.doc, self.file) if not text: return pre return pre + [("r", f"[{text}]({url})")] def _is_media_target(self, path: str) -> bool: item = self.media.package.item_by_path(path) return item is not None and not (item.media_type in ("application/xhtml+xml", "text/html") or path.endswith((".xhtml", ".html", ".htm"))) # ------------------------------------------------------------------ notes def _assign_label(self, note: Note) -> None: if not note.label: self.note_counter += 1 note.label = f"fn-{self.note_counter:03d}" self.book_notes.append(note) def note_ref(self, note: Note, el: Any) -> list[Piece]: self._assign_label(note) mode = self.opt.footnotes num = int(note.label.split("-")[1]) if self.ch: self.ch.stats.footnotes += 1 if mode == "inline" and not self.in_note and not self.in_table_html: text = self._note_inline_text(note) if text is not None: label = "Anmerkung" if self.opt.language.startswith("de") else "Note" return [("r", f" ({label}: {text})")] self.diag.info("note-inline-fallback", "Anmerkung mit Blockinhalt kann nicht inline stehen; als Fußnote ausgegeben.", note.key) if mode == "end-of-book": refs = self.note_refs.setdefault(note.label, []) ref_id = f"fnref-{num:03d}" + (f"-{len(refs) + 1}" if refs else "") refs.append((self.file, ref_id)) target = url_path(rel_link(self.file, self.endnotes_file)) + "#" + note.label if self.in_table_html: # Markdown is not rendered inside an HTML block return [("r", f'{num}')] return [("r", f'[{num}]({target})')] if note not in self.chapter_notes: self.chapter_notes.append(note) if self.opt.format == "gfm" and not self.in_table_html: self.md_ref_labels.add(note.label) return [("r", f"[^{note.label}]")] refs = self.note_refs.setdefault(f"{self.file}:{note.label}", []) ref_id = f"fnref-{num:03d}" + (f"-{len(refs) + 1}" if refs else "") refs.append((self.file, ref_id)) return [("r", f'{num}')] def _note_inline_text(self, note: Note) -> str | None: blocks = self.render_note(note) for b in blocks: if b.startswith(("```", "|", " ", "#", "$$", "\\[")) or re.match(r"\d+\. ", b): return None return " ".join(b.replace("\\\n", " ") for b in blocks).strip() def render_note(self, note: Note) -> list[str]: saved_doc, saved_note = self.doc, self.in_note self.doc = note.doc self.in_note = True try: el = note.element if self.is_block(el): return self.flow(el) return self.render_items(self.children(el)) finally: self.doc, self.in_note = saved_doc, saved_note def _chapter_note_definitions(self) -> list[str]: if self.opt.footnotes in ("keep-links", "end-of-book"): return [] out: list[str] = [] anchor_style: list[str] = [] done: set[str] = set() i = 0 while i < len(self.chapter_notes): # notes may reference further notes note = self.chapter_notes[i] i += 1 if note.label in done: continue done.add(note.label) blocks = self.render_note(note) or ["(leer)"] body = "\n\n".join(blocks) if note.label in self.md_ref_labels: if f"{self.file}:{note.label}" in self.note_refs: # also referenced from an HTML table (): give that link a target body = f'' + body out.append(indent_block(body, f"[^{note.label}]: ", " ")) else: num = int(note.label.split("-")[1]) back = " ".join( f"[↩](#{ref})" for _, ref in self.note_refs.get(f"{self.file}:{note.label}", []) ) anchor_style.append(_labeled_note(f'**{num}:**', blocks, back)) if anchor_style: title = "Anmerkungen" if self.opt.language.startswith("de") else "Notes" out = [f"**{title}**"] + anchor_style + out return out def render_endnotes_file(self, title: str) -> str: """Content of the end-of-book notes file (after all chapters were rendered).""" dummy = Chapter(number=0, title=title, items=[], docs=[]) dummy.filename = self.endnotes_file self.ch = dummy self.file = self.endnotes_file self.min_level = 2 out = [f"# {md_escape(title)}"] i = 0 while i < len(self.book_notes): note = self.book_notes[i] i += 1 num = int(note.label.split("-")[1]) blocks = self.render_note(note) or ["(leer)"] back = " ".join(f"[↩]({url_path(rel_link(self.file, f))}#{ref})" for f, ref in self.note_refs.get(note.label, [])) out.append(_labeled_note(f'**{num}.**', blocks, back)) return "\n\n".join(out).strip() + "\n" # ------------------------------------------------------------------ images def image(self, el: Any, src: str | None = None) -> list[Piece]: src = src if src is not None else (el.get("src") or "") alt = el.get("alt") or "" title = el.get("title") or "" if not src: return [("t", md_escape(alt))] if alt else [] if src.startswith("data:"): self.diag.info("data-uri-image", "Eingebettetes data:-Bild nicht extrahiert; Alternativtext erhalten.", self._source(el)) return [("t", md_escape(f"[Bild: {alt}]"))] if self.resolver.is_external(src): self.diag.warn("external-image", f"Externes Bild {src} nicht geladen (offline); Link erhalten.", self._source(el)) return [("r", f"![{self._alt(alt)}]({self._url(src)})")] path = resolve_href(self.doc, unquote(src.split("#")[0].split("?")[0])) if path is None: self.diag.warn("unsafe-image-path", f"Unsicherer Bildpfad ignoriert: {src}", self._source(el)) return [("t", md_escape(alt))] if not self._exists(path): self.diag.warn("image-missing", f"Bild {src} nicht im EPUB gefunden.", self._source(el), self.file) return [("t", md_escape(f"[Bild fehlt: {alt or src}]"))] problem = self.entry_problem(path) if problem: self.diag.error("media-skipped", f"Medium {path} nicht übernommen: {problem}", self._source(el), self.file, critical=True, exc=ContainerError) return [("t", md_escape(f"[Bild nicht übernommen: {alt or path.rsplit('/', 1)[-1]}]"))] if self._is_formula_image(el, alt, path): return self.formula_image(el, alt, path) if not alt and self.figure_caption: alt = self.figure_caption return self._image_ref(path, alt, title, role="") def _exists(self, path: str) -> bool: return path in self.zip_names def _image_ref(self, path: str, alt: str, title: str, role: str) -> list[Piece]: if self.ch: if path.lower().endswith(".svg"): self.ch.stats.svgs += 1 else: self.ch.stats.images += 1 if not self.opt.extract_media: return [("r", f"\\[Bild: {self._alt(alt)} – {md_escape(path)}\\] ")] mf = self.media.register(path, role=role, hint=alt[:40] if self.media.strategy == "relative" and role != "formula" else "") if self.in_table_html: return [("r", f'{html.escape(alt)}')] title_part = f' "{title.replace(chr(34), chr(39))}"' if title else "" return [("r", f"![{self._alt(alt)}]({url_path(rel_link(self.file, mf.output))}{title_part})")] def _text(self, text: str) -> str: """Escape prose text; zero-width characters and soft hyphens are dropped (P6).""" if not self.opt.keep_invisible_chars: text, n = remove_invisible(text) self.invisible_removed += n return md_escape(text, self.in_table) def _alt(self, alt: str) -> str: if not self.opt.keep_invisible_chars: alt = remove_invisible(alt)[0] return WS.sub(" ", alt).replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]").strip() def _is_formula_image(self, el: Any, alt: str, path: str) -> bool: if looks_like_tex(alt): return True for anc in [el] + _ancestors(el, 4): if any(FORMULA_CLASS.search(c) for c in classes(anc)) or {"equation"} & set(epub_types(anc)): return True return False def _formula_display(self, el: Any, alt: str) -> bool: for anc in [el] + _ancestors(el, 5): cls = classes(anc) if any(INLINE_FORMULA_CLASS.search(c) for c in cls): return False if any(FORMULA_CLASS.search(c) for c in cls) and local(anc.tag) in ("div", "p", "figure", "td"): return True return is_display_tex(alt) and not (el.getparent() is not None and norm_ws(text_content(el.getparent()))) def formula_image(self, el: Any, alt: str, path: str) -> list[Piece]: display = self._formula_display(el, alt) tex = strip_tex_delimiters(alt) if looks_like_tex(alt) else "" source = self._source(el) if tex and self.opt.math in ("auto", "tex") and not self.in_table_html: self.consumed_formula_images.add(path) self.formulas.append({"source": source, "chapter": self.file, "origin": "image-alt-tex", "representation": "tex", "display": str(display).lower()}) return self._tex_pieces(tex, display) if self.ch: self.ch.stats.math_as_image += 1 if display: self.ch.stats.math_block += 1 else: self.ch.stats.math_inline += 1 origin = "svg" if path.lower().endswith(".svg") else "image" self.formulas.append({"source": source, "chapter": self.file, "origin": origin, "representation": "image", "display": str(display).lower()}) if not tex: self.diag.warn("formula-image", f"Formel liegt nur als Bild vor ({path.rsplit('/', 1)[-1]}); als Bild referenziert, keine semantische Textextraktion.", source, self.file) pieces = self._image_ref(path, alt, el.get("title") or "", role="formula") if display: return [("blk", join_inline(pieces))] return pieces def _tex_pieces(self, tex: str, display: bool) -> list[Piece]: tex = tex.strip() if self.ch: if display: self.ch.stats.math_block += 1 else: self.ch.stats.math_inline += 1 if self.in_table and display: display = False if display: body = "\n".join(line for line in tex.split("\n") if line.strip()) if self.opt.math == "tex": return [("blk", f"\\[\n{body}\n\\]")] return [("blk", f"$$\n{body}\n$$")] flat = WS.sub(" ", tex) if self.opt.math == "tex": return [("r", f"\\({flat}\\)")] return [("r", f"${flat}$")] # ------------------------------------------------------------------ svg / math def svg(self, el: Any) -> list[Piece]: images = [e for e in iter_elements(el) if local(e.tag) == "image"] drawing = [e for e in iter_elements(el) if local(e.tag) in ("path", "text", "g", "rect", "circle", "line", "polygon", "polyline", "ellipse", "use")] if len(images) == 1 and not drawing: href = images[0].get(f"{{{XLINK_NS}}}href") or images[0].get("href") or "" return self.image(images[0], src=href) data = etree.tostring(el, encoding="utf-8", with_tail=False) if b"xmlns=" not in data[:300]: data = data.replace(b"')] return [("r", f"![{self._alt(alt)}]({url_path(rel_link(self.file, mf.output))})")] def math(self, el: Any, display: bool) -> list[Piece]: source = self._source(el) mode = self.opt.math if mode in ("auto", "tex"): try: tex = mathml_to_tex(el) self.formulas.append({"source": source, "chapter": self.file, "origin": "mathml", "representation": "tex", "display": str(display).lower()}) return self._tex_pieces(tex, display) except Unsupported as exc: self.diag.warn("mathml-kept", f"MathML ({exc}) nicht sicher nach TeX konvertierbar; MathML erhalten.", source, self.file) if mode == "image": altimg = el.get("altimg") if altimg: path = resolve_href(self.doc, altimg) if path and self._exists(path): alt = el.get("alttext") or "Formel" if self.ch: self.ch.stats.math_as_image += 1 self.formulas.append({"source": source, "chapter": self.file, "origin": "mathml-altimg", "representation": "image", "display": str(display).lower()}) pieces = self._image_ref(path, alt, "", role="formula") return [("blk", join_inline(pieces))] if display else pieces self.diag.warn("mathml-no-image", "Formel ohne Bildfassung; als MathML erhalten.", source, self.file) raw = serialize_mathml(el) if self.ch: self.ch.stats.math_as_mathml += 1 if display: self.ch.stats.math_block += 1 else: self.ch.stats.math_inline += 1 self.formulas.append({"source": source, "chapter": self.file, "origin": "mathml", "representation": "mathml", "display": str(display).lower()}) if display: return [("blk", raw)] return [("r", raw)] # ------------------------------------------------------------------ HTML passthrough def clean_html(self, el: Any, keep_root: bool = False) -> str: """Sanitized HTML; ``keep_root`` keeps an unknown root tag (``--keep-html``).""" saved = self.in_table_html self.in_table_html = True try: out = self._html(el, in_pre=False, keep=keep_root) finally: self.in_table_html = saved out = re.sub(r"\n\s*\n", "\n", out) return out.strip() def _html(self, el: Any, in_pre: bool, keep: bool = False) -> str: marker = self.page_marker(el) if not in_pre else "" if marker.startswith("\\["): marker = marker.replace("\\", "") # inside HTML no Markdown escapes return marker + self._html_core(el, in_pre, keep) def _html_core(self, el: Any, in_pre: bool, keep: bool = False) -> str: name = local(el.tag) if name == "math": # Markdown math ($…$, \(…\)) is not rendered inside an HTML block; MathML is. display = el.get("display") == "block" if self.ch: self.ch.stats.math_as_mathml += 1 if display: self.ch.stats.math_block += 1 else: self.ch.stats.math_inline += 1 self.formulas.append({"source": self._source(el), "chapter": self.file, "origin": "mathml", "representation": "mathml-in-html-table", "display": str(display).lower()}) return serialize_mathml(el) if name in ("img", "svg", "image"): return join_inline(self.inline(el)) if name == "a" and self.notes.extracted and self.notes.note_for_ref(el) is not None: return join_inline(self.link(el)) text_fn = (lambda t: html.escape(t, quote=False).replace("\n", " ")) if in_pre else (lambda t: html.escape(WS.sub(" ", t), quote=False)) inner = [] if el.text: inner.append(text_fn(el.text)) for child in el: if is_element(child): inner.append(self._html(child, in_pre or name == "pre")) if child.tail: inner.append(text_fn(child.tail)) content = "".join(inner) if keep and re.fullmatch(r"[a-z][a-z0-9-]*", name): safe = [f'{k.rsplit("}", 1)[-1]}="{html.escape(v)}"' for k, v in el.attrib.items() if re.fullmatch(r"[A-Za-z_:][-A-Za-z0-9_:.]*", k.rsplit("}", 1)[-1]) and not k.rsplit("}", 1)[-1].lower().startswith(("on", "data-e2m-")) and k.rsplit("}", 1)[-1] != "style" and not v.strip().lower().startswith("javascript:")] return f"<{name}{(' ' + ' '.join(safe)) if safe else ''}>{content}" if name not in HTML_KEEP_TAGS or name == "span": anchor = self.anchors_of(el) if self.opt.css_emphasis and content.strip(): e = self._emphasis(el) if e["hidden"]: self.hidden_elements += 1 return "" for flag, tag, key in ((e["italic"], "em", "italic"), (e["bold"], "strong", "bold"), (e["underline"] and "{content}" self.css_applied[key] += 1 if self.css is not None and content.strip(): style = self.css.computed(el) props = style_props(style, self.base_style) if self.opt.styles == "html" else [] if props: content = f'{content}' self.css_applied["style"] += 1 elif self.opt.styles != "html" and has_highlight(style): content = f"{content}" self.css_applied["mark"] += 1 return anchor + content attrs = [] for k, v in el.attrib.items(): if k.startswith("{"): continue # namespaced (epub:type, xml:lang …) – not valid HTML key = k.rsplit("}", 1)[-1] if key in HTML_KEEP_ATTRS: attrs.append(f'{key}="{html.escape(v)}"') el_anchor = self.resolver.anchor_for(self.doc, el) if el_anchor: attrs.append(f'id="{el_anchor}"') if name == "a": href = el.get("href") or "" url = None if href and self.resolver.is_external(href): url = href elif href: url, status = self.resolver.resolve(self.file, self.doc, href) if url is None and status in ("missing", "unsafe"): self.diag.unresolved(self.doc, href, self.file) if url: attrs.append(f'href="{html.escape(url)}"') elif not el_anchor: return content attr_str = (" " + " ".join(attrs)) if attrs else "" if name in ("br", "hr", "col"): return f"<{name}{attr_str}/>" sep = "\n" if name in ("table", "thead", "tbody", "tfoot", "tr") else "" return f"<{name}{attr_str}>{sep}{content}{sep}"