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>
184 lines
6.4 KiB
Python
184 lines
6.4 KiB
Python
"""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))
|