epub2md/epub2md/chapters.py
dschlueter 86aa93c12c feat: Buchanfang Cover → Titel → Impressum → Inhaltsverzeichnis für alle Bücher
- Kapitelerkennung: erstes Kapitel nur aus einem Bild = Cover ("[Titelinfo]",
  "Deckblatt"), Titel "Titel" = Titelseite
- epub2md_merge: Vorspann reicht bis vor den Hauptteil (Teil, Vorwort,
  Einleitung, nummerierter Titel, > 1000 Wörter), kurze Zwischenseiten
  (Auflage, Verlagsbrief, "Über dieses Buch") bleiben davor
- book.lua: Cover am Inhalt erkannt; LaTeX-Weg (Filter in beiden Schritten):
  \tableofcontents hinter dem Vorspann, \clearpage je Kapitel, kein \maketitle
  vor dem Cover, A4, seitengroßes Cover, DejaVu-Schriften und Mathe-Symbole
  für fehlende Glyphen (Mathebuch: >10.000 fehlende Zeichen → 0)
- Buch-README: bei Formeln LaTeX-Weg empfohlen, sonst weasyprint
- Alle sechs Beispielbücher geprüft: Seite 1 Cover, Verzeichnis nach dem Vorspann

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:48:18 +02:00

427 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Splitting the spine documents into chapter units."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
from .diagnostics import Diagnostics
from .models import Chapter, NavEntry
from .normalize import Doc, has_content
from .utils import (
classes,
epub_types,
is_element,
iter_elements,
local,
norm_ws,
remove_invisible,
roles,
text_content,
)
HEADINGS = {"h1": 1, "h2": 2, "h3": 3, "h4": 4, "h5": 5, "h6": 6}
CONTAINERS = {"body", "div", "section", "article", "aside", "main", "header", "footer", "nav", "span", "hgroup"}
# epub:type / role values that start a new chapter file
SPLIT_TYPES = {
"cover", "titlepage", "halftitlepage", "copyright-page", "dedication", "epigraph", "foreword",
"preface", "prologue", "introduction", "chapter", "part", "conclusion", "epilogue", "afterword",
"appendix", "glossary", "bibliography", "index", "endnotes", "rearnotes", "acknowledgments",
"colophon", "imprint", "errata", "contributors",
}
KIND_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("index-persons", re.compile(r"^(personenregister|namensregister|personenverzeichnis|namensverzeichnis|index of (names|persons))", re.I)),
("index", re.compile(r"^(sachregister|stichwortverzeichnis|register|index|schlagwortverzeichnis|sachverzeichnis)\b", re.I)),
("glossary", re.compile(r"^(glossar|glossary|begriffserkl)", re.I)),
("bibliography", re.compile(r"^(literatur|bibliogra|quellen|references|weiterführende literatur|quellenverzeichnis|literaturverzeichnis)", re.I)),
("endnotes", re.compile(r"^(anmerkungen|endnoten|notes|fußnoten)\s*$", re.I)),
("toc", re.compile(r"^(inhalt|inhaltsverzeichnis|contents|table of contents)\s*$", re.I)),
("preface", re.compile(r"^(vorwort|preface|geleitwort)", re.I)),
("foreword", re.compile(r"^(foreword)", re.I)),
("acknowledgments", re.compile(r"^(danksagung|dank|acknowledg)", re.I)),
("introduction", re.compile(r"^(einleitung|einführung|introduction)\s*$", re.I)),
("appendix", re.compile(r"^(anhang|appendix)", re.I)),
("colophon", re.compile(r"^(impressum|colophon|kolophon)", re.I)),
("dedication", re.compile(r"^(widmung|dedication)", re.I)),
("cover", re.compile(r"^(cover|umschlag|titelbild|deckblatt)\b", re.I)),
("titlepage", re.compile(r"^(titelei|titelseite|title ?page|titel\s*$|title\s*$)", re.I)),
("part", re.compile(r"^(teil|part)\s+([IVXLC]+|\d+|[a-zäöü]+)\b", re.I)),
("epilogue", re.compile(r"^(epilog|nachwort|epilogue|afterword)", re.I)),
("prologue", re.compile(r"^(prolog|prologue)", re.I)),
]
TITLE_CLASS = re.compile(r"^(?!sub).*(heading|title)$")
# Headings below --chapter-level that still get their own file (exact names only, no prefixes)
SPECIAL_HEADING = re.compile(
r"^(literatur(verzeichnis|hinweise|angaben)?|weiterführende literatur|quellen(verzeichnis|angaben|nachweise?)?|"
r"bibliogra(fie|phie)|references|bibliography|glossar|glossary|stichwortverzeichnis|sachregister|"
r"sachverzeichnis|register|index|personenregister|namensregister|personenverzeichnis|namensverzeichnis)$",
re.I,
)
SPECIAL_KINDS = {"bibliography", "glossary", "index", "index-persons"}
NUMBER_PREFIX = re.compile(r"^\s*(?:(?:kapitel|chapter)\s+)?(?:\d+(?:\.\d+)*|[IVXLC]+)\.?\s+", re.I)
@dataclass
class Marker:
"""Start of a new chapter in the flattened item stream."""
element: Any
kind: str # "heading" | "semantic"
level: int = 0
types: list[str] = field(default_factory=list)
unwrapped: bool = False # semantic container that itself contains chapter headings
@dataclass
class IdCarrier:
"""Unwrapped container whose ids must still be emitted as anchors."""
element: Any
@dataclass
class DocStart:
doc: Doc
def heading_level(el: Any) -> int:
return HEADINGS.get(local(el.tag), 0) if is_element(el) else 0
def classify_title(title: str) -> str:
t = NUMBER_PREFIX.sub("", norm_ws(title))
for kind, pattern in KIND_PATTERNS:
if pattern.match(t) or (kind == "part" and pattern.match(norm_ws(title))):
return kind
return ""
def semantic_types(el: Any) -> list[str]:
values = epub_types(el) + [r.removeprefix("doc-") for r in roles(el)]
return [v for v in values if v in SPLIT_TYPES]
class ChapterBuilder:
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._special_level = 0 # level of a split-out special heading whose section is still open
self._in_special = False # current chapter itself is a bibliography/index/glossary
self.book_title = book_title
self.author_names = _surnames(authors or [])
self.nav_titles: dict[str, str] = {}
self._index_nav(nav)
def _index_nav(self, entries: list[NavEntry]) -> None:
for e in entries:
if e.path and e.path not in self.nav_titles and e.label:
self.nav_titles[e.path] = e.label
self._index_nav(e.children)
# ------------------------------------------------------------------ split detection
def _split_kind(self, el: Any, update: bool = True) -> Marker | None:
"""Chapter start at ``el``? ``update=False`` only looks ahead without changing the section state."""
lvl = heading_level(el)
if lvl and has_content(el):
special = _special_heading(el)
if lvl <= self.level:
if update:
self._special_level = 0
self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS
return Marker(el, "heading", lvl)
if self._special_level and lvl <= self._special_level:
# a heading of the same or higher level ends the split-out special section
if update:
self._special_level = lvl if special else 0
return Marker(el, "heading", lvl)
if special and not self._in_special:
if update:
self._special_level = lvl
return Marker(el, "heading", lvl)
if local(el.tag) in CONTAINERS | {"body"}:
types = semantic_types(el)
if types and local(el.tag) != "body":
if update:
self._special_level = 0
self._in_special = bool(SPECIAL_KINDS & set(types))
return Marker(el, "semantic", 0, types)
return None
def _contains_split(self, el: Any) -> bool:
for e in iter_elements(el):
if e is not el and self._split_kind(e, update=False):
return True
return False
def flatten(self, el: Any, doc_path: str) -> list[tuple[str, Any]]:
items: list[tuple[str, Any]] = []
if el.text:
items.append((doc_path, el.text))
for child in el:
if is_element(child):
marker = self._split_kind(child)
unwrap = local(child.tag) in CONTAINERS and self._contains_split(child)
if marker:
marker.unwrapped = unwrap
items.append((doc_path, marker))
if unwrap:
items.append((doc_path, IdCarrier(child)))
items.extend(self.flatten(child, doc_path))
else:
items.append((doc_path, child))
if child.tail:
items.append((doc_path, child.tail))
return items
# ------------------------------------------------------------------ building
def build(self, docs: list[Doc]) -> list[Chapter]:
chapters: list[Chapter] = []
pending: list[tuple[str, Any]] = [] # content without own chapter (anchors only)
for doc in docs:
if doc.skipped:
continue
items = self.flatten(doc.body, doc.path)
segments: list[tuple[Marker | None, list[tuple[str, Any]]]] = [(None, [])]
for item in items:
if isinstance(item[1], Marker):
segments.append((item[1], []))
else:
segments[-1][1].append(item)
head_marker, head_items = segments[0]
body_segments = segments[1:]
if _meaningful(head_items):
first = _first_meaningful(head_items)
if chapters and leading_heading_level(first) > self.level and not semantic_types(doc.body):
chapters[-1].items.extend(pending + head_items)
pending = []
if doc.path not in chapters[-1].docs:
chapters[-1].docs.append(doc.path)
else:
ch = self._new_chapter(len(chapters) + 1, None, pending + head_items, doc)
pending = []
chapters.append(ch)
else:
pending.extend(head_items)
for marker, seg_items in body_segments:
assert marker is not None
if marker.kind == "semantic" and (marker.unwrapped or not _meaningful(seg_items)):
# Wrapper such as <div epub:type="chapter"> around the real <h1>: content before the
# heading (context info, epigraph, anchors) belongs to the heading's chapter.
pending.extend(seg_items)
pending.append((doc.path, _TypeCarrier(marker.types)))
continue
ch = self._new_chapter(len(chapters) + 1, marker, pending + seg_items, doc)
pending = []
chapters.append(ch)
if pending:
if chapters:
chapters[-1].items.extend(pending)
elif _meaningful(pending):
chapters.append(self._new_chapter(1, None, pending, docs[0]))
if not chapters:
self.diag.warn("no-content", "Das EPUB enthält keinen verwertbaren Textinhalt.")
previous_main = ""
titles = [c.title for c in chapters]
for ch in chapters:
self._finalize(ch)
special = {"bibliography", "glossary", "index", "endnotes"} & set(ch.types)
if special and titles.count(ch.title) > 1 and previous_main:
ch.title = f"{ch.title} – {previous_main}"
elif not special:
previous_main = ch.title
return chapters
def _new_chapter(self, number: int, marker: Marker | None, items: list[tuple[str, Any]], doc: Doc) -> Chapter:
ch = Chapter(number=number, title="", items=items, docs=[])
for path, _ in items:
if path not in ch.docs:
ch.docs.append(path)
if doc.path not in ch.docs:
ch.docs.append(doc.path)
types: list[str] = [t for t in doc.types if t in SPLIT_TYPES] if ch.docs[0] == doc.path else []
for _, it in items:
if isinstance(it, _TypeCarrier):
types.extend(it.types)
if marker is not None and marker.kind == "heading":
ch.title_el = marker.element
ch.title_level = marker.level
ch.title = norm_ws(_heading_text(marker.element))
elif marker is not None:
types.extend(marker.types)
title_el = _find_title_element(marker.element)
if title_el is not None:
ch.title_el = title_el
ch.title_level = heading_level(title_el) or 1
ch.title = norm_ws(_heading_text(title_el))
if ch.title_el is None:
# an early heading of the doc (deeper than chapter level) can still serve as title
first = _first_meaningful(items)
if heading_level(first):
ch.title_el = first
ch.title_level = heading_level(first)
ch.title = norm_ws(_heading_text(first))
if not ch.title:
nav = self.nav_titles.get(doc.path, "")
if marker is not None: # section inside a document: its type says more than the document's nav label
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]):
types.append("auxiliary")
ch.types = list(dict.fromkeys(types))
ch.title = remove_invisible(ch.title)[0] # titles feed file names, frontmatter and README
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:
ch.types.append(kind)
if ch.number == 1 and "cover" not in ch.types and _image_only(ch.items):
ch.types.append("cover") # first chapter with nothing but a picture, whatever its label ('[Titelinfo]')
if "index" in ch.types and classify_title(ch.title) == "index-persons":
ch.types.remove("index")
if not ch.types:
ch.types.append("chapter")
def _image_only(items: list[tuple[str, Any]]) -> bool:
"""Content is one or more pictures without any text."""
text = "".join(it if isinstance(it, str) else text_content(it) for _, it in items if isinstance(it, str) or is_element(it))
return not text.strip() and any(is_element(it) and any(local(e.tag) in ("img", "svg", "image") for e in iter_elements(it))
for _, it in items)
@dataclass
class _TypeCarrier:
types: list[str]
def _special_heading(el: Any) -> bool:
title = NUMBER_PREFIX.sub("", norm_ws(_heading_text(el))).strip(" :.")
return bool(SPECIAL_HEADING.match(title))
def leading_heading_level(item: Any) -> int:
"""Heading level of ``item`` or of the first meaningful descendant inside wrapper containers."""
node = item
for _ in range(8):
if not is_element(node):
return 0
lvl = heading_level(node)
if lvl:
return lvl
if local(node.tag) not in CONTAINERS:
return 0
if node.text and node.text.strip():
return 0
nxt = None
for child in node:
if is_element(child) and has_content(child):
nxt = child
break
if child.tail and child.tail.strip():
return 0
node = nxt
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
def _first_meaningful(items: list[tuple[str, Any]]) -> Any:
for _, it in items:
if isinstance(it, (Marker, IdCarrier, DocStart, _TypeCarrier)):
continue
if isinstance(it, str):
if it.strip():
return it
continue
if has_content(it):
return it
return None
def _heading_text(el: Any) -> str:
parts = [el.text or ""]
for child in el:
if is_element(child):
parts.append(" " if local(child.tag) == "br" else _heading_text(child))
parts.append(child.tail or "")
return "".join(parts)
def _find_title_element(el: Any) -> Any:
for e in iter_elements(el):
if e is el:
continue
if heading_level(e):
return e
if any(TITLE_CLASS.search(c) for c in classes(e)) and has_content(e):
# only title-like wrappers without block children
if not any(is_element(c) and local(c.tag) in ("p", "div", "table", "ul", "ol") and has_content(c) for c in e):
return e
if local(e.tag) in ("p", "table", "ul", "ol", "dl", "pre") and has_content(e):
return None
return None
_TYPE_LABELS = {
"cover": "Cover", "titlepage": "Titelei", "copyright-page": "Impressum", "colophon": "Impressum",
"dedication": "Widmung", "epigraph": "Motto", "preface": "Vorwort", "foreword": "Geleitwort",
"acknowledgments": "Danksagung", "glossary": "Glossar", "bibliography": "Literatur",
"index": "Register", "endnotes": "Anmerkungen", "appendix": "Anhang", "toc": "Inhalt",
}
def _type_label(types: list[str]) -> str:
for t in types:
if t in _TYPE_LABELS:
return _TYPE_LABELS[t]
return ""
def text_of(el: Any) -> str:
return norm_ws(text_content(el))