epub2md/epub2md/html_converter.py
dschlueter cc36372329 feat: Option --code-language für Codeblöcke ohne erkannte Sprache
Weist Blöcken ohne erkannte Sprache ein vom Nutzer gewähltes Label zu.
Aliase wie py werden normalisiert, unbekannte Werte ergeben Exit 1.
Erkannte Labels haben Vorrang; im Bericht detected_by "option"
(QA-Befund R1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:01:07 +02:00

1189 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.abc import 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 .codeblocks import (
code_text,
detect_language,
detect_language_from_content,
fence_for,
inline_code,
is_block_code_container,
is_inline_code,
)
from .diagnostics import Diagnostics
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 .references import LinkResolver
from .utils import (
MATHML_NS,
SVG_NS,
XLINK_NS,
classes,
epub_types,
is_element,
iter_elements,
local,
norm_ws,
rel_link,
resolve_href,
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
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:]
out.append(line)
return "\n".join(out)
def join_inline(pieces: list[Piece], hard_break: str = "\\\n") -> str:
out = ""
for kind, s in pieces:
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 _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]) -> None:
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 re.sub(r"\n{3,}", "\n\n", 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", md_escape(item, self.in_table)))
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):
continue
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'<a id="{name}"></a>'
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)
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)
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
blocks = self.flow(el)
if anchor:
if blocks and not blocks[0].startswith(("#", "`", "|", "<table", "> ", "- ", "$$", "\\[", "* * *")) \
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)
self.in_heading = True
try:
text = join_inline(self.inline_children(el), hard_break=" ")
finally:
self.in_heading = False
text = re.sub(r"<a id=\"[^\"]*\"></a>", "", text).strip()
out = []
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
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})
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 <dt> 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 <div> grouping inside <dl>
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 ([anchor] if anchor else []) + [self.clean_html(el)]
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 = "<br>".join(b.replace("\\\n", "<br>").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"
return ""
# ================================================================== inline
def inline_children(self, el: Any) -> list[Piece]:
pieces: list[Piece] = []
if el.text:
pieces.append(("t", md_escape(el.text, self.in_table)))
for child in el:
if is_element(child):
pieces.extend(self.inline(child))
if child.tail:
pieces.append(("t", md_escape(child.tail, self.in_table)))
return pieces
def inline(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 []
if name == "br":
return pre + ([("t", " ")] if self.in_heading else [("br", "")])
if name == "wbr":
return pre
if name == "a":
return 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 ("<del>", "</del>")
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"<kbd>{html.escape(norm_ws(text_content(el)), quote=False)}</kbd>")]
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. <div> inside <sup>)
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), ("r", f"{open_}{core}{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:
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
return [("r", f'<a id="{ref_id}"></a><sup>[{num}]({target})</sup>')]
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'<a id="{ref_id}"></a><sup><a href="#{note.label}">{num}</a></sup>')]
def _note_inline_text(self, note: Note) -> str | None:
blocks = self.render_note(note)
for b in blocks:
if b.startswith(("```", "|", "<table", "- ", "> ", "#", "$$", "\\[")) 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:
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(indent_block(body, f'<a id="{note.label}"></a>**{num}:** ', "") + (f" {back}" if back else ""))
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, []))
body = "\n\n".join(blocks)
out.append(f'<a id="{note.label}"></a>**{num}.** ' + body + (f" {back}" if back else ""))
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}]"))]
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'<img src="{html.escape(url_path(rel_link(self.file, mf.output)))}" alt="{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 _alt(self, alt: str) -> str:
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 _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 _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"):
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"<svg", f'<svg xmlns="{SVG_NS}"'.encode(), 1)
is_formula = any(FORMULA_CLASS.search(c) for a in [el] + _ancestors(el, 3) for c in classes(a))
title = next((norm_ws(text_content(e)) for e in iter_elements(el) if local(e.tag) in ("title", "desc")), "")
alt = el.get("aria-label") or title or ("Formel" if is_formula else "SVG-Grafik")
if self.ch:
self.ch.stats.svgs += 1
if not self.opt.extract_media:
return [("t", md_escape(f"[SVG: {alt}]"))]
name = f"{self.doc.rsplit('/', 1)[-1].rsplit('.', 1)[0]}-svg"
mf = self.media.register_generated(name, ".svg", data, "image/svg+xml", role="formula" if is_formula else "inline-svg")
if is_formula:
self.formulas.append({"source": self._source(el), "chapter": self.file, "origin": "svg",
"representation": "svg", "display": "false"})
self.diag.warn("formula-svg", "Formel liegt nur als SVG vor; als Bild referenziert.", self._source(el), self.file)
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:
name = local(el.tag)
if name == "math":
if self.opt.math in ("auto", "tex"):
try:
tex = mathml_to_tex(el)
return html.escape(f"\\({tex}\\)" if self.opt.math == "tex" else f"${tex}$", quote=False)
except Unsupported:
pass
return serialize_mathml(el)
if name in ("img", "svg", "image"):
if name == "img" and self._is_formula_image(el, el.get("alt") or "", ""):
alt = el.get("alt") or ""
if looks_like_tex(alt) and self.opt.math in ("auto", "tex"):
path = resolve_href(self.doc, el.get("src") or "")
if path:
self.consumed_formula_images.add(path)
tex = strip_tex_delimiters(alt)
return html.escape(f"\\({tex}\\)" if self.opt.math == "tex" else f"${tex}$", quote=False)
pieces = self.inline(el)
return join_inline(pieces)
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", "&#10;")) 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") 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)
return anchor + content
attrs = []
for k, v in el.attrib.items():
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}"