diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 6b942ec..63aa171 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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 diff --git a/README.md b/README.md index 92d572f..71be0ab 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index a053fb8..d397e10 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -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: + """

with nothing but (non-breaking) spaces or
. Empty

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): diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index e0dd2fa..df4770e 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -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"], diff --git a/tests/test_styles.py b/tests/test_styles.py index 36100fd..b80f217 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -66,3 +66,24 @@ def test_adjacent_emphasis_is_merged(convert): text = styled(body, convert=convert).chapter("001") assert "2*×π*" in text assert "*xy*" in text and "**ab**" in text and "*c* *d*" in text + + +def test_p4_scene_breaks(convert): + body = ('

K

 

Erster Absatz.

 

' + '

 

Neue Szene.

Weiter.

***

' + '

Nach Ornament.

 

Abschnitt

Text

⁂

Ende.

') + 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 = ('

G

Davor.

»Erste Zeile

zweite Zeile

' + '

 

Zweite Strophe«

Danach.

') + 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