diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index afd2184..a0e85ab 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -141,10 +141,9 @@ python3 epub2md_merge.py output/meinbuch --force ### Roman (Teile und Kapitel) -Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Das Programm erkennt sehr große -Teile (über 30 000 Wörter) selbst und legt dann eine kurze Datei für den Teil und eine Datei pro -**Kapitel** an; kurze Abschnitte wie Vorwort oder Anhang bleiben eine Datei. Du musst dafür nichts -einstellen. Soll wirklich jeder Abschnitt mit Unterüberschrift eine eigene Datei werden, gilt: +Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Standardmäßig entsteht eine Datei +pro **Teil**. Das können sehr große Dateien werden. Das Programm warnt dann mit `chapter-large` und +empfiehlt die passende Einstellung. Eine Datei pro **Kapitel** bekommst du so: ```bash python3 epub2md.py roman.epub output/roman --chapter-level 2 @@ -389,8 +388,8 @@ gilt hier derselbe Umweg über HTML. - Die einzelnen Kapiteldateien eignen sich als Abschnitte. Die Metadaten im Datenkopf (Buch, Kapitel, Herkunft) helfen beim Zuordnen von Fundstellen. - `manifest.json` listet alle Kapitel mit Titel, Datei, Wortanzahl und Ankern. -- Sehr große Kapitel teilt das Programm selbst an ihren Unterkapiteln. Hast du `--chapter-level` fest - eingestellt und erscheint die Warnung `chapter-large`, dann mit dem empfohlenen Wert neu umwandeln. +- Achte auf die Warnung `chapter-large`: Sehr große Kapiteldateien lassen sich schlecht zerlegen. Dann + mit höherem `--chapter-level` neu umwandeln. --- @@ -515,7 +514,7 @@ python3 epub2md_merge.py --help | `--dry-run` | nur anzeigen, nichts schreiben | | `--verbose` | ausführliche Ausgabe | | `--force` | frühere Ausgabe ersetzen | -| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard `auto`: Ebene 1, sehr große Kapitel werden an ihren Unterkapiteln geteilt) | +| `--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 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ba2ee..13b2530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,6 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] -## [0.3.0] – 2026-10-06 - ### Neu - `pandoc/book.lua`: Inhaltsverzeichnis wie im gedruckten Buch nach Cover, Titelseite und Impressum statt @@ -24,10 +22,6 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/). ### Geändert -- `--chapter-level auto` ist der neue Standard: Ebene 1 wie bisher, aber Kapitel mit über 30 000 Wörtern - und mindestens zwei Unterkapiteln (Teile eines Romans) werden an ihren Unterkapiteln geteilt; alle - anderen Kapitel bleiben ganz (Info `chapter-split`). Der Roman im Test ergibt 78 statt 9 Dateien, die - übrigen Beispielbücher bleiben unverändert. Eine feste Zahl verhält sich wie bisher (R2). - `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, @@ -35,15 +29,6 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/). ### Behoben -- `epub2md_merge.py`: Kapitel-Startanker heißen jetzt `kap-003-name` statt `003-name`. Pandocs EPUB-Ausgabe - löste Links auf IDs mit führender Ziffer nicht auf (C-Buch 32, Mathebuch 20 tote Links im EPUB). - `pandoc/book.lua` erkennt beide Formen (QA3-05). -- Buch-README: Der EPUB-Befehl enthält bei Büchern mit Formeln `--mathml`. Ohne blieben im Mathebuch - 1072 Formeln als TeX-Text stehen (QA3-04). -- TeX aus `alt`-Texten wird für Pandoc portabel gemacht (`\"u` → `ü`, `\O` → `\emptyset`, `@{…}` und - `\arraycolsep` in `array`, leeres `\displaystyle` vor Zellenende). Im Mathebuch kann Pandoc damit 4985 statt - 4977 Formeln setzen; vorher blieben 9 als TeX-Text stehen (QA3-06). -- `pandoc/print.css` und `pandoc/book.lua`: keine Seitenzahl auf dem Cover. - CSS-Auswertung: Es zählen nur noch Stylesheets, die ein Dokument per `` (oder `@import`) einbindet. Bisher wirkten alle CSS-Dateien des Manifests, auch mitgelieferte Layouts anderer Buchreihen. Im C-Buch machte das aus fetten Buchstaben Inline-Code (`` `st`an`d`ard ``) und setzte falsche ``; eine @@ -103,7 +88,6 @@ Erste Fassung. - Alle Befunde der QA-Prüfungen (R1–R7, QA2-01 bis QA2-19) behoben oder abgemildert. - README und Bedienungsanleitung. -[Unveröffentlicht]: https://kitux.de/forgejo/dschlueter/epub2md/compare/v0.3.0...main -[0.3.0]: https://kitux.de/forgejo/dschlueter/epub2md/compare/v0.2.0...v0.3.0 +[Unveröffentlicht]: https://kitux.de/forgejo/dschlueter/epub2md/compare/v0.2.0...main [0.2.0]: https://kitux.de/forgejo/dschlueter/epub2md/compare/v0.1.0...v0.2.0 [0.1.0]: https://kitux.de/forgejo/dschlueter/epub2md/src/tag/v0.1.0 diff --git a/README.md b/README.md index d2f6793..b5ea70c 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ python3 epub2md.py \ --toc \ --verbose -# Jedes Kapitel unterhalb von h2 als eigene Datei (sonst teilt auto nur sehr große Kapitel) +# Roman mit Teilen (h1) und Kapiteln (h2): ein File pro Kapitel python3 epub2md.py roman.epub output/roman --chapter-level 2 # Nur analysieren, nichts schreiben @@ -88,7 +88,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json | `--format {gfm,commonmark}` | `gfm` | `gfm`: Pipe-Tabellen, `~~durchgestrichen~~`, `[^fn]`-Fußnoten. `commonmark`: Tabellen als HTML, ``, Fußnoten als Anker-Links | | `--extract-media` / `--no-extract-media` | an | Medien nach `media/` extrahieren. Ohne Extraktion stehen Bildreferenzen als Text `[Bild: alt – Pfad]` da (keine kaputten Links) | | `--copy-original` / `--no-copy-original` | aus | Original-EPUB nach `source/` kopieren | -| `--chapter-level {auto,1-6}` | `auto` | Überschriftenebene, ab der eine neue Kapiteldatei beginnt. `auto`: Ebene 1; nur Kapitel mit über 30 000 Wörtern und mindestens zwei Unterkapiteln (z. B. Teile eines Romans) werden an ihren Unterkapiteln geteilt (Info `chapter-split`). Eine Zahl gilt ausnahmslos für das ganze Buch | +| `--chapter-level N` | `1` | Überschriftenebene (1–6), ab der eine neue Kapiteldatei beginnt | | `--toc` / `--no-toc` | an | `README.md` mit Inhaltsverzeichnis, Statistik und Reproduktionsbefehl | | `--frontmatter` / `--no-frontmatter` | an | YAML-Frontmatter in jeder Kapiteldatei | | `--footnotes {keep-links,end-of-chapter,end-of-book,inline}` | `end-of-chapter` | siehe [Fußnoten](#fußnoten-modi) | @@ -158,7 +158,7 @@ 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`: +2. Überschriften `h1`…`hN` (N = `--chapter-level`) 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 @@ -188,13 +188,10 @@ Verlagsspezifische Heuristiken: Layout-Tabellen mit Klassen wie `Head1`/`Title_d Überschriften. `
` mit `
`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken. Literaturlisten mit eigenen Labels (`[BryL06]`) werden nicht doppelt nummeriert. -**Teile und Kapitel:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), teilt der -Standard `auto` jeden Teil mit über 30 000 Wörtern und mindestens zwei Kapiteln in eine kurze Datei für den -Teil (Überschrift, Einleitung) und eine Datei pro Kapitel; kleinere Kapitel (Vorwort, Anhang) bleiben ganz. -Verzeichnisse (Register, Literatur, Glossar) werden dabei nie zerlegt. `--chapter-level 2` teilt dagegen -jedes Kapitel des Buches an `h2`. Ist bei einer festen Zahl eine Kapiteldatei größer als 30 000 Wörter und -hat sie mindestens zwei `##`-Unterabschnitte, meldet epub2md eine Warnung `chapter-large` mit der -passenden `--chapter-level`-Empfehlung. +**Tipp:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), erzeugt +`--chapter-level 2` eine Datei pro Kapitel. Mit dem Standard `1` entsteht eine Datei pro Teil. +Ist eine Kapiteldatei größer als 30 000 Wörter und hat sie mindestens zwei `##`-Unterabschnitte, meldet +epub2md eine Warnung `chapter-large` mit der passenden `--chapter-level`-Empfehlung. ## Math-Modi @@ -312,8 +309,7 @@ verwendet und das Verzeichnis gibt den Namen vor. Beim Zusammenfügen passiert Folgendes: - Die Frontmatter der einzelnen Kapitel wird entfernt. -- Jedes Kapitel erhält einen Startanker (``). Er beginnt mit einem Buchstaben, - weil Pandocs EPUB-Ausgabe Links auf IDs mit führender Ziffer nicht auflöst. +- Jedes Kapitel erhält einen Startanker (``). - Links zwischen Kapiteln (`003-x.md#anker`) werden zu Sprunglinks in der Gesamtdatei. - Anker, die in mehreren Kapiteln vorkommen (z. B. `Sec1` in jedem Kapitel eines Springer-Buchs), werden ab dem zweiten Vorkommen in `c012-Sec1` umbenannt, samt aller Links darauf. @@ -391,8 +387,9 @@ Die `README.md` eines konvertierten Buches mit Kästen enthält die Befehle bere **PDF immer über HTML erzeugen.** Beim direkten Weg Markdown → LaTeX verwirft Pandoc allen rohen HTML-Code. Dabei gehen HTML-Tabellen samt Inhalt verloren, und ``-Sprungziele fehlen, sodass interne Links im PDF ins Leere zeigen. Liest Pandoc dagegen die HTML-Fassung, macht es daraus echte Tabellen und Sprungziele. -Getestet mit dem C-Buch (322 Seiten, 34 Tabellen; alle 831 internen Links haben ein Ziel, kein Zeichen -fehlt). Die `README.md` jedes konvertierten Buches enthält den passenden Befehl. +Getestet mit dem C-Buch (405 Seiten, 34 Tabellen, 728 von 755 internen Links funktionieren; die übrigen +stehen in Codeblöcken innerhalb von Tabellen). Die `README.md` jedes konvertierten Buches enthält den +passenden Befehl. Alternativ direkt aus den Kapiteldateien: @@ -407,10 +404,8 @@ pandoc output/programmierbuch/chapters/*.md \ - Ausgaben mit `--math tex` (`\(…\)`) brauchen `-f markdown+tex_math_single_backslash`. - Das YAML-Frontmatter jeder Kapiteldatei wird von Pandoc als Metadaten gelesen. Wer die Kapiteldateien direkt zusammen verarbeitet, sollte mit `--no-frontmatter` konvertieren oder die Gesamtdatei verwenden. -- TeX aus dem E-Book wird für Pandocs Formelparser angepasst, ohne den Inhalt zu ändern: `\"u` → `ü`, - `\O` (leere Menge) → `\emptyset`, keine `@{…}`-Spaltentrenner und kein `\arraycolsep` in `array`, kein - leeres `\displaystyle` vor einem Zellenende. Seltene Handkonstruktionen (z. B. `\mathinner{…\raise…}`) - bleiben als TeX stehen, mit der Warnung „Could not convert TeX math“ (Mathebuch: 1 von 4986 Formeln). +- Einzelne TeX-Befehle aus EPUBs (z. B. `\O` oder `@`-Konstrukte) kennt Pandocs Formelparser nicht. Er + belässt sie als TeX, mit der Warnung „Could not convert TeX math“. ## Bericht und Diagnose diff --git a/epub2md/__init__.py b/epub2md/__init__.py index 292d78c..6885324 100644 --- a/epub2md/__init__.py +++ b/epub2md/__init__.py @@ -1,4 +1,4 @@ """epub2md – konvertiert DRM-freie EPUB-Dateien in kapitelweise Markdown-Sammlungen.""" -__version__ = "0.3.0" +__version__ = "0.2.0" TOOL_NAME = "epub2md" diff --git a/epub2md/chapters.py b/epub2md/chapters.py index 8716c3b..2384eb7 100644 --- a/epub2md/chapters.py +++ b/epub2md/chapters.py @@ -105,11 +105,9 @@ def semantic_types(el: Any) -> list[str]: class ChapterBuilder: def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics, - book_title: str = "", authors: list[str] | None = None, deep_starts: set[Any] | None = None) -> None: + book_title: str = "", authors: list[str] | None = None) -> None: self.level = chapter_level self.diag = diag - self.deep_starts = deep_starts or set() # chapter headings whose sections are split one level deeper - self._deep = False # current chapter is one of them self._special_level = 0 # level of a split-out special heading whose section is still open self._in_special = False # current chapter itself is a bibliography/index/glossary self.book_title = book_title @@ -131,12 +129,6 @@ class ChapterBuilder: if lvl and has_content(el): special = _special_heading(el) if lvl <= self.level: - if update: - self._special_level = 0 - self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS - self._deep = el in self.deep_starts - return Marker(el, "heading", lvl) - if self._deep and lvl == self.level + 1: if update: self._special_level = 0 self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS @@ -204,7 +196,7 @@ class ChapterBuilder: body_segments = segments[1:] if _meaningful(head_items): first = _first_meaningful(head_items) - if chapters and leading_heading_level(first) > self.level + self._deep and not semantic_types(doc.body): + if chapters and leading_heading_level(first) > self.level and not semantic_types(doc.body): chapters[-1].items.extend(pending + head_items) pending = [] if doc.path not in chapters[-1].docs: diff --git a/epub2md/cli.py b/epub2md/cli.py index 8066c45..21865a3 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -46,14 +46,6 @@ def code_language(value: str) -> str: return lang -def _chapter_level(value: str) -> int | str: - if value == "auto": - return value - if value.isdigit() and 1 <= int(value) <= 6: - return int(value) - raise argparse.ArgumentTypeError("auto oder eine Zahl zwischen 1 und 6 erwartet") - - def build_parser() -> argparse.ArgumentParser: p = _Parser( prog="epub2md.py", @@ -69,10 +61,9 @@ def build_parser() -> argparse.ArgumentParser: help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)") p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False, help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)") - p.add_argument("--chapter-level", type=_chapter_level, default="auto", metavar="{auto,1-6}", - help="Überschriftenebene, ab der neue Kapiteldateien entstehen. auto (Standard): Ebene 1, " - "nur sehr große Kapitel (über 30000 Wörter, z. B. Teile eines Romans) werden an ihren " - "Unterkapiteln geteilt. Eine Zahl 1–6 gilt ausnahmslos für das ganze Buch") + p.add_argument("--chapter-level", type=int, default=1, metavar="N", + help="Überschriftenebene 1–6, ab der neue Kapiteldateien entstehen (Standard: 1). " + "Für Bücher mit Teilen (h1) und Kapiteln (h2) ist 2 sinnvoll") p.add_argument("--toc", action=argparse.BooleanOptionalAction, default=True, help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)") p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True, diff --git a/epub2md/mathml.py b/epub2md/mathml.py index fdee4d8..4687944 100644 --- a/epub2md/mathml.py +++ b/epub2md/mathml.py @@ -365,54 +365,7 @@ def strip_tex_delimiters(text: str) -> str: if t.startswith(o) and t.endswith(c) and len(t) >= len(o) + len(c): t = t[len(o) : len(t) - len(c)] break - return portable_tex(t.strip()) - - -UMLAUTS = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"} - - -def portable_tex(tex: str) -> str: - """Publisher TeX → TeX that Pandoc's formula parser (texmath, MathJax, KaTeX) understands, still valid LaTeX: - text accents \\"u → ü, \\O (empty set in Springer books) → \\emptyset, no @{…} column separators in - array specifications, no \\arraycolsep assignments, no empty \\displaystyle before a cell end. Only changes - spacing, never content.""" - tex = re.sub(r'\\"(?:\{([aouAOU])\}|([aouAOU]))', lambda m: UMLAUTS[m.group(1) or m.group(2)], tex) - tex = re.sub(r"\\(?:text|mbox|mathrm)\s*\{\s*\\O\s*\}", r"\\emptyset", tex) - tex = re.sub(r"\\O(?![A-Za-z])", r"\\emptyset", tex) - tex = re.sub(r"\\arraycolsep\s*-?\d*\.?\d+\s*(?:pt|em|ex|mm|cm)", "", tex) - tex = re.sub(r"\\displaystyle\s*(?=\\\\|&|\\end\b)", "", tex) # no-op before a cell end; texmath fails - out: list[str] = [] - pos = 0 - for m in re.finditer(r"\\begin\{array\}\{", tex): - if m.start() < pos: - continue - end = _closing_brace(tex, m.end() - 1) - if end < 0: - break - spec = tex[m.end():end] - i = spec.find("@{") - while i >= 0: - close = _closing_brace(spec, i + 1) - if close < 0: - break - spec = spec[:i] + spec[close + 1:] - i = spec.find("@{") - out.append(tex[pos:m.end()] + spec) - pos = end - return "".join(out) + tex[pos:] - - -def _closing_brace(text: str, open_pos: int) -> int: - """Index of the '}' matching the '{' at ``open_pos`` (-1 if unbalanced).""" - depth = 0 - for i in range(open_pos, len(text)): - if text[i] == "{": - depth += 1 - elif text[i] == "}": - depth -= 1 - if depth == 0: - return i - return -1 + return t.strip() def is_display_tex(text: str) -> bool: diff --git a/epub2md/merge.py b/epub2md/merge.py index 3652e4d..5a052f9 100644 --- a/epub2md/merge.py +++ b/epub2md/merge.py @@ -188,7 +188,7 @@ def assign_anchors(chapters: list[ChapterFile]) -> int: for m in TOKEN.finditer(seg): if m.group("attr") and m.group("aname") == "id" and m.group("avalue") not in ids: ids.append(m.group("avalue")) - start = "kap-" + stems[ch.rel] # ids must start with a letter (Pandoc's EPUB writer breaks '#039-…') + start = stems[ch.rel] while start in used or start in ids: start = "kapitel-" + start ch.start_anchor = start diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index 45500bc..9f1f68e 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -16,7 +16,7 @@ from typing import Any from urllib.parse import unquote from . import TOOL_NAME, __version__ -from .chapters import SPECIAL_KINDS, ChapterBuilder, heading_level +from .chapters import ChapterBuilder from .diagnostics import Diagnostics from .css import StyleSheet, base_style, emphasis from .epub_reader import EpubReader, decode_markup, parse_xml @@ -26,8 +26,7 @@ from .html_converter import RenderOptions, Renderer from .linkcheck import check_links from .media import MediaRegistry from .models import Chapter -from .normalize import (Doc, book_uses_title_tables, has_content, is_html_toc_doc, make_doc, normalize_doc, - remove_keep_tail) +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_element, is_inside, iter_elements, local, resolve_href, sha256_file, slugify, text_content from . import writer @@ -41,7 +40,7 @@ class Options: format: str = "gfm" extract_media: bool = True copy_original: bool = False - chapter_level: int | str = "auto" # 1–6, or "auto": level 1 plus oversized chapters split one level deeper + chapter_level: int = 1 toc: bool = True frontmatter: bool = True footnotes: str = "end-of-chapter" @@ -82,8 +81,8 @@ def check_paths(input_path: Path, output_dir: Path, opts: Options) -> None: raise InputError(f"Eingabedatei nicht gefunden oder keine Datei: {input_path}") if not os.access(input_path, os.R_OK): raise InputError(f"Eingabedatei nicht lesbar: {input_path}") - if opts.chapter_level != "auto" and not (isinstance(opts.chapter_level, int) and 1 <= opts.chapter_level <= 6): - raise InputError("--chapter-level muss auto oder eine Zahl zwischen 1 und 6 sein.") + if not 1 <= opts.chapter_level <= 6: + raise InputError("--chapter-level muss zwischen 1 und 6 liegen.") if opts.dry_run: return if output_dir.exists() and not output_dir.is_dir(): @@ -215,9 +214,10 @@ def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics, def _build_structure(docs: list[Doc], package: Any, opts: Options, diag: Diagnostics) -> tuple[Any, list[Chapter], LinkResolver]: """Notes, chapter units with file names, and the link resolver (incl. NAV/NCX targets).""" + md = package.metadata notes = collect_notes(docs, opts.footnotes, diag) diag.step(f"{len(notes.notes)} Anmerkungen erkannt") - chapters = _build_chapters(docs, package, opts, diag) + chapters = ChapterBuilder(opts.chapter_level, package.toc, diag, md.title, md.authors).build(docs) _assign_filenames(chapters, opts.slug_language) resolver = LinkResolver(docs, chapters, notes, "README.md" if opts.toc else None) nav_targets: list[tuple[str, str]] = [] @@ -296,7 +296,7 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: diag.step(f"Rendere {ch.filename}") ch.markdown = renderer.render_chapter(ch) ch.stats.word_count = _word_count(ch.markdown) - _check_chapter_size(ch, _base_level(opts), diag) + _check_chapter_size(ch, opts.chapter_level, diag) _report_wrap_markers(renderer, diag) if not opts.code_language: _suggest_code_language(renderer, diag) @@ -433,52 +433,16 @@ def _assign_filenames(chapters: list[Chapter], slug_language: str) -> None: ch.filename = f"chapters/{candidate}.md" -def _base_level(opts: Options) -> int: - return 1 if opts.chapter_level == "auto" else int(opts.chapter_level) - - -def _build_chapters(docs: list[Doc], package: Any, opts: Options, diag: Diagnostics) -> list[Chapter]: - """Chapter units at --chapter-level. With "auto", chapters over LARGE_CHAPTER_WORDS with at least two - sub-headings (parts of a novel, say) are split one level deeper; all other chapters stay whole.""" - md = package.metadata - level = _base_level(opts) - if opts.chapter_level == "auto" and level < 6: - probe = ChapterBuilder(level, package.toc, Diagnostics(), md.title, md.authors).build(docs) - deep = {ch.title_el for ch in probe if _oversized(ch, level)} - if deep: - titles = ", ".join(f"„{ch.title}“" for ch in probe if ch.title_el in deep) - diag.info("chapter-split", f"Sehr große Kapitel ({titles}) werden an ihren Überschriften der Ebene " - f"{level + 1} aufgeteilt (--chapter-level auto).") - return ChapterBuilder(level, package.toc, diag, md.title, md.authors, deep_starts=deep).build(docs) - return ChapterBuilder(level, package.toc, diag, md.title, md.authors).build(docs) - - -def _oversized(ch: Chapter, level: int) -> bool: - """Heading-started chapter (not glossary/index/…) with too many words and >= 2 next-level sub-headings.""" - if ch.title_el is None or ch.title_level > level or SPECIAL_KINDS & set(ch.types): - return False - words = 0 - sections = 0 - for _, it in ch.items: - if isinstance(it, str): - words += len(it.split()) - elif is_element(it): - words += len(text_content(it).split()) - sections += sum(1 for e in iter_elements(it) if heading_level(e) == level + 1 and has_content(e)) - return words > LARGE_CHAPTER_WORDS and sections >= 2 - - def _check_chapter_size(ch: Chapter, chapter_level: int, diag: Diagnostics) -> None: """Suggest a deeper --chapter-level for very large chapters with enough sub-headings.""" if ch.stats.word_count <= LARGE_CHAPTER_WORDS or chapter_level >= 6: return sections = len(re.findall(r"^## ", ch.markdown, re.M)) if sections >= 2: - deeper = max(chapter_level, ch.title_level or 1) + 1 diag.warn( "chapter-large", f"Kapitel „{ch.title}“ hat {ch.stats.word_count} Wörter und {sections} Unterabschnitte; " - f"--chapter-level {deeper} würde sie als eigene Dateien ausgeben.", + f"--chapter-level {chapter_level + 1} würde sie als eigene Dateien ausgeben.", ch.docs[0] if ch.docs else "", ch.filename, ) diff --git a/epub2md/writer.py b/epub2md/writer.py index 061326e..cf4333b 100644 --- a/epub2md/writer.py +++ b/epub2md/writer.py @@ -157,7 +157,7 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, f"python3 epub2md_merge.py {shlex.quote(output_dir)}", "# danach in diesem Ordner:", f"pandoc {md_} -s{math}{toc}{lua} -o {html_}", - f"pandoc {md_} --toc{math}{lua} -o {shlex.quote(stem + '.epub')}", + 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```") @@ -166,7 +166,7 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, out.append(f"Das Buch enthält {callouts} {noun} (`> [!TIP]` …). Der Filter `callouts.lua` macht daraus " "in HTML, EPUB und PDF gerahmte Kästen mit Titel; ohne ihn zeigt Pandoc „[!TIP]“ als Text.") if stats.get("formulas"): - out.append("Das Buch enthält Formeln: Für HTML und EPUB `--mathml` (bei HTML auch `--mathjax`) verwenden, sonst belässt " + out.append("Das Buch enthält Formeln: Für HTML `--mathml` (oder `--mathjax`) verwenden, sonst belässt " "Pandoc Matrizen und ähnliche Konstrukte als TeX-Text.") out.append("## Reproduktion") out.append("```bash\n" + " ".join(shlex.quote(c) for c in command) + "\n```") diff --git a/pandoc/book.lua b/pandoc/book.lua index 06a189c..67b76b3 100644 --- a/pandoc/book.lua +++ b/pandoc/book.lua @@ -11,7 +11,7 @@ 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 (first chapter with nothing but one picture) without the caption "Cover", - * turns the chapter anchors into
so that print.css can + * turns the chapter anchors into
so that print.css can start every chapter – including cover, title page and imprint – on a new page. For LaTeX (PDF route md -> html -> pdf, filter in BOTH steps, no --toc) the table of contents becomes @@ -55,7 +55,7 @@ local function title_block(meta) return pandoc.RawBlock("html", table.concat(html, "\n")) end --- Paragraph that only holds chapter start anchors () → id of the first one. +-- 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 @@ -65,8 +65,7 @@ local function chapter_anchor(block) if inl.t ~= "RawInline" then return nil end - local found = inl.text:match('^$') or inl.text:match('^$') - or inl.text:match('^$') or inl.text:match('^$') -- before 0.3.0 + local found = inl.text:match('^$') or inl.text:match('^$') if found then id = id or found elseif inl.text ~= "" then @@ -144,7 +143,6 @@ local function latex_book(doc) blocks:insert(pandoc.RawBlock("latex", "\\clearpage")) blocks:insert(block) elseif block.t == "Div" and block.classes:includes("cover") then - blocks:insert(pandoc.RawBlock("latex", "\\thispagestyle{empty}")) -- no page number on the cover blocks:insert(block:walk({ Image = function(img) -- cover as large as the page allows img.attributes.width = nil img.attributes.height = "20cm" diff --git a/pandoc/print.css b/pandoc/print.css index 2bea7b8..e68f52d 100644 --- a/pandoc/print.css +++ b/pandoc/print.css @@ -3,7 +3,6 @@ --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; } } -@page :first { @bottom-center { content: none; } } /* no page number on the cover / title page */ 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; } diff --git a/tests/test_chapters.py b/tests/test_chapters.py index befe5d6..75d71fd 100644 --- a/tests/test_chapters.py +++ b/tests/test_chapters.py @@ -127,7 +127,7 @@ def test_glossary_anchor_is_placed_before_bold_term(convert): def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert): words = " ".join(["Wort"] * 11000) body = "

Teil 1

" + "".join(f"

Kapitel {i}

{words}

" for i in range(3)) - run = convert(Book(docs=[Doc("t.xhtml", body)]), "--chapter-level", "1") + run = convert(Book(docs=[Doc("t.xhtml", body)])) hints = [w for w in run.report["warnings"] if w["code"] == "chapter-large"] assert len(hints) == 1 assert "--chapter-level 2" in hints[0]["message"] and "3 Unterabschnitte" in hints[0]["message"] @@ -135,33 +135,6 @@ def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert): assert not any(w["code"] == "chapter-large" for w in small.report["warnings"]) -def test_auto_level_splits_only_oversized_chapters(convert): - """Default --chapter-level auto: a part over 30000 words is split at its h2, other chapters stay whole (R2).""" - words = " ".join(["Wort"] * 11000) - docs = [ - Doc("v.xhtml", "

Vorwort

kurz

Dank

danke

Hinweise

lesen

"), - Doc("t1.xhtml", "

Teil 1

Einleitung

"), - *[Doc(f"k{i}.xhtml", f"

Kapitel {i}

{words}

Abschnitt

x

") for i in range(1, 4)], - Doc("t2.xhtml", "

Teil 2

Kapitel 4

kurz

Kapitel 5

kurz

"), - ] - run = convert(Book(docs=docs)) - assert [p.name for p in run.chapters()] == [ - "001-vorwort.md", "002-teil-1.md", "003-kapitel-1.md", "004-kapitel-2.md", "005-kapitel-3.md", "006-teil-2.md"] - assert "## Dank" in run.chapter("001") and "## Kapitel 5" in run.chapter("006") - assert "Einleitung" in run.chapter("002") and "## Abschnitt" in run.chapter("003") - assert run.chapter("003").count("\n# ") == 1 - assert not any(w["code"] == "chapter-large" for w in run.report["warnings"]) - assert any(i["code"] == "chapter-split" for i in run.report["infos"]) - assert run.report["options"]["chapter_level"] == "auto" - explicit = convert(Book(docs=docs), "--chapter-level", "1") - assert len(explicit.chapters()) == 3 - - -def test_chapter_level_rejects_invalid_values(convert): - for value in ("0", "7", "x"): - assert convert(Book(docs=[Doc("a.xhtml", "

A

")]), "--chapter-level", value).rc == 1 - - def test_titles_derived_from_content_when_no_other_source(convert): docs = [ Doc("ccover.xhtml", '
'), diff --git a/tests/test_math.py b/tests/test_math.py index 1266d5a..a89df9c 100644 --- a/tests/test_math.py +++ b/tests/test_math.py @@ -2,7 +2,7 @@ import pytest from lxml import etree from conftest import PNG, SVG, Book, Doc -from epub2md.mathml import Unsupported, mathml_to_tex, portable_tex +from epub2md.mathml import Unsupported, mathml_to_tex M = 'xmlns="http://www.w3.org/1998/Math/MathML"' @@ -111,24 +111,6 @@ def test_book_readme_suggests_mathml_for_pandoc_only_with_formulas(convert): readme = (run.out / "README.md").read_text(encoding="utf-8") assert "## Weiterverarbeitung" in readme and "epub2md_merge.py" in readme assert "--mathml" in readme - pandoc_lines = [ln for ln in readme.splitlines() if ln.startswith("pandoc ")] - assert pandoc_lines and all("--mathml" in ln for ln in pandoc_lines) # EPUB too, else TeX stays text (QA3-04) plain = convert(Book(docs=[Doc("a.xhtml", "

A

Text

")])) plain_readme = (plain.out / "README.md").read_text(encoding="utf-8") assert "## Weiterverarbeitung" in plain_readme and "--mathml" not in plain_readme - - -@pytest.mark.parametrize("tex,expected", [ - (r'\mbox{gr\"un}, \text{F\"{u}r}', r"\mbox{grün}, \text{Für}"), - (r'\text{ f{\"u}r } j', r"\text{ f{ü}r } j"), # braces stay balanced - (r"{\mathscr L}(A,b)={{\O }}", r"{\mathscr L}(A,b)={{\emptyset }}"), - (r"M\cap N=\text{\O }", r"M\cap N=\emptyset"), - (r"\Omega\neq\O", r"\Omega\neq\emptyset"), - (r"\begin{array}{@{}l@{\qquad \quad }l@{}} a & b\end{array}", r"\begin{array}{ll} a & b\end{array}"), - (r"\arraycolsep8pt\begin{array}{c@{\,}r} x\end{array}", r"\begin{array}{cr} x\end{array}"), - (r"a & =&\displaystyle b, \\ & \vdots &\displaystyle \\ c", r"a & =&\displaystyle b, \\ & \vdots &\\ c"), - (r"\frac{a}{b}+\{x\}", r"\frac{a}{b}+\{x\}"), -]) -def test_publisher_tex_is_made_portable(tex, expected): - """Pandoc's texmath rejects \\", \\O, @{…} and \\arraycolsep (Springer alt texts, QA3-06).""" - assert portable_tex(tex) == expected diff --git a/tests/test_merge.py b/tests/test_merge.py index 8277259..1a2d122 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -34,10 +34,10 @@ def test_merge_complete_book(convert, tmp_path): # links between chapters point into the merged file; colliding id "sec" renamed in chapter B assert '' in text and '' in text assert "[B](#c002-sec)" in text and "[A](#sec)" in text and "[hier](#sec)" in text - assert "[Kapitel B](#kap-002-kapitel-b)" in text and '' in text + assert "[Kapitel B](#002-kapitel-b)" in text and '' in text # media paths relative to the merged file, also inside image links assert "![Bild](media/images/0001-bild.png)" in text - assert "[![Bildlink](media/images/0001-bild.png)](#kap-002-kapitel-b)" in text + assert "[![Bildlink](media/images/0001-bild.png)](#002-kapitel-b)" in text # code untouched assert '[kein](link.md) id="sec" `code`' in text # footnote defined in both chapters -> only once in the merged file @@ -63,8 +63,8 @@ def test_merge_to_other_location_rewrites_media_paths(convert, tmp_path): text = target.read_text(encoding="utf-8") assert not text.startswith("---") assert f"![Bild](../{run.out.name}/media/images/0001-bild.png)" in text - assert "1. [Kapitel A](#kap-001-kapitel-a)" in text - assert "kap-001-kapitel-a" in anchors_in(text) + assert "1. [Kapitel A](#001-kapitel-a)" in text + assert "001-kapitel-a" in anchors_in(text) def test_merge_invalid_directory(tmp_path): diff --git a/tests/test_pandoc.py b/tests/test_pandoc.py deleted file mode 100644 index 4f4e00c..0000000 --- a/tests/test_pandoc.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Pandoc path: merged file → EPUB/HTML with the filters from pandoc/ (skipped without pandoc).""" - -import re -import shutil -import subprocess -import zipfile -from pathlib import Path - -import pytest - -from conftest import Book, Doc -from epub2md.merge import main as merge_main - -pytestmark = pytest.mark.skipif(shutil.which("pandoc") is None, reason="pandoc nicht installiert") -FILTERS = Path(__file__).resolve().parent.parent / "pandoc" - - -def merged(convert, book: Book) -> Path: - run = convert(book) - assert merge_main([str(run.out)]) == 0 - return run.out / f"{run.report['input']['name'][:-5]}.md" - - -def pandoc(md: Path, *args: str) -> subprocess.CompletedProcess: - return subprocess.run(["pandoc", md.name, *args], cwd=md.parent, capture_output=True, text=True, check=True) - - -def links_and_ids(epub: Path) -> tuple[list[tuple[str, str]], dict[str, set[str]]]: - with zipfile.ZipFile(epub) as z: - docs = {n: z.read(n).decode("utf-8") for n in z.namelist() if n.endswith(".xhtml")} - ids = {n: set(re.findall(r'\bid="([^"]+)"', t)) for n, t in docs.items()} - links = [(n, h) for n, t in docs.items() for h in re.findall(r'href="([^"]+)"', t) - if not re.match(r"^[a-z]+:", h) and not h.endswith(".css")] - return links, ids - - -def test_epub_links_to_chapter_starts_resolve(convert): - """Chapter start anchors must begin with a letter: Pandoc's EPUB writer breaks '#039-…' (QA3-05).""" - docs = [Doc("a.xhtml", '

Kapitel A

Siehe Kapitel B und Bild.

'), - Doc("b.xhtml", "

Kapitel B

Text B.

"), - Doc("c.xhtml", "

Nur eine Bildseite ohne Überschrift.

")] - md = merged(convert, Book(docs=docs)) - pandoc(md, "--toc", "-o", "buch.epub") - links, ids = links_and_ids(md.parent / "buch.epub") - broken = [] - for doc, href in links: - file, _, frag = href.partition("#") - target = str(Path(doc).parent / file).replace("\\", "/") if file else doc - target = re.sub(r"[^/]+/\.\./", "", target) - if target not in ids or (frag and frag not in ids[target]): - broken.append(f"{doc}: {href}") - assert not broken - - -def test_book_filter_turns_chapter_anchors_into_chapter_starts(convert): - docs = [Doc("a.xhtml", "

Kapitel A

a

"), Doc("b.xhtml", "

Kapitel B

b

")] - md = merged(convert, Book(docs=docs)) - html = pandoc(md, "-s", "--lua-filter", str(FILTERS / "book.lua"), "-t", "html").stdout - assert html.count('class="chapter-start"') == 2 - assert 'id="TOC"' in html and "Kapitel B" in html