epub2md/epub2md/footnotes.py

184 lines
6.4 KiB
Python
Raw Normal View History

"""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))