diff --git a/.gitignore b/.gitignore index 7ac2e8c..8924a81 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ htmlcov/ build/ dist/ -# QA-Läufe (Prompt_Kontrolle.md): erzeugte Daten von tools/qa/ +# QA-Läufe (Prompt_Kontrolle.md) .qa/ .qa-output/ .qa-fixtures/ diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index f1e3639..173f5b5 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -115,14 +115,12 @@ 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. Am Anfang stehen die Buchdaten (Titel, Autor, Verlag, -Datum als `13.01.2025`), die Pandoc für die Titelseite verwendet. +Bilder und Querverweise funktionieren darin weiter. Nützliche Varianten: ```bash -# 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) +# mit Inhaltsverzeichnis am Anfang python3 epub2md_merge.py output/meinbuch --toc # an einen anderen Ort schreiben (Bildpfade werden automatisch angepasst) @@ -141,10 +139,9 @@ python3 epub2md_merge.py output/meinbuch --force ### Roman (Teile und Kapitel) -Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Das Programm erkennt sehr große -Teile (über 30 000 Wörter) selbst und legt dann eine kurze Datei für den Teil und eine Datei pro -**Kapitel** an; kurze Abschnitte wie Vorwort oder Anhang bleiben eine Datei. Du musst dafür nichts -einstellen. Soll wirklich jeder Abschnitt mit Unterüberschrift eine eigene Datei werden, gilt: +Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Standardmäßig entsteht eine Datei +pro **Teil**. Das können sehr große Dateien werden. Das Programm warnt dann mit `chapter-large` und +empfiehlt die passende Einstellung. Eine Datei pro **Kapitel** bekommst du so: ```bash python3 epub2md.py roman.epub output/roman --chapter-level 2 @@ -163,9 +160,8 @@ 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 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: +eingetragen, wenn sie eindeutig erkennbar ist. Ist das ganze Buch in **einer** Sprache geschrieben, +kannst du sie für alle übrigen Codeblöcke vorgeben: ```bash python3 epub2md.py python-buch.epub output/python-buch --code-language python @@ -187,9 +183,7 @@ 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. 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 +`$$…$$` als eigene Zeile. Formeln, die nur als Bild vorliegen, bleiben Bilder und erscheinen in `diagnostics/warnings.md`. | Gewünschte Formeldarstellung | Option | @@ -228,7 +222,7 @@ Blöcke übernommen: ``` GitHub und Obsidian zeigen das als farbigen Kasten mit Symbol; andere Programme als eingerückten Zitatblock. -Für Pandoc (HTML, EPUB, PDF) gibt es einen Filter, der echte Kästen zeichnet, siehe Kapitel 7 („Weiterverarbeiten“). +Für Pandoc (HTML, EPUB, PDF) gibt es einen Filter, der echte Kästen zeichnet, siehe Kapitel 7. Andere Darstellungen wählst du mit `--boxes`: - `quote`: Zitatblock ohne `[!TIP]` @@ -315,57 +309,45 @@ cd output/meinbuch ``` `meinbuch.md` steht im Folgenden für den Namen der Gesamtdatei. Er entspricht dem Namen des -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 -``` +Original-E-Books. | Ziel | Befehl | | --- | --- | -| 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. +| 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` | **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. 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. + 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. -Bei Büchern mit Formeln für HTML und EPUB zusätzlich `--mathml` angeben, sonst bleiben Matrizen und -ähnliche Formeln als TeX-Text stehen. +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. #### Direkt aus den Kapiteldateien @@ -395,8 +377,8 @@ gilt hier derselbe Umweg über HTML. - Die einzelnen Kapiteldateien eignen sich als Abschnitte. Die Metadaten im Datenkopf (Buch, Kapitel, Herkunft) helfen beim Zuordnen von Fundstellen. - `manifest.json` listet alle Kapitel mit Titel, Datei, Wortanzahl und Ankern. -- Sehr große Kapitel teilt das Programm selbst an ihren Unterkapiteln. Hast du `--chapter-level` fest - eingestellt und erscheint die Warnung `chapter-large`, dann mit dem empfohlenen Wert neu umwandeln. +- Achte auf die Warnung `chapter-large`: Sehr große Kapiteldateien lassen sich schlecht zerlegen. Dann + mit höherem `--chapter-level` neu umwandeln. --- @@ -521,14 +503,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 `auto`: Ebene 1, sehr große Kapitel werden an ihren Unterkapiteln geteilt) | +| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) | | `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache | | `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen | | `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten | | `--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 (farbige Markierungen `` bleiben, sie gehören zu `--styles`) | +| `--no-css-emphasis` | Hervorhebungen nur aus HTML-Tags übernehmen, nicht aus dem Stylesheet des Buches | | `--math auto/tex/mathml/image` | Formeldarstellung | | `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung | | `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme | diff --git a/CHANGELOG.md b/CHANGELOG.md index da79cba..7b451cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,75 +3,6 @@ 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. @@ -118,7 +49,5 @@ 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 e4974ce..c8d420e 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ python3 epub2md.py \ --toc \ --verbose -# Jedes Kapitel unterhalb von h2 als eigene Datei (sonst teilt auto nur sehr große Kapitel) +# Roman mit Teilen (h1) und Kapiteln (h2): ein File pro Kapitel python3 epub2md.py roman.epub output/roman --chapter-level 2 # Nur analysieren, nichts schreiben @@ -88,7 +88,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json | `--format {gfm,commonmark}` | `gfm` | `gfm`: Pipe-Tabellen, `~~durchgestrichen~~`, `[^fn]`-Fußnoten. `commonmark`: Tabellen als HTML, ``, Fußnoten als Anker-Links | | `--extract-media` / `--no-extract-media` | an | Medien nach `media/` extrahieren. Ohne Extraktion stehen Bildreferenzen als Text `[Bild: alt – Pfad]` da (keine kaputten Links) | | `--copy-original` / `--no-copy-original` | aus | Original-EPUB nach `source/` kopieren | -| `--chapter-level {auto,1-6}` | `auto` | Überschriftenebene, ab der eine neue Kapiteldatei beginnt. `auto`: Ebene 1; nur Kapitel mit über 30 000 Wörtern und mindestens zwei Unterkapiteln (z. B. Teile eines Romans) werden an ihren Unterkapiteln geteilt (Info `chapter-split`). Eine Zahl gilt ausnahmslos für das ganze Buch | +| `--chapter-level N` | `1` | Überschriftenebene (1–6), ab der eine neue Kapiteldatei beginnt | | `--toc` / `--no-toc` | an | `README.md` mit Inhaltsverzeichnis, Statistik und Reproduktionsbefehl | | `--frontmatter` / `--no-frontmatter` | an | YAML-Frontmatter in jeder Kapiteldatei | | `--footnotes {keep-links,end-of-chapter,end-of-book,inline}` | `end-of-chapter` | siehe [Fußnoten](#fußnoten-modi) | @@ -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`. Hintergrundmarkierungen (``) und Stile steuert unabhängig davon `--styles` | +| `--css-emphasis` / `--no-css-emphasis` | an | Fett, kursiv, unterstrichen, durchgestrichen, hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS-Klasse setzt (z. B. `span.italic`). Einzelwörter in Monospace-Schrift werden zu Inline-Code; per CSS ausgeblendete Elemente (`display:none`) entfallen. Statistik im Bericht unter `css` | | `--keep-invisible-chars` | aus | Nullbreiten-Zeichen und weiche Trennstriche im Fließtext behalten. Standard: entfernen, weil sie Suche und RAG stören (Anzahl im Bericht: `text_cleanup`). Code bleibt immer unverändert | | `--keep-html` | aus | Unbekannte/nicht sicher konvertierbare Elemente als bereinigtes HTML erhalten (sonst: Text übernommen + Eintrag in `diagnostics/unsupported-elements.md`) | | `--strict` | aus | Abbruch bei kritischen Fehlern (fehlende/unlesbare Spine-Dokumente, unsichere ZIP-Pfade, ungültige Spine-Verweise) | @@ -158,9 +158,9 @@ output/mein-buch/ ## Wie Kapitel gebildet werden 1. Die **OPF-Spine** bestimmt die Lesereihenfolge (nicht Dateinamen, nicht das Inhaltsverzeichnis). -2. Überschriften `h1`…`hN` (N = `--chapter-level`, bei `auto` 1 bzw. 2 in sehr großen Kapiteln) und - semantische Abschnitte (`epub:type`/`role`: chapter, part, appendix, glossary, bibliography, index, - preface, …) beginnen neue Kapiteldateien, auch mitten in einer XHTML-Datei. +2. Überschriften `h1`…`hN` (N = `--chapter-level`) 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,10 +170,6 @@ 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 @@ -188,13 +184,10 @@ Verlagsspezifische Heuristiken: Layout-Tabellen mit Klassen wie `Head1`/`Title_d Überschriften. `
` mit `
`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken. Literaturlisten mit eigenen Labels (`[BryL06]`) werden nicht doppelt nummeriert. -**Teile und Kapitel:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), teilt der -Standard `auto` jeden Teil mit über 30 000 Wörtern und mindestens zwei Kapiteln in eine kurze Datei für den -Teil (Überschrift, Einleitung) und eine Datei pro Kapitel; kleinere Kapitel (Vorwort, Anhang) bleiben ganz. -Verzeichnisse (Register, Literatur, Glossar) werden dabei nie zerlegt. `--chapter-level 2` teilt dagegen -jedes Kapitel des Buches an `h2`. Ist bei einer festen Zahl eine Kapiteldatei größer als 30 000 Wörter und -hat sie mindestens zwei `##`-Unterabschnitte, meldet epub2md eine Warnung `chapter-large` mit der -passenden `--chapter-level`-Empfehlung. +**Tipp:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), erzeugt +`--chapter-level 2` eine Datei pro Kapitel. Mit dem Standard `1` entsteht eine Datei pro Teil. +Ist eine Kapiteldatei größer als 30 000 Wörter und hat sie mindestens zwei `##`-Unterabschnitte, meldet +epub2md eine Warnung `chapter-large` mit der passenden `--chapter-level`-Empfehlung. ## Math-Modi @@ -246,19 +239,12 @@ 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`, 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. + (`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. Bei einsprachigen + Büchern kann `--code-language python` (o. Ä.) alle übrigen Blöcke kennzeichnen. Achtung: Das betrifft + auch Programmausgaben und Shell-Befehle im Buch. - **CSS-Formatierung:** Viele Verlage setzen Hervorhebungen nur über Stylesheets. epub2md wertet die CSS-Dateien des Buches aus: Klassen-, Element- und ID-Selektoren, Nachfahren- und Kind-Kombinatoren, Spezifität und `style`-Attribute. `@media`-Blöcke und Selektoren mit Pseudoklassen werden ignoriert. - 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 @@ -310,15 +296,14 @@ 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, hinter Cover, Titelseite und Impressum (für Pandoc-PDFs besser `pandoc/book.lua`, das Seitenzahlen und Unterabschnitte liefert) | +| `--toc` | Inhaltsverzeichnis mit Sprunglinks auf die Kapitel einfügen | | `--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 (``). Er beginnt mit einem Buchstaben, - weil Pandocs EPUB-Ausgabe Links auf IDs mit führender Ziffer nicht auflöst. +- Jedes Kapitel erhält einen Startanker (``). - Links zwischen Kapiteln (`003-x.md#anker`) werden zu Sprunglinks in der Gesamtdatei. - Anker, die in mehreren Kapiteln vorkommen (z. B. `Sec1` in jedem Kapitel eines Springer-Buchs), werden ab dem zweiten Vorkommen in `c012-Sec1` umbenannt, samt aller Links darauf. @@ -327,15 +312,8 @@ Beim Zusammenfügen passiert Folgendes: - Codeblöcke und Inline-Code werden nie verändert. - Kapitelüberschriften bleiben `#`, jedes Kapitel beginnt also mit einer H1. Pandoc behandelt sie als Kapitel. -- Hinter dem Vorspann (Cover, Titelseite, Impressum und kurze Seiten dazwischen, bis vor den ersten Teil, - das Vorwort oder das erste nummerierte Kapitel) steht ein unsichtbarer Platzhalter - `
`; hat das Buch keine eigene Titelseite, steht hinter dem Cover zusätzlich - `
`. `pandoc/book.lua` setzt dort Inhaltsverzeichnis und Titelkopf ein; - GitHub, Obsidian und andere Programme zeigen die leeren `div`s nicht an. - Der Buch-Kopf enthält nur Felder, die im EPUB vorhanden sind (`title`, `subtitle`, `author`, `lang`, - `publisher`, `date`, `identifier`, `rights`, `description`). Ein ISO-Zeitstempel als Datum wird gekürzt, - damit Pandoc auf der Titelseite nicht `2025-01-13T08:00:00+00:00` druckt: bei deutschen Büchern zu - `13.01.2025`, sonst zu `2025-01-13`. `metadata.yaml` behält den Originalwert. + `publisher`, `date`, `identifier`, `rights`, `description`). 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, @@ -351,43 +329,16 @@ konsistente Anker: ```bash cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei -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 +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 -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 +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 ``` -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 @@ -397,8 +348,9 @@ Die `README.md` eines konvertierten Buches mit Kästen enthält die Befehle bere **PDF immer über HTML erzeugen.** Beim direkten Weg Markdown → LaTeX verwirft Pandoc allen rohen HTML-Code. Dabei gehen HTML-Tabellen samt Inhalt verloren, und ``-Sprungziele fehlen, sodass interne Links im PDF ins Leere zeigen. Liest Pandoc dagegen die HTML-Fassung, macht es daraus echte Tabellen und Sprungziele. -Getestet mit dem C-Buch (322 Seiten, 34 Tabellen; alle 831 internen Links haben ein Ziel, kein Zeichen -fehlt). Die `README.md` jedes konvertierten Buches enthält den passenden Befehl. +Getestet mit dem C-Buch (405 Seiten, 34 Tabellen, 728 von 755 internen Links funktionieren; die übrigen +stehen in Codeblöcken innerhalb von Tabellen). Die `README.md` jedes konvertierten Buches enthält den +passenden Befehl. Alternativ direkt aus den Kapiteldateien: @@ -413,10 +365,8 @@ pandoc output/programmierbuch/chapters/*.md \ - Ausgaben mit `--math tex` (`\(…\)`) brauchen `-f markdown+tex_math_single_backslash`. - Das YAML-Frontmatter jeder Kapiteldatei wird von Pandoc als Metadaten gelesen. Wer die Kapiteldateien direkt zusammen verarbeitet, sollte mit `--no-frontmatter` konvertieren oder die Gesamtdatei verwenden. -- TeX aus dem E-Book wird für Pandocs Formelparser angepasst, ohne den Inhalt zu ändern: `\"u` → `ü`, - `\O` (leere Menge) → `\emptyset`, keine `@{…}`-Spaltentrenner und kein `\arraycolsep` in `array`, kein - leeres `\displaystyle` vor einem Zellenende. Seltene Handkonstruktionen (z. B. `\mathinner{…\raise…}`) - bleiben als TeX stehen, mit der Warnung „Could not convert TeX math“ (Mathebuch: 1 von 4986 Formeln). +- Einzelne TeX-Befehle aus EPUBs (z. B. `\O` oder `@`-Konstrukte) kennt Pandocs Formelparser nicht. Er + belässt sie als TeX, mit der Warnung „Could not convert TeX math“. ## Bericht und Diagnose @@ -461,11 +411,6 @@ 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 @@ -492,11 +437,7 @@ epub2md/ ├── models.py Dataclasses (Kapitel, Medien, Noten, Metadaten …) └── utils.py Slugs, YAML-Emitter, Pfadhelfer pandoc/ -├── book.lua Pandoc-Filter: Inhaltsverzeichnis nach Cover/Titelseite/Impressum, Kapitel-Seitenumbrüche -├── callouts.lua Pandoc-Filter: '> [!TIP]'-Kästen → gerahmte Kästen (HTML/EPUB) bzw. Zitatblock mit Titel -├── callouts.css Gestaltung der Kästen (vom Filter eingebunden; muss neben callouts.lua liegen) -└── print.css Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite, Codeumbruch) -tools/qa/ QA-Skripte: CLI-Matrix, Validator, Wortvergleich, Pandoc-Prüfung (siehe tools/qa/README.md) +└── callouts.lua Pandoc-Filter: '> [!TIP]'-Kästen → gerahmte Kästen (HTML/EPUB) bzw. Zitatblock mit Titel ``` Statt BeautifulSoup wird direkt lxml verwendet: Es ist für die XHTML-Bäume ausreichend, schneller und @@ -514,14 +455,7 @@ 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, Ü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 +Anker-Umbenennung, Pfade, Fußnoten, unveränderter Code und Überschreibschutz. 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.`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`. +ausgeschlossen. diff --git a/epub2md/__init__.py b/epub2md/__init__.py index 292d78c..6885324 100644 --- a/epub2md/__init__.py +++ b/epub2md/__init__.py @@ -1,4 +1,4 @@ """epub2md – konvertiert DRM-freie EPUB-Dateien in kapitelweise Markdown-Sammlungen.""" -__version__ = "0.3.0" +__version__ = "0.2.0" TOOL_NAME = "epub2md" diff --git a/epub2md/chapters.py b/epub2md/chapters.py index 8716c3b..0b16de3 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|deckblatt)\b", re.I)), - ("titlepage", re.compile(r"^(titelei|titelseite|title ?page|titel\s*$|title\s*$)", re.I)), + ("cover", re.compile(r"^(cover|umschlag|titelbild)\b", re.I)), + ("titlepage", re.compile(r"^(titelei|titelseite|title ?page)", 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,11 +105,9 @@ def semantic_types(el: Any) -> list[str]: class ChapterBuilder: def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics, - book_title: str = "", authors: list[str] | None = None, deep_starts: set[Any] | None = None) -> None: + book_title: str = "", authors: list[str] | None = None) -> None: self.level = chapter_level self.diag = diag - self.deep_starts = deep_starts or set() # chapter headings whose sections are split one level deeper - self._deep = False # current chapter is one of them self._special_level = 0 # level of a split-out special heading whose section is still open self._in_special = False # current chapter itself is a bibliography/index/glossary self.book_title = book_title @@ -131,12 +129,6 @@ class ChapterBuilder: if lvl and has_content(el): special = _special_heading(el) if lvl <= self.level: - if update: - self._special_level = 0 - self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS - self._deep = el in self.deep_starts - return Marker(el, "heading", lvl) - if self._deep and lvl == self.level + 1: if update: self._special_level = 0 self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS @@ -204,7 +196,7 @@ class ChapterBuilder: body_segments = segments[1:] if _meaningful(head_items): first = _first_meaningful(head_items) - if chapters and leading_heading_level(first) > self.level + self._deep and not semantic_types(doc.body): + if chapters and leading_heading_level(first) > self.level and not semantic_types(doc.body): chapters[-1].items.extend(pending + head_items) pending = [] if doc.path not in chapters[-1].docs: @@ -315,21 +307,12 @@ 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 8066c45..21865a3 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -46,14 +46,6 @@ def code_language(value: str) -> str: return lang -def _chapter_level(value: str) -> int | str: - if value == "auto": - return value - if value.isdigit() and 1 <= int(value) <= 6: - return int(value) - raise argparse.ArgumentTypeError("auto oder eine Zahl zwischen 1 und 6 erwartet") - - def build_parser() -> argparse.ArgumentParser: p = _Parser( prog="epub2md.py", @@ -69,10 +61,9 @@ def build_parser() -> argparse.ArgumentParser: help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)") p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False, help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)") - p.add_argument("--chapter-level", type=_chapter_level, default="auto", metavar="{auto,1-6}", - help="Überschriftenebene, ab der neue Kapiteldateien entstehen. auto (Standard): Ebene 1, " - "nur sehr große Kapitel (über 30000 Wörter, z. B. Teile eines Romans) werden an ihren " - "Unterkapiteln geteilt. Eine Zahl 1–6 gilt ausnahmslos für das ganze Buch") + p.add_argument("--chapter-level", type=int, default=1, metavar="N", + help="Überschriftenebene 1–6, ab der neue Kapiteldateien entstehen (Standard: 1). " + "Für Bücher mit Teilen (h1) und Kapiteln (h2) ist 2 sinnvoll") p.add_argument("--toc", action=argparse.BooleanOptionalAction, default=True, help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)") p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True, diff --git a/epub2md/codeblocks.py b/epub2md/codeblocks.py index 7731a9b..0fcc883 100644 --- a/epub2md/codeblocks.py +++ b/epub2md/codeblocks.py @@ -2,10 +2,8 @@ from __future__ import annotations -import ast import json import re -import warnings from typing import Any from .utils import classes, is_element, local @@ -129,114 +127,9 @@ 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 = strip_line_numbers(code).strip() + text = code.strip() if not text: return "" first = text.split("\n", 1)[0] @@ -274,8 +167,6 @@ 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 b8de5ad..fb8f6ff 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -22,7 +22,6 @@ 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, @@ -304,7 +303,6 @@ 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 @@ -324,7 +322,6 @@ 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 @@ -420,11 +417,7 @@ class Renderer: return "" self.page_markers += 1 page = html.escape(page, quote=False).replace("--", "") - 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 + return f"" if self.opt.page_markers == "comment" else f"\\[S. {md_escape(page)}\\]" def anchors_of(self, el: Any, subtree: bool = False) -> str: nodes = iter_elements(el) if subtree else [el] @@ -629,18 +622,13 @@ 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 [] - 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 + 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))) title = join_inline(pieces, hard_break=" ").strip() kind = kind or _box_type(norm_ws(text_content(title_el))) saved = self.in_box @@ -665,9 +653,7 @@ 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 = ["".join(markers)] if markers else [] - if anchor: - out.append(anchor) + out = [anchor] if anchor else [] head = [f"**{title}**"] if title else [] if self.opt.boxes == "html": out.append(f'
\n\n' + "\n\n".join(head + blocks) + "\n\n
") @@ -801,9 +787,6 @@ 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: @@ -1238,8 +1221,8 @@ class Renderer: finally: self.strong -= 1 if name in ("del", "s", "strike"): - del_open, del_close = ("~~", "~~") if self.opt.format == "gfm" else ("", "") - return pre + self._wrap(self.inline_children(el), del_open, del_close) + marker = ("~~", "~~") if self.opt.format == "gfm" else ("", "") + return pre + self._wrap(self.inline_children(el), marker[0], marker[1]) 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 fdee4d8..4687944 100644 --- a/epub2md/mathml.py +++ b/epub2md/mathml.py @@ -365,54 +365,7 @@ def strip_tex_delimiters(text: str) -> str: if t.startswith(o) and t.endswith(c) and len(t) >= len(o) + len(c): t = t[len(o) : len(t) - len(c)] break - return portable_tex(t.strip()) - - -UMLAUTS = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"} - - -def portable_tex(tex: str) -> str: - """Publisher TeX → TeX that Pandoc's formula parser (texmath, MathJax, KaTeX) understands, still valid LaTeX: - text accents \\"u → ü, \\O (empty set in Springer books) → \\emptyset, no @{…} column separators in - array specifications, no \\arraycolsep assignments, no empty \\displaystyle before a cell end. Only changes - spacing, never content.""" - tex = re.sub(r'\\"(?:\{([aouAOU])\}|([aouAOU]))', lambda m: UMLAUTS[m.group(1) or m.group(2)], tex) - tex = re.sub(r"\\(?:text|mbox|mathrm)\s*\{\s*\\O\s*\}", r"\\emptyset", tex) - tex = re.sub(r"\\O(?![A-Za-z])", r"\\emptyset", tex) - tex = re.sub(r"\\arraycolsep\s*-?\d*\.?\d+\s*(?:pt|em|ex|mm|cm)", "", tex) - tex = re.sub(r"\\displaystyle\s*(?=\\\\|&|\\end\b)", "", tex) # no-op before a cell end; texmath fails - out: list[str] = [] - pos = 0 - for m in re.finditer(r"\\begin\{array\}\{", tex): - if m.start() < pos: - continue - end = _closing_brace(tex, m.end() - 1) - if end < 0: - break - spec = tex[m.end():end] - i = spec.find("@{") - while i >= 0: - close = _closing_brace(spec, i + 1) - if close < 0: - break - spec = spec[:i] + spec[close + 1:] - i = spec.find("@{") - out.append(tex[pos:m.end()] + spec) - pos = end - return "".join(out) + tex[pos:] - - -def _closing_brace(text: str, open_pos: int) -> int: - """Index of the '}' matching the '{' at ``open_pos`` (-1 if unbalanced).""" - depth = 0 - for i in range(open_pos, len(text)): - if text[i] == "{": - depth += 1 - elif text[i] == "}": - depth -= 1 - if depth == 0: - return i - return -1 + return t.strip() def is_display_tex(text: str) -> bool: diff --git a/epub2md/merge.py b/epub2md/merge.py index 3652e4d..cba21ff 100644 --- a/epub2md/merge.py +++ b/epub2md/merge.py @@ -46,8 +46,6 @@ 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 @@ -76,13 +74,12 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]: report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {} if manifest_path.is_file(): manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - entries = [(c["number"], c["title"], c["file"], c.get("types") or [], c.get("word_count", 0)) - for c in manifest.get("chapters", [])] + entries = [(c["number"], c["title"], c["file"]) for c in manifest.get("chapters", [])] else: files = sorted((out_dir / "chapters").glob("*.md")) - 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()] + 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()] if missing: raise MergeError(f"Kapiteldateien fehlen: {', '.join(missing[:5])}") if not chapters: @@ -92,49 +89,6 @@ 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 @@ -188,7 +142,7 @@ def assign_anchors(chapters: list[ChapterFile]) -> int: for m in TOKEN.finditer(seg): if m.group("attr") and m.group("aname") == "id" and m.group("avalue") not in ids: ids.append(m.group("avalue")) - start = "kap-" + stems[ch.rel] # ids must start with a letter (Pandoc's EPUB writer breaks '#039-…') + start = stems[ch.rel] while start in used or start in ids: start = "kapitel-" + start ch.start_anchor = start @@ -271,31 +225,18 @@ 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] = display_date(md[src], md.get("language", "")) if key == "date" else md[src] + fm[key] = 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] - 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): + parts.append("\n".join(lines)) + for ch in 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 db5eae3..bc10e24 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -13,12 +13,9 @@ 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 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 .chapters import ChapterBuilder from .diagnostics import Diagnostics from .css import StyleSheet, base_style, emphasis from .epub_reader import EpubReader, decode_markup, parse_xml @@ -28,10 +25,9 @@ from .html_converter import RenderOptions, Renderer from .linkcheck import check_links from .media import MediaRegistry from .models import Chapter -from .normalize import (Doc, book_uses_title_tables, has_content, is_html_toc_doc, make_doc, normalize_doc, - remove_keep_tail) +from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail from .references import LinkResolver -from .utils import is_element, is_inside, iter_elements, local, resolve_href, sha256_file, slugify, text_content +from .utils import is_element, is_inside, iter_elements, local, sha256_file, slugify, text_content from . import writer MARKER_FILES = ("conversion-report.json", "manifest.json") @@ -43,7 +39,7 @@ class Options: format: str = "gfm" extract_media: bool = True copy_original: bool = False - chapter_level: int | str = "auto" # 1–6, or "auto": level 1 plus oversized chapters split one level deeper + chapter_level: int = 1 toc: bool = True frontmatter: bool = True footnotes: str = "end-of-chapter" @@ -84,8 +80,8 @@ def check_paths(input_path: Path, output_dir: Path, opts: Options) -> None: raise InputError(f"Eingabedatei nicht gefunden oder keine Datei: {input_path}") if not os.access(input_path, os.R_OK): raise InputError(f"Eingabedatei nicht lesbar: {input_path}") - if opts.chapter_level != "auto" and not (isinstance(opts.chapter_level, int) and 1 <= opts.chapter_level <= 6): - raise InputError("--chapter-level muss auto oder eine Zahl zwischen 1 und 6 sein.") + if not 1 <= opts.chapter_level <= 6: + raise InputError("--chapter-level muss zwischen 1 und 6 liegen.") if opts.dry_run: return if output_dir.exists() and not output_dir.is_dir(): @@ -217,9 +213,10 @@ def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics, def _build_structure(docs: list[Doc], package: Any, opts: Options, diag: Diagnostics) -> tuple[Any, list[Chapter], LinkResolver]: """Notes, chapter units with file names, and the link resolver (incl. NAV/NCX targets).""" + md = package.metadata notes = collect_notes(docs, opts.footnotes, diag) diag.step(f"{len(notes.notes)} Anmerkungen erkannt") - chapters = _build_chapters(docs, package, opts, diag) + chapters = ChapterBuilder(opts.chapter_level, package.toc, diag, md.title, md.authors).build(docs) _assign_filenames(chapters, opts.slug_language) resolver = LinkResolver(docs, chapters, notes, "README.md" if opts.toc else None) nav_targets: list[tuple[str, str]] = [] @@ -236,39 +233,18 @@ 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: - """The book's CSS: manifest stylesheets that a spine document links (directly or via @import), in manifest - order, plus +]] local lang = "de" @@ -104,23 +79,15 @@ local function Meta(meta) lang = l end end - 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() + if is_html() then + local includes = meta["header-includes"] + if includes == nil then + includes = pandoc.MetaList({}) + elseif includes.t ~= "MetaList" then + includes = pandoc.MetaList({ includes }) end - 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 + includes:insert(pandoc.MetaBlocks({ pandoc.RawBlock("html", CSS) })) + meta["header-includes"] = includes end return meta end @@ -159,16 +126,7 @@ 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 deleted file mode 100644 index 2bea7b8..0000000 --- a/pandoc/print.css +++ /dev/null @@ -1,32 +0,0 @@ -/* 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 5012e47..a08e5b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import posixpath import sys import zipfile from dataclasses import dataclass, field @@ -26,7 +25,7 @@ SVG = b' -{title}{head} +{title} {body} """ @@ -37,7 +36,6 @@ 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 @@ -92,7 +90,7 @@ def build_epub(path: Path, book: Book) -> Path: {''.join(spine)}{book.spine_extra} """ nav_xhtml = XHTML.format( - title="Inhalt", head="", + title="Inhalt", body='", ) ncx = ( @@ -116,10 +114,7 @@ def build_epub(path: Path, book: Book) -> Path: if book.ncx: z.writestr("OEBPS/toc.ncx", ncx) for d in book.docs: - 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)) + z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, 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 befe5d6..c98dd87 100644 --- a/tests/test_chapters.py +++ b/tests/test_chapters.py @@ -127,7 +127,7 @@ def test_glossary_anchor_is_placed_before_bold_term(convert): def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert): words = " ".join(["Wort"] * 11000) body = "

Teil 1

" + "".join(f"

Kapitel {i}

{words}

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

Vorwort

kurz

Dank

danke

Hinweise

lesen

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

Teil 1

Einleitung

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

Kapitel {i}

{words}

Abschnitt

x

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

Teil 2

Kapitel 4

kurz

Kapitel 5

kurz

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

A

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

A

Text

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

Kapitel A

Siehe Kapitel B und Bild.

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

Kapitel B

Text B.

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

Nur eine Bildseite ohne Überschrift.

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

Kapitel A

a

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

Kapitel B

b

")] - md = merged(convert, Book(docs=docs)) - html = pandoc(md, "-s", "--lua-filter", str(FILTERS / "book.lua"), "-t", "html").stdout - assert html.count('class="chapter-start"') == 2 - assert 'id="TOC"' in html and "Kapitel B" in html diff --git a/tests/test_qa2_open.py b/tests/test_qa2_open.py index 7b003c2..35f3e7b 100644 --- a/tests/test_qa2_open.py +++ b/tests/test_qa2_open.py @@ -32,14 +32,12 @@ 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") - 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) + assert re.search(r"pandoc \S+\.md -s -o \S+\.html && pandoc \S+\.html --toc --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}
Ergebnis: {i}
" for i in range(10)) + body = "

C

" + "".join(f"
{py}\n# {i}
print({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 a62a99d..cc253d8 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -190,10 +190,8 @@ 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 "callouts.lua" not in plain.read_text(encoding="utf-8") + assert "--lua-filter" not in plain.read_text(encoding="utf-8") def test_hidden_span_gets_no_field_separator_and_class_type_wins(convert): @@ -203,30 +201,3 @@ 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 deleted file mode 100644 index c8ab5a9..0000000 --- a/tools/qa/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# 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 deleted file mode 100755 index cdbb9c1..0000000 --- a/tools/qa/check_pandoc.py +++ /dev/null @@ -1,118 +0,0 @@ -"""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 deleted file mode 100755 index 1f2e8f9..0000000 --- a/tools/qa/check_pdf.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/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 deleted file mode 100755 index bed22ec..0000000 --- a/tools/qa/log.sh +++ /dev/null @@ -1,9 +0,0 @@ -# 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 deleted file mode 100755 index 5880622..0000000 --- a/tools/qa/make_fixtures.py +++ /dev/null @@ -1,266 +0,0 @@ -"""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 deleted file mode 100755 index 36ef93a..0000000 --- a/tools/qa/pandoc_full.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/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 deleted file mode 100755 index c248182..0000000 --- a/tools/qa/pandoc_pdfs.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/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 deleted file mode 100755 index 319f845..0000000 --- a/tools/qa/pandoc_run.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/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 deleted file mode 100755 index 0876dde..0000000 --- a/tools/qa/pyblocks.py +++ /dev/null @@ -1,36 +0,0 @@ -"""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 deleted file mode 100644 index 78cae10..0000000 --- a/tools/qa/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100755 index f8eea23..0000000 --- a/tools/qa/run_matrix.py +++ /dev/null @@ -1,418 +0,0 @@ -"""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 deleted file mode 100755 index 3ef642f..0000000 --- a/tools/qa/validate_output.py +++ /dev/null @@ -1,218 +0,0 @@ -"""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 deleted file mode 100755 index 3799f27..0000000 --- a/tools/qa/worddiff.py +++ /dev/null @@ -1,37 +0,0 @@ -"""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()