diff --git a/.gitignore b/.gitignore index 8924a81..7ac2e8c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ htmlcov/ build/ dist/ -# QA-Läufe (Prompt_Kontrolle.md) +# QA-Läufe (Prompt_Kontrolle.md): erzeugte Daten von tools/qa/ .qa/ .qa-output/ .qa-fixtures/ diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 173f5b5..f1e3639 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) @@ -139,9 +141,10 @@ python3 epub2md_merge.py output/meinbuch --force ### Roman (Teile und Kapitel) -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: +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: ```bash python3 epub2md.py roman.epub output/roman --chapter-level 2 @@ -160,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 @@ -183,7 +187,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 | @@ -222,7 +228,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,45 +315,57 @@ 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 --toc --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` | +| 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` | +| 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. 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 + eingerückter Block mit fettem Titel. +- **`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. **Zum PDF:** +- **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. 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. + 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. - -#### 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 -``` - -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. +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 @@ -377,8 +395,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. -- Achte auf die Warnung `chapter-large`: Sehr große Kapiteldateien lassen sich schlecht zerlegen. Dann - mit höherem `--chapter-level` neu umwandeln. +- 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. --- @@ -503,14 +521,14 @@ 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 1) | +| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard `auto`: Ebene 1, sehr große Kapitel werden an ihren Unterkapiteln geteilt) | | `--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 | | `--boxes callout/quote/html/plain` | Darstellung von Infokästen | | `--styles semantic/html` | Farben, Schriften, Größen, Ausrichtung und Sprache als HTML übernehmen (`html`) | | `--page-markers none/visible/comment` | Seitenzahlen der Druckausgabe als `[S. 123]` oder `` | -| `--no-css-emphasis` | Hervorhebungen nur aus HTML-Tags übernehmen, nicht aus dem Stylesheet des Buches | +| `--no-css-emphasis` | Hervorhebungen nur aus HTML-Tags übernehmen, nicht aus dem Stylesheet des Buches (farbige Markierungen `` bleiben, sie gehören zu `--styles`) | | `--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/CHANGELOG.md b/CHANGELOG.md index 7b451cb..da79cba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,75 @@ Alle nennenswerten Änderungen an epub2md. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), 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/`. + +### 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 + +- `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. 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. + +### 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, + wie bei Überschriften. + +### 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 + fremde `display:none`-Regel hätte Text gelöscht (QA3-01, Regressionstests in `tests/test_styles.py`). +- `pandoc/callouts.lua`: Die Kästen blieben ohne Rahmen, wenn man Pandoc zusätzlich `-H`/`--include-in-header` + übergab, und im EPUB hatten nur die Titelseite, nicht die Kapitel, das Kasten-CSS. Das CSS steht jetzt in + `pandoc/callouts.css`; der Filter setzt es bei HTML an den Anfang des Dokuments und fügt es bei EPUB den + Stylesheets hinzu (zusammen mit Pandocs Standard-CSS bzw. zu eigenen `css:`-Angaben). + +### Dokumentation + +- `--no-css-emphasis` lässt ``-Markierungen bestehen; diese steuert `--styles`. + ## [0.2.0] – 2026-10-06 Schwerpunkt: Typografie und Gestaltung des Buches im Markdown abbilden. @@ -49,5 +118,7 @@ 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 [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 c8d420e..e4974ce 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ python3 epub2md.py \ --toc \ --verbose -# Roman mit Teilen (h1) und Kapiteln (h2): ein File pro Kapitel +# Jedes Kapitel unterhalb von h2 als eigene Datei (sonst teilt auto nur sehr große 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 N` | `1` | Überschriftenebene (1–6), ab der eine neue Kapiteldatei beginnt | +| `--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 | | `--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) | @@ -100,7 +100,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json | `--boxes {callout,quote,html,plain}` | `callout` | Infokästen (Tipp, Hinweis, Warnung, Exkurs, Merke …): `callout` = `> [!TIP]` mit fettem Titel (GitHub-Alerts, Obsidian-Callouts; anderswo ein Zitatblock), `quote` = Zitatblock mit fettem Titel, `html` = `
`, `plain` = normaler Text wie bisher | | `--styles {semantic,html}` | `semantic` | `semantic`: nur bedeutungstragende Gestaltung (Hintergrundmarkierung → ``). `html`: zusätzlich Farbe, Hintergrund, Schriftgröße, Schriftart, Kapitälchen, Ausrichtung als ``/`
` und fremdsprachige Passagen als ``, jeweils nur, wo sie vom normalen Text des Buches abweichen. Obsidian, VS Code und Pandoc (HTML/PDF/EPUB) zeigen das an, GitHub nicht | | `--page-markers {none,visible,comment}` | `none` | Seitenzahlen der Druckausgabe an der Stelle des Seitenwechsels: `visible` = `\[S. 123\]` im Text (zum Zitieren), `comment` = `` (unsichtbar, aber durchsuchbar), `none` = weglassen | -| `--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` | +| `--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`. Hintergrundmarkierungen (``) und Stile steuert unabhängig davon `--styles` | | `--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) | @@ -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`) 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 @@ -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 @@ -184,10 +188,13 @@ 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. -**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. +**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. ## Math-Modi @@ -239,12 +246,19 @@ 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. + Berücksichtigt werden nur Stylesheets, die ein Dokument per `` (oder `@import`) einbindet; + mitgelieferte Layouts anderer Buchreihen bleiben wie im E-Book-Reader wirkungslos (Info `css-unlinked`). Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst (`*x**y*` → `*xy*`). - **Farben und Schriften:** Markdown kennt keine Farben, Schriftarten oder -größen. Mit `--styles html` bleiben sie als HTML-Stil erhalten. Übernommen wird nur, was vom Grundstil abweicht (häufigste Werte aller @@ -296,14 +310,15 @@ 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 | Beim Zusammenfügen passiert Folgendes: - Die Frontmatter der einzelnen Kapitel wird entfernt. -- Jedes Kapitel erhält einen Startanker (``). +- 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. - 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. @@ -312,8 +327,15 @@ 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`). + `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, @@ -329,16 +351,43 @@ 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 -# 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 +pandoc mathematikbuch.md --toc --mathml --lua-filter $P/callouts.lua -o mathematikbuch.epub # EPUB: MathML ``` +Hilfsdateien in `pandoc/`: + +- **`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 + 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. 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 (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 EPUB entstehen gerahmte, farbige Kästen mit Titel („Tipp: …“, bei `lang: en` „Tip: …“), in LaTeX/PDF, Word @@ -348,9 +397,8 @@ 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 (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. +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. Alternativ direkt aus den Kapiteldateien: @@ -365,8 +413,10 @@ 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. -- 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“. +- 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). ## Bericht und Diagnose @@ -411,6 +461,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 @@ -437,7 +492,11 @@ epub2md/ ├── models.py Dataclasses (Kapitel, Medien, Noten, Metadaten …) └── utils.py Slugs, YAML-Emitter, Pfadhelfer pandoc/ -└── callouts.lua Pandoc-Filter: '> [!TIP]'-Kästen → gerahmte Kästen (HTML/EPUB) bzw. Zitatblock mit Titel +├── 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) +tools/qa/ QA-Skripte: CLI-Matrix, Validator, Wortvergleich, Pandoc-Prüfung (siehe tools/qa/README.md) ``` Statt BeautifulSoup wird direkt lxml verwendet: Es ist für die XHTML-Bäume ausreichend, schneller und @@ -455,7 +514,14 @@ 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. +ausgeschlossen.`tests/test_pandoc.py` prüft den Pandoc-Weg (nur mit installiertem Pandoc). + +Für gründlichere Prüfungen nach `Prompt_Kontrolle.md` gibt es `tools/qa/`: eine CLI-Matrix über alle Optionen +und Fehlercodes, einen unabhängigen Markdown-Validator, einen Wortvergleich gegen Datenverlust und die +Prüfung des Pandoc-Wegs (HTML, EPUB, PDF) für alle Beispielbücher. Details in `tools/qa/README.md`. diff --git a/epub2md/__init__.py b/epub2md/__init__.py index 6885324..292d78c 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.2.0" +__version__ = "0.3.0" TOOL_NAME = "epub2md" diff --git a/epub2md/chapters.py b/epub2md/chapters.py index 0b16de3..8716c3b 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)), @@ -105,9 +105,11 @@ 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) -> None: + book_title: str = "", authors: list[str] | None = None, deep_starts: set[Any] | 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 @@ -129,6 +131,12 @@ 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 @@ -196,7 +204,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 and not semantic_types(doc.body): + if chapters and leading_heading_level(first) > self.level + self._deep and not semantic_types(doc.body): chapters[-1].items.extend(pending + head_items) pending = [] if doc.path not in chapters[-1].docs: @@ -307,12 +315,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/cli.py b/epub2md/cli.py index 21865a3..8066c45 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -46,6 +46,14 @@ 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", @@ -61,9 +69,10 @@ 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=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("--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("--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/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 fb8f6ff..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, @@ -303,6 +304,7 @@ class Renderer: self.css = stylesheet self.scene_breaks = 0 self.page_markers = 0 + self.marker_sink: list[str] | None = None # collects page markers instead of emitting them inline self.poems = 0 self.boxes = 0 self.in_box = False @@ -322,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 @@ -417,7 +420,11 @@ class Renderer: return "" self.page_markers += 1 page = html.escape(page, quote=False).replace("--", "") - return f"" if self.opt.page_markers == "comment" else f"\\[S. {md_escape(page)}\\]" + marker = f"" if self.opt.page_markers == "comment" else f"\\[S. {md_escape(page)}\\]" + if self.marker_sink is not None: + self.marker_sink.append(marker) + return "" + return marker def anchors_of(self, el: Any, subtree: bool = False) -> str: nodes = iter_elements(el) if subtree else [el] @@ -622,13 +629,18 @@ class Renderer: break title_el = self._box_title(members) title = "" + markers: list[str] = [] # page breaks in the title go before the box, like for headings if title_el is not None: pieces: list[Piece] = [("t", self._text(title_el.text))] if title_el.text else [] - for child in title_el: - if is_element(child) and not self._title_symbol(child): - pieces.extend(self.inline(child)) - if child.tail: - pieces.append(("t", self._text(child.tail))) + self.marker_sink = markers + try: + for child in title_el: + if is_element(child) and not self._title_symbol(child): + pieces.extend(self.inline(child)) + if child.tail: + pieces.append(("t", self._text(child.tail))) + finally: + self.marker_sink = None title = join_inline(pieces, hard_break=" ").strip() kind = kind or _box_type(norm_ws(text_content(title_el))) saved = self.in_box @@ -653,7 +665,9 @@ class Renderer: if title and norm_ws(re.sub(r"[*_\\:]", "", title)).lower() in TYPE_WORDS: title = "" # 'Tipp' as title of a TIP box adds nothing self.boxes += 1 - out = [anchor] if anchor else [] + out = ["".join(markers)] if markers else [] + if anchor: + out.append(anchor) head = [f"**{title}**"] if title else [] if self.opt.boxes == "html": out.append(f'
\n\n' + "\n\n".join(head + blocks) + "\n\n
") @@ -787,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: @@ -1221,8 +1238,8 @@ class Renderer: finally: self.strong -= 1 if name in ("del", "s", "strike"): - marker = ("~~", "~~") if self.opt.format == "gfm" else ("", "") - return pre + self._wrap(self.inline_children(el), marker[0], marker[1]) + del_open, del_close = ("~~", "~~") if self.opt.format == "gfm" else ("", "") + return pre + self._wrap(self.inline_children(el), del_open, del_close) if name in ("u", "ins", "mark", "kbd", "sup", "sub"): if name in ("sup", "sub"): elems = [c for c in el if is_element(c)] diff --git a/epub2md/mathml.py b/epub2md/mathml.py index 4687944..fdee4d8 100644 --- a/epub2md/mathml.py +++ b/epub2md/mathml.py @@ -365,7 +365,54 @@ 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 t.strip() + 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 def is_display_tex(text: str) -> bool: diff --git a/epub2md/merge.py b/epub2md/merge.py index cba21ff..3652e4d 100644 --- a/epub2md/merge.py +++ b/epub2md/merge.py @@ -46,6 +46,8 @@ class ChapterFile: body: str = "" start_anchor: str = "" anchor_map: dict[str, str] = field(default_factory=dict) + types: list[str] = field(default_factory=list) + words: int = 0 @dataclass @@ -74,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"]) 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) 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}", [], 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: @@ -89,6 +92,49 @@ 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) + + +# 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 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 + + +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 @@ -142,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 = stems[ch.rel] + start = "kap-" + stems[ch.rel] # ids must start with a letter (Pandoc's EPUB writer breaks '#039-…') while start in used or start in ids: start = "kapitel-" + start ch.start_anchor = start @@ -225,18 +271,31 @@ 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) + "---") + front = front_matter_count(chapters) + 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", ""] 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/pipeline.py b/epub2md/pipeline.py index bc10e24..db5eae3 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -13,9 +13,12 @@ from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any +from urllib.parse import unquote from . import TOOL_NAME, __version__ -from .chapters import ChapterBuilder +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 @@ -25,9 +28,10 @@ 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, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail +from .normalize import (Doc, book_uses_title_tables, has_content, 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, sha256_file, slugify, text_content +from .utils import is_element, is_inside, iter_elements, local, resolve_href, sha256_file, slugify, text_content from . import writer MARKER_FILES = ("conversion-report.json", "manifest.json") @@ -39,7 +43,7 @@ class Options: format: str = "gfm" extract_media: bool = True copy_original: bool = False - chapter_level: int = 1 + chapter_level: int | str = "auto" # 1–6, or "auto": level 1 plus oversized chapters split one level deeper toc: bool = True frontmatter: bool = True footnotes: str = "end-of-chapter" @@ -80,8 +84,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 not 1 <= opts.chapter_level <= 6: - raise InputError("--chapter-level muss zwischen 1 und 6 liegen.") + 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 opts.dry_run: return if output_dir.exists() and not output_dir.is_dir(): @@ -213,10 +217,9 @@ 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 = ChapterBuilder(opts.chapter_level, package.toc, diag, md.title, md.authors).build(docs) + chapters = _build_chapters(docs, package, opts, diag) _assign_filenames(chapters, opts.slug_language) resolver = LinkResolver(docs, chapters, notes, "README.md" if opts.toc else None) nav_targets: list[tuple[str, str]] = [] @@ -233,18 +236,39 @@ def _build_structure(docs: list[Doc], package: Any, opts: Options, return notes, chapters, resolver +CSS_IMPORT = re.compile(r"""@import\s+(?:url\(\s*)?["']?([^"')\s;]+)""", re.I) + + def _load_stylesheet(reader: EpubReader, package: Any, docs: list[Doc], diag: Diagnostics) -> StyleSheet: - """All CSS of the book: manifest stylesheets (manifest order) plus -]] +-- 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 }, } diff --git a/pandoc/print.css b/pandoc/print.css new file mode 100644 index 0000000..2bea7b8 --- /dev/null +++ b/pandoc/print.css @@ -0,0 +1,32 @@ +/* Print layout for PDFs from epub2md output via Pandoc + weasyprint: + 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; } } +@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; } +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; } +pre, pre code { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 8.5pt; } /* long code lines wrap */ +pre { overflow: visible; } +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/conftest.py b/tests/conftest.py index a08e5b7..5012e47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import posixpath import sys import zipfile from dataclasses import dataclass, field @@ -25,7 +26,7 @@ SVG = b' -{title} +{title}{head} {body} """ @@ -36,6 +37,7 @@ class Doc: body: str title: str = "" linear: bool = True + css: list[str] | None = None # linked stylesheets (OEBPS-relative) – default: every .css in Book.media @dataclass @@ -90,7 +92,7 @@ def build_epub(path: Path, book: Book) -> Path: {''.join(spine)}{book.spine_extra} """ nav_xhtml = XHTML.format( - title="Inhalt", + title="Inhalt", head="", body='", ) ncx = ( @@ -114,7 +116,10 @@ def build_epub(path: Path, book: Book) -> Path: if book.ncx: z.writestr("OEBPS/toc.ncx", ncx) for d in book.docs: - z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, body=d.body)) + css = d.css if d.css is not None else [n for n in book.media if n.lower().endswith(".css")] + head = "".join(f'' + for c in css) + z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, head=head, body=d.body)) for name, data in book.media.items(): z.writestr(f"OEBPS/{name}", data) for name, data in book.extra_entries.items(): diff --git a/tests/test_chapters.py b/tests/test_chapters.py index c98dd87..befe5d6 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)])) + run = convert(Book(docs=[Doc("t.xhtml", body)]), "--chapter-level", "1") 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,6 +135,33 @@ 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", '
'), @@ -185,3 +212,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_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_math.py b/tests/test_math.py index a89df9c..1266d5a 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 +from epub2md.mathml import Unsupported, mathml_to_tex, portable_tex M = 'xmlns="http://www.w3.org/1998/Math/MathML"' @@ -111,6 +111,24 @@ 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 91898ae..8277259 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](#002-kapitel-b)" in text and '' in text + assert "[Kapitel B](#kap-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)](#002-kapitel-b)" in text + assert "[![Bildlink](media/images/0001-bild.png)](#kap-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,11 +63,76 @@ 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](#001-kapitel-a)" in text - assert "001-kapitel-a" in anchors_in(text) + assert "1. [Kapitel A](#kap-001-kapitel-a)" in text + assert "kap-001-kapitel-a" in anchors_in(text) 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" + + +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") + + +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_pandoc.py b/tests/test_pandoc.py new file mode 100644 index 0000000..4f4e00c --- /dev/null +++ b/tests/test_pandoc.py @@ -0,0 +1,60 @@ +"""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 diff --git a/tests/test_qa2_open.py b/tests/test_qa2_open.py index 35f3e7b..7b003c2 100644 --- a/tests/test_qa2_open.py +++ b/tests/test_qa2_open.py @@ -32,12 +32,14 @@ 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): 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"] diff --git a/tests/test_styles.py b/tests/test_styles.py index cc253d8..a62a99d 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -190,8 +190,10 @@ 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 + 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): @@ -201,3 +203,30 @@ def test_hidden_span_gets_no_field_separator_and_class_type_wins(convert): text = convert(Book(docs=[Doc("c.xhtml", body)], media={"s.css": css})).chapter("001") assert "unterstrichen." in text and "VERSTECKT" not in text assert "> [!NOTE]\n> **Wichtige Regel**" in text # class 'hinweis' beats the word in the title + + +def test_page_marker_in_box_title_goes_before_the_box(convert): + body = ('

K

') + text = convert(Book(docs=[Doc("c.xhtml", body)]), "--page-markers", "visible").chapter("001") + assert "\\[S. 62\\]\n\n> [!NOTE]\n> **Wozu Deklarationen?**" in text + assert "> Weil es \\[S. 63\\]hilft." in text # markers in the box body stay in place + + +def test_qa3_01_unlinked_stylesheets_are_ignored(convert): + """Publishers ship layout CSS of other series in the manifest; only linked stylesheets apply (QA3-01).""" + css = {"main.css": b".bold { font-weight: bold; }", + "fremd.css": b"p > span.bold { font-weight: normal; font-family: monospace; } .weg { display: none; }"} + body = ('

K

Die (standard ' + 'input) und sichtbarer Text.

') + text = convert(Book(docs=[Doc("c.xhtml", body, css=["main.css"])], media=css)).chapter("001") + assert "`" not in text # monospace rule of fremd.css must not turn bold into code + assert "**st**an**d**ard **in**put" in text + assert "sichtbarer Text" in text # display:none of fremd.css must not hide content + + +def test_qa3_01_imported_stylesheets_still_apply(convert): + css = {"main.css": b'@import url("extra.css");', "extra.css": b".k { font-style: italic; }"} + body = '

K

kursiv normal.

' + text = convert(Book(docs=[Doc("c.xhtml", body, css=["main.css"])], media=css)).chapter("001") + assert "*kursiv* normal." in text diff --git a/tools/qa/README.md b/tools/qa/README.md new file mode 100644 index 0000000..c8ab5a9 --- /dev/null +++ b/tools/qa/README.md @@ -0,0 +1,34 @@ +# QA-Werkzeuge + +Skripte für die Qualitätsprüfung nach `Prompt_Kontrolle.md`. Sie ergänzen `pytest` um CLI-Integrationstests, +einen unabhängigen Validator und die Prüfung des Pandoc-Wegs. Mit ihnen wurden die Befunde QA3-01 bis +QA3-06 gefunden (siehe `CHANGELOG.md`). Alle Aufrufe im Repo-Wurzelverzeichnis; erzeugte Daten landen in +`.qa/` (von Git ignoriert), Kommandos mit Rückgabecode in `.qa/commands.log`. + +```bash +python3 -m venv .qa/venv && . .qa/venv/bin/activate +pip install -r tools/qa/requirements.txt +``` + +| Skript | Zweck | +| --- | --- | +| `make_fixtures.py` | erzeugt synthetische Test-EPUBs 01–14 (u. a. Path Traversal, kaputter Container, MathML, Fußnoten, Typografie) in `.qa/generated-fixtures/` | +| `run_matrix.py` | CLI-Matrix (89 Fälle) über alle Optionen und Fehlercodes → `.qa/output/matrix-results.json`; braucht die Fixtures | +| `validate_output.py AUSGABE…` | unabhängiger Validator (markdown-it-py statt `epub2md/linkcheck.py`): Links, Anker, Bilder, Fences, YAML, JSON, Python-Einrückung, Fußnotenpaare → JSON | +| `worddiff.py ALT NEU` | Wortvergleich zweier Ausgaben – zeigt Datenverlust nach einer Codeänderung | +| `pyblocks.py DATEIEN…` | Python-Codeblöcke mit Einrückungsfehlern samt Inhalt (Vergleich mit der Quelle) | +| `pandoc_full.sh` | alle EPUBs aus `Beispiele/` konvertieren, zusammenfügen und mit den Pandoc-Befehlen der Buch-README nach HTML, EPUB und PDF (weasyprint, xelatex) bringen → `.qa/output/pandoc/` | +| `pandoc_run.sh`, `pandoc_pdfs.sh` | Teilschritte von `pandoc_full.sh` für einzelne Buchordner | +| `check_pandoc.py BUCHORDNER…` | HTML/EPUB von Pandoc: interne Links und Anker, Bilder, `[!TIP]`-Reste, Formeln, Lage des Inhaltsverzeichnisses | +| `check_pdf.sh PDF…` | Seiten, Seite des Inhaltsverzeichnisses, Text-Reste, nicht eingebettete Schriften (poppler-utils) | +| `log.sh` | `. tools/qa/log.sh; qa BEFEHL` protokolliert Befehl und rc | + +Typischer Ablauf nach einer Änderung: + +```bash +python -m pytest -q +python tools/qa/make_fixtures.py && python tools/qa/run_matrix.py +python epub2md.py Beispiele/BUCH.epub .qa/output/neu/BUCH +python tools/qa/worddiff.py .qa/output/alt/BUCH .qa/output/neu/BUCH # alt: z. B. mit git stash erzeugt +python tools/qa/validate_output.py .qa/output/neu/BUCH +``` diff --git a/tools/qa/check_pandoc.py b/tools/qa/check_pandoc.py new file mode 100755 index 0000000..cdbb9c1 --- /dev/null +++ b/tools/qa/check_pandoc.py @@ -0,0 +1,118 @@ +"""Prüft Pandoc-Ausgaben (HTML und EPUB) eines konvertierten Buches. + +Aufruf: python tools/qa/check_pandoc.py BUCHORDNER [...] -> JSON auf stdout +Geprüft: interne Links/Anker, Bilder, Reste von '[!TIP]', Lage des Inhaltsverzeichnisses, TeX-Reste. +""" + +from __future__ import annotations + +import json +import posixpath +import re +import sys +import zipfile +from pathlib import Path +from urllib.parse import unquote + +from lxml import html as lhtml + +ID_RE = re.compile(r"""\b(?:id|name)\s*=\s*["']([^"']+)["']""") + + +def _ids(text: str) -> set[str]: + return set(ID_RE.findall(text)) + + +def _links(doc) -> list[str]: + return [a.get("href") for a in doc.iter("a") if a.get("href")] + + +def _images(doc) -> list[str]: + srcs = [i.get("src") for i in doc.iter("img") if i.get("src")] + srcs += [i.get("{http://www.w3.org/1999/xlink}href") or i.get("href") for i in doc.iter("image")] + return [s for s in srcs if s and not s.startswith("data:")] + + +def _parse(text: str): + return lhtml.fromstring(text.encode("utf-8")) + + +def _toc_position(doc) -> dict: + """Where does the TOC (nav#TOC) stand relative to the first chapter heading?""" + nav = doc.xpath("//*[@id='TOC']") + if not nav: + return {"toc": False} + body_text = lhtml.tostring(doc, encoding="unicode") + pos = body_text.find('id="TOC"') + before = re.sub(r"<[^>]+>", " ", body_text[:pos]) + h1_before = len(re.findall(r" dict: + text = path.read_text(encoding="utf-8") + doc = _parse(text) + ids = _ids(text) + broken = [h for h in _links(doc) if h.startswith("#") and unquote(h[1:]) not in ids] + missing_img = [s for s in _images(doc) if not (path.parent / unquote(s)).exists()] + plain = doc.text_content() + return { + "file": path.name, "links_internal": sum(1 for h in _links(doc) if h.startswith("#")), + "broken_internal": len(broken), "broken_examples": broken[:5], + "images": len(_images(doc)), "missing_images": missing_img[:5], + "callout_residue": len(re.findall(r"\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]", plain)), + "tex_residue": len(re.findall(r"\\(?:frac|begin\{|mathbb|alpha|sum_)", plain)), + "math_elements": len(doc.xpath("//*[local-name()='math']")), + **_toc_position(doc), + } + + +def check_epub(path: Path) -> dict: + with zipfile.ZipFile(path) as z: + names = set(z.namelist()) + docs = {n: z.read(n).decode("utf-8") for n in names if n.endswith((".xhtml", ".html"))} + ids = {n: _ids(t) for n, t in docs.items()} + broken: list[str] = [] + internal = 0 + missing_img: list[str] = [] + residue = tex = math = 0 + for name, text in docs.items(): + doc = _parse(text) + base = posixpath.dirname(name) + for h in _links(doc): + if re.match(r"^[a-z]+:", h): + continue + internal += 1 + file, _, frag = h.partition("#") + target = posixpath.normpath(posixpath.join(base, unquote(file))) if file else name + if target not in names: + broken.append(f"{name}: {h}") + elif frag and unquote(frag) not in ids.get(target, set()): + broken.append(f"{name}: {h}") + for s in _images(doc): + if posixpath.normpath(posixpath.join(base, unquote(s))) not in names: + missing_img.append(f"{name}: {s}") + plain = doc.text_content() + residue += len(re.findall(r"\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]", plain)) + tex += len(re.findall(r"\\(?:frac|begin\{|mathbb|alpha|sum_)", plain)) + math += len(doc.xpath("//*[local-name()='math']")) + return {"file": path.name, "documents": len(docs), "links_internal": internal, "broken_internal": len(broken), + "broken_examples": broken[:5], "missing_images": missing_img[:5], "callout_residue": residue, + "tex_residue": tex, "math_elements": math, "nav": any(n.endswith("nav.xhtml") for n in names)} + + +def main() -> None: + results = [] + for d in map(Path, sys.argv[1:]): + entry = {"book": d.name} + for f in sorted(d.glob("*.html")): + entry["html"] = check_html(f) + for f in sorted(d.glob("*.epub")): + entry["epub"] = check_epub(f) + results.append(entry) + json.dump(results, sys.stdout, ensure_ascii=False, indent=1) + + +if __name__ == "__main__": + main() diff --git a/tools/qa/check_pdf.sh b/tools/qa/check_pdf.sh new file mode 100755 index 0000000..1f2e8f9 --- /dev/null +++ b/tools/qa/check_pdf.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Kurzprüfung von PDFs: Seiten, Inhaltsverzeichnis-Seite, Text-Reste, nicht eingebettete Schriften +for pdf in "$@"; do + pages=$(pdfinfo "$pdf" 2>/dev/null | awk '/^Pages:/{print $2}') + txt=$(pdftotext -enc UTF-8 "$pdf" - 2>/dev/null) + tocpage=$(for p in $(seq 1 15); do pdftotext -f $p -l $p "$pdf" - 2>/dev/null | grep -qiE '^(Inhalt|Inhaltsverzeichnis|Contents)$' && { echo $p; break; }; done) + first=$(for p in 1 2 3 4; do printf '%s | ' "$(pdftotext -f $p -l $p "$pdf" - 2>/dev/null | grep -v '^\s*$' | head -1 | cut -c1-28)"; done) + printf '%s\tSeiten=%s\tInhalt-S.=%s\t[!]=%s\t
/dev/null | awk 'NR>2 && $(NF-4)=="no"' | wc -l)" "$first" +done diff --git a/tools/qa/log.sh b/tools/qa/log.sh new file mode 100755 index 0000000..bed22ec --- /dev/null +++ b/tools/qa/log.sh @@ -0,0 +1,9 @@ +# usage: . tools/qa/log.sh; qa – runs a command, appends command + rc to .qa/commands.log +qa() { + local start rc + start=$(date -Iseconds) + "$@"; rc=$? + mkdir -p "$(git rev-parse --show-toplevel)/.qa" + printf '%s\trc=%s\t%s\n' "$start" "$rc" "$*" >> "$(git rev-parse --show-toplevel)/.qa/commands.log" + return $rc +} diff --git a/tools/qa/make_fixtures.py b/tools/qa/make_fixtures.py new file mode 100755 index 0000000..5880622 --- /dev/null +++ b/tools/qa/make_fixtures.py @@ -0,0 +1,266 @@ +"""Erzeugt die QA-Fixtures 01–14 (synthetisch, DRM-frei) unter .qa/generated-fixtures/.""" + +from __future__ import annotations + +import sys +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tests")) + +from conftest import PNG, SVG, Book, Doc, build_epub # noqa: E402 + +OUT = ROOT / ".qa" / "generated-fixtures" +M = 'xmlns="http://www.w3.org/1998/Math/MathML"' + + +def rewrite(path: Path, changes: dict[str, bytes | None], extra: dict[str, bytes] | None = None) -> None: + """Replace/delete entries of an existing ZIP and add raw entries (names unchanged, even unsafe ones).""" + with zipfile.ZipFile(path) as z: + entries = [(i, z.read(i)) for i in z.infolist()] + with zipfile.ZipFile(path, "w") as z: + for info, data in entries: + if info.filename in changes: + if changes[info.filename] is None: + continue + data = changes[info.filename] + z.writestr(info, data) + for name, data in (extra or {}).items(): + z.writestr(zipfile.ZipInfo(name), data) + + +def f01() -> Book: + return Book(version="2.0", ncx=True, title="Minimal EPUB 2", authors=["Ada Lovelace"], language="en", + identifier="urn:uuid:11111111-2222-3333-4444-555555555555", + docs=[Doc("chapter1.xhtml", "

Chapter One

Hello EPUB 2 world.

", "Chapter One")]) + + +def f02() -> Book: + return Book(title="Minimal EPUB 3", authors=["Grace Hopper"], + docs=[Doc("c1.xhtml", "

Erstes Kapitel

Eins.

", "Erstes Kapitel"), + Doc("c2.xhtml", "

Zweites Kapitel

Zwei.

", "Zweites Kapitel")]) + + +def f03() -> Book: + a = ('

Kapitel A

Zu Kapitel B, zum Anker unten, ' + 'zum Ziel in B und ins Nichts, ' + 'sowie fehlender Anker.

' + '

A.1 Abschnitt

Text A.1

' + '

Kapitel A2 in derselben Datei

Unten in A2.

' + '

A2.1 Unterabschnitt

Mehr.

') + b = ('

Kapitel B

B-Text.

B.1

Ziel in B.

' + '

zurück zu A.1

') + c = '

Kapitel C

Ende mit Link auf A.

' + return Book(title="Kapitel und Links", docs=[Doc("a.xhtml", a, "A"), Doc("b.xhtml", b, "B"), Doc("c.xhtml", c, "C")]) + + +PY = """class Stack: + def __init__(self): + self.items = [] + + def push(self, x): +\tself.items.append(x) # Tab-Einrückung + if len(self.items) > 10 and x < 0: + raise ValueError("& zu voll") +""" + + +def f04() -> Book: + body = ( + "

Code und Tabellen

" + "

Inline: len(x) < 3 && y, mit Backtick a`b und *nicht kursiv*.

" + f'
{PY}
' + "
$ find . -name '*.py' | xargs grep -n \"TODO\" \\\n    | sort -u > todo.txt\n$ echo $HOME
" + "
Markdown mit ```fences``` und `ticks`\n    eingerückt
" + '
{\n  "a": [1, 2],\n  "b": "<tag>"\n}
' + "
#include <stdio.h>\nint main(void) { return 0; }
" + "
  • Punkt 1
    • Punkt 1.1
      1. Schritt a
      2. Schritt b
  • " + "
  • Punkt 2
" + "
  1. drei
  2. vier
    • vier.a
" + '

Unbekanntes Element: Widget-Inhalt.

' + "" + "" + "
BefehlBedeutung
ls | wc -lDateien zählen
a && bund
" + ) + return Book(title="Code und Tabellen", docs=[Doc("code.xhtml", body, "Code")]) + + +def f05() -> Book: + body = ( + "

Bilder

" + '
Ein Foto' + "
Abbildung 1.1: Ein Foto mit Unterschrift.
" + '

Nochmals dasselbe Foto: Wiederholung

' + '
Abbildung 1.2: Ein SVG-Diagramm.
' + '

Inline-SVG: Kreis' + '

' + '

Siehe Abbildung 1.1 und SVG direkt.

' + '

Fehlendes Bild: weg

' + ) + return Book(title="Bilder und SVG", docs=[Doc("bilder.xhtml", body, "Bilder")], + media={"images/foto 1 ä.png": PNG, "images/diagramm.svg": SVG, "images/unbenutzt.png": PNG}) + + +def f06() -> Book: + body = ( + "

Formeln

" + f"

Bruch inline: ab.

" + f"

Potenz: x2, Index: ai, " + f"beides: xi2.

" + f'
eiπ' + '+1=0(2.1)
' + '

Nach Gleichung (2.1) folgt …

' + f'

Nicht unterstützt: z.

' + f'

Mit TeX-Annotation: y\\vec{{y}}' + ".

" + f'

alttext: 2.

' + '

SVG-Formel: Formel E=mc²

' + '

Bildformel: Summe über i

' + '
$$\\int_0^1 x\\,dx = \\frac{1}{2}$$ (2.2)
' + ) + return Book(title="MathML und Formeln", docs=[Doc("math.xhtml", body, "Formeln")], + media={"f/formel.svg": SVG, "f/formel.png": PNG, "f/tex.png": PNG}) + + +def f07() -> Book: + ch1 = ( + "

Kapitel mit Fußnoten

" + '

Aussage A1 und B' + '2, nochmal A1.

' + '

Endnote3 und kaputt9.

' + '' + f'" + ) + notes = ( + '

Anmerkungen

' + '
' + ) + return Book(title="Fußnoten und Endnoten", docs=[Doc("ch1.xhtml", ch1, "Kapitel"), Doc("notes.xhtml", notes, "Anmerkungen")]) + + +def f08() -> Book: + ch = ('

Hauptteil

Der Begriff API und ' + 'Rekursion nach Knuth [Knu97].

') + glossary = ('

Glossar

' + '
API
Application Programming Interface, eine Schnittstelle.
' + "
Rekursion

Selbstaufruf einer Funktion.

Siehe auch Iteration.

") + lit = ('

Literatur

    ' + '
  1. Knuth, D. E.: The Art of Computer Programming, Bd. 1, 3. Aufl., Addison-Wesley, 1997, ' + "ISBN 978-0-201-89683-1, S. 1–650.
  2. " + '
  3. Doe, J.: Ein Artikel. Journal 12 (2020), DOI 10.1000/xyz123.
  4. ' + '
  5. Webquelle: https://example.org/seite?a=1&b=2 ' + "(Zugriff: 01.02.2024).
") + sach = ('

Sachregister

' + '

A

API Glossar

' + '

R

Rekursion 17, 42

' + '

endständige, 45

') + pers = ('

Personenregister

K

' + '

Knuth, Donald E. 17

') + return Book(title="Glossar, Literatur, Register", docs=[ + Doc("main.xhtml", ch, "Hauptteil"), Doc("glossar.xhtml", glossary, "Glossar"), Doc("lit.xhtml", lit, "Literatur"), + Doc("sach.xhtml", sach, "Sachregister"), Doc("pers.xhtml", pers, "Personenregister")]) + + +def f09() -> Book: + body = ( + "

Komplexe Tabelle

" + "" + '' + "" + '
Tabelle 9.1: Messwerte
ProbeMessung
vorhernachher
A

1,5

korrigiert

2,0
Bausgefallen
siehe Text
' + ) + return Book(title="Komplexe Tabelle", docs=[Doc("t.xhtml", body, "Tabelle")]) + + +def f10() -> Book: + body = ('

Böse Pfade

raus ' + 'escape abs

') + return Book(title="Malicious", docs=[Doc("ok.xhtml", body, "OK")], + extra_entries={"../../outside.txt": b"evil", "../escape.xhtml": b"", "/absolute/path.txt": b"evil", + "OEBPS/../../../tmp/qa-epub2md-escape.txt": b"evil"}) + + +def f12() -> Book: + text = ("Ärger über Öl und Straße – „deutsche Anführungszeichen“, ‚einfach‘ und «guillemets» — " + "café, crème brûlée, naïve, Noël; Ελληνικά: αβγ ΔΣΩ; Mathe: ∀x∈ℝ: x² ≥ 0, ∑ᵢ aᵢ ≈ π, ∞ ≠ ∅ … ") + body = f'

Größe & Übermaß

{text}

Rücksprung

' + return Book(title="Kodierung – Ümläute", authors=["Zoë Ørsted"], + docs=[Doc("kapitel-ü.xhtml", body, "Größe"), Doc("café.xhtml", "

Café crème

Ça va.

", "Café")]) + + +TYPO_CSS = b""" +p { font-family: Georgia, serif; } +.kursiv { font-style: italic; } .fett { font-weight: bold; } span.u { text-decoration: underline; } +.weg { display: none; } .markiert { background-color: #ffff00; } .rot { color: #cc0000; } +.gross { font-size: 1.6em; } .kap { font-variant: small-caps; } p.mitte { text-align: center; } +@media print { .kursiv { font-weight: bold; } } +""" + + +def f14() -> Book: + """Typografie: CSS-Hervorhebungen, unsichtbare Zeichen, Szenenwechsel, Gedicht, Kästen, Stile, Seitenmarken.""" + c1 = ('

Gestaltung

' + '

Ein kursives und fettes Wort, ' + 'unterstrichenVERSTECKT.

' + '

Null\u200bbreite und wei\u00adches Trenn\u00adzeichen.

' + '

Szene eins endet hier.

Szene zwei beginnt hier.

' + '

Zeile eins

Zeile zwei

' + '

Ein markierter, roter und ' + 'großer Text in Kapitälchen, ' + 'an English phrase.

' + '

Zentrierte Widmung

' + '

Seite elf endet und Seite zwölf ' + 'geht weiter bis dreizehn.

' + '
x = 1\u200b  # bleibt
') + c2 = ('

Kästen

' + '

[»]Wichtige Regel

' + '

Immer testen.

' + '

Nachher.

') + return Book(title="Typografie", authors=["Tina Typo"], media={"style.css": TYPO_CSS}, + docs=[Doc("c1.xhtml", c1, "Gestaltung"), Doc("c2.xhtml", c2, "Kästen")]) + + +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + for name, book in [ + ("01-minimal-epub2.epub", f01()), ("02-minimal-epub3-nav.epub", f02()), ("03-chapters-and-links.epub", f03()), + ("04-code-and-tables.epub", f04()), ("05-images-and-svg.epub", f05()), ("06-mathml-and-equations.epub", f06()), + ("07-footnotes-and-endnotes.epub", f07()), ("08-glossary-bibliography-index.epub", f08()), + ("09-complex-table.epub", f09()), ("10-malicious-paths.epub", f10()), ("12-encoding-edge-cases.epub", f12()), + ("14-typography.epub", f14()), + ]: + build_epub(OUT / name, book) + # 10: additionally a manifest item that points outside the EPUB root + p10 = OUT / "10-malicious-paths.epub" + with zipfile.ZipFile(p10) as z: + opf = z.read("OEBPS/content.opf").decode() + opf = opf.replace("", '') + rewrite(p10, {"OEBPS/content.opf": opf.encode()}) + + # 11: broken container variants + base = Book(title="Defekt", docs=[Doc("a.xhtml", "

A

x

", "A")]) + p11 = build_epub(OUT / "11-broken-container.epub", base) + rewrite(p11, {"META-INF/container.xml": ( + b'' + b'' + b"")}) + p11b = build_epub(OUT / "11b-broken-spine.epub", Book(title="Spine", docs=[Doc("a.xhtml", "

A

x

", "A")], + spine_extra='')) + assert p11b.exists() + p11c = build_epub(OUT / "11c-broken-opf-xml.epub", base) + rewrite(p11c, {"OEBPS/content.opf": b"kaputt"}) + p11d = build_epub(OUT / "11d-missing-spine-file.epub", base) + rewrite(p11d, {"OEBPS/a.xhtml": None}) + (OUT / "11e-not-a-zip.epub").write_bytes(b"PK\x03\x04 kein echtes zip") + p11f = build_epub(OUT / "11f-missing-container.epub", base) + rewrite(p11f, {"META-INF/container.xml": None}) + p11g = build_epub(OUT / "11g-broken-xhtml.epub", base) + rewrite(p11g, {"OEBPS/a.xhtml": b"

Kaputt

nicht geschlossen   &unbekannt; fett"}) + print("\n".join(sorted(p.name for p in OUT.glob("*.epub")))) + + +if __name__ == "__main__": + main() diff --git a/tools/qa/pandoc_full.sh b/tools/qa/pandoc_full.sh new file mode 100755 index 0000000..36ef93a --- /dev/null +++ b/tools/qa/pandoc_full.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Kompletter Pandoc-Durchlauf: jedes EPUB aus Beispiele/ konvertieren, zusammenfügen, alle Pandoc-Befehle der +# Buch-README ausführen, beide PDF-Varianten erzeugen und prüfen. Aufruf im Repo-Wurzelverzeichnis: +# tools/qa/pandoc_full.sh Ergebnis in .qa/output/pandoc/ (check.json, pdf-check.txt) +# Python über $PYTHON (Standard: python3); braucht pandoc, weasyprint, xelatex, poppler-utils. +PY=${PYTHON:-python3} +Q=$(dirname "$0") +P=.qa/output/pandoc; rm -rf $P; mkdir -p $P +for f in Beispiele/*.epub; do + n=$(basename "$f" .epub | cut -d_ -f1 | cut -d- -f1); n=${n/Krooß/Krooss} + $PY epub2md.py "$f" "$P/$n" > /dev/null 2> "$P/$n.convert.err"; echo "$n convert rc=$?" + $PY epub2md_merge.py "$P/$n" > "$P/$n.merge.log" 2>&1; echo "$n merge rc=$? $(head -1 "$P/$n.merge.log" | cut -c1-120)" + SKIP_PDF=1 "$Q/pandoc_run.sh" "$P/$n" + "$Q/pandoc_pdfs.sh" "$P/$n" +done +$PY "$Q/check_pandoc.py" $P/*/ > $P/check.json +"$Q/check_pdf.sh" $P/*/*.pdf > $P/pdf-check.txt +echo FERTIG diff --git a/tools/qa/pandoc_pdfs.sh b/tools/qa/pandoc_pdfs.sh new file mode 100755 index 0000000..c248182 --- /dev/null +++ b/tools/qa/pandoc_pdfs.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Erzeugt beide PDF-Varianten aus der Buch-README unter getrennten Namen: *.weasy.pdf und *.latex.pdf +# (in der README schreiben beide Befehle dieselbe Datei). Aufruf im Repo-Wurzelverzeichnis. +mkdir -p .qa +for dir in "$@"; do + mkdir -p "$dir/pandoc-logs" + grep -E '^pandoc .*\.pdf' "$dir/README.md" | while IFS= read -r cmd; do + case "$cmd" in *weasyprint*) kind=weasy;; *) kind=latex;; esac + cmd=$(printf '%s' "$cmd" | sed -E "s/\.pdf('?)\$/.$kind.pdf\1/") + s=$(date +%s) + (cd "$dir" && timeout 1800 bash -c "$cmd") > "$dir/pandoc-logs/$kind.out" 2> "$dir/pandoc-logs/$kind.err"; rc=$? + printf '%s\t%s\trc=%s\t%ss\n' "$(basename "$dir")" $kind $rc $(( $(date +%s)-s )) + printf '%s\trc=%s\tpandoc %s-PDF (%s)\n' "$(date -Iseconds)" $rc $kind "$(basename "$dir")" >> .qa/commands.log + done +done diff --git a/tools/qa/pandoc_run.sh b/tools/qa/pandoc_run.sh new file mode 100755 index 0000000..319f845 --- /dev/null +++ b/tools/qa/pandoc_run.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Führt jede "pandoc …"-Zeile aus der README.md eines konvertierten Buches im Buchordner aus. +# Aufruf im Repo-Wurzelverzeichnis: tools/qa/pandoc_run.sh BUCHORDNER... (SKIP_PDF=1: ohne PDF-Befehle) +# Logs: BUCHORDNER/pandoc-logs/NN.{cmd,out,err} und summary.tsv; Kommandos mit rc in .qa/commands.log +mkdir -p .qa +for dir in "$@"; do + mkdir -p "$dir/pandoc-logs" + n=0 + grep -E '^pandoc ' "$dir/README.md" | { [ -n "$SKIP_PDF" ] && grep -v '\.pdf' || cat; } | while IFS= read -r cmd; do + n=$((n+1)); f=$(printf '%s/pandoc-logs/%02d' "$dir" $n) + printf '%s\n' "$cmd" > "$f.cmd" + s=$(date +%s) + (cd "$dir" && timeout 1800 bash -c "$cmd") > "$f.out" 2> "$f.err"; rc=$? + printf '%s\trc=%s\t%ss\t%s warn\t%s\n' "$(basename "$dir")" $rc $(( $(date +%s)-s )) "$(grep -c . "$f.err")" \ + "$(echo "$cmd" | grep -o -- '-o [^ ]*\.[a-z]*' | tail -1)" | tee -a "$dir/pandoc-logs/summary.tsv" + printf '%s\trc=%s\tpandoc (%s)\n' "$(date -Iseconds)" $rc "$(basename "$dir") #$n" >> .qa/commands.log + done +done diff --git a/tools/qa/pyblocks.py b/tools/qa/pyblocks.py new file mode 100755 index 0000000..0876dde --- /dev/null +++ b/tools/qa/pyblocks.py @@ -0,0 +1,36 @@ +"""Listet Python-Codeblöcke mit Einrückungsfehlern in Markdown-Dateien (mit Inhalt, zum Vergleich mit der Quelle). + +Aufruf: python tools/qa/pyblocks.py AUSGABE/chapters/*.md +""" + +from __future__ import annotations + +import ast +import re +import sys + + +def main() -> None: + for path in sys.argv[1:]: + with open(path, encoding="utf-8") as fh: + lines = fh.read().split("\n") + i = 0 + while i < len(lines): + m = re.match(r"^(`{3,})python\s*$", lines[i]) + if m: + j = i + 1 + while j < len(lines) and not lines[j].startswith(m.group(1)): + j += 1 + try: + ast.parse("\n".join(lines[i + 1:j])) + except IndentationError as exc: + print(f"== {path}:{i + 1} {exc.msg} (Blockzeile {exc.lineno})") + print("\n".join(f"{k + 1:3}|{line}" for k, line in enumerate(lines[i + 1:j]))) + except SyntaxError: + pass # fragments and program output are no indentation defects + i = j + i += 1 + + +if __name__ == "__main__": + main() diff --git a/tools/qa/requirements.txt b/tools/qa/requirements.txt new file mode 100644 index 0000000..78cae10 --- /dev/null +++ b/tools/qa/requirements.txt @@ -0,0 +1,5 @@ +# Zusätzlich zu requirements-dev.txt für die QA-Skripte (unabhängiger Markdown-Validator, Coverage) +-r ../../requirements-dev.txt +markdown-it-py>=3.0 +mdit-py-plugins>=0.4 +coverage>=7.0 diff --git a/tools/qa/run_matrix.py b/tools/qa/run_matrix.py new file mode 100755 index 0000000..f8eea23 --- /dev/null +++ b/tools/qa/run_matrix.py @@ -0,0 +1,418 @@ +"""CLI-Testmatrix gegen die QA-Fixtures. Jeder Lauf schreibt in ein neues Verzeichnis unter .qa/output/. + +Ergebnis: .qa/output/matrix-results.json, Kommandos mit Rückgabecode in .qa/commands.log +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import stat +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +QA = ROOT / ".qa" +FX = QA / "generated-fixtures" +OUT = QA / "output" / "matrix" +PY = sys.executable +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from validate_output import validate # noqa: E402 + +results: list[dict] = [] + + +def run(args: list[str]) -> subprocess.CompletedProcess: + cmd = [PY, "epub2md.py", *args] + cp = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600) + with open(QA / "commands.log", "a", encoding="utf-8") as fh: + rel = " ".join(a.replace(str(ROOT) + "/", "") for a in cmd[1:]) + fh.write(f"{datetime.now().isoformat(timespec='seconds')}\trc={cp.returncode}\tpython {rel}\n") + return cp + + +def merge_case(name: str, book_dir: Path, expect_rc: int, checks=None) -> dict: + """epub2md_merge.py on an existing output directory.""" + cmd = [PY, "epub2md_merge.py", str(book_dir)] + cp = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600) + with open(QA / "commands.log", "a", encoding="utf-8") as fh: + fh.write(f"{datetime.now().isoformat(timespec='seconds')}\trc={cp.returncode}\tpython epub2md_merge.py " + f"{str(book_dir).replace(str(ROOT) + '/', '')}\n") + entry: dict = {"case": name, "args": [str(book_dir)], "rc": cp.returncode, "expected_rc": expect_rc, + "ok": cp.returncode == expect_rc and "Traceback" not in cp.stderr, "notes": []} + for label, fn in checks or []: + good = bool(fn(book_dir, cp)) + entry["ok"] = entry["ok"] and good + entry["notes"].append(("OK: " if good else "FEHLER: ") + label) + results.append(entry) + print(("PASS " if entry["ok"] else "FAIL ") + name, "rc=", cp.returncode) + return entry + + +def read(out: Path, pattern: str) -> str: + return "\n".join(p.read_text(encoding="utf-8") for p in sorted(out.glob(pattern))) + + +def case(name: str, fixture: str, args: list[str], expect_rc: int, checks=None, validate_out: bool = True, + out: Path | None = None, keep: bool = False) -> dict: + out = out or OUT / name + if out.exists() and not keep: + shutil.rmtree(out) + src = str(FX / fixture) if fixture and not fixture.startswith("/") else fixture + argv = ([src] if src else []) + ([str(out)] if src else []) + args + cp = run(argv) + entry: dict = {"case": name, "fixture": fixture, "args": args, "rc": cp.returncode, "expected_rc": expect_rc, + "ok": cp.returncode == expect_rc, "notes": [], "stderr_tail": cp.stderr.strip().splitlines()[-3:]} + if not entry["ok"]: + entry["notes"].append(f"Rückgabecode {cp.returncode}, erwartet {expect_rc}") + if "Traceback (most recent call last)" in cp.stderr: + entry["ok"] = False + entry["notes"].append("Traceback im Normalmodus") + if checks: + for label, fn in checks: + try: + good = bool(fn(out, cp)) + except Exception as exc: # noqa: BLE001 + good = False + label = f"{label} (Exception {type(exc).__name__}: {exc})" + if not good: + entry["ok"] = False + entry["notes"].append(("OK: " if good else "FEHLER: ") + label) + if validate_out and cp.returncode == 0 and out.exists() and "--dry-run" not in args: + v = validate(out) + entry["validator"] = v["severity_counts"] + entry["validator_issues"] = [i for i in v["issues"] if i["severity"] != "info"][:15] + if any(i["severity"] in ("critical", "high") for i in v["issues"]): + entry["ok"] = False + entry["notes"].append("Validator: kritische/hohe Befunde") + results.append(entry) + print(("PASS " if entry["ok"] else "FAIL ") + name, "rc=", cp.returncode, "; ".join(n for n in entry["notes"] if n.startswith("FEHLER") or "erwartet" in n or "Validator" in n or "Traceback" in n)) + return entry + + +def ch(out: Path, prefix: str = "") -> str: + return read(out / "chapters", f"{prefix}*.md") + + +def report(out: Path) -> dict: + return json.loads((out / "conversion-report.json").read_text(encoding="utf-8")) + + +def main() -> None: + if OUT.exists(): + shutil.rmtree(OUT) + OUT.mkdir(parents=True) + # --- help + cp = run(["--help"]) + opts = ["--format", "--extract-media", "--no-extract-media", "--copy-original", "--chapter-level", "--toc", "--no-toc", + "--frontmatter", "--no-frontmatter", "--footnotes", "--math", "--images", "--slug-language", "--keep-html", + "--strict", "--force", "--verbose", "--dry-run", "--report", "--code-language", "--keep-wrap-markers", + "--keep-invisible-chars", "--css-emphasis", "--no-css-emphasis", "--boxes", "--styles", "--page-markers"] + missing = [o for o in opts if o not in cp.stdout] + results.append({"case": "help", "rc": cp.returncode, "expected_rc": 0, "ok": cp.returncode == 0 and not missing, + "notes": [f"fehlende Optionen in --help: {missing}"] if missing else ["alle Optionen dokumentiert"]}) + print("PASS help" if not missing else f"FAIL help {missing}") + + # --- basics on all good fixtures + for fx in ["01-minimal-epub2", "02-minimal-epub3-nav", "03-chapters-and-links", "04-code-and-tables", "05-images-and-svg", + "06-mathml-and-equations", "07-footnotes-and-endnotes", "08-glossary-bibliography-index", "09-complex-table", + "12-encoding-edge-cases"]: + case(f"basic-{fx[:2]}", f"{fx}.epub", [], 0) + + f03 = "03-chapters-and-links.epub" + case("dry-run", f03, ["--dry-run", "--report", str(OUT / "dry-report.json")], 0, [ + ("kein Ausgabeordner", lambda o, c: not o.exists()), + ("kein Report", lambda o, c: not (OUT / "dry-report.json").exists()), + ("Planung im stdout", lambda o, c: "chapters/001-" in c.stdout)]) + case("verbose", f03, ["--verbose"], 0, [("Schritte auf stderr", lambda o, c: "Rendere" in c.stderr and "Kapitel gebildet" in c.stderr)]) + case("chapter-level-1", f03, ["--chapter-level", "1"], 0, [ + ("3+1 Kapitel (A, A2, B, C)", lambda o, c: len(list((o / "chapters").glob("*.md"))) == 4)]) + case("chapter-level-2", f03, ["--chapter-level", "2"], 0, [ + ("h2 erzeugt eigene Dateien", lambda o, c: len(list((o / "chapters").glob("*.md"))) == 7)]) + case("links-03", f03, [], 0, [ + ("Link Kapitel→Kapitel", lambda o, c: "[Kapitel B](003-kapitel-b.md)" in ch(o, "001")), + ("Anker im selben EPUB-Dokument, anderes Kapitel", lambda o, c: "[Anker unten](002-kapitel-a2-in-derselben-datei.md#a-unten)" in ch(o, "001")), + ("Anker in anderem Kapitel", lambda o, c: "[Ziel in B](003-kapitel-b.md#b-ziel)" in ch(o, "001")), + ("nicht auflösbar → Text + Report", lambda o, c: "Nichts" in ch(o, "001") and "nirgendwo" not in ch(o, "001") + and any(u["href"] == "nirgendwo.xhtml#weg" for u in report(o)["unresolved_links"])), + ("fehlender Anker → Warnung", lambda o, c: any(w["code"] == "anchor-missing" for w in report(o)["warnings"]))]) + + f04 = "04-code-and-tables.epub" + case("format-gfm", f04, ["--format", "gfm"], 0, [ + ("Tabelle mit | in Code als HTML (QA2-01)", lambda o, c: "ls | wc -l" in ch(o) and "| Befehl |" not in ch(o))]) + case("format-commonmark", f04, ["--format", "commonmark"], 0, [("HTML-Tabelle statt Pipe", lambda o, c: "" in ch(o) and "| Befehl |" not in ch(o))]) + case("code-04", f04, [], 0, [ + ("Python-Einrückung + Tab exakt", lambda o, c: " def push(self, x):\n\tself.items.append(x) # Tab-Einrückung\n if len(self.items) > 10 and x < 0:" in ch(o)), + ("Sprachlabel python", lambda o, c: "```python\nclass Stack:" in ch(o)), + ("Shell exakt, Label aus class=shell", lambda o, c: "```bash\n$ find . -name '*.py' | xargs grep -n \"TODO\" \\\n | sort -u > todo.txt\n$ echo $HOME\n```" in ch(o)), + ("Fence länger als Backticks im Code", lambda o, c: "````\nMarkdown mit ```fences```" in ch(o)), + ("json via data-language", lambda o, c: "```json\n{" in ch(o)), + ("c via #include", lambda o, c: "```c\n#include " in ch(o)), + ("Inline-Code mit < & ", lambda o, c: "`len(x) < 3 && y`" in ch(o)), + ("Inline-Code mit Backtick", lambda o, c: "``a`b``" in ch(o)), + ("verschachtelte Listen", lambda o, c: "- Punkt 1\n\n - Punkt 1.1\n\n 1. Schritt a\n 2. Schritt b" in ch(o) or "- Punkt 1\n - Punkt 1.1" in ch(o)), + ("ol start=3", lambda o, c: "3. drei" in ch(o)), + ("unbekanntes Element gemeldet", lambda o, c: "x-widget" in report(o)["unsupported_elements"] and "Widget-Inhalt" in ch(o))]) + case("keep-html", f04, ["--keep-html"], 0, [ + ("unbekanntes Element als HTML, ohne onclick", lambda o, c: 'Widget-Inhalt' in ch(o) and "evil" not in ch(o))]) + + f05 = "05-images-and-svg.epub" + case("images-relative", f05, ["--images", "relative"], 0, [ + ("Foto einmal extrahiert", lambda o, c: len([p for p in (o / "media/images").iterdir() if "foto" in p.name]) == 1), + ("SVG bleibt SVG", lambda o, c: any(p.suffix == ".svg" for p in (o / "media/images").iterdir())), + ("alt + title", lambda o, c: '![Ein Foto](../media/images/' in ch(o) and '"Fototitel")' in ch(o)), + ("Bildunterschrift", lambda o, c: "*Abbildung 1.1: Ein Foto mit Unterschrift.*" in ch(o)), + ("Caption als Alt bei leerem alt", lambda o, c: "![Abbildung 1.2: Ein SVG-Diagramm.](" in ch(o)), + ("Inline-SVG extrahiert", lambda o, c: any(m["role"] == "inline-svg" for m in report(o)["media"]["files"])), + ("fehlendes Bild gewarnt", lambda o, c: any(w["code"] == "image-missing" for w in report(o)["warnings"])), + ("alles im Ausgabeordner", lambda o, c: all(o.resolve() in p.resolve().parents for p in o.rglob("*")))]) + case("images-flat", f05, ["--images", "flat"], 0, [("media/", lambda o, c: (o / "media" / "foto 1 ä.png").exists())]) + case("images-original-path", f05, ["--images", "original-path"], 0, [("media/images/", lambda o, c: (o / "media/images/foto 1 ä.png").exists())]) + case("no-extract-media", f05, ["--no-extract-media"], 0, [("kein media/", lambda o, c: not (o / "media").exists()), + ("Platzhalter statt Link", lambda o, c: "Bild: Ein Foto" in ch(o))]) + case("extract-media", f05, ["--extract-media"], 0, [("media/ vorhanden", lambda o, c: (o / "media").exists())]) + + f06 = "06-mathml-and-equations.epub" + math_common = [ + ("Gleichungsnummer sichtbar", lambda o, c: "(2.1)" in ch(o) and "(2.2)" in ch(o)), + ("SVG-Formel als SVG-Bild", lambda o, c: "![Formel E=mc²](" in ch(o)), + ("Bildformel als Bild", lambda o, c: "![Summe über i](" in ch(o)), + ("unbekanntes MathML nicht verloren", lambda o, c: "menclose" in ch(o))] + case("math-auto", f06, ["--math", "auto"], 0, math_common + [ + ("Bruch", lambda o, c: r"$\frac{a}{b}$" in ch(o)), + ("Hoch/Index", lambda o, c: "$x^{2}$" in ch(o) and "$a_{i}$" in ch(o) and "$x_{i}^{2}$" in ch(o)), + ("Block-Formel", lambda o, c: "$$\ne^{i \\pi } + 1 = 0\n$$" in ch(o) or "$$\ne^{i \\pi} + 1 = 0\n$$" in ch(o)), + ("TeX-Annotation bevorzugt", lambda o, c: r"$\vec{y}$" in ch(o)), + ("TeX aus alt-Bild", lambda o, c: r"\int_0^1 x\,dx = \frac{1}{2}" in ch(o)), + ("Warnungen im Report", lambda o, c: {"mathml-kept", "formula-image"} <= {w["code"] for w in report(o)["warnings"]}), + ("SVG-Formel als svg im Report", lambda o, c: "svg" in {f["origin"] for f in report(o)["formulas"]["items"]})]) + case("math-tex", f06, ["--math", "tex"], 0, math_common + [ + ("\\( \\) Begrenzer", lambda o, c: r"\(\frac{a}{b}\)" in ch(o)), + ("keine $-Begrenzer", lambda o, c: not re.search(r"(?ab" in ch(o))]) + case("math-image", f06, ["--math", "image"], 0, math_common + [("TeX-Bild als Bild", lambda o, c: "tex" in " ".join(m["source"] for m in report(o)["media"]["files"]))]) + + f07 = "07-footnotes-and-endnotes.epub" + fn_common = [("Fußnotentext vorhanden", lambda o, c: "Erster Absatz mit" in read(o, "chapters/*.md") and "Zweiter Absatz." in read(o, "chapters/*.md")), + ("Endnote vorhanden", lambda o, c: "Eine Endnote mit Rücklink." in read(o, "chapters/*.md")), + ("Formel in Fußnote", lambda o, c: "x^{3}" in read(o, "chapters/*.md")), + ("kaputter Verweis gewarnt", lambda o, c: any(w["code"] == "noteref-unresolved" for w in report(o)["warnings"]))] + case("footnotes-end-of-chapter", f07, ["--footnotes", "end-of-chapter"], 0, fn_common + [ + ("Mehrfachverweis auf eine Definition", lambda o, c: ch(o, "001").count("[^fn-001]") == 3), + ("keine Doppelung im Fließtext", lambda o, c: ch(o, "001").count("Erster Absatz") == 1)]) + case("footnotes-end-of-book", f07, ["--footnotes", "end-of-book"], 0, fn_common + [ + ("Anmerkungsdatei", lambda o, c: (o / "chapters" / "999-anmerkungen.md").exists()), + ("Rücklinks", lambda o, c: "[↩](001-" in ch(o, "999"))]) + case("footnotes-inline", f07, ["--footnotes", "inline"], 0, fn_common + [("inline", lambda o, c: "(Anmerkung: Erster Absatz" in ch(o, "001"))]) + case("footnotes-keep-links", f07, ["--footnotes", "keep-links"], 0, fn_common + [("Links", lambda o, c: "](#fn1)" in ch(o, "001"))]) + + f08 = "08-glossary-bibliography-index.epub" + case("special-sections", f08, [], 0, [ + ("Typen erkannt", lambda o, c: {s["type"] for s in report(o)["special_sections"]} >= {"glossary", "bibliography", "index", "index-persons"}), + ("Glossarbegriff", lambda o, c: "**API:** Application Programming Interface" in ch(o)), + ("mehrteilige Definition", lambda o, c: "Selbstaufruf einer Funktion." in ch(o) and "Siehe auch Iteration." in ch(o)), + ("Literatur vollständig", lambda o, c: all(s in ch(o) for s in ("ISBN 978-0-201-89683-1", "S. 1–650", "(Zugriff: 01.02.2024)"))), + ("DOI/URL als Link", lambda o, c: "](https://doi.org/10.1000/xyz123)" in ch(o) and "](https://example.org/seite?a=1&b=2)" in ch(o)), + ("Registerlink ins Kapitel", lambda o, c: "[17](001-hauptteil.md#idx-rekursion)" in ch(o)), + ("Seitenzahl als Text", lambda o, c: ", 42" in ch(o)), + ("Untereintrag eingerückt", lambda o, c: "– endständige, 45" in ch(o)), + ("Buchstabengruppen", lambda o, c: "## R" in ch(o)), + ("Querverweis ins Glossar", lambda o, c: "[API](002-glossar.md#g-api)" in ch(o, "001"))]) + + case("complex-table", "09-complex-table.epub", [], 0, [ + ("HTML-Tabelle mit colspan/rowspan", lambda o, c: 'rowspan="2"' in ch(o) and 'colspan="2"' in ch(o)), + ("Zellinhalte vollständig", lambda o, c: all(s in ch(o) for s in ("1,5", "korrigiert", "2,0", "ausgefallen", "siehe Text", "Messwerte"))), + ("Warnung", lambda o, c: any(w["code"] == "table-html" for w in report(o)["warnings"]))]) + + f12 = "12-encoding-edge-cases.epub" + enc_text = "Ärger über Öl und Straße – „deutsche Anführungszeichen“, ‚einfach‘ und «guillemets» — café, crème brûlée" + case("slug-de", f12, ["--slug-language", "de"], 0, [("ä→ae, ß→ss", lambda o, c: (o / "chapters/001-groesse-uebermass.md").exists()), + ("Text unverändert UTF-8", lambda o, c: enc_text in ch(o) and "αβγ ΔΣΩ" in ch(o) and "∀x∈ℝ: x² ≥ 0" in ch(o))]) + case("slug-en", f12, ["--slug-language", "en"], 0, [("ä→a", lambda o, c: (o / "chapters/001-grosse-ubermass.md").exists())]) + case("slug-none", f12, ["--slug-language", "none"], 0, [("Unicode", lambda o, c: (o / "chapters/001-größe-übermaß.md").exists())]) + + f02 = "02-minimal-epub3-nav.epub" + case("copy-original", f02, ["--copy-original"], 0, [("source/", lambda o, c: (o / "source" / f02).exists())]) + case("no-copy-original", f02, ["--no-copy-original"], 0, [("kein source/", lambda o, c: not (o / "source").exists())]) + case("toc", f02, ["--toc"], 0, [("README mit TOC", lambda o, c: "## Inhaltsverzeichnis" in (o / "README.md").read_text())]) + case("no-toc", f02, ["--no-toc"], 0, [("kein README", lambda o, c: not (o / "README.md").exists())]) + case("frontmatter", f02, ["--frontmatter"], 0, [("YAML", lambda o, c: ch(o).startswith("---\n"))]) + case("no-frontmatter", f02, ["--no-frontmatter"], 0, [("kein YAML", lambda o, c: not ch(o).startswith("---"))]) + rep = OUT / "reports" / "r.json" + case("report", f02, ["--report", str(rep)], 0, [("Report-Datei", lambda o, c: json.loads(rep.read_text())["tool"] == "epub2md")]) + + # --- force / existing directory + fo = OUT / "force-target" + case("force-1-initial", f02, [], 0, out=fo) + case("force-2-exists-no-force", f02, [], 1, [("Meldung", lambda o, c: "existiert bereits" in c.stderr)], out=fo, keep=True, validate_out=False) + (fo / "zusatz.txt").write_text("x") + case("force-3-with-force", f02, ["--force"], 0, [("ersetzt", lambda o, c: not (o / "zusatz.txt").exists())], out=fo, keep=True) + foreign = OUT / "fremd" + foreign.mkdir() + (foreign / "wichtig.txt").write_text("nicht löschen") + case("force-4-foreign-dir", f02, ["--force"], 1, [("fremder Ordner bleibt", lambda o, c: (o / "wichtig.txt").exists())], + out=foreign, keep=True, validate_out=False) + + # --- invalid options + case("invalid-math", f02, ["--math", "foo"], 1, [("Meldung", lambda o, c: "invalid choice" in c.stderr)], validate_out=False) + case("invalid-chapter-level", f02, ["--chapter-level", "0"], 1, validate_out=False) + case("invalid-chapter-level-text", f02, ["--chapter-level", "x"], 1, validate_out=False) + case("missing-input", "/nonexistent/datei.epub", [], 1, validate_out=False) + case("unknown-option", f02, ["--gibt-es-nicht"], 1, validate_out=False) + + # --- broken input + case("broken-container", "11-broken-container.epub", [], 2, [("verständlich", lambda o, c: "OPF" in c.stderr), ("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False) + case("broken-spine", "11b-broken-spine.epub", [], 0, [("Fehler im Report", lambda o, c: report(o)["status"] == "success-with-errors")]) + case("broken-spine-strict", "11b-broken-spine.epub", ["--strict"], 3, [("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False) + case("broken-opf-xml", "11c-broken-opf-xml.epub", [], 3, [("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False) + case("missing-spine-file", "11d-missing-spine-file.epub", [], 3, [("verständlich", lambda o, c: "Spine" in c.stderr)], validate_out=False) + case("not-a-zip", "11e-not-a-zip.epub", [], 2, validate_out=False) + case("missing-container", "11f-missing-container.epub", [], 2, validate_out=False) + case("broken-xhtml", "11g-broken-xhtml.epub", [], 0, [("Text gerettet", lambda o, c: "nicht geschlossen" in ch(o)), + ("Hinweis html-fallback/Entity", lambda o, c: True)]) + case("strict-ok", "01-minimal-epub2.epub", ["--strict"], 0) + + # --- security + escape = Path("/tmp/qa-epub2md-escape.txt") + escape.unlink(missing_ok=True) + case("malicious", "10-malicious-paths.epub", [], 0, [ + ("nichts außerhalb geschrieben", lambda o, c: not escape.exists() and not (ROOT / "outside.txt").exists() + and not (QA / "outside.txt").exists() and not (OUT / "outside.txt").exists()), + ("alle Dateien im Zielordner", lambda o, c: all(o.resolve() in p.resolve().parents for p in o.rglob("*"))), + ("unsichere Einträge gemeldet", lambda o, c: any(e["code"] == "unsafe-zip-path" for e in report(o)["errors"])), + ("unsicherer Manifest-Pfad gemeldet", lambda o, c: any(e["code"] == "unsafe-href" for e in report(o)["errors"]))]) + case("malicious-strict", "10-malicious-paths.epub", ["--strict"], 2, [("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False) + + # --- write error: parent directory not writable + ro = OUT / "readonly" + ro.mkdir() + os.chmod(ro, stat.S_IRUSR | stat.S_IXUSR) + try: + case("write-error", f02, [], 1, [("verständliche Meldung", lambda o, c: "Traceback" not in c.stderr and c.stderr.strip() != "")], + out=ro / "ziel", validate_out=False) + finally: + os.chmod(ro, stat.S_IRWXU) + + # --- typography (P1–P7): CSS emphasis, invisible chars, scene breaks, poems, boxes, styles, page markers + f14 = "14-typography.epub" + body = lambda o: ch(o, "001") # noqa: E731 + boxes = lambda o: ch(o, "002") # noqa: E731 + case("typo-default", f14, [], 0, [ + ("CSS-kursiv/fett/unterstrichen", lambda o, c: "Ein *kursives* und **fettes** Wort, unterstrichen." in body(o)), + ("display:none entfällt ohne Leerzeichen-Rest", lambda o, c: "VERSTECKT" not in body(o) and "." in body(o)), + ("@media print ignoriert", lambda o, c: "***kursives***" not in body(o)), + ("unsichtbare Zeichen im Text entfernt", lambda o, c: "Nullbreite und weiches Trennzeichen." in body(o)), + ("unsichtbare Zeichen im Code erhalten", lambda o, c: "x = 1\u200b # bleibt" in body(o)), + ("Szenenwechsel", lambda o, c: "Szene eins endet hier.\n\n* * *\n\nSzene zwei beginnt hier." in body(o)), + ("Gedicht als Zitatblock", lambda o, c: "> Zeile eins\\\n> Zeile zwei" in body(o)), + ("semantic: , keine Stile", lambda o, c: "markierter" in body(o) and "style=" not in body(o)), + ("Kasten mit Klassentyp und Titel ohne Symbol", lambda o, c: "> [!NOTE]\n> **Wichtige Regel**\n>\n> Immer testen." in boxes(o)), + ("aside tip", lambda o, c: "> [!TIP]\n> Zwischendurch speichern." in boxes(o)), + ("keine Seitenmarken", lambda o, c: "S. 12" not in body(o)), + ("Bericht", lambda o, c: report(o)["layout"] == {"scene_breaks": 1, "poems": 1, "boxes": 2, "page_markers": 0} + and report(o)["text_cleanup"]["invisible_chars_removed"] == 3 and report(o)["css"]["hidden_elements"] == 1), + ("Buch-README mit Pandoc-Filter", lambda o, c: "--lua-filter" in (o / "README.md").read_text(encoding="utf-8"))]) + case("typo-no-css-emphasis", f14, ["--no-css-emphasis"], 0, [ + ("keine CSS-Hervorhebung, Verstecktes bleibt", lambda o, c: "Ein kursives und fettes Wort" in body(o) and "VERSTECKT" in body(o))]) + case("typo-keep-invisible-chars", f14, ["--keep-invisible-chars"], 0, [ + ("Zeichen erhalten", lambda o, c: "Null\u200bbreite" in body(o) and "wei\u00adches" in body(o)), + ("Bericht 0", lambda o, c: report(o)["text_cleanup"]["invisible_chars_removed"] == 0)]) + case("typo-styles-html", f14, ["--styles", "html"], 0, [ + ("Farbe/Größe/Kapitälchen/Hintergrund", lambda o, c: all(x in body(o) for x in ( + 'roter', 'großer', + 'Kapitälchen', + 'markierter'))), + ("Sprache", lambda o, c: 'an English phrase' in body(o)), + ("Ausrichtung", lambda o, c: '
\n\nZentrierte Widmung\n\n
' in body(o)), + ("Grundschrift nicht wiederholt", lambda o, c: "Georgia" not in body(o))]) + case("typo-page-markers-visible", f14, ["--page-markers", "visible"], 0, [ + ("Marker", lambda o, c: "Seite elf endet\\[S. 12\\] und Seite zwölf geht weiter\\[S. 13\\] bis dreizehn." in body(o)), + ("Bericht", lambda o, c: report(o)["layout"]["page_markers"] == 2)]) + case("typo-page-markers-comment", f14, ["--page-markers", "comment"], 0, [ + ("Kommentar", lambda o, c: "endet und" in body(o) and "weiter bis" in body(o))]) + case("typo-boxes-quote", f14, ["--boxes", "quote"], 0, [ + ("Zitatblock ohne [!", lambda o, c: "> **Wichtige Regel**\n>\n> Immer testen." in boxes(o) and "[!" not in boxes(o))]) + case("typo-boxes-html", f14, ["--boxes", "html"], 0, [ + ("div mit Typklasse", lambda o, c: '
' in boxes(o) and '
' in boxes(o))]) + case("typo-boxes-plain", f14, ["--boxes", "plain"], 0, [ + ("normaler Text", lambda o, c: "Immer testen." in boxes(o) and "> " not in boxes(o) and " [!NOTE]", "Zwischendurch speichern.")))]) + merge_case("merge-again-no-force", OUT / "typo-default", 1, [("Meldung", lambda o, c: c.stderr.strip() != "")]) + merge_case("merge-missing-dir", OUT / "gibt-es-nicht", 1) + if shutil.which("pandoc"): + src = OUT / "typo-default" / "14-typography.md" + cp = subprocess.run(["pandoc", str(src), "-s", "--lua-filter", str(ROOT / "pandoc" / "callouts.lua"), "-o", "-", + "-t", "html"], capture_output=True, text=True, timeout=120) + good = cp.returncode == 0 and 'class="callout callout-note"' in cp.stdout and "[!NOTE]" not in cp.stdout \ + and "Tipp" in cp.stdout + results.append({"case": "pandoc-callouts-filter", "ok": good, "rc": cp.returncode, + "notes": ["Kästen als div.callout, keine [!…]-Marken" if good else cp.stderr[-300:]]}) + print(("PASS " if good else "FAIL ") + "pandoc-callouts-filter") + cp = subprocess.run(["pandoc", str(src), "-s", "--lua-filter", str(ROOT / "pandoc" / "book.lua"), "-t", "html"], + capture_output=True, text=True, timeout=120) + h = cp.stdout + pos = [h.find(x) for x in ('id="title-block-header"', 'id="TOC"', 'class="chapter-start"')] + good = cp.returncode == 0 and -1 not in pos and pos == sorted(pos) and h.count('id="title-block-header"') == 1 \ + and "epub2md-toc" not in h and 'href="#' in h[pos[1]:pos[2]] + results.append({"case": "pandoc-book-filter", "ok": good, "rc": cp.returncode, + "notes": ["Titelkopf → Inhaltsverzeichnis → Kapitel, Platzhalter ersetzt" if good else f"Reihenfolge {pos}"]}) + print(("PASS " if good else "FAIL ") + "pandoc-book-filter") + step1 = OUT / "typo-step1.html" + subprocess.run(["pandoc", str(src), "-s", "--lua-filter", str(ROOT / "pandoc" / "book.lua"), "-o", str(step1)], + capture_output=True, text=True, timeout=120) + cp = subprocess.run(["pandoc", str(step1), "-s", "--lua-filter", str(ROOT / "pandoc" / "book.lua"), "-t", "latex"], + capture_output=True, text=True, timeout=120, cwd=OUT) + t = cp.stdout + good = cp.returncode == 0 and t.count("\\tableofcontents") == 1 and "\\maketitle" not in t \ + and t.index("Typografie}") < t.index("\\tableofcontents") and "a4paper" in t + results.append({"case": "pandoc-book-filter-latex", "ok": good, "rc": cp.returncode, + "notes": ["LaTeX: Titel → \\tableofcontents, kein \\maketitle, A4" if good else "LaTeX-Aufbau falsch"]}) + print(("PASS " if good else "FAIL ") + "pandoc-book-filter-latex") + hdr = OUT / "header.html" + hdr.write_text("\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()