From a54fdda2fab08bc12045da6b3b49c533d55a572c Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 02:15:35 +0200 Subject: [PATCH 01/24] =?UTF-8?q?feat:=20unsichtbare=20Zeichen=20im=20Flie?= =?UTF-8?q?=C3=9Ftext=20entfernen=20(P6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nullbreiten-Zeichen (U+200B–U+200D, U+2060, U+FEFF) und weiche Trennstriche (U+00AD) werden aus Fließtext, Alternativtexten und Kapiteltiteln entfernt; Code bleibt unverändert. Im Mathebuch sind das 198 Zeichen (z. B. '1.​1'). Abschaltbar mit --keep-invisible-chars; die Anzahl steht im Bericht unter text_cleanup. Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 1 + README.md | 1 + epub2md/chapters.py | 13 ++++++++++++- epub2md/cli.py | 4 ++++ epub2md/html_converter.py | 18 +++++++++++++++--- epub2md/pipeline.py | 5 ++++- epub2md/utils.py | 9 +++++++++ tests/test_styles.py | 18 ++++++++++++++++++ 8 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 tests/test_styles.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index afb00f6..b7a473a 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -434,6 +434,7 @@ python3 epub2md_merge.py --help | `--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 | +| `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten | | `--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 f48bfdd..caf194f 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json | `--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-invisible-chars` | aus | Nullbreiten-Zeichen und weiche Trennstriche im Fließtext behalten. Standard: entfernen, weil sie Suche und RAG stören (Anzahl im Bericht: `text_cleanup`). Code bleibt immer unverändert | | `--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) | diff --git a/epub2md/chapters.py b/epub2md/chapters.py index 5845852..0b16de3 100644 --- a/epub2md/chapters.py +++ b/epub2md/chapters.py @@ -9,7 +9,17 @@ from typing import Any from .diagnostics import Diagnostics from .models import Chapter, NavEntry from .normalize import Doc, has_content -from .utils import classes, epub_types, is_element, iter_elements, local, norm_ws, roles, text_content +from .utils import ( + classes, + epub_types, + is_element, + iter_elements, + local, + norm_ws, + remove_invisible, + roles, + text_content, +) HEADINGS = {"h1": 1, "h2": 2, "h3": 3, "h4": 4, "h5": 5, "h6": 6} CONTAINERS = {"body", "div", "section", "article", "aside", "main", "header", "footer", "nav", "span", "hgroup"} @@ -270,6 +280,7 @@ class ChapterBuilder: if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]): types.append("auxiliary") ch.types = list(dict.fromkeys(types)) + ch.title = remove_invisible(ch.title)[0] # titles feed file names, frontmatter and README return ch def _title_from_content(self, items: list[tuple[str, Any]], number: int) -> tuple[str, str]: diff --git a/epub2md/cli.py b/epub2md/cli.py index 4679493..4f40c58 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -85,6 +85,9 @@ def build_parser() -> argparse.ArgumentParser: 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("--keep-invisible-chars", action="store_true", + help="Nullbreiten-Zeichen und weiche Trennstriche (U+00AD) im Fließtext behalten; Standard: entfernen " + "(stören Suche und Weiterverarbeitung). Code bleibt immer unverändert") p.add_argument("--strict", action="store_true", help="Bei kritischen Fehlern abbrechen: fehlende/unlesbare Spine-Dokumente, nicht dekodierbares " "XHTML, unsichere ZIP-Pfade, übersprungene (verdächtige) Medien") @@ -119,6 +122,7 @@ def main(argv: list[str] | None = None) -> int: keep_html=args.keep_html, code_language=args.code_language or "", keep_wrap_markers=args.keep_wrap_markers, + keep_invisible_chars=args.keep_invisible_chars, strict=args.strict, verbose=args.verbose, dry_run=args.dry_run, diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index 68c0c4d..d842b90 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -45,6 +45,7 @@ from .utils import ( local, norm_ws, rel_link, + remove_invisible, resolve_href, text_content, url_path, @@ -93,6 +94,7 @@ class RenderOptions: 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 + keep_invisible_chars: bool = False # --keep-invisible-chars: keep zero-width chars and soft hyphens def md_escape(text: str, table: bool = False) -> str: @@ -231,6 +233,7 @@ class Renderer: notes: NoteIndex, diag: Diagnostics, zip_names: set[str], entry_problem: Callable[[str], str | None] | None = None) -> None: self.entry_problem = entry_problem or (lambda path: None) + self.invisible_removed = 0 self.opt = options self.zip_names = zip_names self.in_table_html = False @@ -293,7 +296,7 @@ class Renderer: if doc: self.doc = doc if isinstance(item, str): - buf.append(("t", md_escape(item, self.in_table))) + buf.append(("t", self._text(item))) elif isinstance(item, IdCarrier): anchor = self.resolver.anchor_for(self.doc, item.element) if anchor: @@ -755,12 +758,12 @@ class Renderer: def inline_children(self, el: Any) -> list[Piece]: pieces: list[Piece] = [] if el.text: - pieces.append(("t", md_escape(el.text, self.in_table))) + pieces.append(("t", self._text(el.text))) for child in el: if is_element(child): pieces.extend(self.inline(child)) if child.tail: - pieces.append(("t", md_escape(child.tail, self.in_table))) + pieces.append(("t", self._text(child.tail))) return pieces def inline(self, el: Any) -> list[Piece]: @@ -1046,7 +1049,16 @@ class Renderer: title_part = f' "{title.replace(chr(34), chr(39))}"' if title else "" return [("r", f"![{self._alt(alt)}]({url_path(rel_link(self.file, mf.output))}{title_part})")] + def _text(self, text: str) -> str: + """Escape prose text; zero-width characters and soft hyphens are dropped (P6).""" + if not self.opt.keep_invisible_chars: + text, n = remove_invisible(text) + self.invisible_removed += n + return md_escape(text, self.in_table) + def _alt(self, alt: str) -> str: + if not self.opt.keep_invisible_chars: + alt = remove_invisible(alt)[0] return WS.sub(" ", alt).replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]").strip() def _is_formula_image(self, el: Any, alt: str, path: str) -> bool: diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index e5dd9dc..7c683cb 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -47,6 +47,7 @@ class Options: keep_html: bool = False code_language: str = "" keep_wrap_markers: bool = False + keep_invisible_chars: bool = False strict: bool = False verbose: bool = False dry_run: bool = False @@ -235,7 +236,8 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: 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, keep_wrap_markers=opts.keep_wrap_markers), + code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers, + keep_invisible_chars=opts.keep_invisible_chars), resolver, media, notes, diag, set(reader.names), reader.entry_problem, ) width = max(3, len(str(len(chapters) + 1))) @@ -495,6 +497,7 @@ def _report(input_path: Path, sha: str, generated_at: str, package: Any, opts: O "referenced": sum(1 for n in notes.notes.values() if n.ref_count), "rendered": len(renderer.book_notes)}, "special_sections": special, + "text_cleanup": {"invisible_chars_removed": renderer.invisible_removed}, "warnings": warnings, "errors": errors, "infos": [d.as_dict() for d in diag.items if d.level == "info"], diff --git a/epub2md/utils.py b/epub2md/utils.py index 5d88b3e..d9115c0 100644 --- a/epub2md/utils.py +++ b/epub2md/utils.py @@ -185,6 +185,15 @@ def text_content(el: Any) -> str: return "".join(parts) +INVISIBLE = re.compile("[\u200b\u200c\u200d\u2060\ufeff\u00ad]") # zero-width chars, BOM, soft hyphen + + +def remove_invisible(text: str) -> tuple[str, int]: + """Remove zero-width characters and soft hyphens; returns (text, number removed).""" + cleaned, n = INVISIBLE.subn("", text) + return cleaned, n + + def norm_ws(text: str) -> str: return re.sub(r"[ \t\r\n\f]+", " ", text or "").strip() diff --git a/tests/test_styles.py b/tests/test_styles.py new file mode 100644 index 0000000..8ce166f --- /dev/null +++ b/tests/test_styles.py @@ -0,0 +1,18 @@ +"""Typography and layout: invisible characters, CSS emphasis, scene breaks, boxes, styles, page markers.""" + +from conftest import Book, Doc + +ZW, SHY = "​", "­" + + +def test_p6_invisible_characters_removed_outside_code(convert): + body = (f"

Kapitel 1.{ZW}1

Lese{SHY}rinnen und 1.{ZW}1 sowie{ZW}Wort.

" + f"
a{ZW}b

x{SHY}y

") + run = convert(Book(docs=[Doc("c.xhtml", body)])) + text = run.chapter("001") + assert "Leserinnen und 1.1 sowieWort." in text + assert "# Kapitel 1.1" in text and 'chapter_title: "Kapitel 1.1"' in text + assert f"a{ZW}b" in text and f"x{SHY}y" in text # code stays verbatim + assert run.report["text_cleanup"]["invisible_chars_removed"] == 4 + kept = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-invisible-chars").chapter("001") + assert f"Lese{SHY}rinnen" in kept From 5f7c352df192e313be6eeb05eb25af6a4f310ab7 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 04:52:36 +0200 Subject: [PATCH 02/24] =?UTF-8?q?feat:=20Hervorhebungen=20aus=20dem=20CSS?= =?UTF-8?q?=20des=20Buches=20=C3=BCbernehmen=20(P1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Modul epub2md/css.py: Es wertet die Stylesheets des Buches aus (Typ-, Klassen- und ID-Selektoren, Nachfahre/Kind, Spezifität, style-Attribute; @media und Pseudoklassen werden ignoriert). Daraus werden: - fett/kursiv → **/*, unterstrichen → , durchgestrichen → ~~, vertical-align super/sub → / - Monospace-Einzelwörter → Inline-Code - display:none → Element entfällt Das gilt im Fließtext, für ganze Absätze und in HTML-Tabellen (dort als HTML-Tags). Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst ('*x**y*' brach Markdown; Mathebuch: 305 → 249 Stellen). Beispiele: C-Buch 799× kursiv und 648× fett, die vorher verloren gingen. Abschaltbar mit --no-css-emphasis. Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 1 + README.md | 5 + epub2md/cli.py | 4 + epub2md/css.py | 194 ++++++++++++++++++++++++++++++++++++++ epub2md/html_converter.py | 119 ++++++++++++++++++++++- epub2md/pipeline.py | 31 ++++-- tests/test_styles.py | 50 ++++++++++ 7 files changed, 395 insertions(+), 9 deletions(-) create mode 100644 epub2md/css.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index b7a473a..6b942ec 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -435,6 +435,7 @@ python3 epub2md_merge.py --help | `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache | | `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen | | `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten | +| `--no-css-emphasis` | Hervorhebungen nur aus HTML-Tags übernehmen, nicht aus dem Stylesheet des Buches | | `--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 caf194f..92d572f 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json | `--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`) | +| `--css-emphasis` / `--no-css-emphasis` | an | Fett, kursiv, unterstrichen, durchgestrichen, hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS-Klasse setzt (z. B. `span.italic`). Einzelwörter in Monospace-Schrift werden zu Inline-Code; per CSS ausgeblendete Elemente (`display:none`) entfallen. Statistik im Bericht unter `css` | | `--keep-invisible-chars` | aus | Nullbreiten-Zeichen und weiche Trennstriche im Fließtext behalten. Standard: entfernen, weil sie Suche und RAG stören (Anzahl im Bericht: `text_cleanup`). Code bleibt immer unverändert | | `--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) | @@ -238,6 +239,10 @@ weil CommonMark keine `[^…]`-Syntax kennt. (`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. Bei einsprachigen Büchern kann `--code-language python` (o. Ä.) alle übrigen Blöcke kennzeichnen. Achtung: Das betrifft auch Programmausgaben und Shell-Befehle im Buch. +- **CSS-Formatierung:** Viele Verlage setzen Hervorhebungen nur über Stylesheets. epub2md wertet die + 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*`). - **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/cli.py b/epub2md/cli.py index 4f40c58..0555128 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -85,6 +85,9 @@ def build_parser() -> argparse.ArgumentParser: 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("--css-emphasis", action=argparse.BooleanOptionalAction, default=True, + help="Fett/kursiv/unterstrichen/hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS " + "setzt; per CSS ausgeblendete Elemente weglassen (Standard: an)") p.add_argument("--keep-invisible-chars", action="store_true", help="Nullbreiten-Zeichen und weiche Trennstriche (U+00AD) im Fließtext behalten; Standard: entfernen " "(stören Suche und Weiterverarbeitung). Code bleibt immer unverändert") @@ -123,6 +126,7 @@ def main(argv: list[str] | None = None) -> int: code_language=args.code_language or "", keep_wrap_markers=args.keep_wrap_markers, keep_invisible_chars=args.keep_invisible_chars, + css_emphasis=args.css_emphasis, strict=args.strict, verbose=args.verbose, dry_run=args.dry_run, diff --git a/epub2md/css.py b/epub2md/css.py new file mode 100644 index 0000000..528afa4 --- /dev/null +++ b/epub2md/css.py @@ -0,0 +1,194 @@ +"""Minimal CSS evaluation for formatting that publishers express only through stylesheets. + +Supports type/class/id selectors, descendant (` `) and child (`>`) combinators, specificity and source +order, and `style` attributes. Selectors with pseudo-classes, attribute selectors or sibling combinators +are ignored (conservative: an unknown rule never adds formatting). @-blocks (@media, @font-face, @page …) +are skipped. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +from .utils import classes, local + +COMPOUND = re.compile(r"^(\*|[a-zA-Z][\w-]*)?((?:[.#][\w-]+)*)$") +MONOSPACE = re.compile(r"monospace|courier|consolas|menlo|monaco|lucida console|source code|dejavu sans mono|" + r"liberation mono|fira mono|fira code|inconsolata|andale mono|\bmono\b", re.I) + + +@dataclass +class _Compound: + tag: str | None + classes: frozenset[str] + ids: frozenset[str] + + def matches(self, el: Any) -> bool: + if self.tag and self.tag != "*" and local(el.tag) != self.tag: + return False + if self.ids and (el.get("id") or "").lower() not in self.ids: + return False + return not self.classes or self.classes <= set(classes(el)) + + +@dataclass +class _Rule: + parts: list[tuple[str, _Compound]] # (combinator to the previous part, compound); first combinator is "" + specificity: tuple[int, int, int] + order: int + decls: dict[str, str] + + def matches(self, el: Any) -> bool: + if not self.parts[-1][1].matches(el): + return False + node = el + for i in range(len(self.parts) - 1, 0, -1): + comb = self.parts[i][0] + compound = self.parts[i - 1][1] + node = node.getparent() + if comb == ">": + if node is None or not compound.matches(node): + return False + else: # descendant + while node is not None and not compound.matches(node): + node = node.getparent() + if node is None: + return False + return True + + +def _parse_declarations(text: str) -> dict[str, str]: + decls: dict[str, str] = {} + for part in text.split(";"): + if ":" not in part: + continue + prop, value = part.split(":", 1) + prop = prop.strip().lower() + value = re.sub(r"\s*!important\s*$", "", value.strip(), flags=re.I).strip() + if prop and value: + decls[prop] = value + return decls + + +def _parse_selector(sel: str) -> list[tuple[str, _Compound]] | None: + sel = sel.strip().lower() + if not sel or re.search(r"[:\[\]+~]", sel): + return None + tokens = re.split(r"\s*(>)\s*|\s+", sel) + parts: list[tuple[str, _Compound]] = [] + comb = "" + for tok in tokens: + if tok is None or tok == "": + continue + if tok == ">": + comb = ">" + continue + m = COMPOUND.match(tok) + if not m: + return None + rest = m.group(2) or "" + parts.append((comb if parts else "", _Compound( + tag=m.group(1), + classes=frozenset(re.findall(r"\.([\w-]+)", rest)), + ids=frozenset(re.findall(r"#([\w-]+)", rest)), + ))) + comb = " " + return parts or None + + +def _strip_at_blocks(css: str) -> str: + """Remove @-rules including nested blocks (@media, @font-face, @supports, @page …).""" + out: list[str] = [] + i = 0 + while i < len(css): + if css[i] == "@": + brace = css.find("{", i) + semi = css.find(";", i) + if semi != -1 and (brace == -1 or semi < brace): # @import/@charset …; + i = semi + 1 + continue + if brace == -1: + break + depth, j = 0, brace + while j < len(css): + if css[j] == "{": + depth += 1 + elif css[j] == "}": + depth -= 1 + if depth == 0: + break + j += 1 + i = j + 1 + continue + out.append(css[i]) + i += 1 + return "".join(out) + + +class StyleSheet: + def __init__(self, css_texts: list[str]) -> None: + self.rules: list[_Rule] = [] + self._index: dict[str, list[_Rule]] = {} + self._cache: dict[Any, dict[str, str]] = {} + order = 0 + for css in css_texts: + css = re.sub(r"/\*.*?\*/", "", css, flags=re.S) + css = _strip_at_blocks(css) + for selectors, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css): + decls = _parse_declarations(body) + if not decls: + continue + for sel in selectors.split(","): + parts = _parse_selector(sel) + if not parts: + continue + spec = (sum(len(c.ids) for _, c in parts), sum(len(c.classes) for _, c in parts), + sum(1 for _, c in parts if c.tag and c.tag != "*")) + rule = _Rule(parts, spec, order, decls) + order += 1 + self.rules.append(rule) + last = parts[-1][1] + keys = [f".{c}" for c in last.classes] or ([last.tag] if last.tag and last.tag != "*" else ["*"]) + for key in keys[:1]: # one index entry suffices: every key must match anyway + self._index.setdefault(key, []).append(rule) + + def computed(self, el: Any) -> dict[str, str]: + """Declared properties for ``el`` (no inheritance), including its ``style`` attribute.""" + cached = self._cache.get(el) + if cached is not None: + return cached + candidates: list[_Rule] = [] + for key in [f".{c}" for c in classes(el)] + [local(el.tag), "*"]: + candidates.extend(self._index.get(key, ())) + result: dict[str, str] = {} + for rule in sorted({id(r): r for r in candidates}.values(), key=lambda r: (r.specificity, r.order)): + if rule.matches(el): + result.update(rule.decls) + if el.get("style"): + result.update(_parse_declarations(el.get("style"))) + self._cache[el] = result + return result + + +def inline_style(el: Any) -> dict[str, str]: + """Only the ``style`` attribute (used when no stylesheet is available).""" + return _parse_declarations(el.get("style") or "") + + +def emphasis(style: dict[str, str]) -> dict[str, bool]: + """Semantic formatting expressed by a computed style.""" + weight = style.get("font-weight", "").lower() + deco = (style.get("text-decoration-line", "") + " " + style.get("text-decoration", "")).lower() + valign = style.get("vertical-align", "").lower() + return { + "bold": weight in ("bold", "bolder") or (weight.isdigit() and int(weight) >= 600), + "italic": style.get("font-style", "").lower() in ("italic", "oblique"), + "underline": "underline" in deco, + "strike": "line-through" in deco, + "sup": valign == "super", + "sub": valign == "sub", + "mono": bool(MONOSPACE.search(style.get("font-family", ""))), + "hidden": style.get("display", "").lower() == "none", + } diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index d842b90..a053fb8 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -9,6 +9,7 @@ from __future__ import annotations import html import re +from collections import Counter from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Any @@ -17,6 +18,7 @@ from urllib.parse import unquote from lxml import etree from .chapters import IdCarrier, Marker, heading_level +from .css import StyleSheet, emphasis, inline_style from .codeblocks import ( code_text, detect_language, @@ -95,6 +97,7 @@ class RenderOptions: 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 keep_invisible_chars: bool = False # --keep-invisible-chars: keep zero-width chars and soft hyphens + css_emphasis: bool = True # --no-css-emphasis: ignore bold/italic/… that only the stylesheet expresses def md_escape(text: str, table: bool = False) -> str: @@ -126,9 +129,23 @@ def escape_line_starts(text: str) -> str: return "\n".join(out) +EMPH_SEP = "\x1f" # separates open marker, content and close marker inside an ("e", …) piece + + def join_inline(pieces: list[Piece], hard_break: str = "\\\n") -> str: out = "" + last_e: tuple[int, str, str, str, int] | None = None # (start, open, core, close, end) of the last emphasis for kind, s in pieces: + if kind == "e": + open_, core, close = s.split(EMPH_SEP) + if last_e and last_e[4] == len(out) and last_e[1] == open_ and last_e[3] == close: + # directly adjacent identical emphasis (xy): merge, "*x**y*" would break + out = out[: last_e[0]] + core = last_e[2] + core + start = len(out) + out += open_ + core + close + last_e = (start, open_, core, close, len(out)) + continue if kind == "t": s = WS.sub(" ", s) if not out or out.endswith((" ", "\n")): @@ -178,6 +195,17 @@ def _collapse_blank_lines(text: str) -> str: return "\n".join(out) +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"} + + +def _wrap_text(text: str, marker: str) -> str: + """Wrap a finished paragraph in emphasis markers (whitespace stays outside).""" + core = text.strip() + return f"{marker}{core}{marker}" if core else text + + _BLOCK_SYNTAX = ("```", "~~~", "|", " ", "#", "$$", "\\[", "* * *") @@ -231,8 +259,12 @@ def serialize_mathml(math: Any) -> str: class Renderer: def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry, notes: NoteIndex, diag: Diagnostics, zip_names: set[str], - entry_problem: Callable[[str], str | None] | None = None) -> None: + entry_problem: Callable[[str], str | None] | None = None, + stylesheet: StyleSheet | None = None) -> None: self.entry_problem = entry_problem or (lambda path: None) + self.css = stylesheet + self.css_applied: Counter[str] = Counter() + self.hidden_elements = 0 self.invisible_removed = 0 self.opt = options self.zip_names = zip_names @@ -364,6 +396,8 @@ class Renderer: def block(self, el: Any) -> list[str]: name = local(el.tag) + if self._hidden(el): + return [] 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) @@ -404,7 +438,24 @@ class Renderer: special = self._index_block(el, anchor) if special is not None: return special - blocks = self.flow(el) + italic = bold = False + if self.opt.css_emphasis and local(el.tag) in ("p", "div") and not self.in_heading \ + and not any(local(d.tag) in BLOCK_TAGS for d in iter_elements(el) if d is not el) \ + and text_content(el).strip(): + e = self._emphasis(el) + italic, bold = e["italic"] and not self.em, e["bold"] and not self.strong + self.em += italic + self.strong += bold + try: + blocks = self.flow(el) + finally: + self.em -= italic + self.strong -= bold + if (italic or bold) and len(blocks) == 1 and not _is_block_syntax(blocks[0]) and not blocks[0].startswith("<"): + for flag, marker, key in ((italic, "*", "italic"), (bold, "**", "bold")): + if flag: + blocks[0] = _wrap_text(blocks[0], marker) + self.css_applied[key] += 1 if anchor: if blocks and not blocks[0].startswith(("#", "`", "|", " ", "- ", "$$", "\\[", "* * *")) \ and not re.match(r"\d+\. ", blocks[0]): @@ -766,7 +817,58 @@ class Renderer: pieces.append(("t", self._text(child.tail))) return pieces + def _emphasis(self, el: Any) -> dict[str, bool]: + style = self.css.computed(el) if self.css is not None else inline_style(el) + return emphasis(style) + + def _hidden(self, el: Any) -> bool: + """display:none in the book's CSS: readers do not show the element, so neither do we.""" + if not self.opt.css_emphasis or not is_element(el) or not self._emphasis(el)["hidden"]: + return False + self.hidden_elements += 1 + return True + def inline(self, el: Any) -> list[Piece]: + """Inline rendering plus formatting that only the stylesheet expresses (P1).""" + name = local(el.tag) + if not self.opt.css_emphasis or name in CSS_SKIP_TAGS: + return self._inline_core(el) + if self._hidden(el): + return [] + if self.in_heading or is_inline_code(el) or not text_content(el).strip(): + return self._inline_core(el) + e = self._emphasis(el) + if e["mono"] and name != "a" and re.fullmatch(r"\S+", norm_ws(text_content(el))) \ + and not any(local(d.tag) in ("img", "svg", "math", "a") for d in iter_elements(el) if d is not el): + self.css_applied["code"] += 1 + anchor = self.anchors_of(el) + return ([("r", anchor)] if anchor else []) + [("r", inline_code(norm_ws(text_content(el))))] + has_link = name == "a" or any(local(d.tag) == "a" for d in iter_elements(el)) + italic = e["italic"] and name not in ITALIC_TAGS and not self.em + bold = e["bold"] and name not in BOLD_TAGS and not self.strong + strike_marker = ("~~", "~~") if self.opt.format == "gfm" else ("", "") + wrappers = [ + (e["sup"] and name != "sup", "", "", "sup"), + (e["sub"] and name != "sub", "", "", "sub"), + (e["underline"] and name not in ("u", "ins") and not has_link, "", "", "underline"), + (e["strike"] and name not in ("del", "s", "strike"), strike_marker[0], strike_marker[1], "strike"), + (italic, "*", "*", "italic"), + (bold, "**", "**", "bold"), + ] + self.em += italic + self.strong += bold + try: + pieces = self._inline_core(el) + finally: + self.em -= italic + self.strong -= bold + for flag, open_, close, key in wrappers: + if flag: + pieces = self._wrap(pieces, open_, close) + self.css_applied[key] += 1 + return pieces + + def _inline_core(self, el: Any) -> list[Piece]: name = local(el.tag) anchor = self.anchors_of(el) if name not in ("a",) else "" pre: list[Piece] = [("r", anchor)] if anchor and not self.in_heading else [] @@ -844,7 +946,7 @@ class Renderer: core = core[: -len(hard)].rstrip() if not core: return [("t", lead or trail)] if (lead or trail) else [] - return [("t", lead), ("r", f"{open_}{core}{close}"), ("t", trail)] + return [("t", lead), ("e", f"{open_}{EMPH_SEP}{core}{EMPH_SEP}{close}"), ("t", trail)] # ------------------------------------------------------------------ links @@ -1236,6 +1338,17 @@ class Renderer: return f"<{name}{(' ' + ' '.join(safe)) if safe else ''}>{content}" if name not in HTML_KEEP_TAGS or name == "span": anchor = self.anchors_of(el) + if self.opt.css_emphasis and content.strip(): + e = self._emphasis(el) + if e["hidden"]: + self.hidden_elements += 1 + return "" + for flag, tag, key in ((e["italic"], "em", "italic"), (e["bold"], "strong", "bold"), + (e["underline"] and "{content}" + self.css_applied[key] += 1 return anchor + content attrs = [] for k, v in el.attrib.items(): diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index 7c683cb..e0dd2fa 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -16,7 +16,8 @@ from typing import Any from . import TOOL_NAME, __version__ from .chapters import ChapterBuilder from .diagnostics import Diagnostics -from .epub_reader import EpubReader, parse_xml +from .css import StyleSheet +from .epub_reader import EpubReader, decode_markup, parse_xml from .errors import ContainerError, InputError, StructureError from .footnotes import collect_notes from .html_converter import RenderOptions, Renderer @@ -25,7 +26,7 @@ from .media import MediaRegistry from .models import Chapter from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail from .references import LinkResolver -from .utils import is_inside, iter_elements, sha256_file, slugify +from .utils import is_inside, iter_elements, local, sha256_file, slugify, text_content from . import writer MARKER_FILES = ("conversion-report.json", "manifest.json") @@ -48,6 +49,7 @@ class Options: code_language: str = "" keep_wrap_markers: bool = False keep_invisible_chars: bool = False + css_emphasis: bool = True strict: bool = False verbose: bool = False dry_run: bool = False @@ -134,9 +136,10 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio package = reader.load_package() diag.step(f"EPUB {package.version}: {package.metadata.title!r}, {len(package.spine)} Spine-Dokumente") docs = _load_docs(reader, package, diag) + stylesheet = _load_stylesheet(reader, package, docs, diag) # before normalization drops +]] + +local lang = "de" + +local function is_html() + return FORMAT:match("html") ~= nil or FORMAT:match("epub") ~= nil +end + +local function label_for(kind) + local labels = LABELS[lang] or LABELS.de + return labels[kind] or labels.NOTE +end + +local function drop_leading_breaks(inlines) + while #inlines > 0 and (inlines[1].t == "SoftBreak" or inlines[1].t == "LineBreak" or inlines[1].t == "Space") do + inlines:remove(1) + end + return inlines +end + +-- Build the box from its kind ("TIP" …), optional title inlines and body blocks. +local function make_box(kind, title, body) + local head = pandoc.Inlines({ pandoc.Str(label_for(kind)) }) + if title and #title > 0 then + head:insert(pandoc.Str(":")) + head:insert(pandoc.Space()) + head:extend(title) + end + local blocks = pandoc.Blocks({ pandoc.Para({ pandoc.Strong(head) }) }) + blocks:extend(body) + if is_html() then + return pandoc.Div(blocks, pandoc.Attr("", { "callout", "callout-" .. kind:lower() })) + end + return pandoc.BlockQuote(blocks) +end + +local function Meta(meta) + if meta.lang then + local l = pandoc.utils.stringify(meta.lang):lower():sub(1, 2) + if LABELS[l] then + lang = l + end + end + if is_html() then + local includes = meta["header-includes"] + if includes == nil then + includes = pandoc.MetaList({}) + elseif includes.t ~= "MetaList" then + includes = pandoc.MetaList({ includes }) + end + includes:insert(pandoc.MetaBlocks({ pandoc.RawBlock("html", CSS) })) + meta["header-includes"] = includes + end + return meta +end + +local function BlockQuote(quote) + local first = quote.content[1] + if not first or first.t ~= "Para" or #first.content == 0 or first.content[1].t ~= "Str" then + return nil + end + local kind = first.content[1].text:match("^%[!(%a+)%]$") + if not kind then + return nil + end + kind = kind:upper() + local rest = drop_leading_breaks(first.content:clone()) + rest:remove(1) + rest = drop_leading_breaks(rest) + local body = pandoc.Blocks({}) + for i = 2, #quote.content do + body:insert(quote.content[i]) + end + local title = nil + if #rest == 1 and rest[1].t == "Strong" then + title = rest[1].content -- "> **Title**" line + elseif #rest > 0 then + body:insert(1, pandoc.Para(rest)) -- text directly after the marker + end + return make_box(kind, title, body) +end + +--
read back from HTML: keep it as a box in non-HTML formats. +local function Div(div) + if not div.classes:includes("callout") or is_html() then + return nil + end + return pandoc.BlockQuote(div.content) +end + +return { + { Meta = Meta }, + { BlockQuote = BlockQuote, Div = Div }, +} diff --git a/tests/test_styles.py b/tests/test_styles.py index dc3ca05..c3b42ea 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -109,7 +109,7 @@ def test_p2_boxes_become_callouts(convert): from conftest import PNG run = convert(Book(docs=[Doc("c.xhtml", body)], media={"box.css": BOX_CSS, "tick.png": PNG})) text = run.chapter("001") - assert "> [!NOTE]\n> **\\[»\\] Erwartungen**\n>\n> Kein Kompendium.\n>\n> Zweiter Absatz." in text + assert "> [!NOTE]\n> **Erwartungen**\n>\n> Kein Kompendium.\n>\n> Zweiter Absatz." in text assert "> [!IMPORTANT]\n> **Wichtig:** Funktionen sind Objekte." in text and "tick" not in text assert "> [!TIP]\n> Gelb mit Blau mischen.\n>\n> Mehr Weiß." in text # label 'Tipp' = type word, no title assert "> [!NOTE]\n> **Exkurs**\n>\n> Am Rande." in text @@ -184,3 +184,11 @@ def test_p5_page_markers(convert): assert vis.report["layout"]["page_markers"] == 6 com = convert(Book(docs=[Doc("c.xhtml", PAGES)]), "--page-markers", "comment").chapter("001") assert "" in com and "Text weiter" in com + + +def test_book_readme_suggests_callout_filter(convert): + body = '

