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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -243,6 +243,13 @@ weil CommonMark keine `[^…]`-Syntax kennt.
|
|||
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.
|
||||
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).
|
||||
- **DRM:** wird erkannt und abgelehnt.
|
||||
- **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne
|
||||
|
|
|
|||
|
|
@ -195,6 +195,8 @@ def _collapse_blank_lines(text: str) -> str:
|
|||
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"}
|
||||
BOLD_TAGS = {"strong", "b"}
|
||||
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:
|
||||
self.entry_problem = entry_problem or (lambda path: None)
|
||||
self.css = stylesheet
|
||||
self.scene_breaks = 0
|
||||
self.poems = 0
|
||||
self.css_applied: Counter[str] = Counter()
|
||||
self.hidden_elements = 0
|
||||
self.invisible_removed = 0
|
||||
|
|
@ -398,6 +402,13 @@ class Renderer:
|
|||
name = local(el.tag)
|
||||
if self._hidden(el):
|
||||
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
|
||||
if ch is not None and el is ch.title_el and not heading_level(el):
|
||||
return self.heading(el, forced_level=1)
|
||||
|
|
@ -432,6 +443,85 @@ class Renderer:
|
|||
return [self.clean_html(el, keep_root=True)]
|
||||
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]:
|
||||
anchor = self.anchors_of(el)
|
||||
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},
|
||||
"css": {"rules": len(renderer.css.rules) if renderer.css else 0, "applied": dict(renderer.css_applied),
|
||||
"hidden_elements": renderer.hidden_elements},
|
||||
"layout": {"scene_breaks": renderer.scene_breaks, "poems": renderer.poems},
|
||||
"warnings": warnings,
|
||||
"errors": errors,
|
||||
"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")
|
||||
assert "<sup>2</sup>*×π*" 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