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?**
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`)?**
Manche E-Book-Seiten (Titelseite, Impressum) haben weder Überschrift noch Titel. Dann wird der
interne Dateiname verwendet.
**Warum heißen manche Kapiteldateien „Titelseite“, „Impressum“ oder nach ihren ersten Wörtern?**
Manche E-Book-Seiten haben weder Überschrift noch Titel. Das Programm erkennt sie dann am Inhalt.
Gelingt das nicht, werden die ersten Wörter der Seite verwendet, nur im Notfall der interne Dateiname.
**Warum ist ein Codebeispiel falsch eingerückt?**
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
`1.2`), wird an das laufende Kapitel angehängt.
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).
Links darauf zeigen auf `README.md`.
6. Überschriftenebenen werden je Datei verschoben: Der Kapiteltitel ist `#`, alle weiteren beginnen bei `##`.
@ -336,8 +341,8 @@ Verlustfreiheit behauptet (`lossless: false`).
## Bekannte Einschränkungen
- Überschriftslose Dokumente ohne NAV-Eintrag und ohne `<title>` werden nach dem Dateinamen benannt
(z. B. `cinnertitle`).
- Titel überschriftsloser Seiten werden aus dem Inhalt abgeleitet (Cover, Titelseite, Impressum, erste
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
zusammen (z. B. Autor- und Titelzeilen in Springer-Kapitelköpfen).
- 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:
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.diag = diag
self.book_title = book_title
self.author_names = _surnames(authors or [])
self.nav_titles: dict[str, str] = {}
self._index_nav(nav)
@ -230,6 +233,10 @@ class ChapterBuilder:
ch.title = _type_label(types) or nav or doc.title
else:
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.synthetic_title = True
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))
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:
kind = classify_title(ch.title)
if kind and kind not in ch.types:
@ -276,6 +303,17 @@ def leading_heading_level(item: Any) -> int:
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:
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 = collect_notes(docs, opts.footnotes, diag)
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)
readme_path = "README.md" if opts.toc else None
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"]
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"])
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"]