"""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"
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.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("--", "")
return f"" if self.opt.page_markers == "comment" else f"\\[S. {md_escape(page)}\\]"
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 = ""
if title_el is not None:
pieces: list[Piece] = [("t", self._text(title_el.text))] if title_el.text else []
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)))
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 = [anchor] if anchor else []
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.
)."""
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"{tag}>")
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"})")]
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'')]
title_part = f' "{title.replace(chr(34), chr(39))}"' if title else ""
return [("r", f")}{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")})")]
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}{name}>"
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}{tag}>"
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}{name}>{sep}"