feat: Verzeichnisse unterhalb von --chapter-level als eigene Datei

Überschriften, die genau einem Verzeichnisnamen entsprechen (Quellen,
Literatur, Glossar, Register, Index …), beginnen auch unterhalb von
--chapter-level eine eigene Datei mit passendem Typ. Die nächste Überschrift
gleicher oder höherer Ebene beendet den Abschnitt; innerhalb eines Kapitels,
das selbst ein Verzeichnis ist, wird nicht weiter geteilt. Im Roman-Beispiel
wird 'Quellen' aus dem Anhang so zum Literaturverzeichnis; die anderen vier
Beispielbücher bleiben unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-06 00:45:03 +02:00
commit 2125555e2a
4 changed files with 77 additions and 5 deletions

View file

@ -215,6 +215,18 @@ Standardmäßig stehen die Fußnoten am Ende des jeweiligen Kapitels. Alternativ
--- ---
### Verzeichnisse: Quellen, Literatur, Register, Glossar
Quellen- und Literaturverzeichnisse, Sach- und Personenregister, Glossare und Anhänge aus dem E-Book werden
übernommen. Sie stehen jeweils in einer eigenen Datei und sind in der `README.md` unter „Besondere Abschnitte“
verlinkt. Das gilt auch, wenn ein solches Verzeichnis im Buch nur ein Unterabschnitt ist, etwa „Quellen“
im Anhang eines Romans. Neu erzeugt wird nichts: Hat ein Buch kein Register, entsteht auch keins.
- Register: Buchstabengruppen werden zu Überschriften, Untereinträge sind eingerückt, Verweise auf
Textstellen bleiben anklickbare Links.
- Literatur: Autoren, Titel, DOI, ISBN und Internetadressen bleiben vollständig erhalten; Adressen
bleiben Links.
## 7. Weiterverarbeiten ## 7. Weiterverarbeiten
### In Obsidian lesen ### In Obsidian lesen

View file

@ -167,7 +167,13 @@ output/mein-buch/
- erst als letzte Notlösung der Dateiname - erst als letzte Notlösung der Dateiname
5. Ein HTML-Inhaltsverzeichnis wird übersprungen, wenn NAV/NCX vorhanden ist (keine doppelte Ausgabe). 5. Ein HTML-Inhaltsverzeichnis wird übersprungen, wenn NAV/NCX vorhanden ist (keine doppelte Ausgabe).
Links darauf zeigen auf `README.md`. Links darauf zeigen auf `README.md`.
6. Überschriftenebenen werden je Datei verschoben: Der Kapiteltitel ist `#`, alle weiteren beginnen bei `##`. 6. Verzeichnisse erhalten immer eine eigene Datei, auch unterhalb von `--chapter-level`, z. B. ein
`## Quellen` im Anhang. Das gilt nur für Überschriften, die genau einem Verzeichnisnamen entsprechen:
Literatur(verzeichnis), Quellen(verzeichnis), Bibliografie, References, Glossar, Stichwortverzeichnis,
Sach-/Personen-/Namensregister, Register, Index. Die nächste Überschrift gleicher oder höherer Ebene
beendet den Abschnitt. Innerhalb eines Kapitels, das selbst ein Verzeichnis ist, wird nicht weiter
geteilt.
7. Überschriftenebenen werden je Datei verschoben: Der Kapiteltitel ist `#`, alle weiteren beginnen bei `##`.
Verlagsspezifische Heuristiken: Layout-Tabellen mit Klassen wie `Head1`/`Title_document` werden zu Verlagsspezifische Heuristiken: Layout-Tabellen mit Klassen wie `Head1`/`Title_document` werden zu
Überschriften. `<pre>` mit `<br/>`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken. Überschriften. `<pre>` mit `<br/>`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken.

View file

