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
### In Obsidian lesen

View file

@ -167,7 +167,13 @@ output/mein-buch/
- erst als letzte Notlösung der Dateiname
5. Ein HTML-Inhaltsverzeichnis wird übersprungen, wenn NAV/NCX vorhanden ist (keine doppelte Ausgabe).
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
Ü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)),
]
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)
@ -90,6 +98,8 @@ class ChapterBuilder:
book_title: str = "", authors: list[str] | None = None) -> None:
self.level = chapter_level
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.author_names = _surnames(authors or [])
self.nav_titles: dict[str, str] = {}
@ -103,19 +113,37 @@ class ChapterBuilder:
# ------------------------------------------------------------------ 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)
if lvl and lvl <= self.level and has_content(el):
return Marker(el, "heading", lvl)
if lvl and has_content(el):
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"}:
types = semantic_types(el)
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 None
def _contains_split(self, el: Any) -> bool:
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 False
@ -279,6 +307,11 @@ class _TypeCarrier:
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:
"""Heading level of ``item`` or of the first meaningful descendant inside wrapper containers."""
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 "Das Wetter war schlecht." 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"]