feat: Szenenwechsel und Gedichte übernehmen (P4)

- Ein leerer Absatz (nur Leerzeichen/br) zwischen zwei Textabsätzen wird
  zu '* * *'; mehrere ergeben einen Wechsel.
- Ornament-Absätze (***, ⁂ …) werden ebenfalls zu '* * *'.
- Leere divs (z. B. Springer ClearBoth) und reine Ankerabsätze zählen nicht.
- Gedichte (poem/verse/gedicht, z3998:poem) werden zum Zitatblock mit einer
  Zeile pro Vers und Leerzeile zwischen Strophen.
Roman: 239 Szenenwechsel und 2 Gedichte (vorher verloren bzw. als lose
Absätze); die Sachbücher sind praktisch unverändert. Bericht: layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-06 04:57:03 +02:00
commit 9b0f060af2
5 changed files with 125 additions and 0 deletions

View file

@ -147,6 +147,12 @@ empfiehlt die passende Einstellung. Eine Datei pro **Kapitel** bekommst du so:
python3 epub2md.py roman.epub output/roman --chapter-level 2 python3 epub2md.py roman.epub output/roman --chapter-level 2
``` ```
### Roman (Szenenwechsel, Gedichte)
Szenenwechsel, die im Buch nur als Leerzeile zwischen zwei Absätzen erscheinen, werden als `* * *`
übernommen; sonst würden zwei Szenen nahtlos ineinanderlaufen. Gedichte und Liedtexte bleiben Zeile für
Zeile erhalten (als eingerückter Zitatblock, Strophen durch Leerzeile getrennt).
### Programmierbuch ### Programmierbuch
```bash ```bash

View file

@ -243,6 +243,13 @@ weil CommonMark keine `[^…]`-Syntax kennt.
CSS-Dateien des Buches aus: Klassen-, Element- und ID-Selektoren, Nachfahren- und Kind-Kombinatoren, CSS-Dateien des Buches aus: Klassen-, Element- und ID-Selektoren, Nachfahren- und Kind-Kombinatoren,
Spezifität und `style`-Attribute. `@media`-Blöcke und Selektoren mit Pseudoklassen werden ignoriert. Spezifität und `style`-Attribute. `@media`-Blöcke und Selektoren mit Pseudoklassen werden ignoriert.
Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst (`*x**y*` → `*xy*`). Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst (`*x**y*` → `*xy*`).
- **Szenenwechsel und Verse:**
- Leere Absätze zwischen zwei Textabsätzen werden zu `* * *`; mehrere hintereinander ergeben einen
Wechsel. Ebenso werden Ornament-Absätze wie `***` oder `⁂` zu `* * *`.
- Leere `div`s und Absätze, die nur Anker enthalten, gelten als Layout und nicht als Wechsel.
- Gedichte (`poem`, `verse`, `gedicht` … bzw. `z3998:poem`) werden zum Zitatblock mit einer Zeile pro Vers
und einer Leerzeile zwischen Strophen.
- Anzahl im Bericht unter `layout`.
- **Gescannte Texte:** nur Bilder, kein Text (keine OCR). - **Gescannte Texte:** nur Bilder, kein Text (keine OCR).
- **DRM:** wird erkannt und abgelehnt. - **DRM:** wird erkannt und abgelehnt.
- **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne - **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne

View file