@ -42,6 +42,14 @@ KIND_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("prologue", re.compile(r"^(prolog|prologue)", re.I)), ("prologue", re.compile(r"^(prolog|prologue)", re.I)),
] ]
TITLE_CLASS = re.compile(r"^(?!sub).*(heading|title)$") TITLE_CLASS = re.compile(r"^(?!sub).*(heading|title)$")
# Headings below --chapter-level that still get their own file (exact names only, no prefixes)
SPECIAL_HEADING = re.compile(
r"^(literatur(verzeichnis|hinweise|angaben)?|weiterführende literatur|quellen(verzeichnis|angaben|nachweise?)?|"
r"bibliogra(fie|phie)|references|bibliography|glossar|glossary|stichwortverzeichnis|sachregister|"
r"sachverzeichnis|register|index|personenregister|namensregister|personenverzeichnis|namensverzeichnis)$",
re.I,
)
SPECIAL_KINDS = {"bibliography", "glossary", "index", "index-persons"}
NUMBER_PREFIX = re.compile(r"^\s*(?:(?:kapitel|chapter)\s+)?(?:\d+(?:\.\d+)*|[IVXLC]+)\.?\s+", re.I) NUMBER_PREFIX = re.compile(r"^\s*(?:(?:kapitel|chapter)\s+)?(?:\d+(?:\.\d+)*|[IVXLC]+)\.?\s+", re.I)
@ -90,6 +98,8 @@ class ChapterBuilder:
book_title: str = "", authors: list[str] | None = None) -> None: book_title: str = "", authors: list[str] | None = None) -> None:
self.level = chapter_level self.level = chapter_level
self.diag = diag self.diag = diag
self._special_level = 0 # level of a split-out special heading whose section is still open
self._in_special = False # current chapter itself is a bibliography/index/glossary
self.book_title = book_title self.book_title = book_title
self.author_names = _surnames(authors or []) self.author_names = _surnames(authors or [])
self.nav_titles: dict[str, str] = {} self.nav_titles: dict[str, str] = {}
@ -103,19 +113,37 @@ class ChapterBuilder:
# ------------------------------------------------------------------ split detection # ------------------------------------------------------------------ split detection
def _split_kind(self, el: Any) -> Marker | None: def _split_kind(self, el: Any, update: bool = True) -> Marker | None:
"""Chapter start at ``el``? ``update=False`` only looks ahead without changing the section state."""
lvl = heading_level(el) lvl = heading_level(el)
if lvl and lvl <= self.level and has_content(el): if lvl and has_content(el):
return Marker(el, "heading", lvl) special = _special_heading(el)
if lvl <= self.level:
if update:
self._special_level = 0
self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS
return Marker(el, "heading", lvl)
if self._special_level and lvl <= self._special_level:
# a heading of the same or higher level ends the split-out special section
if update:
self._special_level = lvl if special else 0
return Marker(el, "heading", lvl)
if special and not self._in_special:
if update:
self._special_level = lvl
return Marker(el, "heading", lvl)
if local(el.tag) in CONTAINERS | {"body"}: if local(el.tag) in CONTAINERS | {"body"}:
types = semantic_types(el) types = semantic_types(el)
if types and local(el.tag) != "body": if types and local(el.tag) != "body":
if update:
self._special_level = 0
self._in_special = bool(SPECIAL_KINDS & set(types))
return Marker(el, "semantic", 0, types) return Marker(el, "semantic", 0, types)
return None return None
def _contains_split(self, el: Any) -> bool: def _contains_split(self, el: Any) -> bool:
for e in iter_elements(el): for e in iter_elements(el):
if e is not el and self._split_kind(e): if e is not el and self._split_kind(e, update=False):
return True return True
return False return False
@ -279,6 +307,11 @@ class _TypeCarrier:
types: list[str] types: list[str]
def _special_heading(el: Any) -> bool:
title = NUMBER_PREFIX.sub("", norm_ws(_heading_text(el))).strip(" :.")
return bool(SPECIAL_HEADING.match(title))
def leading_heading_level(item: Any) -> int: def leading_heading_level(item: Any) -> int:
"""Heading level of ``item`` or of the first meaningful descendant inside wrapper containers.""" """Heading level of ``item`` or of the first meaningful descendant inside wrapper containers."""
node = item node = item

View file

@ -164,3 +164,24 @@ def test_adjacent_field_spans_get_a_separator(convert):
assert "J. Liesen, V. Mehrmann Lineare Algebra Springer Studium" in text assert "J. Liesen, V. Mehrmann Lineare Algebra Springer Studium" in text
assert "Das Wetter war schlecht." in text assert "Das Wetter war schlecht." in text
assert "Hauptbahnhof und x<sub>i</sub>." in text assert "Hauptbahnhof und x<sub>i</sub>." in text
def test_special_sections_below_chapter_level_get_own_files(convert):
body = ("<h1>Anhang</h1><p>Vorbemerkung.</p>"
"<h2><em>Quellen</em></h2><ul><li>Archiv A</li><li>Buch B, ISBN 978-0-201-89683-1</li></ul>"
"<h2>Danksagung</h2><p>Danke allen.</p>"
"<h2>Literatur und Film</h2><p>Ein normaler Abschnitt.</p>")
run = convert(Book(docs=[Doc("a.xhtml", body)]))
assert [p.name for p in run.chapters()] == ["001-anhang.md", "002-quellen.md", "003-danksagung.md"]
assert "Literatur und Film" in run.chapter("003") # not a special heading: stays in the chapter
assert "Archiv A" in run.chapter("002") and "Danke" not in run.chapter("002")
assert frontmatter(run.chapter("002"))["epub_type"] == ["bibliography"]
readme = (run.out / "README.md").read_text(encoding="utf-8")
assert "Literatur / Quellen: [Quellen](chapters/002-quellen.md)" in readme
def test_special_chapter_is_not_split_further(convert):
body = ("<h1>Literatur</h1><h2>Quellen</h2><p>Q1</p><h2>Sekundärliteratur</h2><p>S1</p>"
"<h1>Register</h1><h2>A</h2><p>Apfel</p><h2>Index</h2><p>x</p>")
run = convert(Book(docs=[Doc("a.xhtml", body)]))
assert [p.name for p in run.chapters()] == ["001-literatur.md", "002-register.md"]