Nullbreiten-Zeichen (U+200B–U+200D, U+2060, U+FEFF) und weiche Trennstriche (U+00AD) werden aus Fließtext, Alternativtexten und Kapiteltiteln entfernt; Code bleibt unverändert. Im Mathebuch sind das 198 Zeichen (z. B. '1.1'). Abschaltbar mit --keep-invisible-chars; die Anzahl steht im Bericht unter text_cleanup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1268 lines
55 KiB
Python
1268 lines
55 KiB
Python
"""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 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 .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 .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,
|
||
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
|
||
|
||
|
||
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)
|
||
|
||
|
||
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 _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)
|
||
|
||
|
||
_BLOCK_SYNTAX = ("```", "~~~", "|", "<table", "- ", "> ", "#", "$$", "\\[", "* * *")
|
||
|
||
|
||
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) -> None:
|
||
self.entry_problem = entry_problem or (lambda path: None)
|
||
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):
|
||
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
|
||
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 <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 [self.clean_html(el)] # clean_html keeps the id on <table> 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 = "<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"
|
||
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 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 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'<a id="{ref_id}"></a><sup><a href="{target}">{num}</a></sup>')]
|
||
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:
|
||
if f"{self.file}:{note.label}" in self.note_refs:
|
||
# also referenced from an HTML table (<a href="#fn-…">): give that link a target
|
||
body = f'<a id="{note.label}"></a>' + 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'<a id="{note.label}"></a>**{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'<a id="{note.label}"></a>**{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'<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")}{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"<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)
|
||
if self.in_table_html: # Markdown is not rendered inside an HTML block
|
||
return [("r", f'<img src="{html.escape(url_path(rel_link(self.file, mf.output)))}" alt="{html.escape(alt)}"/>')]
|
||
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:
|
||
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") 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():
|
||
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}"
|
||
|