K

Merke

Wichtiger Satz.

' + readme = (convert(Book(docs=[Doc("c.xhtml", body)])).out / "README.md").read_text(encoding="utf-8") + assert "--lua-filter" in readme and "pandoc/callouts.lua" in readme and "1 Infokasten " in readme + plain = convert(Book(docs=[Doc("c.xhtml", body)]), "--boxes", "plain").out / "README.md" + assert "--lua-filter" not in plain.read_text(encoding="utf-8") From e8fd6c2fb9666f381786d7e80ce067454f4ab01c Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 05:36:25 +0200 Subject: [PATCH 08/24] fix: Kastentyp aus Verlagsklasse, kein Feld-Trenner vor ausgeblendeten Spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _box_type erkennt hinweis/note/notiz/anmerkung/info als NOTE; damit gewinnt die Klasse "box hinweis" gegen ein Typwort im Titel ("Wichtige …") - _separate_field_spans setzt kein Leerzeichen, wenn einer der Spans per CSS display:none ist ("x ." → "x.") - gefunden mit der neuen QA-Fixture 14-typography (QA-Matrix jetzt 85 Fälle, inkl. epub2md_merge.py und Pandoc-Filter) Co-Authored-By: Claude Opus 5.5 --- epub2md/html_converter.py | 2 ++ epub2md/normalize.py | 12 +++++++----- epub2md/pipeline.py | 11 +++++++---- tests/test_styles.py | 9 +++++++++ 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index 3e619cf..fb8f6ff 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -221,6 +221,8 @@ def _box_type(hint: str) -> str: return "important" if re.search(r"tipp|\btip|hint", h): return "tip" + if re.search(r"hinweis|\bnote|notiz|anmerkung|\binfo", h): + return "note" return "" diff --git a/epub2md/normalize.py b/epub2md/normalize.py index 8f9dc66..78db4e1 100644 --- a/epub2md/normalize.py +++ b/epub2md/normalize.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from collections.abc import Callable from dataclasses import dataclass, field from typing import Any @@ -80,7 +81,8 @@ def book_uses_title_tables(docs: list[Doc]) -> bool: return False -def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None: +def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool, + hidden: Callable[[Any], bool] | None = None) -> None: body = doc.body for el in list(iter_elements(body)): if not _attached(el, body): @@ -107,19 +109,19 @@ 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) + separated = _separate_field_spans(body, hidden or (lambda el: False)) 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: +def _separate_field_spans(body: Any, hidden: Callable[[Any], bool]) -> int: """Insert a space between adjacent class-styled "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. + with an uppercase letter or digit. Spans hidden by the book's CSS (display:none) get no separator. """ count = 0 for parent in iter_elements(body): @@ -132,7 +134,7 @@ def _separate_field_spans(body: Any) -> int: 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(): + if (rt[0].isupper() or rt[0].isdigit()) and not hidden(left) and not hidden(right): left.tail = " " count += 1 return count diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index c9021b4..bc10e24 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -8,6 +8,7 @@ import re import shutil import sys import tempfile +from collections.abc import Callable from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path @@ -16,7 +17,7 @@ from typing import Any from . import TOOL_NAME, __version__ from .chapters import ChapterBuilder from .diagnostics import Diagnostics -from .css import StyleSheet, base_style +from .css import StyleSheet, base_style, emphasis from .epub_reader import EpubReader, decode_markup, parse_xml from .errors import ContainerError, InputError, StructureError from .footnotes import collect_notes @@ -140,7 +141,8 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio diag.step(f"EPUB {package.version}: {package.metadata.title!r}, {len(package.spine)} Spine-Dokumente") docs = _load_docs(reader, package, diag) stylesheet = _load_stylesheet(reader, package, docs, diag) # before normalization drops -]] +-- callouts.css next to this filter is the single source of the box styles. +local function read_file(path) + local fh = io.open(path, "r") + if not fh then + return nil + end + local text = fh:read("a") + fh:close() + return text +end + +local CSS_PATH = (PANDOC_SCRIPT_FILE and PANDOC_SCRIPT_FILE:match("^(.*[/\\])") or "") .. "callouts.css" +local CSS = read_file(CSS_PATH) +if not CSS then + io.stderr:write("callouts.lua: " .. CSS_PATH .. " nicht gefunden – Kästen ohne Rahmen\n") +end + +-- EPUB: a stylesheet in metadata `css` replaces Pandoc's default epub.css, so combine both in a temp file. +local function epub_stylesheet() + local default = "" + local pipe = io.popen("pandoc --print-default-data-file epub.css 2>/dev/null") + if pipe then + default = pipe:read("a") or "" + pipe:close() + end + local path = os.tmpname() -- small temp file; Pandoc reads it after the filter has finished + local fh = io.open(path, "w") + if not fh then + return nil + end + fh:write(default, "\n", CSS) + fh:close() + return path +end local lang = "de" @@ -79,15 +104,23 @@ local function Meta(meta) lang = l end end - if is_html() then - local includes = meta["header-includes"] - if includes == nil then - includes = pandoc.MetaList({}) - elseif includes.t ~= "MetaList" then - includes = pandoc.MetaList({ includes }) + if CSS and FORMAT:match("epub") then + local css = meta.css + local path = nil + if css == nil then -- no own stylesheet: default epub.css + ours; otherwise just add ours + path = epub_stylesheet() end - includes:insert(pandoc.MetaBlocks({ pandoc.RawBlock("html", CSS) })) - meta["header-includes"] = includes + if css == nil then + css = pandoc.List({}) + elseif pandoc.utils.type(css) ~= "List" then + css = pandoc.List({ css }) + end + if path then + css:insert(pandoc.MetaString(path)) + else + css:insert(pandoc.MetaString(CSS_PATH)) + end + meta.css = css end return meta end @@ -126,7 +159,16 @@ local function Div(div) return pandoc.BlockQuote(div.content) end +-- HTML: the stylesheet goes into the body, because a command-line -H replaces header-includes from a filter. +local function Pandoc(doc) + if CSS and FORMAT:match("html") and not FORMAT:match("epub") then + doc.blocks:insert(1, pandoc.RawBlock("html", "")) + end + return doc +end + return { { Meta = Meta }, { BlockQuote = BlockQuote, Div = Div }, + { Pandoc = Pandoc }, } From 13617c8d3e22b82babecbf91367cbff933aef8a1 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 06:03:55 +0200 Subject: [PATCH 12/24] =?UTF-8?q?feat:=20pandoc/print.css=20=E2=80=93=20Dr?= =?UTF-8?q?uck-Layout=20f=C3=BCr=20PDFs=20mit=20weasyprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A4, Seitenzahlen, jedes Kapitel auf neuer Seite, Inhaltsverzeichnis auf eigener Seite, Silbentrennung (nicht in Code), umbrechende Codezeilen; bringt Grundschrift und Titelkopf mit, da --css Pandocs Standard-CSS ersetzt - Buch-README: zusätzlicher weasyprint-Befehl mit Filter und print.css - Doku: Bedienungsanleitung und README um den weasyprint-Weg ergänzt Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 10 +++++++--- CHANGELOG.md | 5 +++++ README.md | 14 +++++++++++++- epub2md/writer.py | 7 +++++++ pandoc/print.css | 19 +++++++++++++++++++ tests/test_styles.py | 1 + 6 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 pandoc/print.css diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index e4d4d90..8fa4a26 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -319,7 +319,7 @@ Original-E-Books. | Word | `pandoc meinbuch.md -o meinbuch.docx` | | LibreOffice | `pandoc meinbuch.md -o meinbuch.odt` | | PDF mit LaTeX | `pandoc meinbuch.md -s -o meinbuch.html && pandoc meinbuch.html --toc --pdf-engine=xelatex -o meinbuch.pdf` | -| PDF ohne LaTeX | `pandoc meinbuch.md --toc --pdf-engine=weasyprint -o meinbuch.pdf` | +| PDF ohne LaTeX | `pandoc meinbuch.md -s --toc --css ~/Python_Programs/epub2md/pandoc/print.css --pdf-engine=weasyprint -o meinbuch.pdf` | | Kindle (AZW3) | `pandoc meinbuch.md --toc -o meinbuch.epub && ebook-convert meinbuch.epub meinbuch.azw3` | **Zum PDF:** @@ -327,8 +327,11 @@ Original-E-Books. - **Mit LaTeX** immer den Zwischenschritt über HTML nehmen, wie in der Tabelle. Beim direkten Weg von Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Bei Büchern mit Formeln ist der LaTeX-Weg die beste Wahl. -- **Mit weasyprint** genügt ein Schritt; Tabellen bleiben erhalten. Für Bücher mit vielen Formeln ist der - LaTeX-Weg sicherer. +- **Mit weasyprint** genügt ein Schritt; Tabellen, Farben, Infokästen und die Gestaltung aus `--styles html` + bleiben erhalten. Für Bücher mit vielen Formeln ist der LaTeX-Weg sicherer. +- **`print.css`** (im Ordner `pandoc/` von epub2md) sorgt beim weasyprint-Weg für ein Buchlayout: A4, + Seitenzahlen, jedes Kapitel auf einer neuen Seite, Silbentrennung und umbrechende lange Codezeilen. Ohne + sie übernimmt weasyprint das Bildschirmlayout, und lange Codezeilen werden am Seitenrand abgeschnitten. Bei Büchern mit Formeln für HTML zusätzlich `--mathml` angeben, sonst bleiben Matrizen als TeX-Text stehen. @@ -342,6 +345,7 @@ F=~/Python_Programs/epub2md/pandoc/callouts.lua # Pfad zu deinem epub2md-Ord pandoc meinbuch.md -s --toc --lua-filter $F -o meinbuch.html pandoc meinbuch.md --toc --lua-filter $F -o meinbuch.epub pandoc meinbuch.md -s --lua-filter $F -o meinbuch.html && pandoc meinbuch.html --toc --lua-filter $F --pdf-engine=xelatex -o meinbuch.pdf +pandoc meinbuch.md -s --toc --lua-filter $F --css ~/Python_Programs/epub2md/pandoc/print.css --pdf-engine=weasyprint -o meinbuch.pdf ``` In HTML, EPUB und im PDF über weasyprint werden daraus gerahmte, farbige Kästen mit Titel („Tipp: …“). Im diff --git a/CHANGELOG.md b/CHANGELOG.md index fcabe44..b5ac697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/). - Seitenmarken (`--page-markers`) aus einem Kastentitel stehen jetzt vor dem Kasten statt im fetten Titel, wie bei Überschriften. +### Neu + +- `pandoc/print.css`: Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite, + Silbentrennung außer in Code, umbrechende Codezeilen). Die Buch-README enthält den passenden Befehl. + ### Behoben - `pandoc/callouts.lua`: Die Kästen blieben ohne Rahmen, wenn man Pandoc zusätzlich `-H`/`--include-in-header` diff --git a/README.md b/README.md index a9853b7..40a2c51 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,17 @@ pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html pandoc mathematikbuch.html --toc --pdf-engine=xelatex -o mathematikbuch.pdf ``` +**PDF mit weasyprint** (ohne LaTeX; Farben, Kästen und `--styles html` bleiben erhalten): + +```bash +pandoc buch.md -s --toc --lua-filter $F --css ~/Python_Programs/epub2md/pandoc/print.css \ + --pdf-engine=weasyprint -o buch.pdf +``` + +`pandoc/print.css` liefert das Buchlayout: A4, Seitenzahlen, Kapitel auf neuer Seite, Silbentrennung (nicht in +Code) und umbrechende Codezeilen. Mit `--css` lässt Pandoc sein eigenes Stylesheet weg, deshalb bringt +`print.css` auch Grundschrift, Zeilenabstand und Titelkopf mit. Getestet mit dem Python-Buch (342 Seiten). + **Infokästen:** Pandoc kennt die Schreibweise `> [!TIP]` erst ab neueren Versionen (Erweiterung `alerts`); 3.1.3 (Ubuntu 24.04) zeigt „[!TIP]“ als Text. Der Filter `pandoc/callouts.lua` behebt das: In HTML und EPUB entstehen gerahmte, farbige Kästen mit Titel („Tipp: …“, bei `lang: en` „Tip: …“), in LaTeX/PDF, Word @@ -438,7 +449,8 @@ epub2md/ └── utils.py Slugs, YAML-Emitter, Pfadhelfer pandoc/ ├── callouts.lua Pandoc-Filter: '> [!TIP]'-Kästen → gerahmte Kästen (HTML/EPUB) bzw. Zitatblock mit Titel -└── callouts.css Gestaltung der Kästen (vom Filter eingebunden; muss neben callouts.lua liegen) +├── callouts.css Gestaltung der Kästen (vom Filter eingebunden; muss neben callouts.lua liegen) +└── print.css Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite, Codeumbruch) ``` Statt BeautifulSoup wird direkt lxml verwendet: Es ist für die XHTML-Bäume ausreichend, schneller und diff --git a/epub2md/writer.py b/epub2md/writer.py index 3a25bc1..17cae9e 100644 --- a/epub2md/writer.py +++ b/epub2md/writer.py @@ -17,6 +17,7 @@ from .references import LinkResolver from .utils import rel_link, to_yaml, url_path CALLOUT_FILTER = Path(__file__).resolve().parent.parent / "pandoc" / "callouts.lua" +PRINT_CSS = Path(__file__).resolve().parent.parent / "pandoc" / "print.css" SPECIAL_LABELS = { "glossary": "Glossar", @@ -146,6 +147,12 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, f"pandoc {shlex.quote(stem + '.md')} -s{math}{lua} -o {shlex.quote(stem + '.html')} && " f"pandoc {shlex.quote(stem + '.html')} --toc{lua} --pdf-engine=xelatex -o {shlex.quote(stem + '.pdf')}", ] + if PRINT_CSS.is_file(): + commands += [ + "# PDF ohne LaTeX mit weasyprint: Farben, Kästen und HTML-Stile bleiben erhalten", + f"pandoc {shlex.quote(stem + '.md')} -s --toc{math}{lua} --css {shlex.quote(str(PRINT_CSS))} " + f"--pdf-engine=weasyprint -o {shlex.quote(stem + '.pdf')}", + ] out.append("## Weiterverarbeitung") out.append("```bash\n" + "\n".join(commands) + "\n```") if callouts: diff --git a/pandoc/print.css b/pandoc/print.css new file mode 100644 index 0000000..af35eb3 --- /dev/null +++ b/pandoc/print.css @@ -0,0 +1,19 @@ +/* Print layout for PDFs from epub2md output via Pandoc + weasyprint: + pandoc buch.md -s --toc --lua-filter pandoc/callouts.lua --css pandoc/print.css --pdf-engine=weasyprint -o buch.pdf */ +@page { size: A4; margin: 2cm 1.8cm; @bottom-center { content: counter(page); font-size: 9pt; } } +body { max-width: none; font-size: 10.5pt; line-height: 1.4; hyphens: auto; text-align: justify; + font-family: "DejaVu Serif", "Liberation Serif", Georgia, serif; } +#title-block-header, #title-block-header h1.title { text-align: center; margin-bottom: 2em; } +h1, h2, h3, h4 { text-align: left; hyphens: manual; page-break-after: avoid; } +pre, code { font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; hyphens: none; } +pre { background: #f6f8fa; padding: 0.5em 0.8em; border-radius: 0.3em; page-break-inside: auto; } +blockquote { margin: 1em 0 1em 1.5em; padding-left: 0.8em; border-left: 2px solid #ccc; color: #444; } +nav#TOC ul { list-style: none; padding-left: 1.2em; } +a { color: inherit; text-decoration: none; } +pre, pre code { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 8.5pt; } /* long code lines wrap */ +pre { overflow: visible; } +img { max-width: 100%; height: auto; } +h1 { page-break-before: always; } /* every chapter on a new page */ +#title-block-header h1, #TOC h1 { page-break-before: auto; } +nav#TOC { page-break-after: always; } +table { font-size: 9pt; } diff --git a/tests/test_styles.py b/tests/test_styles.py index f3d3884..2964d5b 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -190,6 +190,7 @@ def test_book_readme_suggests_callout_filter(convert): body = '

K

Merke

Wichtiger Satz.

' readme = (convert(Book(docs=[Doc("c.xhtml", body)])).out / "README.md").read_text(encoding="utf-8") assert "--lua-filter" in readme and "pandoc/callouts.lua" in readme and "1 Infokasten " in readme + assert "pandoc/print.css --pdf-engine=weasyprint" in readme plain = convert(Book(docs=[Doc("c.xhtml", body)]), "--boxes", "plain").out / "README.md" assert "--lua-filter" not in plain.read_text(encoding="utf-8") From 65c90c67ca525ce3b3a1a9a427c4140f4f682bae Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 06:09:10 +0200 Subject: [PATCH 13/24] =?UTF-8?q?feat:=20epub2md=5Fmerge=20k=C3=BCrzt=20IS?= =?UTF-8?q?O-Datum=20im=20Buch-Kopf=20(13.01.2025)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit display_date(): '2025-01-13T08:00:00+00:00' → '13.01.2025' bei deutschen Büchern, sonst '2025-01-13'; andere Formate unverändert. metadata.yaml behält den Originalwert. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +++++++----- README.md | 4 +++- epub2md/merge.py | 14 +++++++++++++- tests/test_merge.py | 13 +++++++++++++ 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5ac697..31115d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,18 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] -### Geändert - -- Seitenmarken (`--page-markers`) aus einem Kastentitel stehen jetzt vor dem Kasten statt im fetten Titel, - wie bei Überschriften. - ### Neu - `pandoc/print.css`: Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite, Silbentrennung außer in Code, umbrechende Codezeilen). Die Buch-README enthält den passenden Befehl. +### Geändert + +- `epub2md_merge.py` kürzt ISO-Zeitstempel im Buch-Kopf (`2025-01-13T08:00:00+00:00` → `13.01.2025`, bei + nicht-deutschen Büchern `2025-01-13`), damit die Pandoc-Titelseite ein lesbares Datum zeigt. +- Seitenmarken (`--page-markers`) aus einem Kastentitel stehen jetzt vor dem Kasten statt im fetten Titel, + wie bei Überschriften. + ### Behoben - `pandoc/callouts.lua`: Die Kästen blieben ohne Rahmen, wenn man Pandoc zusätzlich `-H`/`--include-in-header` diff --git a/README.md b/README.md index 40a2c51..d6b1a89 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,9 @@ Beim Zusammenfügen passiert Folgendes: - Kapitelüberschriften bleiben `#`, jedes Kapitel beginnt also mit einer H1. Pandoc behandelt sie als Kapitel. - Der Buch-Kopf enthält nur Felder, die im EPUB vorhanden sind (`title`, `subtitle`, `author`, `lang`, - `publisher`, `date`, `identifier`, `rights`, `description`). + `publisher`, `date`, `identifier`, `rights`, `description`). Ein ISO-Zeitstempel als Datum wird gekürzt, + damit Pandoc auf der Titelseite nicht `2025-01-13T08:00:00+00:00` druckt: bei deutschen Büchern zu + `13.01.2025`, sonst zu `2025-01-13`. `metadata.yaml` behält den Originalwert. Am Ende werden alle Links der Gesamtdatei geprüft. Ein Link auf einen nicht vorhandenen Anker zeigt auf den Kapitelanfang und erzeugt eine Warnung auf stderr. Die Zusammenfassung nennt Kapitelanzahl, diff --git a/epub2md/merge.py b/epub2md/merge.py index cba21ff..80ef7b9 100644 --- a/epub2md/merge.py +++ b/epub2md/merge.py @@ -89,6 +89,18 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]: return report, chapters +ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:[T ][\d:.]+(?:Z|[+-]\d{2}:?\d{2})?)?$") + + +def display_date(value: str, language: str) -> str: + """'2025-01-13T08:00:00+00:00' → '13.01.2025' (German) or '2025-01-13'; other formats unchanged.""" + m = ISO_DATE.match(value.strip()) + if not m: + return value + year, month, day = m.groups() + return f"{day}.{month}.{year}" if language.lower().startswith("de") else f"{year}-{month}-{day}" + + def default_target(out_dir: Path, report: dict) -> Path: name = (report.get("input") or {}).get("name") or (out_dir.name + ".epub") stem = name[:-5] if name.lower().endswith(".epub") else name @@ -225,7 +237,7 @@ def merge(out_dir: Path, target: Path | None = None, *, frontmatter: bool = True ("publisher", "publisher"), ("date", "published"), ("identifier", "identifier"), ("rights", "rights"), ("description", "description")): if md.get(src): - fm[key] = md[src] + fm[key] = display_date(md[src], md.get("language", "")) if key == "date" else md[src] if fm: parts.append("---\n" + to_yaml(fm) + "---") if toc: diff --git a/tests/test_merge.py b/tests/test_merge.py index 91898ae..3e02003 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -71,3 +71,16 @@ def test_merge_invalid_directory(tmp_path): assert merge_main([str(tmp_path / "gibt-es-nicht")]) == 1 (tmp_path / "leer").mkdir() assert merge_main([str(tmp_path / "leer")]) == 1 + + +def test_merge_shortens_iso_date(convert): + from epub2md.merge import display_date + assert display_date("2025-01-13T08:00:00+00:00", "de") == "13.01.2025" + assert display_date("2025-01-13", "de-DE") == "13.01.2025" + assert display_date("2025-01-13T08:00:00Z", "en") == "2025-01-13" + assert display_date("2025", "de") == "2025" # year only / other formats stay + assert display_date("Januar 2025", "de") == "Januar 2025" + run = convert(book()) + assert merge_main([str(run.out)]) == 0 + text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8") + assert yaml.safe_load(text.split("---\n")[1])["date"] == "01.01.2024" From 99c3c609ca130b914fbbcdd618aadca6c334d45d Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 06:27:27 +0200 Subject: [PATCH 14/24] feat: Inhaltsverzeichnis nach Cover, Titelseite und Impressum (pandoc/book.lua) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pandocs --toc setzt Inhaltsverzeichnis und Titelkopf immer vor den Buchtext, also vor Cover, Titelseite und Impressum. - epub2md_merge.py: Kapiteltypen aus manifest.json; Platzhalter
hinter dem Vorspann und, ohne eigene Titelseite,
hinter dem Cover; --toc-Liste an derselben Stelle - pandoc/book.lua (HTML/weasyprint, statt --toc): baut dort das Inhaltsverzeichnis, lässt Pandocs Titelkopf bei eigener Titelseite weg, Cover ohne Bildunterschrift, Kapitelanker → div.chapter-start; andere Formate: nur Platzhalter entfernen - print.css: jedes Kapitel (auch Cover/Titelseite/Impressum) und das Verzeichnis auf neuer Seite, Verzeichnis mit Punktlinie und Seitenzahl - Buch-README, Bedienungsanleitung, README: Befehle mit book.lua Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 72 ++++++++++----------- CHANGELOG.md | 5 ++ README.md | 32 ++++++---- epub2md/merge.py | 41 ++++++++++-- epub2md/writer.py | 7 ++- pandoc/book.lua | 140 +++++++++++++++++++++++++++++++++++++++++ pandoc/print.css | 28 ++++++--- tests/test_merge.py | 34 ++++++++++ tests/test_styles.py | 3 +- 9 files changed, 298 insertions(+), 64 deletions(-) create mode 100644 pandoc/book.lua diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 8fa4a26..72e83bf 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -222,7 +222,7 @@ Blöcke übernommen: ``` GitHub und Obsidian zeigen das als farbigen Kasten mit Symbol; andere Programme als eingerückten Zitatblock. -Für Pandoc (HTML, EPUB, PDF) gibt es einen Filter, der echte Kästen zeichnet, siehe Kapitel 7. +Für Pandoc (HTML, EPUB, PDF) gibt es einen Filter, der echte Kästen zeichnet, siehe Kapitel 7 („Weiterverarbeiten“). Andere Darstellungen wählst du mit `--boxes`: - `quote`: Zitatblock ohne `[!TIP]` @@ -309,50 +309,52 @@ cd output/meinbuch ``` `meinbuch.md` steht im Folgenden für den Namen der Gesamtdatei. Er entspricht dem Namen des -Original-E-Books. +Original-E-Books. `$P` ist der Ordner mit den Pandoc-Hilfsdateien von epub2md; lege ihn einmal fest: + +```bash +P=~/Python_Programs/epub2md/pandoc # Pfad zu deinem epub2md-Ordner anpassen +``` | Ziel | Befehl | | --- | --- | -| Webseite (HTML) | `pandoc meinbuch.md -s --toc -o meinbuch.html` | -| Webseite mit Formeln | `pandoc meinbuch.md -s --toc --mathml -o meinbuch.html` | -| E-Book (EPUB) | `pandoc meinbuch.md --toc -o meinbuch.epub` | -| Word | `pandoc meinbuch.md -o meinbuch.docx` | -| LibreOffice | `pandoc meinbuch.md -o meinbuch.odt` | -| PDF mit LaTeX | `pandoc meinbuch.md -s -o meinbuch.html && pandoc meinbuch.html --toc --pdf-engine=xelatex -o meinbuch.pdf` | -| PDF ohne LaTeX | `pandoc meinbuch.md -s --toc --css ~/Python_Programs/epub2md/pandoc/print.css --pdf-engine=weasyprint -o meinbuch.pdf` | -| Kindle (AZW3) | `pandoc meinbuch.md --toc -o meinbuch.epub && ebook-convert meinbuch.epub meinbuch.azw3` | +| PDF (empfohlen) | `pandoc meinbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua --css $P/print.css --pdf-engine=weasyprint -o meinbuch.pdf` | +| Webseite (HTML) | `pandoc meinbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html` | +| Webseite mit Formeln | wie HTML, zusätzlich `--mathml` | +| E-Book (EPUB) | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.epub` | +| Word | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.docx` | +| LibreOffice | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.odt` | +| PDF mit LaTeX | `pandoc meinbuch.md -s --lua-filter $P/callouts.lua -o meinbuch.html && pandoc meinbuch.html --toc --lua-filter $P/callouts.lua --pdf-engine=xelatex -o meinbuch.pdf` | +| Kindle (AZW3) | erst EPUB wie oben, dann `ebook-convert meinbuch.epub meinbuch.azw3` | + +Die drei Hilfsdateien: + +- **`book.lua`** ordnet den Buchanfang wie im gedruckten Buch: Cover, Titelseite, Impressum, dann das + Inhaltsverzeichnis, jeweils auf einer neuen Seite. Ohne den Filter setzt Pandoc mit `--toc` das + Inhaltsverzeichnis (und seinen eigenen Titelkopf) vor das Cover. Mit `book.lua` deshalb **kein** `--toc` + angeben. Hat das Buch keine eigene Titelseite, setzt der Filter Pandocs Titelkopf hinter das Cover. Er + wirkt nur bei HTML und beim PDF über weasyprint; EPUB, Word und LibreOffice bauen ihr Verzeichnis mit + `--toc` selbst. +- **`callouts.lua`** zeichnet die Infokästen (`> [!TIP]`). Pandoc in der Ubuntu-Version kennt diese + Schreibweise nicht und druckt „[!TIP]“ sonst einfach mit. In HTML, EPUB und im PDF über weasyprint werden + daraus gerahmte, farbige Kästen mit Titel („Tipp: …“), im PDF über LaTeX und in Word/LibreOffice ein + eingerückter Block mit fettem Titel. +- **`print.css`** liefert das Druck-Layout für weasyprint: A4, Seitenzahlen, jedes Kapitel auf einer neuen + Seite, Inhaltsverzeichnis mit Seitenzahlen, Silbentrennung und umbrechende lange Codezeilen. Ohne sie + übernimmt weasyprint das Bildschirmlayout, und lange Codezeilen werden am Seitenrand abgeschnitten. + +Du musst die Befehle nicht abtippen: Die `README.md` im Ausgabeordner enthält sie fertig, mit allen Pfaden. **Zum PDF:** +- **Mit weasyprint** (empfohlen) genügt ein Schritt. Tabellen, Farben, Infokästen und die Gestaltung aus + `--styles html` bleiben erhalten, der Buchanfang ist richtig geordnet. Für Bücher mit vielen Formeln ist + der LaTeX-Weg sicherer. - **Mit LaTeX** immer den Zwischenschritt über HTML nehmen, wie in der Tabelle. Beim direkten Weg von - Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Bei - Büchern mit Formeln ist der LaTeX-Weg die beste Wahl. -- **Mit weasyprint** genügt ein Schritt; Tabellen, Farben, Infokästen und die Gestaltung aus `--styles html` - bleiben erhalten. Für Bücher mit vielen Formeln ist der LaTeX-Weg sicherer. -- **`print.css`** (im Ordner `pandoc/` von epub2md) sorgt beim weasyprint-Weg für ein Buchlayout: A4, - Seitenzahlen, jedes Kapitel auf einer neuen Seite, Silbentrennung und umbrechende lange Codezeilen. Ohne - sie übernimmt weasyprint das Bildschirmlayout, und lange Codezeilen werden am Seitenrand abgeschnitten. + Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Das + Inhaltsverzeichnis steht hier am Anfang, vor dem Cover. Bei Büchern mit Formeln für HTML zusätzlich `--mathml` angeben, sonst bleiben Matrizen als TeX-Text stehen. -#### Infokästen richtig darstellen - -Pandoc in der Ubuntu-Version kennt die Kasten-Schreibweise `> [!TIP]` nicht und druckt „[!TIP]“ einfach mit. -Dafür liegt im epub2md-Ordner der Filter `pandoc/callouts.lua`. Hänge ihn an jeden Pandoc-Befehl an: - -```bash -F=~/Python_Programs/epub2md/pandoc/callouts.lua # Pfad zu deinem epub2md-Ordner -pandoc meinbuch.md -s --toc --lua-filter $F -o meinbuch.html -pandoc meinbuch.md --toc --lua-filter $F -o meinbuch.epub -pandoc meinbuch.md -s --lua-filter $F -o meinbuch.html && pandoc meinbuch.html --toc --lua-filter $F --pdf-engine=xelatex -o meinbuch.pdf -pandoc meinbuch.md -s --toc --lua-filter $F --css ~/Python_Programs/epub2md/pandoc/print.css --pdf-engine=weasyprint -o meinbuch.pdf -``` - -In HTML, EPUB und im PDF über weasyprint werden daraus gerahmte, farbige Kästen mit Titel („Tipp: …“). Im -PDF über LaTeX und in Word/LibreOffice wird ein eingerückter Block mit fettem Titel daraus. Du musst nicht -selbst nachsehen, ob dein Buch Kästen hat: Die `README.md` im Ausgabeordner enthält in diesem Fall die -Befehle bereits mit Filter. - #### Direkt aus den Kapiteldateien Ohne Gesamtdatei geht es auch. Dann im Ordner `chapters/` arbeiten und die Buchdaten aus diff --git a/CHANGELOG.md b/CHANGELOG.md index 31115d1..41e40f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/). ### Neu +- `pandoc/book.lua`: Inhaltsverzeichnis wie im gedruckten Buch nach Cover, Titelseite und Impressum statt + davor (Pandocs `--toc` setzt es immer an den Anfang). `epub2md_merge.py` markiert dafür das Ende des + Vorspanns; ohne eigene Titelseite kommt Pandocs Titelkopf hinter das Cover. Jedes Kapitel, auch Cover, + Titelseite und Impressum, beginnt mit `print.css` auf einer neuen Seite; das Inhaltsverzeichnis hat + Seitenzahlen. Mit `epub2md_merge.py --toc` steht dessen Verzeichnis an derselben Stelle. - `pandoc/print.css`: Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite, Silbentrennung außer in Code, umbrechende Codezeilen). Die Buch-README enthält den passenden Befehl. diff --git a/README.md b/README.md index d6b1a89..c91bfab 100644 --- a/README.md +++ b/README.md @@ -331,9 +331,11 @@ konsistente Anker: ```bash cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei -F=~/Python_Programs/epub2md/pandoc/callouts.lua # Pfad zum epub2md-Ordner anpassen -pandoc programmierbuch.md --toc -s --lua-filter $F -o programmierbuch.html -pandoc programmierbuch.md --toc --lua-filter $F -o programmierbuch.epub +P=~/Python_Programs/epub2md/pandoc # Pfad zum epub2md-Ordner anpassen +pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua \ + --css $P/print.css --pdf-engine=weasyprint -o programmierbuch.pdf # PDF (empfohlen) +pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o programmierbuch.html +pandoc programmierbuch.md --toc --lua-filter $P/callouts.lua -o programmierbuch.epub cd output/mathematikbuch pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html @@ -341,16 +343,21 @@ pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html pandoc mathematikbuch.html --toc --pdf-engine=xelatex -o mathematikbuch.pdf ``` -**PDF mit weasyprint** (ohne LaTeX; Farben, Kästen und `--styles html` bleiben erhalten): +Hilfsdateien in `pandoc/`: -```bash -pandoc buch.md -s --toc --lua-filter $F --css ~/Python_Programs/epub2md/pandoc/print.css \ - --pdf-engine=weasyprint -o buch.pdf -``` - -`pandoc/print.css` liefert das Buchlayout: A4, Seitenzahlen, Kapitel auf neuer Seite, Silbentrennung (nicht in -Code) und umbrechende Codezeilen. Mit `--css` lässt Pandoc sein eigenes Stylesheet weg, deshalb bringt -`print.css` auch Grundschrift, Zeilenabstand und Titelkopf mit. Getestet mit dem Python-Buch (342 Seiten). +- **`book.lua`** (HTML, PDF über weasyprint; statt `--toc`): Pandocs `--toc` setzt Inhaltsverzeichnis und + Titelkopf immer an den Anfang, vor Cover, Titelseite und Impressum. `epub2md_merge.py` markiert deshalb das + Ende des Vorspanns (`
`, Kapiteltypen aus `manifest.json`) und, falls das Buch + keine eigene Titelseite hat, die Stelle hinter dem Cover (`
`). Der + Filter baut dort das Inhaltsverzeichnis (`--toc-depth` gilt), lässt Pandocs Titelkopf bei vorhandener + Titelseite weg, zeigt das Cover ohne Bildunterschrift und macht aus den Kapitelankern + `div.chapter-start` für Seitenumbrüche. Bei anderen Formaten entfernt er nur die Platzhalter. Wer selbst + `--toc` angibt, bekommt Pandocs Verzeichnis wie gewohnt. +- **`print.css`** (weasyprint): A4, Seitenzahlen, jedes Kapitel (auch Cover, Titelseite, Impressum) und das + Inhaltsverzeichnis auf neuer Seite, Inhaltsverzeichnis mit Punktlinie und Seitenzahl, Silbentrennung + (nicht in Code), umbrechende Codezeilen. Mit `--css` lässt Pandoc sein eigenes Stylesheet weg, deshalb + bringt `print.css` auch Grundschrift, Zeilenabstand und Titelkopf mit. Getestet mit dem Python-Buch + (362 Seiten). **Infokästen:** Pandoc kennt die Schreibweise `> [!TIP]` erst ab neueren Versionen (Erweiterung `alerts`); 3.1.3 (Ubuntu 24.04) zeigt „[!TIP]“ als Text. Der Filter `pandoc/callouts.lua` behebt das: In HTML und @@ -450,6 +457,7 @@ epub2md/ ├── models.py Dataclasses (Kapitel, Medien, Noten, Metadaten …) └── utils.py Slugs, YAML-Emitter, Pfadhelfer pandoc/ +├── book.lua Pandoc-Filter: Inhaltsverzeichnis nach Cover/Titelseite/Impressum, Kapitel-Seitenumbrüche ├── callouts.lua Pandoc-Filter: '> [!TIP]'-Kästen → gerahmte Kästen (HTML/EPUB) bzw. Zitatblock mit Titel ├── callouts.css Gestaltung der Kästen (vom Filter eingebunden; muss neben callouts.lua liegen) └── print.css Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite, Codeumbruch) diff --git a/epub2md/merge.py b/epub2md/merge.py index 80ef7b9..0eb2532 100644 --- a/epub2md/merge.py +++ b/epub2md/merge.py @@ -46,6 +46,7 @@ class ChapterFile: body: str = "" start_anchor: str = "" anchor_map: dict[str, str] = field(default_factory=dict) + types: list[str] = field(default_factory=list) @dataclass @@ -74,12 +75,12 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]: report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {} if manifest_path.is_file(): manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - entries = [(c["number"], c["title"], c["file"]) for c in manifest.get("chapters", [])] + entries = [(c["number"], c["title"], c["file"], c.get("types") or []) for c in manifest.get("chapters", [])] else: files = sorted((out_dir / "chapters").glob("*.md")) - entries = [(i + 1, p.stem, f"chapters/{p.name}") for i, p in enumerate(files)] - chapters = [ChapterFile(n, t, rel) for n, t, rel in entries if (out_dir / rel).is_file()] - missing = [rel for _, _, rel in entries if not (out_dir / rel).is_file()] + entries = [(i + 1, p.stem, f"chapters/{p.name}", []) for i, p in enumerate(files)] + chapters = [ChapterFile(n, t, rel, types=list(ty)) for n, t, rel, ty in entries if (out_dir / rel).is_file()] + missing = [rel for _, _, rel, _ in entries if not (out_dir / rel).is_file()] if missing: raise MergeError(f"Kapiteldateien fehlen: {', '.join(missing[:5])}") if not chapters: @@ -89,6 +90,21 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]: return report, chapters +# Front matter that precedes the table of contents in a printed book (cover, title page, imprint …). +FRONT_TYPES = {"cover", "titlepage", "halftitlepage", "colophon", "copyright-page", "imprint", "dedication", + "epigraph", "frontispiece", "seriespage", "imprimatur"} +TOC_PLACEHOLDER = '
' # pandoc/book.lua puts the table of contents here +TITLE_PLACEHOLDER = '
' # … and Pandoc's title block here (no own title page) + + +def front_matter_count(chapters: list[ChapterFile]) -> int: + """Number of leading chapters that are front matter (cover, title page, imprint …).""" + n = 0 + while n < len(chapters) and FRONT_TYPES & set(chapters[n].types): + n += 1 + return n + + ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:[T ][\d:.]+(?:Z|[+-]\d{2}:?\d{2})?)?$") @@ -240,15 +256,28 @@ def merge(out_dir: Path, target: Path | None = None, *, frontmatter: bool = True fm[key] = display_date(md[src], md.get("language", "")) if key == "date" else md[src] if fm: parts.append("---\n" + to_yaml(fm) + "---") + front = front_matter_count(chapters) + has_title_page = any("titlepage" in ch.types for ch in chapters[:front]) + cover_first = bool(chapters) and "cover" in chapters[0].types if toc: lines = ["## Inhalt" if (report.get("metadata") or {}).get("language", "de").startswith("de") else "## Contents", ""] lines += [f"{ch.number}. [{_escape_link_text(ch.title)}](#{ch.start_anchor})" for ch in chapters] - parts.append("\n".join(lines)) - for ch in chapters: + toc_block = "\n".join(lines) + else: + toc_block = TOC_PLACEHOLDER + if not has_title_page and not cover_first: + parts.append(TITLE_PLACEHOLDER) + if front == 0: + parts.append(toc_block) + for i, ch in enumerate(chapters): segments = [(is_code, seg if is_code else rewrite_segment(seg, ch)) for is_code, seg in _non_code_segments(ch.body)] body, n = _drop_duplicate_definitions(_join(segments), seen_defs) dropped += n parts.append(f'
\n\n{body.strip()}') + if i == 0 and cover_first and not has_title_page: + parts.append(TITLE_PLACEHOLDER) + if i + 1 == front: + parts.append(toc_block) text = "\n\n".join(parts).rstrip() + "\n" existing = {p.resolve().as_posix() for p in out_dir.rglob("*") if p.is_file()} diff --git a/epub2md/writer.py b/epub2md/writer.py index 17cae9e..f00c8de 100644 --- a/epub2md/writer.py +++ b/epub2md/writer.py @@ -18,6 +18,7 @@ from .utils import rel_link, to_yaml, url_path CALLOUT_FILTER = Path(__file__).resolve().parent.parent / "pandoc" / "callouts.lua" PRINT_CSS = Path(__file__).resolve().parent.parent / "pandoc" / "print.css" +BOOK_FILTER = Path(__file__).resolve().parent.parent / "pandoc" / "book.lua" SPECIAL_LABELS = { "glossary": "Glossar", @@ -137,11 +138,13 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, lua = "" if callouts and CALLOUT_FILTER.is_file(): # '> [!TIP]' boxes: Pandoc needs the filter to draw real boxes lua = f" --lua-filter {shlex.quote(str(CALLOUT_FILTER))}" + # book.lua: table of contents after cover/title page/imprint instead of Pandoc's --toc at the very top + toc = f" --lua-filter {shlex.quote(str(BOOK_FILTER))}" if BOOK_FILTER.is_file() else " --toc" 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}{lua} -o {shlex.quote(stem + '.html')}", + f"pandoc {shlex.quote(stem + '.md')} -s{math}{toc}{lua} -o {shlex.quote(stem + '.html')}", f"pandoc {shlex.quote(stem + '.md')} --toc{lua} -o {shlex.quote(stem + '.epub')}", "# PDF über HTML: Pandoc übernimmt dann auch HTML-Tabellen und interne Sprungziele", f"pandoc {shlex.quote(stem + '.md')} -s{math}{lua} -o {shlex.quote(stem + '.html')} && " @@ -150,7 +153,7 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, if PRINT_CSS.is_file(): commands += [ "# PDF ohne LaTeX mit weasyprint: Farben, Kästen und HTML-Stile bleiben erhalten", - f"pandoc {shlex.quote(stem + '.md')} -s --toc{math}{lua} --css {shlex.quote(str(PRINT_CSS))} " + f"pandoc {shlex.quote(stem + '.md')} -s{math}{toc}{lua} --css {shlex.quote(str(PRINT_CSS))} " f"--pdf-engine=weasyprint -o {shlex.quote(stem + '.pdf')}", ] out.append("## Weiterverarbeitung") diff --git a/pandoc/book.lua b/pandoc/book.lua new file mode 100644 index 0000000..ef78a17 --- /dev/null +++ b/pandoc/book.lua @@ -0,0 +1,140 @@ +--[[ +book.lua – Pandoc filter for book files from epub2md_merge.py (HTML output, e.g. PDF via weasyprint). + +Pandoc's --toc always puts the table of contents (and its own title block) before the whole text, +i.e. before the book's cover, title page and imprint. epub2md_merge.py marks the right places: + +
after the cover, only if the book has no title page of its own +
after the front matter (cover, title page, imprint …) + +This filter, for HTML output: + * builds the table of contents at the placeholder (use it INSTEAD of --toc; --toc-depth is honoured), + * drops Pandoc's title block when the book has its own title page, or moves it to the placeholder, + * shows the cover picture (chapter "NNN-cover") without the caption "Cover", + * turns the chapter anchors into
so that print.css can + start every chapter – including cover, title page and imprint – on a new page. + +For other formats it only removes the placeholders (EPUB, DOCX, ODT: use --toc there). + +Usage: + pandoc buch.md -s --lua-filter /pfad/zu/epub2md/pandoc/book.lua \ + --lua-filter /pfad/zu/epub2md/pandoc/callouts.lua --css /pfad/zu/epub2md/pandoc/print.css \ + --pdf-engine=weasyprint -o buch.pdf +]] + +local TOC_ID, TITLE_ID = "epub2md-toc", "epub2md-titlepage" +local TOC_TITLE = { de = "Inhaltsverzeichnis", en = "Contents" } + +local function is_placeholder(block, id) + return block.t == "Div" and block.identifier == id and #block.content == 0 +end + +local function esc(s) + return (s:gsub("&", "&"):gsub("<", "<"):gsub(">", ">")) +end + +--
as Pandoc's HTML template writes it. +local function title_block(meta) + local html = { '
', '

' .. esc(pandoc.utils.stringify(meta.title)) .. "

" } + if meta.subtitle then + html[#html + 1] = '

' .. esc(pandoc.utils.stringify(meta.subtitle)) .. "

" + end + local authors = meta.author + if authors and pandoc.utils.type(authors) ~= "List" then + authors = { authors } + end + for _, a in ipairs(authors or {}) do + html[#html + 1] = '

' .. esc(pandoc.utils.stringify(a)) .. "

" + end + if meta.date then + html[#html + 1] = '

' .. esc(pandoc.utils.stringify(meta.date)) .. "

" + end + html[#html + 1] = "
" + return pandoc.RawBlock("html", table.concat(html, "\n")) +end + +-- Paragraph that only holds chapter start anchors () → id of the first one. +local function chapter_anchor(block) + if block.t ~= "Para" and block.t ~= "Plain" then + return nil + end + local id = nil + for _, inl in ipairs(block.content) do + if inl.t ~= "RawInline" then + return nil + end + local found = inl.text:match('^$') or inl.text:match('^$') + if found then + id = id or found + elseif inl.text ~= "" then + return nil + end + end + return id +end + +function Pandoc(doc) + local html = FORMAT:match("html") and not FORMAT:match("epub") + local toc_at, title_at = nil, nil + for i, block in ipairs(doc.blocks) do + if is_placeholder(block, TOC_ID) then + toc_at = i + elseif is_placeholder(block, TITLE_ID) then + title_at = i + end + end + if not toc_at and not title_at then + return nil -- not a file from epub2md_merge.py + end + if not html then -- placeholders are top-level blocks: just drop them + local kept = pandoc.Blocks({}) + for _, block in ipairs(doc.blocks) do + if not is_placeholder(block, TOC_ID) and not is_placeholder(block, TITLE_ID) then + kept:insert(block) + end + end + doc.blocks = kept + return doc + end + + local blocks = pandoc.Blocks({}) + for i, block in ipairs(doc.blocks) do + local anchor = chapter_anchor(block) + if i == toc_at then + if PANDOC_WRITER_OPTIONS.table_of_contents then + -- --toc given as well: Pandoc's own table of contents stays at the top, no second one + else + local lang = doc.meta.lang and pandoc.utils.stringify(doc.meta.lang):sub(1, 2):lower() or "de" + local toc = pandoc.structure.table_of_contents(doc.blocks, PANDOC_WRITER_OPTIONS) + blocks:insert(pandoc.Div({ + pandoc.Header(1, TOC_TITLE[lang] or TOC_TITLE.de, pandoc.Attr("toc-title", { "unnumbered", "unlisted" })), + toc, + }, pandoc.Attr("TOC", { "toc" }))) + end + elseif i == title_at then + if doc.meta.title then + blocks:insert(title_block(doc.meta)) + end + elseif anchor then + blocks:insert(pandoc.Div({}, pandoc.Attr(anchor, { "chapter-start" }))) + local nxt = doc.blocks[i + 1] + if anchor:match("^%d%d%d%-cover") and nxt and nxt.t == "Figure" then + -- cover chapter: the picture without the caption "Cover", centred on its own page + local imgs = {} + nxt:walk({ Image = function(img) imgs[#imgs + 1] = img end }) + if #imgs == 1 then + doc.blocks[i + 1] = pandoc.Div({ pandoc.Plain({ imgs[1] }) }, pandoc.Attr("", { "cover" })) + end + end + else + blocks:insert(block) + end + end + doc.blocks = blocks + -- The book's own title page (or the moved block) replaces Pandoc's title block at the very top. + if doc.meta.title then + doc.meta.pagetitle = doc.meta.pagetitle or pandoc.utils.stringify(doc.meta.title) + doc.meta.title = nil + end + return doc +end diff --git a/pandoc/print.css b/pandoc/print.css index af35eb3..e68f52d 100644 --- a/pandoc/print.css +++ b/pandoc/print.css @@ -1,5 +1,7 @@ /* Print layout for PDFs from epub2md output via Pandoc + weasyprint: - pandoc buch.md -s --toc --lua-filter pandoc/callouts.lua --css pandoc/print.css --pdf-engine=weasyprint -o buch.pdf */ + pandoc buch.md -s --lua-filter pandoc/book.lua --lua-filter pandoc/callouts.lua --css pandoc/print.css \ + --pdf-engine=weasyprint -o buch.pdf + book.lua places the table of contents after cover, title page and imprint and marks chapter starts. */ @page { size: A4; margin: 2cm 1.8cm; @bottom-center { content: counter(page); font-size: 9pt; } } body { max-width: none; font-size: 10.5pt; line-height: 1.4; hyphens: auto; text-align: justify; font-family: "DejaVu Serif", "Liberation Serif", Georgia, serif; } @@ -7,13 +9,23 @@ body { max-width: none; font-size: 10.5pt; line-height: 1.4; hyphens: auto; text h1, h2, h3, h4 { text-align: left; hyphens: manual; page-break-after: avoid; } pre, code { font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; hyphens: none; } pre { background: #f6f8fa; padding: 0.5em 0.8em; border-radius: 0.3em; page-break-inside: auto; } -blockquote { margin: 1em 0 1em 1.5em; padding-left: 0.8em; border-left: 2px solid #ccc; color: #444; } -nav#TOC ul { list-style: none; padding-left: 1.2em; } -a { color: inherit; text-decoration: none; } pre, pre code { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 8.5pt; } /* long code lines wrap */ pre { overflow: visible; } -img { max-width: 100%; height: auto; } -h1 { page-break-before: always; } /* every chapter on a new page */ -#title-block-header h1, #TOC h1 { page-break-before: auto; } -nav#TOC { page-break-after: always; } +blockquote { margin: 1em 0 1em 1.5em; padding-left: 0.8em; border-left: 2px solid #ccc; color: #444; } +a { color: inherit; text-decoration: none; } +img { max-width: 100%; max-height: 24cm; height: auto; } table { font-size: 9pt; } +.cover { text-align: center; } +.cover img { max-height: 25cm; } + +/* New pages: every chapter (book.lua: div.chapter-start, also cover, title page, imprint), the title + block, the table of contents; without book.lua every

. No second break for the chapter's own

. */ +h1, .chapter-start, #title-block-header, #TOC { page-break-before: always; } +.chapter-start + h1, #title-block-header h1, #TOC h1 { page-break-before: auto; } +#TOC { page-break-after: always; } + +/* Table of contents with dotted leaders and page numbers. */ +#TOC ul { list-style: none; padding-left: 1.2em; margin: 0; } +#TOC > ul { padding-left: 0; } +#TOC li { text-align: left; } +#TOC a::after { content: leader(".") target-counter(attr(href), page); } diff --git a/tests/test_merge.py b/tests/test_merge.py index 3e02003..2aa702a 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -84,3 +84,37 @@ def test_merge_shortens_iso_date(convert): assert merge_main([str(run.out)]) == 0 text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8") assert yaml.safe_load(text.split("---\n")[1])["date"] == "01.01.2024" + + +def front_book() -> Book: + docs = [Doc("cover.xhtml", '
Cover
'), + Doc("titel.xhtml", "

Erika Musterfrau

Vorspann

Roman

"), + Doc("impressum.xhtml", "

© 2024 Testverlag

Das Werk ist urheberrechtlich geschützt.

"), + Doc("a.xhtml", "

Kapitel A

a

")] + return Book(title="Vorspann", docs=docs, nav=[("Kapitel A", "a.xhtml")], media={"img/a.png": PNG}) + + +def test_merge_places_toc_placeholder_after_front_matter(convert): + run = convert(front_book()) + assert merge_main([str(run.out)]) == 0 + text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8") + cover, title, imprint = text.index("![Cover]"), text.index("\nRoman"), text.index("© 2024") + toc, first = text.index('
'), text.index("# Kapitel A") + assert cover < title < imprint < toc < first + assert "epub2md-titlepage" not in text # the book has its own title page + + +def test_merge_title_placeholder_after_cover_without_own_title_page(convert): + docs = [Doc("cover.xhtml", '
Cover
'), Doc("a.xhtml", "

Kapitel A

a

")] + run = convert(Book(title="Ohne Titelei", docs=docs, nav=[("Kapitel A", "a.xhtml")], media={"img/a.png": PNG})) + assert merge_main([str(run.out)]) == 0 + text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8") + assert text.index("![Cover]") < text.index('
') \ + < text.index('
') < text.index("# Kapitel A") + + +def test_merge_own_toc_replaces_placeholder(convert): + run = convert(front_book()) + assert merge_main([str(run.out), "--toc"]) == 0 + text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8") + assert "epub2md-toc" not in text and text.index("© 2024") < text.index("## Inhalt") < text.index("# Kapitel A") diff --git a/tests/test_styles.py b/tests/test_styles.py index 2964d5b..b3faa65 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -191,8 +191,9 @@ def test_book_readme_suggests_callout_filter(convert): readme = (convert(Book(docs=[Doc("c.xhtml", body)])).out / "README.md").read_text(encoding="utf-8") assert "--lua-filter" in readme and "pandoc/callouts.lua" in readme and "1 Infokasten " in readme assert "pandoc/print.css --pdf-engine=weasyprint" in readme + assert "pandoc/book.lua --lua-filter" in readme # table of contents after the front matter plain = convert(Book(docs=[Doc("c.xhtml", body)]), "--boxes", "plain").out / "README.md" - assert "--lua-filter" not in plain.read_text(encoding="utf-8") + assert "callouts.lua" not in plain.read_text(encoding="utf-8") def test_hidden_span_gets_no_field_separator_and_class_type_wins(convert): From 86aa93c12cb61d6809fa851a784e07ce044234d6 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 06:48:18 +0200 Subject: [PATCH 15/24] =?UTF-8?q?feat:=20Buchanfang=20Cover=20=E2=86=92=20?= =?UTF-8?q?Titel=20=E2=86=92=20Impressum=20=E2=86=92=20Inhaltsverzeichnis?= =?UTF-8?q?=20f=C3=BCr=20alle=20B=C3=BCcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kapitelerkennung: erstes Kapitel nur aus einem Bild = Cover ("[Titelinfo]", "Deckblatt"), Titel "Titel" = Titelseite - epub2md_merge: Vorspann reicht bis vor den Hauptteil (Teil, Vorwort, Einleitung, nummerierter Titel, > 1000 Wörter), kurze Zwischenseiten (Auflage, Verlagsbrief, "Über dieses Buch") bleiben davor - book.lua: Cover am Inhalt erkannt; LaTeX-Weg (Filter in beiden Schritten): \tableofcontents hinter dem Vorspann, \clearpage je Kapitel, kein \maketitle vor dem Cover, A4, seitengroßes Cover, DejaVu-Schriften und Mathe-Symbole für fehlende Glyphen (Mathebuch: >10.000 fehlende Zeichen → 0) - Buch-README: bei Formeln LaTeX-Weg empfohlen, sonst weasyprint - Alle sechs Beispielbücher geprüft: Seite 1 Cover, Verzeichnis nach dem Vorspann Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 21 +++--- CHANGELOG.md | 7 +- README.md | 20 ++++-- epub2md/chapters.py | 13 +++- epub2md/merge.py | 32 +++++++-- epub2md/writer.py | 29 +++++---- pandoc/book.lua | 144 +++++++++++++++++++++++++++++++++++++++-- tests/test_chapters.py | 15 +++++ tests/test_merge.py | 18 ++++++ tests/test_qa2_open.py | 4 +- 10 files changed, 257 insertions(+), 46 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 72e83bf..0ef72d1 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -323,7 +323,7 @@ P=~/Python_Programs/epub2md/pandoc # Pfad zu deinem epub2md-Ordner anpassen | E-Book (EPUB) | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.epub` | | Word | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.docx` | | LibreOffice | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.odt` | -| PDF mit LaTeX | `pandoc meinbuch.md -s --lua-filter $P/callouts.lua -o meinbuch.html && pandoc meinbuch.html --toc --lua-filter $P/callouts.lua --pdf-engine=xelatex -o meinbuch.pdf` | +| PDF mit LaTeX (Bücher mit Formeln) | `pandoc meinbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html && pandoc meinbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex -o meinbuch.pdf` | | Kindle (AZW3) | erst EPUB wie oben, dann `ebook-convert meinbuch.epub meinbuch.azw3` | Die drei Hilfsdateien: @@ -331,9 +331,11 @@ Die drei Hilfsdateien: - **`book.lua`** ordnet den Buchanfang wie im gedruckten Buch: Cover, Titelseite, Impressum, dann das Inhaltsverzeichnis, jeweils auf einer neuen Seite. Ohne den Filter setzt Pandoc mit `--toc` das Inhaltsverzeichnis (und seinen eigenen Titelkopf) vor das Cover. Mit `book.lua` deshalb **kein** `--toc` - angeben. Hat das Buch keine eigene Titelseite, setzt der Filter Pandocs Titelkopf hinter das Cover. Er - wirkt nur bei HTML und beim PDF über weasyprint; EPUB, Word und LibreOffice bauen ihr Verzeichnis mit - `--toc` selbst. + angeben. Hat das Buch keine eigene Titelseite, setzt der Filter Pandocs Titelkopf hinter das Cover. Das + gilt für HTML und für beide PDF-Wege (beim LaTeX-Weg den Filter in beiden Schritten angeben); EPUB, Word + und LibreOffice bauen ihr Verzeichnis mit `--toc` selbst. Kurze Seiten zwischen Titelseite und Impressum + (Auflagenvermerk, Verlagsbrief, „Über dieses Buch“) bleiben im Vorspann; das Inhaltsverzeichnis folgt vor + dem ersten Teil, Vorwort oder nummerierten Kapitel. - **`callouts.lua`** zeichnet die Infokästen (`> [!TIP]`). Pandoc in der Ubuntu-Version kennt diese Schreibweise nicht und druckt „[!TIP]“ sonst einfach mit. In HTML, EPUB und im PDF über weasyprint werden daraus gerahmte, farbige Kästen mit Titel („Tipp: …“), im PDF über LaTeX und in Word/LibreOffice ein @@ -346,12 +348,13 @@ Du musst die Befehle nicht abtippen: Die `README.md` im Ausgabeordner enthält s **Zum PDF:** -- **Mit weasyprint** (empfohlen) genügt ein Schritt. Tabellen, Farben, Infokästen und die Gestaltung aus - `--styles html` bleiben erhalten, der Buchanfang ist richtig geordnet. Für Bücher mit vielen Formeln ist - der LaTeX-Weg sicherer. +- **Mit weasyprint** (empfohlen für Bücher ohne Formeln) genügt ein Schritt. Tabellen, Farben, + Infokästen und die Gestaltung aus `--styles html` bleiben erhalten. - **Mit LaTeX** immer den Zwischenschritt über HTML nehmen, wie in der Tabelle. Beim direkten Weg von - Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Das - Inhaltsverzeichnis steht hier am Anfang, vor dem Cover. + Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Für + Bücher mit Formeln ist das der richtige Weg, weil weasyprint keine Formeln setzen kann. Farben und + Kastenrahmen gehen dabei verloren; Kästen erscheinen als eingerückter Block mit fettem Titel. `book.lua` + stellt dabei A4 ein und wählt die Schrift DejaVu, die auch Zeichen wie ∈, λ oder ≠ im Fließtext enthält. Bei Büchern mit Formeln für HTML zusätzlich `--mathml` angeben, sonst bleiben Matrizen als TeX-Text stehen. diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e40f1..63f0e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,12 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/). davor (Pandocs `--toc` setzt es immer an den Anfang). `epub2md_merge.py` markiert dafür das Ende des Vorspanns; ohne eigene Titelseite kommt Pandocs Titelkopf hinter das Cover. Jedes Kapitel, auch Cover, Titelseite und Impressum, beginnt mit `print.css` auf einer neuen Seite; das Inhaltsverzeichnis hat - Seitenzahlen. Mit `epub2md_merge.py --toc` steht dessen Verzeichnis an derselben Stelle. + Seitenzahlen. Mit `epub2md_merge.py --toc` steht dessen Verzeichnis an derselben Stelle. Gilt auch für den + PDF-Weg über LaTeX (`\tableofcontents` hinter dem Vorspann); bei Büchern mit Formeln empfiehlt die + Buch-README diesen Weg. Dort setzt der Filter außerdem A4, ein seitengroßes Cover und DejaVu-Schriften, + sodass keine Zeichen mehr fehlen (Mathebuch: vorher über 10.000 fehlende Glyphen wie ∈, λ, ≠). +- Kapitelerkennung: Ein erstes Kapitel nur aus einem Bild ist das Cover (auch „[Titelinfo]“, „Deckblatt“), + ein Kapitel „Titel“ die Titelseite. Der Vorspann darf kurze Zwischenseiten enthalten. - `pandoc/print.css`: Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite, Silbentrennung außer in Code, umbrechende Codezeilen). Die Buch-README enthält den passenden Befehl. diff --git a/README.md b/README.md index c91bfab..14af3d8 100644 --- a/README.md +++ b/README.md @@ -337,10 +337,10 @@ pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.l pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o programmierbuch.html pandoc programmierbuch.md --toc --lua-filter $P/callouts.lua -o programmierbuch.epub -cd output/mathematikbuch -pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html -# PDF über den Zwischenschritt HTML (siehe unten) -pandoc mathematikbuch.html --toc --pdf-engine=xelatex -o mathematikbuch.pdf +cd output/mathematikbuch # Formeln: PDF über HTML und LaTeX (weasyprint setzt kein MathML) +pandoc mathematikbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o mathematikbuch.html +pandoc mathematikbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex \ + -o mathematikbuch.pdf ``` Hilfsdateien in `pandoc/`: @@ -351,8 +351,16 @@ Hilfsdateien in `pandoc/`: keine eigene Titelseite hat, die Stelle hinter dem Cover (`
`). Der Filter baut dort das Inhaltsverzeichnis (`--toc-depth` gilt), lässt Pandocs Titelkopf bei vorhandener Titelseite weg, zeigt das Cover ohne Bildunterschrift und macht aus den Kapitelankern - `div.chapter-start` für Seitenumbrüche. Bei anderen Formaten entfernt er nur die Platzhalter. Wer selbst - `--toc` angibt, bekommt Pandocs Verzeichnis wie gewohnt. + `div.chapter-start` für Seitenumbrüche. Beim LaTeX-Weg (Filter in beiden Schritten) wird daraus + `\tableofcontents` an derselben Stelle, `\clearpage` vor jedem Kapitel und kein `\maketitle` vor dem Cover. + Außerdem A4 mit 2 cm Rand, das Cover in Seitengröße und – falls installiert und nicht selbst gewählt – + DejaVu-Schriften (`mainfont`, `monofont`, `mathfont`): Latin Modern fehlen Zeichen wie ∈ λ ≠ ≥ □ und + schmale Leerzeichen (im Mathebuch über 10.000 Stellen). ℓ, 〈 〉, ∖, ⋯ und „↩︎“ werden als Mathe-Symbole gesetzt. + Bei anderen Formaten entfernt er nur die Platzhalter. Wer selbst `--toc` angibt, bekommt Pandocs + Verzeichnis wie gewohnt. + - Der Vorspann reicht bis vor das erste Kapitel des Hauptteils (Teil, Vorwort, Einleitung, Prolog, + nummerierter Titel oder mehr als 1000 Wörter), sofern er Cover, Titelseite oder Impressum enthält. Ein + erstes Kapitel nur aus einem Bild gilt als Cover, ein Kapitel „Titel“ als Titelseite. - **`print.css`** (weasyprint): A4, Seitenzahlen, jedes Kapitel (auch Cover, Titelseite, Impressum) und das Inhaltsverzeichnis auf neuer Seite, Inhaltsverzeichnis mit Punktlinie und Seitenzahl, Silbentrennung (nicht in Code), umbrechende Codezeilen. Mit `--css` lässt Pandoc sein eigenes Stylesheet weg, deshalb diff --git a/epub2md/chapters.py b/epub2md/chapters.py index 0b16de3..2384eb7 100644 --- a/epub2md/chapters.py +++ b/epub2md/chapters.py @@ -45,8 +45,8 @@ KIND_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ("appendix", re.compile(r"^(anhang|appendix)", re.I)), ("colophon", re.compile(r"^(impressum|colophon|kolophon)", re.I)), ("dedication", re.compile(r"^(widmung|dedication)", re.I)), - ("cover", re.compile(r"^(cover|umschlag|titelbild)\b", re.I)), - ("titlepage", re.compile(r"^(titelei|titelseite|title ?page)", re.I)), + ("cover", re.compile(r"^(cover|umschlag|titelbild|deckblatt)\b", re.I)), + ("titlepage", re.compile(r"^(titelei|titelseite|title ?page|titel\s*$|title\s*$)", re.I)), ("part", re.compile(r"^(teil|part)\s+([IVXLC]+|\d+|[a-zäöü]+)\b", re.I)), ("epilogue", re.compile(r"^(epilog|nachwort|epilogue|afterword)", re.I)), ("prologue", re.compile(r"^(prolog|prologue)", re.I)), @@ -307,12 +307,21 @@ class ChapterBuilder: kind = classify_title(ch.title) if kind and kind not in ch.types: ch.types.append(kind) + if ch.number == 1 and "cover" not in ch.types and _image_only(ch.items): + ch.types.append("cover") # first chapter with nothing but a picture, whatever its label ('[Titelinfo]') if "index" in ch.types and classify_title(ch.title) == "index-persons": ch.types.remove("index") if not ch.types: ch.types.append("chapter") +def _image_only(items: list[tuple[str, Any]]) -> bool: + """Content is one or more pictures without any text.""" + text = "".join(it if isinstance(it, str) else text_content(it) for _, it in items if isinstance(it, str) or is_element(it)) + return not text.strip() and any(is_element(it) and any(local(e.tag) in ("img", "svg", "image") for e in iter_elements(it)) + for _, it in items) + + @dataclass class _TypeCarrier: types: list[str] diff --git a/epub2md/merge.py b/epub2md/merge.py index 0eb2532..5a052f9 100644 --- a/epub2md/merge.py +++ b/epub2md/merge.py @@ -47,6 +47,7 @@ class ChapterFile: start_anchor: str = "" anchor_map: dict[str, str] = field(default_factory=dict) types: list[str] = field(default_factory=list) + words: int = 0 @dataclass @@ -75,12 +76,13 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]: report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {} if manifest_path.is_file(): manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - entries = [(c["number"], c["title"], c["file"], c.get("types") or []) for c in manifest.get("chapters", [])] + entries = [(c["number"], c["title"], c["file"], c.get("types") or [], c.get("word_count", 0)) + for c in manifest.get("chapters", [])] else: files = sorted((out_dir / "chapters").glob("*.md")) - entries = [(i + 1, p.stem, f"chapters/{p.name}", []) for i, p in enumerate(files)] - chapters = [ChapterFile(n, t, rel, types=list(ty)) for n, t, rel, ty in entries if (out_dir / rel).is_file()] - missing = [rel for _, _, rel, _ in entries if not (out_dir / rel).is_file()] + entries = [(i + 1, p.stem, f"chapters/{p.name}", [], 0) for i, p in enumerate(files)] + chapters = [ChapterFile(n, t, rel, types=list(ty), words=w) for n, t, rel, ty, w in entries if (out_dir / rel).is_file()] + missing = [rel for _, _, rel, _, _ in entries if not (out_dir / rel).is_file()] if missing: raise MergeError(f"Kapiteldateien fehlen: {', '.join(missing[:5])}") if not chapters: @@ -97,9 +99,25 @@ TOC_PLACEHOLDER = '
' # pandoc/book.lua puts the tab TITLE_PLACEHOLDER = '
' # … and Pandoc's title block here (no own title page) +# Where the main matter starts: a part, preface, introduction …, a numbered title or a longer text. +MAIN_TYPES = {"part", "preface", "foreword", "introduction", "prologue", "preamble", "appendix", "bodymatter", "toc"} +NUMBERED_TITLE = re.compile(r"^\s*(\d+|[IVXLC]+\.\s|teil\b|kapitel\b|part\b|chapter\b|buch\b|book\b)", re.I) +FRONT_MAX_WORDS = 1000 +FRONT_MAX_CHAPTERS = 12 + + def front_matter_count(chapters: list[ChapterFile]) -> int: - """Number of leading chapters that are front matter (cover, title page, imprint …).""" - n = 0 + """Number of leading chapters before the table of contents: cover, title page, imprint and the short + pages between them (series page, edition note, publisher's letter …) up to the start of the main matter.""" + k = 0 + while k < min(len(chapters), FRONT_MAX_CHAPTERS): + ch = chapters[k] + if MAIN_TYPES & set(ch.types) or NUMBERED_TITLE.match(ch.title) or ch.words > FRONT_MAX_WORDS: + break + k += 1 + if k < len(chapters) and any(FRONT_TYPES & set(c.types) for c in chapters[:k]): + return k + n = 0 # no clear start of the main matter: only the unbroken run of typed front matter while n < len(chapters) and FRONT_TYPES & set(chapters[n].types): n += 1 return n @@ -257,7 +275,7 @@ def merge(out_dir: Path, target: Path | None = None, *, frontmatter: bool = True if fm: parts.append("---\n" + to_yaml(fm) + "---") front = front_matter_count(chapters) - has_title_page = any("titlepage" in ch.types for ch in chapters[:front]) + has_title_page = any({"titlepage", "halftitlepage"} & set(ch.types) for ch in chapters[:front]) cover_first = bool(chapters) and "cover" in chapters[0].types if toc: lines = ["## Inhalt" if (report.get("metadata") or {}).get("language", "de").startswith("de") else "## Contents", ""] diff --git a/epub2md/writer.py b/epub2md/writer.py index f00c8de..cf4333b 100644 --- a/epub2md/writer.py +++ b/epub2md/writer.py @@ -139,23 +139,26 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, if callouts and CALLOUT_FILTER.is_file(): # '> [!TIP]' boxes: Pandoc needs the filter to draw real boxes lua = f" --lua-filter {shlex.quote(str(CALLOUT_FILTER))}" # book.lua: table of contents after cover/title page/imprint instead of Pandoc's --toc at the very top - toc = f" --lua-filter {shlex.quote(str(BOOK_FILTER))}" if BOOK_FILTER.is_file() else " --toc" + book = f" --lua-filter {shlex.quote(str(BOOK_FILTER))}" if BOOK_FILTER.is_file() else "" + toc = book or " --toc" + md_, html_, pdf_ = (shlex.quote(stem + ext) for ext in (".md", ".html", ".pdf")) + latex = [ + "# PDF mit LaTeX über HTML: Pandoc übernimmt dann auch HTML-Tabellen und interne Sprungziele", + f"pandoc {md_} -s{math}{book}{lua} -o {html_} && pandoc {html_}{toc}{lua} --pdf-engine=xelatex -o {pdf_}", + ] + weasy = [ + "# PDF mit weasyprint: Farben, Kästen und HTML-Stile bleiben erhalten" + ("" if math else " (empfohlen)"), + f"pandoc {md_} -s{math}{toc}{lua} --css {shlex.quote(str(PRINT_CSS))} --pdf-engine=weasyprint -o {pdf_}", + ] if PRINT_CSS.is_file() else [] + if math: + latex[0] += " (empfohlen: weasyprint kann keine Formeln setzen)" 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')} -s{math}{toc}{lua} -o {shlex.quote(stem + '.html')}", - f"pandoc {shlex.quote(stem + '.md')} --toc{lua} -o {shlex.quote(stem + '.epub')}", - "# PDF über HTML: Pandoc übernimmt dann auch HTML-Tabellen und interne Sprungziele", - f"pandoc {shlex.quote(stem + '.md')} -s{math}{lua} -o {shlex.quote(stem + '.html')} && " - f"pandoc {shlex.quote(stem + '.html')} --toc{lua} --pdf-engine=xelatex -o {shlex.quote(stem + '.pdf')}", - ] - if PRINT_CSS.is_file(): - commands += [ - "# PDF ohne LaTeX mit weasyprint: Farben, Kästen und HTML-Stile bleiben erhalten", - f"pandoc {shlex.quote(stem + '.md')} -s{math}{toc}{lua} --css {shlex.quote(str(PRINT_CSS))} " - f"--pdf-engine=weasyprint -o {shlex.quote(stem + '.pdf')}", - ] + f"pandoc {md_} -s{math}{toc}{lua} -o {html_}", + f"pandoc {md_} --toc{lua} -o {shlex.quote(stem + '.epub')}", + ] + (latex + weasy if math else weasy + latex) out.append("## Weiterverarbeitung") out.append("```bash\n" + "\n".join(commands) + "\n```") if callouts: diff --git a/pandoc/book.lua b/pandoc/book.lua index ef78a17..67b76b3 100644 --- a/pandoc/book.lua +++ b/pandoc/book.lua @@ -10,11 +10,13 @@ i.e. before the book's cover, title page and imprint. epub2md_merge.py marks the This filter, for HTML output: * builds the table of contents at the placeholder (use it INSTEAD of --toc; --toc-depth is honoured), * drops Pandoc's title block when the book has its own title page, or moves it to the placeholder, - * shows the cover picture (chapter "NNN-cover") without the caption "Cover", + * shows the cover (first chapter with nothing but one picture) without the caption "Cover", * turns the chapter anchors into
so that print.css can start every chapter – including cover, title page and imprint – on a new page. -For other formats it only removes the placeholders (EPUB, DOCX, ODT: use --toc there). +For LaTeX (PDF route md -> html -> pdf, filter in BOTH steps, no --toc) the table of contents becomes +\tableofcontents at the same place, every chapter starts with \clearpage and there is no \maketitle +before the cover. For other formats it only removes the placeholders (EPUB, DOCX, ODT: use --toc there). Usage: pandoc buch.md -s --lua-filter /pfad/zu/epub2md/pandoc/book.lua \ @@ -73,7 +75,123 @@ local function chapter_anchor(block) return id end +-- Characters in running text that even DejaVu lacks: typeset as math symbols ("↩︎": footnote back-links). +local LATEX_SYMBOLS = { ["↩"] = "$\\hookleftarrow$", ["ℓ"] = "$\\ell$", ["\u{2329}"] = "$\\langle$", + ["\u{232A}"] = "$\\rangle$", ["\u{3008}"] = "$\\langle$", ["\u{3009}"] = "$\\rangle$", + ["∖"] = "$\\setminus$", ["⋯"] = "$\\cdots$", ["\u{FE0E}"] = "" } +local LATEX_FONTS = { mainfont = "DejaVu Serif", monofont = "DejaVu Sans Mono", mathfont = "DejaVu Math TeX Gyre" } + +local function font_installed(name) + local pipe = io.popen("fc-list -q " .. string.format("%q", name) .. " && echo yes") + local out = pipe and pipe:read("a") or "" + if pipe then + pipe:close() + end + return out:match("yes") ~= nil +end + +local function latex_symbols(str) + local found = false + for _, c in utf8.codes(str.text) do + if LATEX_SYMBOLS[utf8.char(c)] then + found = true + break + end + end + if not found then + return nil + end + local out, buf = pandoc.Inlines({}), {} + for _, c in utf8.codes(str.text) do + local ch = utf8.char(c) + local tex = LATEX_SYMBOLS[ch] + if tex then + if #buf > 0 then + out:insert(pandoc.Str(table.concat(buf))) + buf = {} + end + if tex ~= "" then + out:insert(pandoc.RawInline("latex", tex)) + end + else + buf[#buf + 1] = ch + end + end + if #buf > 0 then + out:insert(pandoc.Str(table.concat(buf))) + end + return out +end + +-- LaTeX (2nd step of the PDF route md -> html -> pdf): the HTML from step 1 already holds the table of +-- contents (div#TOC.toc), the chapter starts (div.chapter-start) and, if moved, Pandoc's title block. +local function latex_book(doc) + local found = false + doc.blocks:walk({ Div = function(d) + if d.identifier == "TOC" and d.classes:includes("toc") or d.classes:includes("chapter-start") then + found = true + end + end }) + if not found then + return nil + end + local blocks = pandoc.Blocks({}) + for _, block in ipairs(doc.blocks) do + if block.t == "Div" and block.identifier == "TOC" and block.classes:includes("toc") then + blocks:insert(pandoc.RawBlock("latex", "\\clearpage\n\\tableofcontents\n\\clearpage")) + elseif block.t == "Div" and block.classes:includes("chapter-start") then + blocks:insert(pandoc.RawBlock("latex", "\\clearpage")) + blocks:insert(block) + elseif block.t == "Div" and block.classes:includes("cover") then + blocks:insert(block:walk({ Image = function(img) -- cover as large as the page allows + img.attributes.width = nil + img.attributes.height = "20cm" + return img + end })) + elseif block.t == "Div" and block.identifier == "title-block-header" then + blocks:insert(pandoc.RawBlock("latex", "\\clearpage\n\\begin{center}\n\\vspace*{3cm}")) + for _, b in ipairs(block.content) do + if b.t == "Header" then + local title = pandoc.Inlines({ pandoc.RawInline("latex", "{\\Huge\\bfseries ") }) + title:extend(b.content) + title:insert(pandoc.RawInline("latex", "}")) + blocks:insert(pandoc.Para(title)) + else + blocks:insert(b) + end + end + blocks:insert(pandoc.RawBlock("latex", "\\end{center}")) + else + blocks:insert(block) + end + end + doc.blocks = blocks:walk({ Str = latex_symbols }) + -- fonts with a wide Unicode coverage (∈ λ ≠ ≥ □, thin spaces …), unless the user chose fonts himself + -- A4 with moderate margins, like print.css (Pandoc's LaTeX default: US letter, wide margins) + doc.meta.papersize = doc.meta.papersize or "a4" + doc.meta.geometry = doc.meta.geometry or "margin=2cm" + for var, font in pairs(LATEX_FONTS) do + if doc.meta[var] == nil and font_installed(font) then + doc.meta[var] = font + end + end + -- no \maketitle at the very top (before the cover); keep the PDF metadata + if doc.meta.title then + doc.meta["title-meta"] = pandoc.utils.stringify(doc.meta.title) + doc.meta.title = nil + end + if doc.meta.author then + doc.meta["author-meta"] = pandoc.utils.stringify(doc.meta.author) + doc.meta.author = nil + end + doc.meta.date = nil + return doc +end + function Pandoc(doc) + if FORMAT:match("latex") then + return latex_book(doc) + end local html = FORMAT:match("html") and not FORMAT:match("epub") local toc_at, title_at = nil, nil for i, block in ipairs(doc.blocks) do @@ -98,6 +216,7 @@ function Pandoc(doc) end local blocks = pandoc.Blocks({}) + local seen_chapter = false for i, block in ipairs(doc.blocks) do local anchor = chapter_anchor(block) if i == toc_at then @@ -117,15 +236,26 @@ function Pandoc(doc) end elseif anchor then blocks:insert(pandoc.Div({}, pandoc.Attr(anchor, { "chapter-start" }))) - local nxt = doc.blocks[i + 1] - if anchor:match("^%d%d%d%-cover") and nxt and nxt.t == "Figure" then - -- cover chapter: the picture without the caption "Cover", centred on its own page - local imgs = {} + -- cover: the first chapter, if it holds nothing but one picture ('Cover', '[Titelinfo]', 'Deckblatt' …); + -- shown without the caption "Cover", centred on its own page + local nxt, after = doc.blocks[i + 1], doc.blocks[i + 2] + local ends = after == nil or chapter_anchor(after) ~= nil or is_placeholder(after, TOC_ID) + or is_placeholder(after, TITLE_ID) + if not seen_chapter and nxt and (nxt.t == "Figure" or nxt.t == "Para") and ends then + local imgs, text = {}, false nxt:walk({ Image = function(img) imgs[#imgs + 1] = img end }) - if #imgs == 1 then + if nxt.t == "Para" then + for _, inl in ipairs(nxt.content) do + if inl.t ~= "Image" and inl.t ~= "Space" and inl.t ~= "SoftBreak" then + text = true + end + end + end + if #imgs == 1 and not text then doc.blocks[i + 1] = pandoc.Div({ pandoc.Plain({ imgs[1] }) }, pandoc.Attr("", { "cover" })) end end + seen_chapter = true else blocks:insert(block) end diff --git a/tests/test_chapters.py b/tests/test_chapters.py index c98dd87..75d71fd 100644 --- a/tests/test_chapters.py +++ b/tests/test_chapters.py @@ -185,3 +185,18 @@ def test_special_chapter_is_not_split_further(convert): "

Register

A

Apfel

Index

x

") run = convert(Book(docs=[Doc("a.xhtml", body)])) assert [p.name for p in run.chapters()] == ["001-literatur.md", "002-register.md"] + + +def test_first_image_only_chapter_is_cover_and_titel_is_title_page(convert): + from conftest import PNG + docs = [Doc("info.xhtml", '
', "[Titelinfo]"), + Doc("t.xhtml", "

Erika Musterfrau

Testbuch

", "Titel"), + Doc("d.xhtml", '
Deckblatt
', "Deckblatt"), + Doc("c1.xhtml", "

Eins

Text

")] + run = convert(Book(docs=docs, nav=[("Eins", "c1.xhtml")], media={"c.png": PNG})) + types = {c["file"]: c["types"] for c in run.report["chapters"]} + first, second, third = sorted(types)[:3] + assert "cover" in types[first] + assert "titlepage" in types[second] + assert "cover" in types[third] # 'Deckblatt' by title + assert "cover" not in types[sorted(types)[3]] diff --git a/tests/test_merge.py b/tests/test_merge.py index 2aa702a..1a2d122 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -118,3 +118,21 @@ def test_merge_own_toc_replaces_placeholder(convert): assert merge_main([str(run.out), "--toc"]) == 0 text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8") assert "epub2md-toc" not in text and text.index("© 2024") < text.index("## Inhalt") < text.index("# Kapitel A") + + +def test_merge_front_matter_region_with_gaps(convert): + """Krooss/Blake-like: short untyped pages between title page and imprint; main matter starts at '1 …'.""" + docs = [Doc("cover.xhtml", '
Cover
'), + Doc("titel.xhtml", "

Erika Musterfrau

Vorspann

Roman

"), + Doc("auflage.xhtml", "

Vorspann

4. Auflage 2025

"), + Doc("impressum.xhtml", "

© 2024 Testverlag

Das Werk ist urheberrechtlich geschützt.

"), + Doc("brief.xhtml", "

Liebe Leserin, lieber Leser,

Viel Spaß.

"), + Doc("k1.xhtml", "

1 Der Einstieg

a

"), Doc("k2.xhtml", "

Zwischenspiel

b

")] + book = Book(title="Vorspann", docs=docs, nav=[("1 Der Einstieg", "k1.xhtml"), ("Zwischenspiel", "k2.xhtml")], + media={"img/a.png": PNG}) + run = convert(book) + assert merge_main([str(run.out)]) == 0 + text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8") + toc = text.index('
') + assert text.index("© 2024") < text.index("Viel Spaß.") < toc < text.index("# 1 Der Einstieg") + assert text.count("epub2md-toc") == 1 and "epub2md-titlepage" not in text diff --git a/tests/test_qa2_open.py b/tests/test_qa2_open.py index 35f3e7b..e9b1e41 100644 --- a/tests/test_qa2_open.py +++ b/tests/test_qa2_open.py @@ -32,7 +32,9 @@ def test_qa2_10_book_readme_recommends_pdf_via_html(convert): """Pandoc drops raw HTML (tables, ) when going Markdown -> LaTeX; the HTML route keeps both.""" run = convert(Book(docs=[Doc("a.xhtml", "

A

x

")])) readme = (run.out / "README.md").read_text(encoding="utf-8") - assert re.search(r"pandoc \S+\.md -s -o \S+\.html && pandoc \S+\.html --toc --pdf-engine=xelatex -o \S+\.pdf", readme) + book = r"--lua-filter \S+/pandoc/book\.lua" # table of contents after the front matter (both steps) + assert re.search(rf"pandoc \S+\.md -s {book} -o \S+\.html && pandoc \S+\.html {book} --pdf-engine=xelatex -o \S+\.pdf", + readme) def test_qa2_11_code_language_hint(convert): From 344e0d12b6b75c49e1400bc5d7b20cb88be927e9 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 07:08:37 +0200 Subject: [PATCH 16/24] docs: README und Bedienungsanleitung auf den aktuellen Stand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kapitelbildung: Cover-Erkennung am Inhalt, Titel "Deckblatt"/"Titel" - Merge: --toc hinter dem Vorspann, unsichtbare Platzhalter für book.lua, Datumsformat im Buch-Kopf - book.lua gilt für HTML und beide PDF-Wege - Tests: test_styles.py und neue Merge-Tests beschrieben Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 6 ++++-- README.md | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 0ef72d1..a0e85ab 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -115,12 +115,14 @@ python3 epub2md_merge.py output/meinbuch ``` Ergebnis: `output/meinbuch/meinbuch.md`. Die Datei heißt wie das Original-E-Book, nur mit `.md`. -Bilder und Querverweise funktionieren darin weiter. +Bilder und Querverweise funktionieren darin weiter. Am Anfang stehen die Buchdaten (Titel, Autor, Verlag, +Datum als `13.01.2025`), die Pandoc für die Titelseite verwendet. Nützliche Varianten: ```bash -# mit Inhaltsverzeichnis am Anfang +# mit Inhaltsverzeichnis (Liste der Kapitel) hinter Cover, Titelseite und Impressum – +# für PDFs mit Pandoc nicht nötig, dort baut book.lua ein Verzeichnis mit Seitenzahlen (Kapitel 7) python3 epub2md_merge.py output/meinbuch --toc # an einen anderen Ort schreiben (Bildpfade werden automatisch angepasst) diff --git a/README.md b/README.md index 14af3d8..3e07c04 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,10 @@ output/mein-buch/ - ©/ISBN/„urheberrechtlich“ → „Impressum“ - sonst die ersten acht Wörter - erst als letzte Notlösung der Dateiname + + Unabhängig vom Titel gilt ein erstes Kapitel, das nur aus einem Bild besteht, als Cover (z. B. + „[Titelinfo]“); die Titel „Deckblatt“/„Umschlag“ ergeben ein Cover, „Titel“/„Titelei“ eine Titelseite. + Diese Typen stehen in `manifest.json` und bestimmen in der Gesamtdatei die Stelle des Inhaltsverzeichnisses. 5. Ein HTML-Inhaltsverzeichnis wird übersprungen, wenn NAV/NCX vorhanden ist (keine doppelte Ausgabe). Links darauf zeigen auf `README.md`. 6. Verzeichnisse erhalten immer eine eigene Datei, auch unterhalb von `--chapter-level`, z. B. ein @@ -296,7 +300,7 @@ verwendet und das Verzeichnis gibt den Namen vor. | --- | --- | | `-o`, `--output DATEI.md` | anderer Zielort; Medienpfade werden relativ zum Ziel umgeschrieben | | `--frontmatter` / `--no-frontmatter` | ein YAML-Kopf mit Buchmetadaten (`title`, `author`, `lang`, …) für Pandoc (Standard: an) | -| `--toc` | Inhaltsverzeichnis mit Sprunglinks auf die Kapitel einfügen | +| `--toc` | Inhaltsverzeichnis mit Sprunglinks auf die Kapitel einfügen, hinter Cover, Titelseite und Impressum (für Pandoc-PDFs besser `pandoc/book.lua`, das Seitenzahlen und Unterabschnitte liefert) | | `--force` | bestehende Zieldatei überschreiben (sonst Abbruch mit Exit 1) | | `--version` | Programmversion | @@ -312,6 +316,11 @@ Beim Zusammenfügen passiert Folgendes: - Codeblöcke und Inline-Code werden nie verändert. - Kapitelüberschriften bleiben `#`, jedes Kapitel beginnt also mit einer H1. Pandoc behandelt sie als Kapitel. +- Hinter dem Vorspann (Cover, Titelseite, Impressum und kurze Seiten dazwischen, bis vor den ersten Teil, + das Vorwort oder das erste nummerierte Kapitel) steht ein unsichtbarer Platzhalter + `
`; hat das Buch keine eigene Titelseite, steht hinter dem Cover zusätzlich + `
`. `pandoc/book.lua` setzt dort Inhaltsverzeichnis und Titelkopf ein; + GitHub, Obsidian und andere Programme zeigen die leeren `div`s nicht an. - Der Buch-Kopf enthält nur Felder, die im EPUB vorhanden sind (`title`, `subtitle`, `author`, `lang`, `publisher`, `date`, `identifier`, `rights`, `description`). Ein ISO-Zeitstempel als Datum wird gekürzt, damit Pandoc auf der Titelseite nicht `2025-01-13T08:00:00+00:00` druckt: bei deutschen Büchern zu @@ -345,7 +354,7 @@ pandoc mathematikbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua Hilfsdateien in `pandoc/`: -- **`book.lua`** (HTML, PDF über weasyprint; statt `--toc`): Pandocs `--toc` setzt Inhaltsverzeichnis und +- **`book.lua`** (HTML und beide PDF-Wege; statt `--toc`): Pandocs `--toc` setzt Inhaltsverzeichnis und Titelkopf immer an den Anfang, vor Cover, Titelseite und Impressum. `epub2md_merge.py` markiert deshalb das Ende des Vorspanns (`
`, Kapiteltypen aus `manifest.json`) und, falls das Buch keine eigene Titelseite hat, die Stelle hinter dem Cover (`
`). Der @@ -486,7 +495,10 @@ ruff check epub2md tests && mypy epub2md --ignore-missing-imports # optional, Die Tests erzeugen synthetische Minimal-EPUBs (EPUB 2/3, NAV/NCX, Code, Tabellen, MathML, SVG, Fußnoten, Glossar/Literatur/Register, unsichere ZIP-Pfade, DRM, XXE, defekte Container, Kodierung) und prüfen die tatsächliche CLI-Ausgabe. `tests/test_merge.py` prüft die Gesamtdatei: Reihenfolge, -Anker-Umbenennung, Pfade, Fußnoten, unveränderter Code und Überschreibschutz. Liegen EPUBs unter `Beispiele/`, werden sie zusätzlich auf +Anker-Umbenennung, Pfade, Fußnoten, unveränderter Code, Überschreibschutz, Datumsformat und die Stelle des +Inhaltsverzeichnisses hinter dem Vorspann. `tests/test_styles.py` prüft die Typografie: CSS-Hervorhebungen, +unsichtbare Zeichen, Szenenwechsel, Gedichte, Infokästen, `--styles`, `--page-markers` und die Pandoc-Befehle +in der Buch-README. Liegen EPUBs unter `Beispiele/`, werden sie zusätzlich auf Struktur-Invarianten geprüft: keine kaputten Links, balancierte Code-Fences, gültiges YAML, alle Medien vorhanden. Dabei werden keine Buchtexte verglichen. `Beispiele/` ist per `.gitignore` vom Repository ausgeschlossen. From 568844e8c4e7c7211585b71dd4e898d629561c42 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 21:07:49 +0200 Subject: [PATCH 17/24] fix: nur eingebundene Stylesheets auswerten (QA3-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher wirkten alle CSS-Dateien des Manifests, auch mitgelieferte Layouts anderer Buchreihen. Im C-Buch (21 CSS, nur main.css verlinkt) wurden fette Buchstaben zu Inline-Code und Registerbuchstaben zu ; eine fremde display:none-Regel konnte Text löschen. _load_stylesheet wertet jetzt nur per eingebundene Stylesheets (transitiv inkl. @import) und \n", encoding="utf-8") + cp = subprocess.run(["pandoc", str(src), "-s", "-H", str(hdr), "--lua-filter", str(ROOT / "pandoc" / "callouts.lua"), + "-t", "html"], capture_output=True, text=True, timeout=120) + good = cp.returncode == 0 and ".callout-tip {" in cp.stdout + results.append({"case": "pandoc-callouts-filter-with-H", "ok": good, "rc": cp.returncode, + "notes": ["Kasten-CSS trotz -H vorhanden" if good else "Kasten-CSS fehlt bei -H"]}) + print(("PASS " if good else "FAIL ") + "pandoc-callouts-filter-with-H") + epub = OUT / "typo.epub" + cp = subprocess.run(["pandoc", str(src), "--lua-filter", str(ROOT / "pandoc" / "callouts.lua"), "-o", str(epub)], + capture_output=True, text=True, timeout=120) + import zipfile + with zipfile.ZipFile(epub) as z: + css = "".join(z.read(n).decode() for n in z.namelist() if n.endswith(".css")) + chapters = [z.read(n).decode() for n in z.namelist() if "/ch0" in n] + good = cp.returncode == 0 and ".callout-tip {" in css and "font-family" in css \ + and all("stylesheet" in c for c in chapters) and any('class="callout' in c for c in chapters) + results.append({"case": "pandoc-callouts-filter-epub", "ok": good, "rc": cp.returncode, + "notes": ["EPUB: Kasten-CSS + Standard-CSS, in allen Kapiteln verlinkt" if good else "EPUB-CSS fehlt"]}) + print(("PASS " if good else "FAIL ") + "pandoc-callouts-filter-epub") + + # --- determinism + a = case("determinism-a", "06-mathml-and-equations.epub", [], 0, out=OUT / "det-a") + b = case("determinism-b", "06-mathml-and-equations.epub", [], 0, out=OUT / "det-b") + diff = [p.name for p in (OUT / "det-a/chapters").glob("*.md") if p.read_bytes() != (OUT / "det-b/chapters" / p.name).read_bytes()] + results.append({"case": "determinism", "ok": not diff and a["ok"] and b["ok"], "notes": [f"abweichende Dateien: {diff}"] if diff else ["Kapiteldateien identisch"]}) + + (QA / "output" / "matrix-results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") + failed = [r["case"] for r in results if not r["ok"]] + print(f"\n{len(results)} Fälle, {len(results) - len(failed)} ok, {len(failed)} fehlgeschlagen: {failed}") + + +if __name__ == "__main__": + main() diff --git a/tools/qa/validate_output.py b/tools/qa/validate_output.py new file mode 100755 index 0000000..3ef642f --- /dev/null +++ b/tools/qa/validate_output.py @@ -0,0 +1,218 @@ +"""Unabhängiger Validator für epub2md-Ausgaben (nutzt markdown-it-py, nicht epub2md/linkcheck.py). + +Aufruf: python tools/qa/validate_output.py AUSGABEVERZEICHNIS [...] -> JSON auf stdout +""" + +from __future__ import annotations + +import ast +import hashlib +import json +import posixpath +import re +import sys +import unicodedata +from pathlib import Path +from urllib.parse import unquote + +import yaml +from markdown_it import MarkdownIt +from mdit_py_plugins.dollarmath import dollarmath_plugin +from mdit_py_plugins.footnote import footnote_plugin + +MD = MarkdownIt("commonmark").enable(["table", "strikethrough"]).use(footnote_plugin).use(dollarmath_plugin) +ATTR = re.compile(r"""\b(href|src|id)\s*=\s*"([^"]*)\"""") +NAV_RESIDUE = re.compile(r"^\s*(next|previous|prev|zurück|weiter|vorherige seite|nächste seite|table of contents|back to top)\s*$", re.I) +ENTITY_IN_CODE = re.compile(r"&(lt|gt|amp|quot|#\d+|#x[0-9a-f]+);", re.I) + + +def gh_slug(text: str) -> str: + text = unicodedata.normalize("NFC", text).strip().lower() + text = re.sub(r"[^\w\- ]", "", text) + return text.replace(" ", "-") + + +def split_frontmatter(text: str) -> tuple[dict | None, str, str | None]: + if not text.startswith("---\n"): + return None, text, None + end = text.find("\n---\n", 4) + if end < 0: + return None, text, "Frontmatter nicht geschlossen" + try: + data = yaml.safe_load(text[4:end]) + except yaml.YAMLError as exc: + return None, text[end + 5 :], f"YAML ungültig: {exc}" + return data, text[end + 5 :], None + + +def walk(tokens): + for t in tokens: + yield t + if t.children: + yield from walk(t.children) + + +def analyse(rel: str, body: str) -> dict: + tokens = MD.parse(body) + info: dict = {"links": [], "anchors": set(), "fn_refs": set(), "fn_defs": set(), "code": [], "issues": []} + lines = body.split("\n") + heading_next = False + for t in walk(tokens): + if t.type == "heading_open": + heading_next = True + elif t.type == "inline" and heading_next: + heading_next = False + plain = re.sub(r"<[^>]+>", "", "".join(c.content for c in (t.children or []) if c.type in ("text", "code_inline"))) + info["anchors"].add(gh_slug(plain)) + if t.type == "link_open": + info["links"].append(t.attrs.get("href", "")) + elif t.type == "image": + info["links"].append(t.attrs.get("src", "")) + elif t.type in ("html_inline", "html_block"): + for kind, value in ATTR.findall(t.content): + if kind == "id": + info["anchors"].add(value) + else: + info["links"].append(value) + elif t.type == "footnote_ref": + info["fn_refs"].add(t.meta.get("label")) + elif t.type == "footnote_block_open": + pass + elif t.type == "footnote_open": + info["fn_defs"].add(t.meta.get("label")) + elif t.type == "fence": + info["code"].append((t.info.strip(), t.content, t.map)) + # unclosed fence: markdown-it lets it run to the end of the document + if t.map and t.map[1] >= len(lines) and not re.match(r"^\s*(`{3,}|~{3,})\s*$", lines[t.map[1] - 1] if t.map[1] - 1 < len(lines) else ""): + info["issues"].append(("high", "unclosed-fence", f"Code-Fence ab Zeile {t.map[0] + 1} nicht geschlossen")) + # raw footnote definitions that markdown-it did not pick up (e.g. broken indentation) + for m in re.finditer(r"^\[\^([^\]]+)\]:", body, re.M): + info["fn_defs"].add(m.group(1)) + for ln in lines: + if NAV_RESIDUE.match(ln): + info["issues"].append(("medium", "nav-residue", f"Navigationsrest: {ln.strip()!r}")) + for lang, code, rng in info["code"]: + if ENTITY_IN_CODE.search(code): + info["issues"].append(("high", "entity-in-code", f"HTML-Entity im Codeblock (Zeile {rng[0] + 1}): {ENTITY_IN_CODE.search(code).group(0)}")) + if lang == "python": + try: + ast.parse(code) + except TabError as exc: # mixed tabs/spaces: inherited from the source, not a conversion defect + info["issues"].append(("info", "python-tabs", f"Tab/Leerzeichen gemischt (Zeile {rng[0] + 1}): {exc.msg}")) + except IndentationError as exc: + info["issues"].append(("high", "python-indentation", f"Python-Einrückung kaputt (Zeile {rng[0] + 1}): {exc.msg}")) + except SyntaxError: + info["issues"].append(("info", "python-syntax", f"Python-Snippet nicht vollständig parsebar (Zeile {rng[0] + 1})")) + if "�" in body: + info["issues"].append(("medium", "replacement-char", "Ersatzzeichen U+FFFD in der Ausgabe")) + return info + + +def validate(out: Path) -> dict: + result: dict = {"output": str(out), "issues": [], "stats": {}} + + def issue(sev: str, code: str, msg: str, file: str = "") -> None: + result["issues"].append({"severity": sev, "code": code, "message": msg, "file": file}) + + md_files = sorted(p for p in out.rglob("*.md") if "diagnostics" not in p.parts) + analysed: dict[str, dict] = {} + bodies: dict[str, str] = {} + for p in md_files: + rel = p.relative_to(out).as_posix() + text = p.read_text(encoding="utf-8") + fm, body, err = split_frontmatter(text) + if err: + issue("high", "frontmatter", err, rel) + if rel.startswith("chapters/") and fm is not None: + for key in ("book_title", "chapter_number", "chapter_title", "source_epub", "conversion_status"): + if key not in fm: + issue("medium", "frontmatter-key", f"Frontmatter-Schlüssel fehlt: {key}", rel) + if rel.startswith("chapters/") and not body.strip(): + issue("high", "empty-chapter", "Kapiteldatei ohne Inhalt", rel) + bodies[rel] = body + a = analyse(rel, body) + analysed[rel] = a + for sev, code, msg in a["issues"]: + issue(sev, code, msg, rel) + # links + existing = {p.relative_to(out).as_posix() for p in out.rglob("*") if p.is_file()} + n_links = 0 + for rel, a in analysed.items(): + for link in a["links"]: + n_links += 1 + if not link or re.match(r"^[a-z][a-z0-9+.-]*:", link, re.I): + continue + target, _, frag = link.partition("#") + target, frag = unquote(target), unquote(frag) + resolved = posixpath.normpath(posixpath.join(posixpath.dirname(rel), target)) if target else rel + if resolved.startswith(".."): + issue("critical", "link-outside", f"Link verlässt Ausgabeordner: {link}", rel) + elif resolved not in existing: + issue("high", "broken-link", f"Ziel fehlt: {link}", rel) + elif frag and resolved in analysed and frag not in analysed[resolved]["anchors"] \ + and not frag.startswith("fn"): # markdown-it footnote ids are renderer-specific + issue("high", "broken-anchor", f"Anker fehlt: {link}", rel) + missing_defs = a["fn_refs"] - a["fn_defs"] + unused_defs = a["fn_defs"] - a["fn_refs"] + if missing_defs: + issue("high", "footnote-missing-def", f"Fußnotenverweise ohne Definition: {sorted(missing_defs)}", rel) + if unused_defs: + issue("medium", "footnote-unused-def", f"Fußnotendefinitionen ohne Verweis: {sorted(unused_defs)}", rel) + # duplicate chapter bodies + seen: dict[str, str] = {} + for rel, body in bodies.items(): + norm = re.sub(r"\s+", " ", body).strip() + if rel.startswith("chapters/") and len(norm) > 200: + h = hashlib.sha1(norm.encode()).hexdigest() + if h in seen: + issue("medium", "duplicate-chapter", f"Inhalt identisch mit {seen[h]}", rel) + seen[h] = rel + # JSON / YAML files + for name in ("manifest.json", "conversion-report.json"): + p = out / name + if not p.exists(): + issue("high", "missing-file", f"{name} fehlt") + continue + try: + data = json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + issue("high", "json-invalid", f"{name}: {exc}") + continue + for ch in data.get("chapters", []): + if not (out / ch["file"]).is_file(): + issue("high", "json-ref-missing", f"{name}: Kapitel fehlt {ch['file']}") + media = data.get("media", []) + media = media.get("files", []) if isinstance(media, dict) else media + for m in media: + if not (out / m["file"]).is_file(): + issue("high", "json-ref-missing", f"{name}: Medium fehlt {m['file']}") + if name == "conversion-report.json": + n_files = len([p for p in md_files if p.parts[-2] == "chapters"]) + if len(data["chapters"]) != n_files: + issue("medium", "report-mismatch", f"Bericht nennt {len(data['chapters'])} Kapitel, vorhanden {n_files}") + if data.get("warnings") and data.get("lossless") not in (False,): + issue("medium", "report-claims", "Bericht mit Warnungen behauptet nicht lossless=false") + result["report_status"] = data.get("status") + result["report_warnings"] = len(data.get("warnings", [])) + if (out / "metadata.yaml").exists(): + try: + yaml.safe_load((out / "metadata.yaml").read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + issue("high", "yaml-invalid", f"metadata.yaml: {exc}") + result["stats"] = { + "markdown_files": len(md_files), + "links_checked": n_links, + "code_blocks": sum(len(a["code"]) for a in analysed.values()), + "footnote_refs": sum(len(a["fn_refs"]) for a in analysed.values()), + "media_files": len([e for e in existing if e.startswith("media/")]), + } + counts: dict[str, int] = {} + for i in result["issues"]: + counts[i["severity"]] = counts.get(i["severity"], 0) + 1 + result["severity_counts"] = counts + return result + + +if __name__ == "__main__": + results = [validate(Path(p)) for p in sys.argv[1:]] + print(json.dumps(results, ensure_ascii=False, indent=2)) diff --git a/tools/qa/worddiff.py b/tools/qa/worddiff.py new file mode 100755 index 0000000..3799f27 --- /dev/null +++ b/tools/qa/worddiff.py @@ -0,0 +1,37 @@ +"""Vergleicht die Wörter zweier epub2md-Ausgaben (Datenverlust-Check nach einer Codeänderung). + +Aufruf: python tools/qa/worddiff.py ALT_AUSGABE NEU_AUSGABE +Gezählt werden die Wörter aller chapters/*.md ohne Frontmatter, Markup, Link- und Bildziele. Gibt die Wörter +aus, die nur in einer der beiden Ausgaben vorkommen (Multimengen-Differenz). +""" + +from __future__ import annotations + +import collections +import glob +import re +import sys + + +def words(out_dir: str) -> collections.Counter[str]: + counter: collections.Counter[str] = collections.Counter() + for path in sorted(glob.glob(out_dir + "/chapters/*.md")): + with open(path, encoding="utf-8") as fh: + text = fh.read() + text = re.sub(r"\A---\n.*?\n---\n", "", text, flags=re.S) + text = re.sub(r"\]\([^)]*\)|<[^>]+>|[*_`#>|\\\[\]↪➥]", " ", text) + counter.update(re.findall(r"\w+", text)) + return counter + + +def main() -> None: + old, new = words(sys.argv[1]), words(sys.argv[2]) + lost, gained = old - new, new - old + print(f"Wörter alt {sum(old.values())}, neu {sum(new.values())}; " + f"nur alt {sum(lost.values())}, nur neu {sum(gained.values())}") + print("nur alt:", lost.most_common(25)) + print("nur neu:", gained.most_common(25)) + + +if __name__ == "__main__": + main() From 29c6294584998be8cbd0fcf0e18e499b0bfcef67 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 22:19:04 +0200 Subject: [PATCH 23/24] docs: README, Bedienungsanleitung und CHANGELOG auf Stand 0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EPUB mit Formeln: --mathml (Tabelle der Pandoc-Befehle, Beispiel Mathebuch) - Formel-TeX aus Alternativtexten wird für Pandoc angepasst - print.css: keine Seitenzahl auf dem Cover - Bekannte Einschränkungen: CSS je Buch statt je Dokument, nicht umsetzbare Handkonstruktionen in TeX - CHANGELOG: tools/qa unter „Unveröffentlicht“ - überlange Zeilen neu umbrochen Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 13 +++++++++---- CHANGELOG.md | 6 ++++++ README.md | 22 ++++++++++++++-------- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index afd2184..02b51d5 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -186,7 +186,9 @@ python3 epub2md.py mathe.epub output/mathe ``` Formeln, die im E-Book als echte Formeln (MathML oder TeX) vorliegen, werden zu TeX: `$…$` im Text, -`$$…$$` als eigene Zeile. Formeln, die nur als Bild vorliegen, bleiben Bilder und erscheinen in +`$$…$$` als eigene Zeile. Viele Verlage legen das TeX einer Formel in den Alternativtext eines Formelbildes; +epub2md übernimmt es und passt Schreibweisen an, die Pandoc nicht versteht (z. B. `\"u` → `ü`), ohne den +Inhalt zu ändern. Formeln, die nur als Bild vorliegen, bleiben Bilder und erscheinen in `diagnostics/warnings.md`. | Gewünschte Formeldarstellung | Option | @@ -324,6 +326,7 @@ P=~/Python_Programs/epub2md/pandoc # Pfad zu deinem epub2md-Ordner anpassen | Webseite (HTML) | `pandoc meinbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html` | | Webseite mit Formeln | wie HTML, zusätzlich `--mathml` | | E-Book (EPUB) | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.epub` | +| E-Book mit Formeln | wie EPUB, zusätzlich `--mathml` | | Word | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.docx` | | LibreOffice | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.odt` | | PDF mit LaTeX (Bücher mit Formeln) | `pandoc meinbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html && pandoc meinbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex -o meinbuch.pdf` | @@ -343,8 +346,9 @@ Die drei Hilfsdateien: Schreibweise nicht und druckt „[!TIP]“ sonst einfach mit. In HTML, EPUB und im PDF über weasyprint werden daraus gerahmte, farbige Kästen mit Titel („Tipp: …“), im PDF über LaTeX und in Word/LibreOffice ein eingerückter Block mit fettem Titel. -- **`print.css`** liefert das Druck-Layout für weasyprint: A4, Seitenzahlen, jedes Kapitel auf einer neuen - Seite, Inhaltsverzeichnis mit Seitenzahlen, Silbentrennung und umbrechende lange Codezeilen. Ohne sie +- **`print.css`** liefert das Druck-Layout für weasyprint: A4, Seitenzahlen (nicht auf dem Cover), jedes + Kapitel auf einer neuen Seite, Inhaltsverzeichnis mit Seitenzahlen, Silbentrennung und umbrechende lange + Codezeilen. Ohne sie übernimmt weasyprint das Bildschirmlayout, und lange Codezeilen werden am Seitenrand abgeschnitten. Du musst die Befehle nicht abtippen: Die `README.md` im Ausgabeordner enthält sie fertig, mit allen Pfaden. @@ -359,7 +363,8 @@ Du musst die Befehle nicht abtippen: Die `README.md` im Ausgabeordner enthält s Kastenrahmen gehen dabei verloren; Kästen erscheinen als eingerückter Block mit fettem Titel. `book.lua` stellt dabei A4 ein und wählt die Schrift DejaVu, die auch Zeichen wie ∈, λ oder ≠ im Fließtext enthält. -Bei Büchern mit Formeln für HTML zusätzlich `--mathml` angeben, sonst bleiben Matrizen als TeX-Text stehen. +Bei Büchern mit Formeln für HTML und EPUB zusätzlich `--mathml` angeben, sonst bleiben Matrizen und +ähnliche Formeln als TeX-Text stehen. #### Direkt aus den Kapiteldateien diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ba2ee..cd1abd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] +### Neu + +- `tools/qa/`: QA-Skripte versioniert (CLI-Matrix mit 89 Fällen, Generator für Test-EPUBs, unabhängiger + Markdown-Validator, Wortvergleich gegen Datenverlust, Prüfung des Pandoc-Wegs für HTML, EPUB und PDF). + Beschreibung in `tools/qa/README.md`; erzeugte Daten landen in `.qa/`. + ## [0.3.0] – 2026-10-06 ### Neu diff --git a/README.md b/README.md index 091b931..01b8e72 100644 --- a/README.md +++ b/README.md @@ -158,9 +158,9 @@ output/mein-buch/ ## Wie Kapitel gebildet werden 1. Die **OPF-Spine** bestimmt die Lesereihenfolge (nicht Dateinamen, nicht das Inhaltsverzeichnis). -2. Überschriften `h1`…`hN` (N = `--chapter-level`, bei `auto` 1 bzw. 2 in sehr großen Kapiteln) und semantische Abschnitte (`epub:type`/`role`: - chapter, part, appendix, glossary, bibliography, index, preface, …) beginnen neue Kapiteldateien, - auch mitten in einer XHTML-Datei. +2. Überschriften `h1`…`hN` (N = `--chapter-level`, bei `auto` 1 bzw. 2 in sehr großen Kapiteln) und + semantische Abschnitte (`epub:type`/`role`: chapter, part, appendix, glossary, bibliography, index, + preface, …) beginnen neue Kapiteldateien, auch mitten in einer XHTML-Datei. 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 @@ -356,6 +356,7 @@ cd output/mathematikbuch # Formeln: PDF über HTML und LaTeX (weasypri pandoc mathematikbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o mathematikbuch.html pandoc mathematikbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex \ -o mathematikbuch.pdf +pandoc mathematikbuch.md --toc --mathml --lua-filter $P/callouts.lua -o mathematikbuch.epub # EPUB: MathML ``` Hilfsdateien in `pandoc/`: @@ -376,11 +377,11 @@ Hilfsdateien in `pandoc/`: - Der Vorspann reicht bis vor das erste Kapitel des Hauptteils (Teil, Vorwort, Einleitung, Prolog, nummerierter Titel oder mehr als 1000 Wörter), sofern er Cover, Titelseite oder Impressum enthält. Ein erstes Kapitel nur aus einem Bild gilt als Cover, ein Kapitel „Titel“ als Titelseite. -- **`print.css`** (weasyprint): A4, Seitenzahlen, jedes Kapitel (auch Cover, Titelseite, Impressum) und das - Inhaltsverzeichnis auf neuer Seite, Inhaltsverzeichnis mit Punktlinie und Seitenzahl, Silbentrennung - (nicht in Code), umbrechende Codezeilen. Mit `--css` lässt Pandoc sein eigenes Stylesheet weg, deshalb - bringt `print.css` auch Grundschrift, Zeilenabstand und Titelkopf mit. Getestet mit dem Python-Buch - (362 Seiten). +- **`print.css`** (weasyprint): A4, Seitenzahlen (nicht auf dem Cover), jedes Kapitel (auch Cover, + Titelseite, Impressum) und das Inhaltsverzeichnis auf neuer Seite, Inhaltsverzeichnis mit Punktlinie und + Seitenzahl, Silbentrennung (nicht in Code), umbrechende Codezeilen. Mit `--css` lässt Pandoc sein eigenes + Stylesheet weg, deshalb bringt `print.css` auch Grundschrift, Zeilenabstand und Titelkopf mit. Getestet + mit dem Python-Buch (362 Seiten). **Infokästen:** Pandoc kennt die Schreibweise `> [!TIP]` erst ab neueren Versionen (Erweiterung `alerts`); 3.1.3 (Ubuntu 24.04) zeigt „[!TIP]“ als Text. Der Filter `pandoc/callouts.lua` behebt das: In HTML und @@ -455,6 +456,11 @@ Verlustfreiheit behauptet (`lossless: false`). realen Buch geprüft (434 Bilder, 246 Figuren, ausführliche Alternativtexte). - Rein per CSS erzeugte Inhalte (`::before`/`content:`, z. B. automatische Schrittnummern) werden nicht übernommen, weil Pseudo-Elemente nicht ausgewertet werden. +- Die eingebundenen Stylesheets gelten für das ganze Buch, nicht je Kapiteldokument. Bindet nur ein + Kapitel ein bestimmtes Stylesheet ein, wirkt es auch auf die anderen. In Fußnoten greifen CSS-Regeln + mit Vorfahren-Selektoren nicht, weil die Noten vor dem Rendern aus dem Text gelöst werden. +- Von Hand gebaute TeX-Konstrukte (z. B. `\mathinner{…\raise…}`) kann Pandoc nicht in MathML umsetzen; + sie bleiben als TeX-Text stehen (Mathebuch: 1 von 4986 Formeln). ## Architektur From 2526ee6789619ffcfb619aa29f1c7f63170b25bf Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 23:46:50 +0200 Subject: [PATCH 24/24] =?UTF-8?q?feat:=20Sprachlabels=20f=C3=BCr=20Codebl?= =?UTF-8?q?=C3=B6cke=20aus=20Inhalt=20und=20Buchkontext=20(R1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gedruckte Zeilennummern ("00 #include …") werden für die Erkennung ignoriert, bleiben aber im Block stehen. - Python zusätzlich per ast.parse (Kontrollstrukturen, def/class, Import mit Code) und an >>>-Sitzungen erkannt; YAML zählt nicht. - Vordurchlauf bestimmt die Sprache des Buches aus den sicher erkannten Blöcken; passt sie, reichen für kurze Schnipsel schwächere Signale (detected_by: "book-context"). Programmausgaben bleiben ohne Label. Ohne Label: C-Buch 518 -> 132 von 574, Python-Buch 492 -> 326 von 747. Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 5 +- CHANGELOG.md | 9 ++++ README.md | 11 ++-- epub2md/codeblocks.py | 111 +++++++++++++++++++++++++++++++++++++- epub2md/html_converter.py | 5 ++ epub2md/pipeline.py | 17 ++++++ tests/test_codeblocks.py | 54 +++++++++++++++++++ tests/test_qa2_open.py | 2 +- 8 files changed, 207 insertions(+), 7 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 02b51d5..f1e3639 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -163,8 +163,9 @@ python3 epub2md.py python-buch.epub output/python-buch ``` Programmcode bleibt zeichengenau erhalten, inklusive Einrückung. Die Programmiersprache wird nur -eingetragen, wenn sie eindeutig erkennbar ist. Ist das ganze Buch in **einer** Sprache geschrieben, -kannst du sie für alle übrigen Codeblöcke vorgeben: +eingetragen, wenn sie eindeutig erkennbar ist. Ist ein Buch erkennbar in **einer** Sprache geschrieben, +erhalten auch kurze Codeschnipsel dieses Label (etwa `char ch = 65;` in einem C-Buch). Programmausgaben +bleiben ohne Label. Willst du wirklich alle übrigen Blöcke kennzeichnen, gib die Sprache vor: ```bash python3 epub2md.py python-buch.epub output/python-buch --code-language python diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1abd9..da79cba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/). Markdown-Validator, Wortvergleich gegen Datenverlust, Prüfung des Pandoc-Wegs für HTML, EPUB und PDF). Beschreibung in `tools/qa/README.md`; erzeugte Daten landen in `.qa/`. +### Verbessert + +- Sprachlabels für Codeblöcke (R1): Gedruckte Zeilennummern (`00 #include …`) verdecken die Sprache + nicht mehr. Python wird zusätzlich per `ast.parse` erkannt (Kontrollstrukturen, `def`/`class`, Import + mit Code) sowie an `>>>`-Sitzungen. Ein Vordurchlauf bestimmt die Sprache des Buches aus den sicher + erkannten Blöcken. Passt sie, reichen für kurze Schnipsel schwächere Signale (`detected_by: + "book-context"`); Programmausgaben bleiben ohne Label. Ohne Label waren vorher im C-Buch 518 von 574 + Blöcken, jetzt 132 (fast nur Ausgaben). Im Python-Buch waren es 492 von 747, jetzt 326 (Ausgaben). + ## [0.3.0] – 2026-10-06 ### Neu diff --git a/README.md b/README.md index 01b8e72..e4974ce 100644 --- a/README.md +++ b/README.md @@ -246,9 +246,14 @@ weil CommonMark keine `[^…]`-Syntax kennt. - **Syntax-Highlighting:** Farb-Spans, ``/`` in Listings und Zeilennummern werden entfernt. Einrückung, Tabs, `<`, `>`, `&`, `$`, `|`, Backslashes und Backticks bleiben exakt erhalten. Die Sprache kommt aus Klassen/Attributen (`language-python`, `data-lang`, …) oder starken Inhaltssignalen - (`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. Bei einsprachigen - Büchern kann `--code-language python` (o. Ä.) alle übrigen Blöcke kennzeichnen. Achtung: Das betrifft - auch Programmausgaben und Shell-Befehle im Buch. + (`#include`, Shebang, `>>>`, Python-Code, der sich parsen lässt und `def`/`class`/Kontrollstrukturen + enthält, …). Gedruckte Zeilennummern als Text (`00 #include …`) stören die Erkennung nicht und bleiben + im Block stehen. Sind sich die sicher erkannten Blöcke einig (mindestens 5, davon 90 % eine Sprache), + genügen für die übrigen schwächere Signale: in einem Python-Buch Zuweisungen und Aufrufe, in einem + Buch mit C-artiger Sprache Anweisungen und Deklarationen mit `;` oder `{` (im Bericht + `detected_by: "book-context"`). Programmausgaben (Literale, Zahlen, Text) bleiben ohne Label. + `--code-language python` (o. Ä.) kennzeichnet alle übrigen Blöcke, auch Programmausgaben und + Shell-Befehle im Buch. - **CSS-Formatierung:** Viele Verlage setzen Hervorhebungen nur über Stylesheets. epub2md wertet die 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. diff --git a/epub2md/codeblocks.py b/epub2md/codeblocks.py index 0fcc883..7731a9b 100644 --- a/epub2md/codeblocks.py +++ b/epub2md/codeblocks.py @@ -2,8 +2,10 @@ from __future__ import annotations +import ast import json import re +import warnings from typing import Any from .utils import classes, is_element, local @@ -127,9 +129,114 @@ def detect_language(el: Any) -> tuple[str, str]: return "", "" +LINE_NUMBER_PREFIX = re.compile(r"^(\d{1,4})( +|$)") + + +def strip_line_numbers(code: str) -> str: + """Remove printed line numbers ("00 #include …") – for language detection only, the block keeps them. + + Applies when at least 80 % of the non-blank lines (at least two) start with consecutive numbers. + """ + lines = code.split("\n") + filled = [ln for ln in lines if ln.strip()] + matches = [LINE_NUMBER_PREFIX.match(ln) for ln in filled] + numbers = [int(m.group(1)) for m in matches if m] + if len(numbers) < 2 or len(numbers) < 0.8 * len(filled): + return code + if any(b != a + 1 for a, b in zip(numbers, numbers[1:])): + return code + width = min((len(m.group(2)) for m in matches if m and m.group(2)), default=0) + out = [] + for ln in lines: + m = LINE_NUMBER_PREFIX.match(ln) if ln.strip() else None + out.append(ln[len(m.group(1)) + min(width, len(m.group(2))):] if m else ln) + return "\n".join(out) + + +def _python_module(text: str) -> ast.Module | None: + """The parsed module, or None when the text is not valid Python (never executed, warnings suppressed).""" + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return ast.parse(text) + except (SyntaxError, ValueError, RecursionError, MemoryError): + return None + + +PY_COMPOUND = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.If, ast.For, ast.AsyncFor, ast.While, + ast.With, ast.AsyncWith, ast.Try) +PY_CODE_EXPR = (ast.Call, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.Lambda, ast.Await) + + +def _python_strength(text: str) -> int: + """2 = surely Python (block statement, or import plus code), 1 = Python code (assignment, call, + comprehension …), 0 = not Python or only literals/names – which is what program output looks like.""" + if any(ln.rstrip().endswith((";", "{")) for ln in text.split("\n")): + return 0 + module = _python_module(text) + if module is None or not module.body: + return 0 + imports = sum(isinstance(n, (ast.Import, ast.ImportFrom)) for n in module.body) + code = 0 + for node in module.body: + if isinstance(node, PY_COMPOUND): + return 2 + if isinstance(node, ast.Expr): + value = node.value + if isinstance(value, ast.Constant) and isinstance(value.value, str): + continue # docstring + if not isinstance(value, PY_CODE_EXPR): + return 0 # a bare name, number or literal line is program output ("Jane", "42", "[1, 4]") + code += 1 + elif isinstance(node, ast.AnnAssign) and node.value is None: + pass # "name: value" is YAML or a Makefile rule as often as Python + elif not isinstance(node, (ast.Import, ast.ImportFrom, ast.Pass)): + code += 1 + if imports and code: + return 2 + return 1 if code else 0 + + +CURLY_STATEMENT = re.compile(r"[=(]|^\s*[A-Za-z_]\w*[\s*]+[A-Za-z_*]") # assignment/call or "type name" + + +def _curly_signal(text: str) -> bool: + """C-family syntax: a statement or declaration ending with ';' or '{', or a function body on one line.""" + lines = [re.sub(r"(^|\s)//.*$", "", ln).rstrip() for ln in text.split("\n")] + if any(ln.endswith((";", "{")) and CURLY_STATEMENT.search(ln) for ln in lines): + return True + return bool(re.search(r"\w\s*\([^)]*\)\s*\{.*\}", text)) + + +CURLY_LANGS = {"c", "cpp", "java", "csharp", "javascript", "typescript", "go", "rust", "php", "kotlin", "swift", "scala"} + + +def detect_language_from_book(code: str, book_language: str) -> str: + """Weak content signals, accepted only when they fit the book's dominant language.""" + if not book_language: + return "" + text = strip_line_numbers(code).strip() + if not text: + return "" + if book_language == "python" and _python_strength(text) >= 1: + return "python" + if book_language in CURLY_LANGS and _curly_signal(text): + return book_language + return "" + + +def dominant_language(languages: list[str]) -> str: + """The book's language when the reliably detected blocks agree (at least 5 blocks, 90 %).""" + detected = [lang for lang in languages if lang and lang != "text"] + if len(detected) < 5: + return "" + lang = max(set(detected), key=detected.count) + return lang if detected.count(lang) >= 0.9 * len(detected) else "" + + def detect_language_from_content(code: str) -> str: """Very conservative content heuristics – only strong signals.""" - text = code.strip() + text = strip_line_numbers(code).strip() if not text: return "" first = text.split("\n", 1)[0] @@ -167,6 +274,8 @@ def detect_language_from_content(code: str) -> str: c_like = sum(1 for ln in lines if ln.rstrip().endswith((";", "{"))) if py_signals >= 2 and c_like == 0: return "python" + if re.search(r"^>>> \S", text, re.M) or _python_strength(text) == 2: + return "python" if re.match(r"\s*(SELECT|INSERT\s+INTO|CREATE\s+(TABLE|INDEX|VIEW)|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b", text): return "sql" if re.match(r"(?m)^FROM\s+\S+", text) and re.search(r"(?m)^(RUN|CMD|COPY|ENTRYPOINT)\s", text): diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index 15ff259..b8de5ad 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -22,6 +22,7 @@ from .css import StyleSheet, emphasis, has_highlight, inline_style, style_props from .codeblocks import ( code_text, detect_language, + detect_language_from_book, detect_language_from_content, fence_for, inline_code, @@ -323,6 +324,7 @@ class Renderer: self.consumed_formula_images: set[str] = set() self.formulas: list[dict[str, str]] = [] self.code_blocks: list[dict[str, Any]] = [] + self.book_code_language = "" # dominant language of the reliably detected blocks (pre-scan) self.endnotes_file = "" # per chapter state self.ch: Chapter | None = None @@ -799,6 +801,9 @@ class Renderer: if not lang: lang = detect_language_from_content(code) how = "content" if lang else "" + if not lang: + lang = detect_language_from_book(code, self.book_code_language) + how = "book-context" if lang else "" if not lang and self.opt.code_language: lang, how = self.opt.code_language, "option" if self.ch: diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index 45500bc..db5eae3 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -17,6 +17,8 @@ from urllib.parse import unquote from . import TOOL_NAME, __version__ from .chapters import SPECIAL_KINDS, ChapterBuilder, heading_level +from .codeblocks import (code_text, detect_language, detect_language_from_content, dominant_language, + is_block_code_container, join_wrap_markers) from .diagnostics import Diagnostics from .css import StyleSheet, base_style, emphasis from .epub_reader import EpubReader, decode_markup, parse_xml @@ -292,6 +294,7 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: notes_title = "Anmerkungen" if language.startswith("de") else "Notes" endnotes_number = 999 if len(chapters) < 999 else len(chapters) + 1 renderer.endnotes_file = f"chapters/{endnotes_number:0{width}d}-{slugify(notes_title, opts.slug_language)}.md" + renderer.book_code_language = _book_code_language(chapters) for ch in chapters: diag.step(f"Rendere {ch.filename}") ch.markdown = renderer.render_chapter(ch) @@ -312,6 +315,20 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: return _Rendered(renderer, media, cover_out, endnotes, endnotes_md, skipped) +def _book_code_language(chapters: list[Chapter]) -> str: + """Pre-scan: the language of the book's code blocks, from the reliably detected blocks only.""" + langs: list[str] = [] + for ch in chapters: + for _, item in ch.items: + if not is_element(item): + continue + for el in iter_elements(item): + if local(el.tag) in ("pre", "listing", "xmp", "plaintext") or is_block_code_container(el): + lang, _ = detect_language(el) + langs.append(lang or detect_language_from_content(join_wrap_markers(code_text(el))[0])) + return dominant_language(langs) + + def _suggest_code_language(renderer: Renderer, diag: Diagnostics) -> None: """Book-level hint: many unlabeled blocks while every recognized block has the same language.""" blocks = renderer.code_blocks diff --git a/tests/test_codeblocks.py b/tests/test_codeblocks.py index 0cf0ca9..6084d45 100644 --- a/tests/test_codeblocks.py +++ b/tests/test_codeblocks.py @@ -118,3 +118,57 @@ def test_print_wrap_markers_can_be_kept(convert): run = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-wrap-markers") (_, _, code), = fenced_blocks(run.chapter("001")) assert code == "a = (1,\n↪ 2)" + + +def test_printed_line_numbers_do_not_hide_the_language(convert): + listing = ("00 // Kapitel1/hallo.c\n01 #include <stdio.h>\n \n02 int main(void) {\n" + "03 printf(\"Hallo\\n\");\n04 return 0;\n05 }") + run = convert(Book(docs=[Doc("c.xhtml", f"

N

{listing}
")])) + (_, lang, code), = fenced_blocks(run.chapter("001")) + assert lang == "c" + assert code.startswith("00 // Kapitel1/hallo.c\n01 #include ") # numbers stay in the block + + +def test_python_is_recognized_by_parsing(convert): + blocks = ["from collections import ChainMap\n\nchain = ChainMap({}, {})\nprint(chain)", + "zahl = -1\nif zahl < 0:\n print(\"negativ\")", + ">>> 2 + 3\n5", + "name: epub2md\nversion: 1", # YAML is valid Python syntax, but no Python code + "[1, 4, 9]", # program output + "x = compute()"] # Python, but too weak without book context + body = "

P

" + "".join(f"
{b}
" for b in blocks) + run = convert(Book(docs=[Doc("c.xhtml", body)])) + langs = [lang for _, lang, _ in fenced_blocks(run.chapter("001"))] + assert langs == ["python", "python", "python", "", "", ""] + + +def test_weak_signals_follow_the_book_language(convert): + c_listing = "#include <stdio.h>\nint main(void) { return 0; }" + snippets = ["char ch = 65; // 'A'", "if (a || b) {\n x();\n}", "int remove(const char *pathname);", + "struct node {\n int value;\n};", "Ergebnis: 12345", "|00003.14|", "gcc -Wall -o prog prog.c", + "Dezimal: 65; Zeichen: A"] + body = "

C

" + "".join(f"
{c_listing}
" for _ in range(5)) + body += "".join(f"
{s}
" for s in snippets) + run = convert(Book(docs=[Doc("c.xhtml", body)])) + langs = [lang for _, lang, _ in fenced_blocks(run.chapter("001"))][5:] + assert langs == ["c", "c", "c", "c", "", "", "", ""] + by = [item["detected_by"] for item in run.report["code_blocks"]["items"]][5:9] + assert by == ["book-context"] * 4 + + +def test_python_book_labels_assignments_but_not_output(convert): + py = "def f(x):\n return x" + snippets = ["foo = decorate(foo)", "random.sample(population, k)", "[x * x for x in range(5)]", + "True", "('A', 'B', 'C')", "Hallo Welt", "4\n3\nnohtyP", "Jane\n42\nPerson(name='Jane')"] + body = "

P

" + "".join(f"
{py}
" for _ in range(5)) + body += "".join(f"
{s}
" for s in snippets) + run = convert(Book(docs=[Doc("c.xhtml", body)])) + langs = [lang for _, lang, _ in fenced_blocks(run.chapter("001"))][5:] + assert langs == ["python", "python", "python", "", "", "", "", ""] + + +def test_mixed_book_gets_no_context_labels(convert): + body = "

M

" + "
def f(x):\n    return x
" * 3 + body += "
#include <stdio.h>\nint main(void) { return 0; }
" * 3 + "
x = compute();
" + run = convert(Book(docs=[Doc("c.xhtml", body)])) + assert fenced_blocks(run.chapter("001"))[-1][1] == "" diff --git a/tests/test_qa2_open.py b/tests/test_qa2_open.py index e9b1e41..7b003c2 100644 --- a/tests/test_qa2_open.py +++ b/tests/test_qa2_open.py @@ -39,7 +39,7 @@ def test_qa2_10_book_readme_recommends_pdf_via_html(convert): def test_qa2_11_code_language_hint(convert): py = "def f(x):\n return x\n\nclass A:\n pass" - body = "

C

" + "".join(f"
{py}\n# {i}
print({i})
" for i in range(10)) + body = "

C

" + "".join(f"
{py}\n# {i}
Ergebnis: {i}
" for i in range(10)) run = convert(Book(docs=[Doc("c.xhtml", body)])) hints = [i for i in run.report["infos"] if i["code"] == "code-language-hint"] assert hints and "--code-language python" in hints[0]["message"]