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