@ -195,6 +195,8 @@ def _collapse_blank_lines(text: str) -> str:
return "\n".join(out) return "\n".join(out)
ORNAMENT = re.compile(r"(?:[*∗⁂❖✻✱✳◆◇•·~][ \t]*){1,9}")
POEM_CLASS = re.compile(r"(?:^|[-_])(poem|poetry|verse|vers|lyrics|stanza|gedicht|lied|song)(?:$|[-_\d])")
ITALIC_TAGS = {"em", "i", "cite", "dfn", "var"} ITALIC_TAGS = {"em", "i", "cite", "dfn", "var"}
BOLD_TAGS = {"strong", "b"} BOLD_TAGS = {"strong", "b"}
CSS_SKIP_TAGS = {"br", "wbr", "img", "svg", "image", "math", "code", "samp", "tt", "kbd", "pre"} CSS_SKIP_TAGS = {"br", "wbr", "img", "svg", "image", "math", "code", "samp", "tt", "kbd", "pre"}
@ -263,6 +265,8 @@ class Renderer:
stylesheet: StyleSheet | None = None) -> None: stylesheet: StyleSheet | None = None) -> None:
self.entry_problem = entry_problem or (lambda path: None) self.entry_problem = entry_problem or (lambda path: None)
self.css = stylesheet self.css = stylesheet
self.scene_breaks = 0
self.poems = 0
self.css_applied: Counter[str] = Counter() self.css_applied: Counter[str] = Counter()
self.hidden_elements = 0 self.hidden_elements = 0
self.invisible_removed = 0 self.invisible_removed = 0
@ -398,6 +402,13 @@ class Renderer:
name = local(el.tag) name = local(el.tag)
if self._hidden(el): if self._hidden(el):
return [] return []
if name in ("p", "div") and not self.in_table:
if self._is_ornament(el) or self._is_scene_gap(el):
self.scene_breaks += 1
anchor = self.anchors_of(el, subtree=True)
return ([anchor] if anchor else []) + ["* * *"]
if name in ("div", "section", "blockquote") and self._is_poem(el):
return self.poem(el)
ch = self.ch ch = self.ch
if ch is not None and el is ch.title_el and not heading_level(el): if ch is not None and el is ch.title_el and not heading_level(el):
return self.heading(el, forced_level=1) return self.heading(el, forced_level=1)
@ -432,6 +443,85 @@ class Renderer:
return [self.clean_html(el, keep_root=True)] return [self.clean_html(el, keep_root=True)]
return self._flow_with_anchor(el) return self._flow_with_anchor(el)
# ------------------------------------------------------------------ scene breaks and verse (P4)
@staticmethod
def _is_empty(el: Any) -> bool:
return not text_content(el).replace("\xa0", " ").strip() and \
not any(local(e.tag) in ("img", "svg", "math", "image", "hr") for e in iter_elements(el))
def _is_blank_p(self, el: Any) -> bool:
"""<p> with nothing but (non-breaking) spaces or <br>. Empty <div>s are layout helpers (Springer's
div.ClearBoth) and paragraphs holding only anchors are index targets – neither is a scene break."""
return local(el.tag) == "p" and self._is_empty(el) and \
all(local(c.tag) == "br" for c in el if is_element(c))
def _is_prose(self, el: Any) -> bool:
return local(el.tag) in ("p", "div") and not self._is_empty(el) and not self._is_ornament(el) \
and not self._is_poem(el)
@staticmethod
def _element_siblings(el: Any, forward: bool) -> Any:
node = el.getnext() if forward else el.getprevious()
while node is not None:
if is_element(node):
yield node
node = node.getnext() if forward else node.getprevious()
def _is_ornament(self, el: Any) -> bool:
"""Paragraph that only contains a typographic separator such as *** or ⁂."""
return bool(ORNAMENT.fullmatch(text_content(el).replace("\xa0", " ").strip()))
def _is_scene_gap(self, el: Any) -> bool:
"""Empty paragraph between two prose paragraphs (first of a run of empties) → scene break."""
if not self._is_blank_p(el):
return False
prev = next(self._element_siblings(el, forward=False), None)
if prev is None or not self._is_prose(prev):
return False
for nxt in self._element_siblings(el, forward=True):
if self._is_blank_p(nxt):
continue # further empty paragraphs of the same gap
return self._is_prose(nxt)
return False
def _is_poem(self, el: Any) -> bool:
types = set(epub_types(el))
return bool(types & {"z3998:poem", "z3998:verse", "z3998:song", "z3998:lyrics"}) or \
any(POEM_CLASS.search(c) for c in classes(el))
def poem(self, el: Any) -> list[str]:
"""Verse: one quoted line per verse line, stanzas separated by an empty quote line."""
anchor = self.anchors_of(el)
stanzas: list[list[str]] = [[]]
def new_stanza() -> None:
if stanzas[-1]:
stanzas.append([])
def walk(container: Any) -> None:
for child in container:
if not is_element(child) or self._hidden(child):
continue
if local(child.tag) in ("div", "section") and any(
is_element(c) and local(c.tag) in ("p", "div", "span") for c in child):
new_stanza() # stanza container
walk(child)
new_stanza()
elif self._is_empty(child):
new_stanza()
else:
line = join_inline([("r", self.anchors_of(child))] + self.inline_children(child))
stanzas[-1].append(escape_line_starts(line).replace("\n", "\n> "))
walk(el)
stanzas = [s for s in stanzas if s]
if not stanzas:
return self._flow_with_anchor(el)
self.poems += 1
text = "\n>\n".join("\\\n".join(f"> {line}" for line in stanza) for stanza in stanzas)
return ([anchor] if anchor else []) + [text]
def _flow_with_anchor(self, el: Any) -> list[str]: def _flow_with_anchor(self, el: Any) -> list[str]:
anchor = self.anchors_of(el) anchor = self.anchors_of(el)
if self.ch is not None and {"index", "index-persons", "glossary"} & set(self.ch.types): if self.ch is not None and {"index", "index-persons", "glossary"} & set(self.ch.types):

View file

@ -517,6 +517,7 @@ def _report(input_path: Path, sha: str, generated_at: str, package: Any, opts: O
"text_cleanup": {"invisible_chars_removed": renderer.invisible_removed}, "text_cleanup": {"invisible_chars_removed": renderer.invisible_removed},
"css": {"rules": len(renderer.css.rules) if renderer.css else 0, "applied": dict(renderer.css_applied), "css": {"rules": len(renderer.css.rules) if renderer.css else 0, "applied": dict(renderer.css_applied),
"hidden_elements": renderer.hidden_elements}, "hidden_elements": renderer.hidden_elements},
"layout": {"scene_breaks": renderer.scene_breaks, "poems": renderer.poems},
"warnings": warnings, "warnings": warnings,
"errors": errors, "errors": errors,
"infos": [d.as_dict() for d in diag.items if d.level == "info"], "infos": [d.as_dict() for d in diag.items if d.level == "info"],

View file

@ -66,3 +66,24 @@ def test_adjacent_emphasis_is_merged(convert):
text = styled(body, convert=convert).chapter("001") text = styled(body, convert=convert).chapter("001")
assert "<sup>2</sup>*×π*" in text assert "<sup>2</sup>*×π*" in text
assert "*xy*" in text and "**ab**" in text and "*c* *d*" in text assert "*xy*" in text and "**ab**" in text and "*c* *d*" in text
def test_p4_scene_breaks(convert):
body = ('<h1>K</h1><p class="leerzeile">&#160;</p><p>Erster Absatz.</p><p class="leerzeile">&#160;</p>'
'<p class="leerzeile">&#160;</p><p>Neue Szene.</p><p>Weiter.</p><p class="centertext">***</p>'
'<p>Nach Ornament.</p><p>&#160;</p><h2>Abschnitt</h2><p>Text</p><p>⁂</p><p>Ende.</p>')
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
body_md = text.split("# K", 1)[1]
assert "Erster Absatz.\n\n* * *\n\nNeue Szene." in body_md
assert body_md.count("* * *") == 3 # two empties collapse into one; ***, ⁂ are ornaments; none before the heading
assert "Weiter.\n\n* * *\n\nNach Ornament." in body_md
assert "\\*" not in body_md
assert body_md.strip().startswith("Erster Absatz.") # no break at chapter start
def test_p4_poems_keep_lines_and_stanzas(convert):
body = ('<h1>G</h1><p>Davor.</p><div class="poem"><p>»Erste Zeile</p><p>zweite Zeile</p>'
'<p class="leerzeile">&#160;</p><p>Zweite Strophe«</p></div><p>Danach.</p>')
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
assert "> »Erste Zeile\\\n> zweite Zeile\n>\n> Zweite Strophe«" in text
assert "* * *" not in text