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, `<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 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 <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 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", "<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"]