From 2125555e2aefb0dc35fbc1710c60c2dfd50ccf66 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 00:45:03 +0200 Subject: [PATCH] feat: Verzeichnisse unterhalb von --chapter-level als eigene Datei MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ü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 --- BEDIENUNGSANLEITUNG.md | 12 ++++++++++++ README.md | 8 +++++++- epub2md/chapters.py | 41 +++++++++++++++++++++++++++++++++++++---- tests/test_chapters.py | 21 +++++++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 030c819..afb00f6 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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 diff --git a/README.md b/README.md index 7a1ba5e..f48bfdd 100644 --- a/README.md +++ b/README.md @@ -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. `
` mit `
`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken. diff --git a/epub2md/chapters.py b/epub2md/chapters.py index e9a767a..5845852 100644 --- a/epub2md/chapters.py +++ b/epub2md/chapters.py @@ -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 diff --git a/tests/test_chapters.py b/tests/test_chapters.py index 7c1712b..c98dd87 100644 --- a/tests/test_chapters.py +++ b/tests/test_chapters.py @@ -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 xi." in text + + +def test_special_sections_below_chapter_level_get_own_files(convert): + body = ("

Anhang

Vorbemerkung.

" + "

Quellen

  • Archiv A
  • Buch B, ISBN 978-0-201-89683-1
" + "

Danksagung

Danke allen.

" + "

Literatur und Film

Ein normaler Abschnitt.

") + 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 = ("

Literatur

Quellen

Q1

Sekundärliteratur

S1

" + "

Register

A

Apfel

Index

x

") + run = convert(Book(docs=[Doc("a.xhtml", body)])) + assert [p.name for p in run.chapters()] == ["001-literatur.md", "002-register.md"]