epub2md/epub2md/footnotes.py
dschlueter 02449734a2 feat: epub2md – EPUB nach kapitelweisem Markdown
Kommandozeilenwerkzeug, das DRM-freie EPUB-2/3-Dateien offline in
kapitelweise Markdown-Dateien umwandelt. Erzeugt werden außerdem Medien,
metadata.yaml, manifest.json, conversion-report.json und Diagnose-Dateien.

- Spine-basierte Kapitelbildung mit --chapter-level, epub:type-Abschnitten,
  Fortsetzungsdateien und Erkennung von Layout-Überschriften
- Code mit exakter Einrückung, sicheren Fences und konservativer
  Spracherkennung
- MathML->TeX (konservativ), TeX aus alt-Texten, SVG-/Bildformeln
  bleiben Bilder
- Fußnoten-Modi end-of-chapter, end-of-book, inline und keep-links
- Interne Links/Anker werden umgeschrieben, Linkcheck über die Ausgabe
- Sicher: kein Entpacken, Schutz vor Path Traversal und XXE,
  DRM-Erkennung, atomares Schreiben, --force nur für frühere Ausgaben
- 82 pytest-Tests mit synthetischen EPUBs und Invariantentests für Beispiele/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:33:42 +02:00

184 lines
6.4 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.

"""Detection and extraction of footnotes/endnotes and their references."""
from __future__ import annotations
from typing import Any
from urllib.parse import unquote, urlsplit
from .diagnostics import Diagnostics
from .models import Note
from .normalize import Doc, has_content, remove_keep_tail
from .utils import classes, epub_types, is_element, iter_elements, local, resolve_href, roles
NOTE_TYPES = {"footnote", "endnote", "rearnote", "note"}
NOTE_ROLES = {"doc-footnote", "doc-endnote"}
NOTE_CLASSES = {"footnote", "endnote", "fn", "fussnote", "fußnote"}
CONTAINER_TYPES = {"footnotes", "endnotes", "rearnotes"}
CONTAINER_ROLES = {"doc-endnotes"}
CONTAINER_CLASSES = {"footnotes", "endnotes", "footnotesection", "endnotesection", "fussnoten"}
NOTEREF_ATTR = "data-e2m-noteref"
def _is_note(el: Any) -> bool:
return bool(
NOTE_TYPES.intersection(epub_types(el))
or NOTE_ROLES.intersection(roles(el))
or NOTE_CLASSES.intersection(classes(el))
)
def _is_container(el: Any) -> bool:
return bool(
CONTAINER_TYPES.intersection(epub_types(el))
or CONTAINER_ROLES.intersection(roles(el))
or CONTAINER_CLASSES.intersection(classes(el))
)
def _note_kind(el: Any) -> str:
for e in iter_elements(el):
t = set(epub_types(e)) | {r.removeprefix("doc-") for r in roles(e)}
if t & {"endnote", "rearnote"}:
return "endnote"
if "footnote" in t:
return "footnote"
return "footnote"
def _href_target(doc_path: str, href: str) -> tuple[str, str] | None:
parts = urlsplit(href)
if parts.scheme or parts.netloc:
return None
path = unquote(parts.path)
frag = unquote(parts.fragment)
if not frag:
return None
target = resolve_href(doc_path, path) if path else doc_path
return (target, frag) if target else None
class NoteIndex:
def __init__(self) -> None:
self.notes: dict[str, Note] = {}
self.by_target: dict[tuple[str, str], Note] = {}
self.extracted = False
def note_for_ref(self, el: Any) -> Note | None:
key = el.get(NOTEREF_ATTR)
return self.notes.get(key) if key else None
def collect_notes(docs: list[Doc], mode: str, diag: Diagnostics) -> NoteIndex:
index = NoteIndex()
# 1. note elements (outermost candidates only)
for doc in docs:
for el in iter_elements(doc.body):
if not _is_note(el):
continue
anc = el.getparent()
nested = False
while anc is not None:
if _is_note(anc):
nested = True
break
anc = anc.getparent()
if nested:
continue
ids = {e.get("id") for e in iter_elements(el) if e.get("id")}
if not ids:
continue
key = f"{doc.path}#{sorted(ids)[0]}"
note = Note(key=key, element=el, doc=doc.path, ids=ids, kind=_note_kind(el))
index.notes[key] = note
for i in ids:
index.by_target[(doc.path, i)] = note
# 2. references
for doc in docs:
for a in iter_elements(doc.body):
if local(a.tag) != "a" or not a.get("href"):
continue
target = _href_target(doc.path, a.get("href"))
is_noteref = "noteref" in epub_types(a) or "doc-noteref" in roles(a)
found = index.by_target.get(target) if target else None
if found is None:
if is_noteref:
diag.warn("noteref-unresolved", f"Fußnotenverweis {a.get('href')} verweist auf kein erkanntes Ziel.", doc.path)
continue
if _inside(a, found.element):
continue # backlink inside the note itself
a.set(NOTEREF_ATTR, found.key)
found.ref_count += 1
found.ref_anchor_ids.update(_ref_ids(a))
# 3. extraction
if mode == "keep-links":
return index
for note in index.notes.values():
if note.ref_count == 0:
semantic = NOTE_TYPES.intersection(epub_types(note.element)) or NOTE_ROLES.intersection(roles(note.element))
if semantic:
diag.warn("note-unreferenced", "Anmerkung ohne Verweis im Text – an Ort und Stelle belassen.",
f"{note.doc}#{sorted(note.ids)[0]}")
continue
_strip_backlinks(note)
remove_keep_tail(note.element)
index.extracted = True
for doc in docs:
for el in list(iter_elements(doc.body)):
if el is not doc.body and _is_container(el) and el.getparent() is not None and not _has_body_content(el):
remove_keep_tail(el)
return index
def _inside(el: Any, ancestor: Any) -> bool:
node = el
while node is not None:
if node is ancestor:
return True
node = node.getparent()
return False
def _ref_ids(a: Any) -> set[str]:
ids = {e.get("id") for e in iter_elements(a) if e.get("id")}
parent = a.getparent()
for _ in range(2):
if parent is None or local(parent.tag) not in ("sup", "span", "small"):
break
if parent.get("id"):
ids.add(parent.get("id"))
parent = parent.getparent()
return ids
def _strip_backlinks(note: Note) -> None:
for a in list(iter_elements(note.element)):
if local(a.tag) != "a" or not a.get("href"):
continue
target = _href_target(note.doc, a.get("href"))
if target and target[1] in note.ref_anchor_ids:
parent = a.getparent()
remove_keep_tail(a)
# drop now-empty label wrappers such as <span class="FootnoteNumber">
while parent is not None and parent is not note.element and not has_content(parent) and not parent.get("id"):
grand = parent.getparent()
remove_keep_tail(parent)
parent = grand
def _has_body_content(container: Any) -> bool:
"""Content of a notes container apart from its heading."""
for child in container:
if not is_element(child):
continue
name = local(child.tag)
if name in ("h1", "h2", "h3", "h4", "h5", "h6", "hr"):
continue
if {"heading", "headings", "title"} & set(classes(child)):
continue
if has_content(child):
return True
if container.text and container.text.strip():
return True
return any(c.tail and c.tail.strip() for c in container if is_element(c))