From 6db834adddb98cbca40df553f4ac1bae9046bb0c Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 5 Oct 2026 20:45:16 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20Druck-Umbruchzeichen=20(=E2=86=AA/?= =?UTF-8?q?=E2=9E=A5)=20in=20Codebl=C3=B6cken=20aufl=C3=B6sen=20(QA=20R6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fortsetzungszeilen werden wieder mit genau einem Leerzeichen zur ursprünglichen Codezeile zusammengefügt. Im Python-Beispielbuch sind das 195 Stellen; danach lassen sich 247 statt 203 von 255 Python-Blöcken parsen. Abschaltbar mit --keep-wrap-markers; der Bericht enthält wrap_lines_joined pro Block und einen Hinweis pro Kapitel. Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 11 ++++++++--- README.md | 5 +++-- epub2md/cli.py | 4 ++++ epub2md/codeblocks.py | 21 +++++++++++++++++++++ epub2md/html_converter.py | 7 ++++++- epub2md/pipeline.py | 10 +++++++++- tests/test_codeblocks.py | 17 +++++++++++++++++ 7 files changed, 68 insertions(+), 7 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 89fdebd..54dccfd 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -164,6 +164,10 @@ python3 epub2md.py python-buch.epub output/python-buch --code-language python Das hilft Editoren beim Einfärben des Codes. Nachteil: Auch Programmausgaben und Shell-Befehle im Buch werden dann als Python markiert. +Zu lange Codezeilen sind im gedruckten Buch oft umbrochen und die Fortsetzung mit `↪` markiert. Das +Programm fügt solche Zeilen automatisch wieder zusammen, damit der Code lauffähig ist. Wer die +Druckfassung unverändert haben möchte, gibt `--keep-wrap-markers` an. + ### Mathematikbuch ```bash @@ -313,9 +317,9 @@ Nein, es wird nur gelesen. Mit `--copy-original` wird zusätzlich eine Kopie in Manche E-Book-Seiten (Titelseite, Impressum) haben weder Überschrift noch Titel. Dann wird der interne Dateiname verwendet. -**Warum steht in einem Codebeispiel ein `↪` oder ist es falsch eingerückt?** -So steht es im E-Book selbst (Umbruchzeichen bzw. Satzfehler des Verlags). Der Code wird bewusst -zeichengetreu übernommen. +**Warum ist ein Codebeispiel falsch eingerückt?** +So steht es im E-Book selbst (Satzfehler des Verlags). Der Code wird bewusst zeichengetreu +übernommen. Nur die Druck-Umbruchzeichen `↪` werden aufgelöst (abschaltbar mit `--keep-wrap-markers`). **Können Dateinamen Umlaute enthalten?** Standardmäßig werden Umlaute umschrieben (`ä` → `ae`). Mit `--slug-language none` bleiben sie erhalten, @@ -345,6 +349,7 @@ python3 epub2md_merge.py --help | `--force` | frühere Ausgabe ersetzen | | `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) | | `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache | +| `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen | | `--math auto/tex/mathml/image` | Formeldarstellung | | `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung | | `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme | diff --git a/README.md b/README.md index a460f63..aadaa0f 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json | `--images {relative,flat,original-path}` | `relative` | `relative`: `media/images/0001-name.ext`; `flat`: `media/name.ext`; `original-path`: `media/` | | `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt | | `--code-language SPRACHE` | – | Sprachlabel für Codeblöcke ohne erkannte Sprache (z. B. `python`, `c`; Aliase wie `py` erlaubt). Erkannte Labels haben Vorrang; im Bericht `detected_by: "option"` | +| `--keep-wrap-markers` | aus | Druck-Umbruchzeichen `↪`/`➥` in Codeblöcken stehen lassen. Standard: Fortsetzungszeilen werden wieder zur ursprünglichen Codezeile zusammengefügt (Anzahl im Bericht: `wrap_lines_joined`) | | `--keep-html` | aus | Unbekannte/nicht sicher konvertierbare Elemente als bereinigtes HTML erhalten (sonst: Text übernommen + Eintrag in `diagnostics/unsupported-elements.md`) | | `--strict` | aus | Abbruch bei kritischen Fehlern (fehlende/unlesbare Spine-Dokumente, unsichere ZIP-Pfade, ungültige Spine-Verweise) | | `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) | @@ -339,8 +340,8 @@ Verlustfreiheit behauptet (`lossless: false`). (z. B. `cinnertitle`). - Aneinanderstoßende Inline-Elemente, zwischen denen auch in der Quelle kein Leerraum steht, laufen zusammen (z. B. Autor- und Titelzeilen in Springer-Kapitelköpfen). -- Druck-Fortsetzungszeichen (`↪`) in Listings und fehlerhafte Einrückungen der Vorlage werden - zeichengetreu übernommen. +- Fehlerhafte Einrückungen der Vorlage werden zeichengetreu übernommen. Druck-Fortsetzungszeichen + (`↪`/`➥`) am Zeilenanfang werden dagegen standardmäßig aufgelöst (siehe `--keep-wrap-markers`). - Formelbilder ohne TeX-Alternativtext bleiben Bilder. - Audio/Video sind implementiert, aber nur mit synthetischen Daten geprüft. Für Bildbände gibt es bislang keine Testbücher. diff --git a/epub2md/cli.py b/epub2md/cli.py index e6defcb..fbcb2e5 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -82,6 +82,9 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--code-language", type=code_language, default=None, metavar="SPRACHE", help="Sprachlabel für Codeblöcke ohne erkannte Sprache, z. B. python oder c " "(erkannte Labels haben Vorrang; Standard: kein Label)") + p.add_argument("--keep-wrap-markers", action="store_true", + help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte " + "Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt") p.add_argument("--strict", action="store_true", help="Bei kritischen Fehlern (fehlende Spine-Dateien, unsichere Pfade, kaputtes XHTML) abbrechen") p.add_argument("--force", action="store_true", @@ -114,6 +117,7 @@ def main(argv: list[str] | None = None) -> int: slug_language=args.slug_language, keep_html=args.keep_html, code_language=args.code_language or "", + keep_wrap_markers=args.keep_wrap_markers, strict=args.strict, verbose=args.verbose, dry_run=args.dry_run, diff --git a/epub2md/codeblocks.py b/epub2md/codeblocks.py index 65c3b13..0fcc883 100644 --- a/epub2md/codeblocks.py +++ b/epub2md/codeblocks.py @@ -180,6 +180,27 @@ def detect_language_from_content(code: str) -> str: return "" +WRAP_MARKER = re.compile(r"^[ \t]*[↪➥][ \t]?") + + +def join_wrap_markers(code: str) -> tuple[str, int]: + """Undo print line wrapping: a line starting with ↪/➥ continues the previous line. + + Publishers wrap over-long code lines at a space and mark the continuation; the logical line is + restored with exactly one space. Returns (code, number of joined lines). + """ + out: list[str] = [] + joined = 0 + for line in code.split("\n"): + m = WRAP_MARKER.match(line) + if m and out: + out[-1] = out[-1].rstrip(" \t") + " " + line[m.end():].lstrip(" \t") + joined += 1 + else: + out.append(line) + return "\n".join(out), joined + + def fence_for(code: str) -> str: longest = max((len(m) for m in re.findall(r"`+", code)), default=0) return "`" * max(3, longest + 1) diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index 7490a17..b007651 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -24,6 +24,7 @@ from .codeblocks import ( fence_for, inline_code, is_block_code_container, + join_wrap_markers, is_inline_code, ) from .diagnostics import Diagnostics @@ -90,6 +91,7 @@ class RenderOptions: keep_html: bool = False language: str = "de" code_language: str = "" # --code-language: label for blocks without a detected language + keep_wrap_markers: bool = False # --keep-wrap-markers: do not join ↪-continued code lines def md_escape(text: str, table: bool = False) -> str: @@ -418,6 +420,9 @@ class Renderer: out = [anchors] if anchors else [] if not code.strip(): return out + joined = 0 + if not self.opt.keep_wrap_markers: + code, joined = join_wrap_markers(code) lang, how = detect_language(el) if not lang: lang = detect_language_from_content(code) @@ -429,7 +434,7 @@ class Renderer: if not lang: self.ch.stats.code_blocks_unlabeled += 1 self.code_blocks.append({"source": self._source(el), "chapter": self.file, "language": lang, - "detected_by": how, "lines": code.count("\n") + 1}) + "detected_by": how, "lines": code.count("\n") + 1, "wrap_lines_joined": joined}) fence = fence_for(code) out.append(f"{fence}{lang}\n{code}\n{fence}") return out diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index 1bc1454..ec5574e 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -46,6 +46,7 @@ class Options: slug_language: str = "de" keep_html: bool = False code_language: str = "" + keep_wrap_markers: bool = False strict: bool = False verbose: bool = False dry_run: bool = False @@ -183,7 +184,7 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio renderer = Renderer( RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math, extract_media=opts.extract_media, keep_html=opts.keep_html, language=language, - code_language=opts.code_language), + code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers), resolver, media, notes, diag, set(reader.names), ) width = max(3, len(str(len(chapters) + 1))) @@ -195,6 +196,13 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio ch.markdown = renderer.render_chapter(ch) ch.stats.word_count = _word_count(ch.markdown) _check_chapter_size(ch, opts.chapter_level, diag) + wraps: dict[str, int] = {} + for block in renderer.code_blocks: + if block["wrap_lines_joined"]: + wraps[block["chapter"]] = wraps.get(block["chapter"], 0) + block["wrap_lines_joined"] + for chapter_file, joined in wraps.items(): + diag.info("wrap-markers-joined", f"{joined} Druck-Zeilenumbrüche (↪) in Codeblöcken zusammengeführt.", + chapter_file, chapter_file) endnotes: Chapter | None = None endnotes_md = "" if opts.footnotes == "end-of-book" and renderer.book_notes: diff --git a/tests/test_codeblocks.py b/tests/test_codeblocks.py index 704cdd6..0cf0ca9 100644 --- a/tests/test_codeblocks.py +++ b/tests/test_codeblocks.py @@ -101,3 +101,20 @@ def test_code_language_option_rejects_unknown_values(convert): run = convert(Book(docs=[Doc("c.xhtml", "

C

x
")]), "--code-language", "quatsch") assert run.rc == 1 assert "quatsch" in run.stderr + + +def test_print_wrap_markers_are_joined(convert): + body = ("

W

    return (s * (s -\n    ↪ distance(a, b)) *\n        s)\n"
+            "x = ['a', 'b',\n↪ 'c']
") + run = convert(Book(docs=[Doc("c.xhtml", body)])) + (_, _, code), = fenced_blocks(run.chapter("001")) + assert code == " return (s * (s - distance(a, b)) *\n s)\nx = ['a', 'b', 'c']" + item = run.report["code_blocks"]["items"][0] + assert item["wrap_lines_joined"] == 2 + + +def test_print_wrap_markers_can_be_kept(convert): + body = "

W

a = (1,\n↪ 2)
" + run = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-wrap-markers") + (_, _, code), = fenced_blocks(run.chapter("001")) + assert code == "a = (1,\n↪ 2)" From 3ac5e1a8cca015134a7bfc65d1c24eb2445c73b3 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 5 Oct 2026 20:46:34 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20Titel=20=C3=BCberschriftsloser=20Se?= =?UTF-8?q?iten=20aus=20dem=20Inhalt=20ableiten=20(QA=20R3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seiten ohne Überschrift, NAV-Eintrag und werden am Inhalt erkannt: - Bild am Buchanfang -> Cover - Buchtitel und Autor -> Titelseite - ©/ISBN/urheberrechtlich -> Impressum - sonst die ersten acht Wörter Erst danach greift der Dateiname. Im Roman-Beispiel werden aus cinnertitle/cimprint so 'Titelseite' und 'Impressum'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- BEDIENUNGSANLEITUNG.md | 6 +++--- README.md | 11 ++++++++--- epub2md/chapters.py | 40 +++++++++++++++++++++++++++++++++++++++- epub2md/pipeline.py | 2 +- tests/test_chapters.py | 19 +++++++++++++++++++ 5 files changed, 70 insertions(+), 8 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 54dccfd..07f9cec 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -313,9 +313,9 @@ Nein. Das Programm arbeitet vollständig offline und greift nicht aufs Netz zu. **Wird das Original-E-Book verändert?** Nein, es wird nur gelesen. Mit `--copy-original` wird zusätzlich eine Kopie in den Ausgabeordner gelegt. -**Warum heißen manche Kapiteldateien seltsam (z. B. `cinnertitle`)?** -Manche E-Book-Seiten (Titelseite, Impressum) haben weder Überschrift noch Titel. Dann wird der -interne Dateiname verwendet. +**Warum heißen manche Kapiteldateien „Titelseite“, „Impressum“ oder nach ihren ersten Wörtern?** +Manche E-Book-Seiten haben weder Überschrift noch Titel. Das Programm erkennt sie dann am Inhalt. +Gelingt das nicht, werden die ersten Wörter der Seite verwendet, nur im Notfall der interne Dateiname. **Warum ist ein Codebeispiel falsch eingerückt?** So steht es im E-Book selbst (Satzfehler des Verlags). Der Code wird bewusst zeichengetreu diff --git a/README.md b/README.md index aadaa0f..fa95893 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,12 @@ output/mein-buch/ 3. Eine XHTML-Datei, die nur mit einer tieferen Überschrift beginnt (z. B. eine Datei pro Abschnitt `1.2`), wird an das laufende Kapitel angehängt. 4. Dateien ohne Überschrift (Cover, Titelei, Widmung) werden eigene Kapitel. Ihr Titel kommt aus - NAV/NCX, `<title>`, `epub:type` oder dem Dateinamen. + NAV/NCX, `<title>` oder `epub:type`. Fehlt all das, entscheidet der Inhalt: + - nur ein Bild am Buchanfang → „Cover“ + - Buchtitel und Autorenname → „Titelseite“ + - ©/ISBN/„urheberrechtlich“ → „Impressum“ + - sonst die ersten acht Wörter + - erst als letzte Notlösung der Dateiname 5. Ein HTML-Inhaltsverzeichnis wird übersprungen, wenn NAV/NCX vorhanden ist (keine doppelte Ausgabe). Links darauf zeigen auf `README.md`. 6. Überschriftenebenen werden je Datei verschoben: Der Kapiteltitel ist `#`, alle weiteren beginnen bei `##`. @@ -336,8 +341,8 @@ Verlustfreiheit behauptet (`lossless: false`). ## Bekannte Einschränkungen -- Überschriftslose Dokumente ohne NAV-Eintrag und ohne `<title>` werden nach dem Dateinamen benannt - (z. B. `cinnertitle`). +- Titel überschriftsloser Seiten werden aus dem Inhalt abgeleitet (Cover, Titelseite, Impressum, erste + Wörter). Das ist eine Heuristik und trifft nicht jede Seite. - Aneinanderstoßende Inline-Elemente, zwischen denen auch in der Quelle kein Leerraum steht, laufen zusammen (z. B. Autor- und Titelzeilen in Springer-Kapitelköpfen). - Fehlerhafte Einrückungen der Vorlage werden zeichengetreu übernommen. Druck-Fortsetzungszeichen diff --git a/epub2md/chapters.py b/epub2md/chapters.py index 89472ab..e9a767a 100644 --- a/epub2md/chapters.py +++ b/epub2md/chapters.py @@ -86,9 +86,12 @@ def semantic_types(el: Any) -> list[str]: class ChapterBuilder: - def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics) -> None: + 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.book_title = book_title + self.author_names = _surnames(authors or []) self.nav_titles: dict[str, str] = {} self._index_nav(nav) @@ -230,6 +233,10 @@ class ChapterBuilder: 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]): @@ -237,6 +244,26 @@ class ChapterBuilder: ch.types = list(dict.fromkeys(types)) 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: @@ -276,6 +303,17 @@ def leading_heading_level(item: Any) -> int: 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 diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index ec5574e..1316ebf 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -159,7 +159,7 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio # --- notes, chapters, links notes = collect_notes(docs, opts.footnotes, diag) diag.step(f"{len(notes.notes)} Anmerkungen erkannt") - chapters = ChapterBuilder(opts.chapter_level, package.toc, diag).build(docs) + chapters = ChapterBuilder(opts.chapter_level, package.toc, diag, md.title, md.authors).build(docs) _assign_filenames(chapters, opts.slug_language) readme_path = "README.md" if opts.toc else None resolver = LinkResolver(docs, chapters, notes, readme_path) diff --git a/tests/test_chapters.py b/tests/test_chapters.py index 2e12493..f123df0 100644 --- a/tests/test_chapters.py +++ b/tests/test_chapters.py @@ -133,3 +133,22 @@ def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert): assert "--chapter-level 2" in hints[0]["message"] and "3 Unterabschnitte" in hints[0]["message"] small = convert(Book(docs=[Doc("t.xhtml", "<h1>Teil</h1><h2>A</h2><p>x</p><h2>B</h2><p>y</p>")])) assert not any(w["code"] == "chapter-large" for w in small.report["warnings"]) + + +def test_titles_derived_from_content_when_no_other_source(convert): + docs = [ + Doc("ccover.xhtml", '<div><img src="c.png" alt=""/></div>'), + Doc("cinner.xhtml", "<p>Erika Musterfrau</p><p>Testbuch</p><p>Roman</p>"), + Doc("cimp.xhtml", "<p>© 2024 Testverlag</p><p>Das Werk ist urheberrechtlich geschützt.</p>"), + Doc("cwid.xhtml", "<p>Für Anna, die immer an mich geglaubt hat, und für alle anderen.</p>"), + Doc("c1.xhtml", "<h1>Eins</h1><p>Text</p>"), + ] + from conftest import PNG + run = convert(Book(docs=docs, nav=[("Eins", "c1.xhtml")], media={"c.png": PNG})) + names = [p.name for p in run.chapters()] + assert names == ["001-cover.md", "002-titelseite.md", "003-impressum.md", + "004-fuer-anna-die-immer-an-mich-geglaubt-hat.md", "005-eins.md"] + types = {c["file"]: c["types"] for c in run.report["chapters"]} + assert "cover" in types["chapters/001-cover.md"] + assert "titlepage" in types["chapters/002-titelseite.md"] + assert "colophon" in types["chapters/003-impressum.md"] From 5f58589c2e14b0289096cecf2e1b884c37643982 Mon Sep 17 00:00:00 2001 From: dschlueter <dschlueter@kitux.de> Date: Mon, 5 Oct 2026 20:47:40 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20Leerzeichen=20zwischen=20aneinander?= =?UTF-8?q?sto=C3=9Fenden=20Feld-Spans=20(QA=20R4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei <span>-Geschwister mit unterschiedlichen Klassen, ohne Text dazwischen und mit Großbuchstabe oder Ziffer rechts bekommen ein trennendes Leerzeichen. Initialen (D|as) und Wortteile (Haupt|bahnhof) bleiben unberührt. Im Mathebuch werden so 49 Stellen korrigiert, die anderen drei Beispielbücher bleiben unverändert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 6 ++++-- epub2md/normalize.py | 29 +++++++++++++++++++++++++++++ tests/test_chapters.py | 12 ++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fa95893..0e8650e 100644 --- a/README.md +++ b/README.md @@ -343,8 +343,10 @@ Verlustfreiheit behauptet (`lossless: false`). - Titel überschriftsloser Seiten werden aus dem Inhalt abgeleitet (Cover, Titelseite, Impressum, erste Wörter). Das ist eine Heuristik und trifft nicht jede Seite. -- Aneinanderstoßende Inline-Elemente, zwischen denen auch in der Quelle kein Leerraum steht, laufen - zusammen (z. B. Autor- und Titelzeilen in Springer-Kapitelköpfen). +- Aneinanderstoßende Feld-Spans ohne Leerraum (z. B. Autor | Titel | Reihe in Springer-Kapitelköpfen) + erhalten ein trennendes Leerzeichen. Das gilt nur bei unterschiedlichen Klassen und wenn rechts ein + Großbuchstabe oder eine Ziffer folgt. Andere direkt aneinanderstoßende Inline-Elemente bleiben + unverändert, damit Initialen und Wortteile nicht zerrissen werden. - Fehlerhafte Einrückungen der Vorlage werden zeichengetreu übernommen. Druck-Fortsetzungszeichen (`↪`/`➥`) am Zeilenanfang werden dagegen standardmäßig aufgelöst (siehe `--keep-wrap-markers`). - Formelbilder ohne TeX-Alternativtext bleiben Bilder. diff --git a/epub2md/normalize.py b/epub2md/normalize.py index 886c9b0..6152766 100644 --- a/epub2md/normalize.py +++ b/epub2md/normalize.py @@ -102,6 +102,35 @@ def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None: doc.toc_ids.update(e.get("id") for e in iter_elements(el) if e.get("id")) remove_keep_tail(el) diag.info("html-toc-removed", "HTML-Inhaltsverzeichnis entfernt (ersetzt durch README.md).", doc.path) + separated = _separate_field_spans(body) + if separated: + diag.info("inline-separator-added", + f"{separated} Leerzeichen zwischen aneinanderstoßenden Feld-Spans eingefügt.", doc.path) + + +def _separate_field_spans(body: Any) -> int: + """Insert a space between adjacent class-styled <span> "fields" that touch without whitespace. + + Publishers sometimes lay out metadata as sibling spans (author | title | series) separated only by + CSS. Narrow rule to keep drop caps ("D|as") and split words ("Haupt|bahnhof") intact: both spans + carry different classes, no text between them, both have >= 2 characters and the right one starts + with an uppercase letter or digit. + """ + count = 0 + for parent in iter_elements(body): + children = [c for c in parent if is_element(c)] + for left, right in zip(children, children[1:]): + if local(left.tag) != "span" or local(right.tag) != "span" or left.getnext() is not right: + continue + if left.tail or not classes(left) or not classes(right) or classes(left) == classes(right): + continue + lt, rt = text_content(left), text_content(right) + if len(lt.strip()) < 2 or len(rt.strip()) < 2 or lt[-1].isspace() or rt[0].isspace(): + continue + if rt[0].isupper() or rt[0].isdigit(): + left.tail = " " + count += 1 + return count def _attached(el: Any, body: Any) -> bool: diff --git a/tests/test_chapters.py b/tests/test_chapters.py index f123df0..7c1712b 100644 --- a/tests/test_chapters.py +++ b/tests/test_chapters.py @@ -152,3 +152,15 @@ def test_titles_derived_from_content_when_no_other_source(convert): assert "cover" in types["chapters/001-cover.md"] assert "titlepage" in types["chapters/002-titelseite.md"] assert "colophon" in types["chapters/003-impressum.md"] + + +def test_adjacent_field_spans_get_a_separator(convert): + body = ('<h1>K</h1><div class="ContextInformation"><span class="Names">J. Liesen, V. Mehrmann</span>' + '<span class="BookTitles"><span class="BookTitle">Lineare Algebra</span></span>' + '<span class="Series">Springer Studium</span></div>' + '<p><span class="dropcap">D</span><span class="rest">as Wetter</span> war schlecht.</p>' + '<p><span class="a">Haupt</span><span class="b">bahnhof</span> und x<sub>i</sub>.</p>') + text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001") + assert "J. Liesen, V. Mehrmann Lineare Algebra Springer Studium" in text + assert "Das Wetter war schlecht." in text + assert "Hauptbahnhof und x<sub>i</sub>." in text From bdc611eefc7f6452f89945935caa3df9017959ca Mon Sep 17 00:00:00 2001 From: dschlueter <dschlueter@kitux.de> Date: Mon, 5 Oct 2026 20:48:41 +0200 Subject: [PATCH 4/5] feat: Abschnitt Weiterverarbeitung in der Buch-README (QA R7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die erzeugte README.md nennt den Merge-Befehl mit dem tatsächlichen Ausgabeordner und passende Pandoc-Befehle. Bei Büchern mit Formeln enthalten sie --mathml, ergänzt um einen Hinweis zur Formeldarstellung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- epub2md/pipeline.py | 2 +- epub2md/writer.py | 16 +++++++++++++++- tests/test_math.py | 10 ++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index 1316ebf..07374d2 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -249,7 +249,7 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio stats = _book_stats(all_chapters, media, renderer, diag) if opts.toc: files["README.md"] = writer.readme(md, chapters, endnotes, package.toc, resolver, stats, cover_out, - package.version, input_path.name, opts.command) + package.version, input_path.name, opts.command, str(output_dir)) existing = {mf.output for mf in media.files.values()} if opts.extract_media else set() if opts.copy_original: existing.add(f"source/{input_path.name}") diff --git a/epub2md/writer.py b/epub2md/writer.py index 83f4cc5..9c891e2 100644 --- a/epub2md/writer.py +++ b/epub2md/writer.py @@ -87,7 +87,7 @@ def _nav_lines(entries: list[NavEntry], resolver: LinkResolver, depth: int = 0) def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, nav: list[NavEntry], resolver: LinkResolver, stats: dict[str, int], cover: str, epub_version: str, source_name: str, - command: list[str]) -> str: + command: list[str], output_dir: str = "OUTPUT_VERZEICHNIS") -> str: out = [f"# {md_escape(md.title or source_name)}"] if md.subtitle: out.append(f"*{md_escape(md.subtitle)}*") @@ -128,6 +128,20 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, out.append("\n".join(["| Kennzahl | Anzahl |", "| --- | ---: |"] + [f"| {lbl} | {stats.get(k, 0)} |" for k, lbl in labels])) out.append("Details: [conversion-report.json](conversion-report.json), " "[manifest.json](manifest.json), [Warnungen](diagnostics/warnings.md)") + stem = source_name[:-5] if source_name.lower().endswith(".epub") else source_name + math = " --mathml" if stats.get("formulas") else "" + commands = [ + "# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)", + f"python3 epub2md_merge.py {shlex.quote(output_dir)}", + "# danach in diesem Ordner:", + f"pandoc {shlex.quote(stem + '.md')} --toc -s{math} -o {shlex.quote(stem + '.html')}", + f"pandoc {shlex.quote(stem + '.md')} --toc -o {shlex.quote(stem + '.epub')}", + ] + out.append("## Weiterverarbeitung") + out.append("```bash\n" + "\n".join(commands) + "\n```") + if math: + out.append("Das Buch enthält Formeln: Für HTML `--mathml` (oder `--mathjax`) verwenden, sonst belässt " + "Pandoc Matrizen und ähnliche Konstrukte als TeX-Text.") out.append("## Reproduktion") out.append("```bash\n" + " ".join(shlex.quote(c) for c in command) + "\n```") return "\n\n".join(out) + "\n" diff --git a/tests/test_math.py b/tests/test_math.py index b3eba6c..a89df9c 100644 --- a/tests/test_math.py +++ b/tests/test_math.py @@ -104,3 +104,13 @@ def test_svg_file_formula_is_reported_as_svg(convert): report = convert(math_book()).report origins = {f["origin"] for f in report["formulas"]["items"]} assert "svg" in origins + + +def test_book_readme_suggests_mathml_for_pandoc_only_with_formulas(convert): + run = convert(math_book()) + readme = (run.out / "README.md").read_text(encoding="utf-8") + assert "## Weiterverarbeitung" in readme and "epub2md_merge.py" in readme + assert "--mathml" in readme + plain = convert(Book(docs=[Doc("a.xhtml", "<h1>A</h1><p>Text</p>")])) + plain_readme = (plain.out / "README.md").read_text(encoding="utf-8") + assert "## Weiterverarbeitung" in plain_readme and "--mathml" not in plain_readme From 84821a2fcd534b8f61ede3524b02c4c23cf37730 Mon Sep 17 00:00:00 2001 From: dschlueter <dschlueter@kitux.de> Date: Mon, 5 Oct 2026 20:50:19 +0200 Subject: [PATCH 5/5] refactor: pipeline._convert in Teilschritte zerlegt (QA R5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _convert (zuletzt 153 Zeilen) orchestriert jetzt nur noch die Teilschritte _prepare_docs, _build_structure, _render (mit _report_wrap_markers und _collect_remaining_media) und _assemble. Längste Funktion im Modul: 48 Zeilen. Keine Verhaltensänderung: Die Ausgabe aller vier Beispielbücher (Standard und end-of-book) ist bis auf Zeitstempel und Pfade byteidentisch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- epub2md/pipeline.py | 106 ++++++++++++++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 33 deletions(-) diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index 07374d2..60c01df 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -131,11 +131,36 @@ def _load_docs(reader: EpubReader, package: Any, diag: Diagnostics) -> list[Doc] def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Options, diag: Diagnostics, generated_at: str, sha: str) -> Result: package = reader.load_package() - md = package.metadata - diag.step(f"EPUB {package.version}: {md.title!r}, {len(package.spine)} Spine-Dokumente") + diag.step(f"EPUB {package.version}: {package.metadata.title!r}, {len(package.spine)} Spine-Dokumente") docs = _load_docs(reader, package, diag) + _prepare_docs(docs, package, diag) + notes, chapters, resolver = _build_structure(docs, package, opts, diag) + rendered = _render(reader, package, chapters, resolver, notes, opts, diag) + files, all_chapters, report = _assemble(input_path, output_dir, opts, diag, generated_at, sha, package, docs, + notes, chapters, resolver, rendered) + if opts.dry_run: + return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir) - # --- normalization and HTML-TOC handling + _write_output(reader, input_path, output_dir, opts, files, rendered.media) + if opts.report: + report_path = Path(opts.report) + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(writer.to_json(report), encoding="utf-8") + return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir) + + +@dataclass +class _Rendered: + renderer: Renderer + media: MediaRegistry + cover_out: str + endnotes: Chapter | None + endnotes_md: str + skipped_formula_images: int + + +def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics) -> None: + """Normalize publisher markup and drop HTML tables of contents that NAV/NCX already cover.""" title_tables = book_uses_title_tables(docs) for doc in docs: normalize_doc(doc, diag, title_tables) @@ -156,13 +181,16 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio for d in docs: d.skipped = "" - # --- notes, chapters, links + +def _build_structure(docs: list[Doc], package: Any, opts: Options, + diag: Diagnostics) -> tuple[Any, list[Chapter], LinkResolver]: + """Notes, chapter units with file names, and the link resolver (incl. NAV/NCX targets).""" + md = package.metadata notes = collect_notes(docs, opts.footnotes, diag) diag.step(f"{len(notes.notes)} Anmerkungen erkannt") chapters = ChapterBuilder(opts.chapter_level, package.toc, diag, md.title, md.authors).build(docs) _assign_filenames(chapters, opts.slug_language) - readme_path = "README.md" if opts.toc else None - resolver = LinkResolver(docs, chapters, notes, readme_path) + resolver = LinkResolver(docs, chapters, notes, "README.md" if opts.toc else None) nav_targets: list[tuple[str, str]] = [] def walk(entries: list[Any]) -> None: @@ -174,8 +202,13 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio walk(package.toc) resolver.add_targets(nav_targets) diag.step(f"{len(chapters)} Kapitel gebildet") + return notes, chapters, resolver - # --- rendering + +def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: LinkResolver, notes: Any, + opts: Options, diag: Diagnostics) -> _Rendered: + """Render all chapters (and the end-of-book notes file) to Markdown and register media.""" + md = package.metadata media = MediaRegistry(package, opts.images, opts.slug_language) cover_out = "" if md.cover_path and opts.extract_media and reader.exists(md.cover_path): @@ -196,13 +229,7 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio ch.markdown = renderer.render_chapter(ch) ch.stats.word_count = _word_count(ch.markdown) _check_chapter_size(ch, opts.chapter_level, diag) - wraps: dict[str, int] = {} - for block in renderer.code_blocks: - if block["wrap_lines_joined"]: - wraps[block["chapter"]] = wraps.get(block["chapter"], 0) + block["wrap_lines_joined"] - for chapter_file, joined in wraps.items(): - diag.info("wrap-markers-joined", f"{joined} Druck-Zeilenumbrüche (↪) in Codeblöcken zusammengeführt.", - chapter_file, chapter_file) + _report_wrap_markers(renderer, diag) endnotes: Chapter | None = None endnotes_md = "" if opts.footnotes == "end-of-book" and renderer.book_notes: @@ -211,8 +238,23 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio endnotes.filename = renderer.endnotes_file endnotes.stats.footnotes = len(renderer.book_notes) endnotes.stats.word_count = _word_count(endnotes_md) + skipped = _collect_remaining_media(reader, package, media, renderer, opts) + return _Rendered(renderer, media, cover_out, endnotes, endnotes_md, skipped) - # --- remaining media (fonts, css, audio, unreferenced images) + +def _report_wrap_markers(renderer: Renderer, diag: Diagnostics) -> None: + wraps: dict[str, int] = {} + for block in renderer.code_blocks: + if block["wrap_lines_joined"]: + wraps[block["chapter"]] = wraps.get(block["chapter"], 0) + block["wrap_lines_joined"] + for chapter_file, joined in wraps.items(): + diag.info("wrap-markers-joined", f"{joined} Druck-Zeilenumbrüche (↪) in Codeblöcken zusammengeführt.", + chapter_file, chapter_file) + + +def _collect_remaining_media(reader: EpubReader, package: Any, media: MediaRegistry, renderer: Renderer, + opts: Options) -> int: + """Register fonts, CSS, audio and unreferenced images; returns the number of formula images replaced by TeX.""" skipped_formula_images = 0 if opts.extract_media: for item in package.manifest.values(): @@ -229,11 +271,18 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio for mf in media.files.values(): if mf.data is None and reader.exists(mf.source): mf.size = reader.names[mf.source].file_size + return skipped_formula_images - # --- assemble output files in memory - unresolved_notes = [n for n in notes.notes.values() if n.ref_count and not n.label and notes.extracted] - for n in unresolved_notes: - diag.warn("note-not-rendered", "Anmerkung wurde referenziert, aber nicht ausgegeben.", n.key) + +def _assemble(input_path: Path, output_dir: Path, opts: Options, diag: Diagnostics, generated_at: str, sha: str, + package: Any, docs: list[Doc], notes: Any, chapters: list[Chapter], resolver: LinkResolver, + rendered: _Rendered) -> tuple[dict[str, str], list[Chapter], dict[str, Any]]: + """Build all output files in memory (chapters, README, metadata, manifest, report, diagnostics).""" + md = package.metadata + renderer, media, endnotes = rendered.renderer, rendered.media, rendered.endnotes + for n in notes.notes.values(): + if n.ref_count and not n.label and notes.extracted: + diag.warn("note-not-rendered", "Anmerkung wurde referenziert, aber nicht ausgegeben.", n.key) opf_dir = posixpath.dirname(package.opf_path) files: dict[str, str] = {} for ch in chapters: @@ -243,12 +292,12 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio files[ch.filename] = front + ch.markdown if endnotes is not None: front = writer.chapter_frontmatter(endnotes, md, input_path.name, opf_dir, 0) if opts.frontmatter else "" - files[endnotes.filename] = front + endnotes_md + files[endnotes.filename] = front + rendered.endnotes_md all_chapters = chapters + ([endnotes] if endnotes else []) stats = _book_stats(all_chapters, media, renderer, diag) if opts.toc: - files["README.md"] = writer.readme(md, chapters, endnotes, package.toc, resolver, stats, cover_out, + files["README.md"] = writer.readme(md, chapters, endnotes, package.toc, resolver, stats, rendered.cover_out, package.version, input_path.name, opts.command, str(output_dir)) existing = {mf.output for mf in media.files.values()} if opts.extract_media else set() if opts.copy_original: @@ -261,23 +310,14 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio stats["warnings"] = diag.count("warning") report = _report(input_path, sha, generated_at, package, opts, all_chapters, media, renderer, notes, diag, - docs, broken, skipped_formula_images) - files["metadata.yaml"] = writer.metadata_yaml(md, input_path.name, sha, package.version, cover_out, + docs, broken, rendered.skipped_formula_images) + files["metadata.yaml"] = writer.metadata_yaml(md, input_path.name, sha, package.version, rendered.cover_out, opts.public(), generated_at) files["manifest.json"] = writer.to_json(_manifest(md, all_chapters, media, diag)) files["conversion-report.json"] = writer.to_json(report) files["diagnostics/warnings.md"] = writer.warnings_md(diag) files["diagnostics/unsupported-elements.md"] = writer.unsupported_md(diag) - - if opts.dry_run: - return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir) - - _write_output(reader, input_path, output_dir, opts, files, media) - if opts.report: - report_path = Path(opts.report) - report_path.parent.mkdir(parents=True, exist_ok=True) - report_path.write_text(writer.to_json(report), encoding="utf-8") - return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir) + return files, all_chapters, report def _mostly_links(el: Any) -> bool: