- 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>
427 lines
18 KiB
Python
427 lines
18 KiB
Python
"""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))
|