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?**
|
**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
|
||||||
|
|
|
||||||
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue