feat: Titel überschriftsloser Seiten aus dem Inhalt ableiten (QA R3)

Seiten ohne Überschrift, NAV-Eintrag und <title> werden am Inhalt erkannt:
- Bild am Buchanfang -> Cover
- Buchtitel und Autor -> Titelseite
- ©/ISBN/urheberrechtlich -> Impressum
- sonst die ersten acht Wörter
Erst danach greift der Dateiname. Im Roman-Beispiel werden aus
cinnertitle/cimprint so 'Titelseite' und 'Impressum'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-05 20:46:34 +02:00
commit 3ac5e1a8cc
5 changed files with 70 additions and 8 deletions

View file

@ -313,9 +313,9 @@ Nein. Das Programm arbeitet vollständig offline und greift nicht aufs Netz zu.
**Wird das Original-E-Book verändert?** **Wird das Original-E-Book verändert?**
Nein, es wird nur gelesen. Mit `--copy-original` wird zusätzlich eine Kopie in den Ausgabeordner gelegt. Nein, es wird nur gelesen. Mit `--copy-original` wird zusätzlich eine Kopie in den Ausgabeordner gelegt.
**Warum heißen manche Kapiteldateien seltsam (z. B. `cinnertitle`)?** **Warum heißen manche Kapiteldateien „Titelseite“, „Impressum“ oder nach ihren ersten Wörtern?**
Manche E-Book-Seiten (Titelseite, Impressum) haben weder Überschrift noch Titel. Dann wird der Manche E-Book-Seiten haben weder Überschrift noch Titel. Das Programm erkennt sie dann am Inhalt.
interne Dateiname verwendet. Gelingt das nicht, werden die ersten Wörter der Seite verwendet, nur im Notfall der interne Dateiname.
**Warum ist ein Codebeispiel falsch eingerückt?** **Warum ist ein Codebeispiel falsch eingerückt?**
So steht es im E-Book selbst (Satzfehler des Verlags). Der Code wird bewusst zeichengetreu So steht es im E-Book selbst (Satzfehler des Verlags). Der Code wird bewusst zeichengetreu

View file

@ -159,7 +159,12 @@ output/mein-buch/
3. Eine XHTML-Datei, die nur mit einer tieferen Überschrift beginnt (z. B. eine Datei pro Abschnitt 3. Eine XHTML-Datei, die nur mit einer tieferen Überschrift beginnt (z. B. eine Datei pro Abschnitt
`1.2`), wird an das laufende Kapitel angehängt. `1.2`), wird an das laufende Kapitel angehängt.
4. Dateien ohne Überschrift (Cover, Titelei, Widmung) werden eigene Kapitel. Ihr Titel kommt aus 4. Dateien ohne Überschrift (Cover, Titelei, Widmung) werden eigene Kapitel. Ihr Titel kommt aus
NAV/NCX, `<title>`, `epub:type` oder dem Dateinamen. NAV/NCX, `<title>` oder `epub:type`. Fehlt all das, entscheidet der Inhalt:
- nur ein Bild am Buchanfang → „Cover“
- Buchtitel und Autorenname → „Titelseite“
- ©/ISBN/„urheberrechtlich“ → „Impressum“
- sonst die ersten acht Wörter
- 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. Überschriftenebenen werden je Datei verschoben: Der Kapiteltitel ist `#`, alle weiteren beginnen bei `##`.
@ -336,8 +341,8 @@ Verlustfreiheit behauptet (`lossless: false`).
## Bekannte Einschränkungen ## Bekannte Einschränkungen
- Überschriftslose Dokumente ohne NAV-Eintrag und ohne `<title>` werden nach dem Dateinamen benannt - Titel überschriftsloser Seiten werden aus dem Inhalt abgeleitet (Cover, Titelseite, Impressum, erste
(z. B. `cinnertitle`). Wörter). Das ist eine Heuristik und trifft nicht jede Seite.
- Aneinanderstoßende Inline-Elemente, zwischen denen auch in der Quelle kein Leerraum steht, laufen - Aneinanderstoßende Inline-Elemente, zwischen denen auch in der Quelle kein Leerraum steht, laufen
zusammen (z. B. Autor- und Titelzeilen in Springer-Kapitelköpfen). zusammen (z. B. Autor- und Titelzeilen in Springer-Kapitelköpfen).
- Fehlerhafte Einrückungen der Vorlage werden zeichengetreu übernommen. Druck-Fortsetzungszeichen - Fehlerhafte Einrückungen der Vorlage werden zeichengetreu übernommen. Druck-Fortsetzungszeichen

View file

@ -86,9 +86,12 @@ def semantic_types(el: Any) -> list[str]:
class ChapterBuilder: class ChapterBuilder:
def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics) -> None: def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics,
book_title: str = "", authors: list[str] | None = None) -> None:
self.level = chapter_level self.level = chapter_level
self.diag = diag self.diag = diag
self.book_title = book_title
self.author_names = _surnames(authors or [])
self.nav_titles: dict[str, str] = {} self.nav_titles: dict[str, str] = {}
self._index_nav(nav) self._index_nav(nav)
@ -230,6 +233,10 @@ class ChapterBuilder:
ch.title = _type_label(types) or nav or doc.title ch.title = _type_label(types) or nav or doc.title
else: else:
ch.title = nav or doc.title or _type_label(types) ch.title = nav or doc.title or _type_label(types)
if not ch.title:
ch.title, content_type = self._title_from_content(items, number)
if content_type:
types.append(content_type)
ch.title = ch.title or doc.path.rsplit("/", 1)[-1].rsplit(".", 1)[0] ch.title = ch.title or doc.path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
ch.synthetic_title = True ch.synthetic_title = True
if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]): if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]):
@ -237,6 +244,26 @@ class ChapterBuilder:
ch.types = list(dict.fromkeys(types)) ch.types = list(dict.fromkeys(types))
return ch return ch
def _title_from_content(self, items: list[tuple[str, Any]], number: int) -> tuple[str, str]:
"""Title for a document without heading, nav entry and <title>: classify its content."""
text = norm_ws(" ".join(it if isinstance(it, str) else text_content(it)
for _, it in items if isinstance(it, str) or is_element(it)))
has_image = any(is_element(it) and any(local(e.tag) in ("img", "svg", "image") for e in iter_elements(it))
for _, it in items)
if not text and has_image:
return ("Cover", "cover") if number == 1 else ("Bild", "")
low = text.lower()
if self.book_title and len(self.book_title) >= 4 and self.book_title.lower() in low \
and any(name.lower() in low for name in self.author_names):
return "Titelseite", "titlepage"
if re.search(r"©|\bisbn\b|copyright|urheberrechtlich|alle rechte vorbehalten|all rights reserved", low):
return "Impressum", "colophon"
words = text.split()
if not words:
return "", ""
short = " ".join(words[:8])
return (short + " …" if len(words) > 8 else short), ""
def _finalize(self, ch: Chapter) -> None: def _finalize(self, ch: Chapter) -> None:
kind = classify_title(ch.title) kind = classify_title(ch.title)
if kind and kind not in ch.types: if kind and kind not in ch.types:
@ -276,6 +303,17 @@ def leading_heading_level(item: Any) -> int:
return 0 return 0
def _surnames(authors: list[str]) -> list[str]:
"""'Adler-Olsen, Jussi' -> 'Adler-Olsen'; 'Erika Musterfrau' -> 'Musterfrau'."""
names = []
for a in authors:
a = a.strip()
name = a.split(",")[0].strip() if "," in a else (a.split()[-1] if a.split() else "")
if len(name) >= 3:
names.append(name)
return names
def _meaningful(items: list[tuple[str, Any]]) -> bool: def _meaningful(items: list[tuple[str, Any]]) -> bool:
return _first_meaningful(items) is not None return _first_meaningful(items) is not None

View file

@ -159,7 +159,7 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
# --- notes, chapters, links # --- notes, chapters, links
notes = collect_notes(docs, opts.footnotes, diag) notes = collect_notes(docs, opts.footnotes, diag)
diag.step(f"{len(notes.notes)} Anmerkungen erkannt") diag.step(f"{len(notes.notes)} Anmerkungen erkannt")
chapters = ChapterBuilder(opts.chapter_level, package.toc, diag).build(docs) chapters = ChapterBuilder(opts.chapter_level, package.toc, diag, md.title, md.authors).build(docs)
_assign_filenames(chapters, opts.slug_language) _assign_filenames(chapters, opts.slug_language)
readme_path = "README.md" if opts.toc else None readme_path = "README.md" if opts.toc else None
resolver = LinkResolver(docs, chapters, notes, readme_path) resolver = LinkResolver(docs, chapters, notes, readme_path)

View file

@ -133,3 +133,22 @@ def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert):
assert "--chapter-level 2" in hints[0]["message"] and "3 Unterabschnitte" in hints[0]["message"] assert "--chapter-level 2" in hints[0]["message"] and "3 Unterabschnitte" in hints[0]["message"]
small = convert(Book(docs=[Doc("t.xhtml", "<h1>Teil</h1><h2>A</h2><p>x</p><h2>B</h2><p>y</p>")])) small = convert(Book(docs=[Doc("t.xhtml", "<h1>Teil</h1><h2>A</h2><p>x</p><h2>B</h2><p>y</p>")]))
assert not any(w["code"] == "chapter-large" for w in small.report["warnings"]) assert not any(w["code"] == "chapter-large" for w in small.report["warnings"])
def test_titles_derived_from_content_when_no_other_source(convert):
docs = [
Doc("ccover.xhtml", '<div><img src="c.png" alt=""/></div>'),
Doc("cinner.xhtml", "<p>Erika Musterfrau</p><p>Testbuch</p><p>Roman</p>"),
Doc("cimp.xhtml", "<p>© 2024 Testverlag</p><p>Das Werk ist urheberrechtlich geschützt.</p>"),
Doc("cwid.xhtml", "<p>Für Anna, die immer an mich geglaubt hat, und für alle anderen.</p>"),
Doc("c1.xhtml", "<h1>Eins</h1><p>Text</p>"),
]
from conftest import PNG
run = convert(Book(docs=docs, nav=[("Eins", "c1.xhtml")], media={"c.png": PNG}))
names = [p.name for p in run.chapters()]
assert names == ["001-cover.md", "002-titelseite.md", "003-impressum.md",
"004-fuer-anna-die-immer-an-mich-geglaubt-hat.md", "005-eins.md"]
types = {c["file"]: c["types"] for c in run.report["chapters"]}
assert "cover" in types["chapters/001-cover.md"]
assert "titlepage" in types["chapters/002-titelseite.md"]
assert "colophon" in types["chapters/003-impressum.md"]