diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md
index 54dccfd..07f9cec 100644
--- a/BEDIENUNGSANLEITUNG.md
+++ b/BEDIENUNGSANLEITUNG.md
@@ -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
diff --git a/README.md b/README.md
index aadaa0f..fa95893 100644
--- a/README.md
+++ b/README.md
@@ -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, `
`, `epub:type` oder dem Dateinamen.
+ NAV/NCX, `` 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 `` 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
diff --git a/epub2md/chapters.py b/epub2md/chapters.py
index 89472ab..e9a767a 100644
--- a/epub2md/chapters.py
+++ b/epub2md/chapters.py
@@ -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 : 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
diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py
index ec5574e..1316ebf 100644
--- a/epub2md/pipeline.py
+++ b/epub2md/pipeline.py
@@ -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)
diff --git a/tests/test_chapters.py b/tests/test_chapters.py
index 2e12493..f123df0 100644
--- a/tests/test_chapters.py
+++ b/tests/test_chapters.py
@@ -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", "Teil
A
x
B
y
")]))
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", ''),
+ Doc("cinner.xhtml", "Erika Musterfrau
Testbuch
Roman
"),
+ Doc("cimp.xhtml", "© 2024 Testverlag
Das Werk ist urheberrechtlich geschützt.
"),
+ Doc("cwid.xhtml", "Für Anna, die immer an mich geglaubt hat, und für alle anderen.
"),
+ Doc("c1.xhtml", "Eins
Text
"),
+ ]
+ 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"]