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:
parent
6db834addd
commit
3ac5e1a8cc
5 changed files with 70 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
11
README.md
11
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, `<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue