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:
parent
5f7c352df1
commit
9b0f060af2
5 changed files with 125 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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"> </p><p>Erster Absatz.</p><p class="leerzeile"> </p>'
|
||||||
|
'<p class="leerzeile"> </p><p>Neue Szene.</p><p>Weiter.</p><p class="centertext">***</p>'
|
||||||
|
'<p>Nach Ornament.</p><p> </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"> </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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue