diff --git a/.gitignore b/.gitignore index 8924a81..7ac2e8c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ htmlcov/ build/ dist/ -# QA-Läufe (Prompt_Kontrolle.md) +# QA-Läufe (Prompt_Kontrolle.md): erzeugte Daten von tools/qa/ .qa/ .qa-output/ .qa-fixtures/ diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index afb00f6..f1e3639 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -115,12 +115,14 @@ python3 epub2md_merge.py output/meinbuch ``` Ergebnis: `output/meinbuch/meinbuch.md`. Die Datei heißt wie das Original-E-Book, nur mit `.md`. -Bilder und Querverweise funktionieren darin weiter. +Bilder und Querverweise funktionieren darin weiter. Am Anfang stehen die Buchdaten (Titel, Autor, Verlag, +Datum als `13.01.2025`), die Pandoc für die Titelseite verwendet. Nützliche Varianten: ```bash -# mit Inhaltsverzeichnis am Anfang +# mit Inhaltsverzeichnis (Liste der Kapitel) hinter Cover, Titelseite und Impressum – +# für PDFs mit Pandoc nicht nötig, dort baut book.lua ein Verzeichnis mit Seitenzahlen (Kapitel 7) python3 epub2md_merge.py output/meinbuch --toc # an einen anderen Ort schreiben (Bildpfade werden automatisch angepasst) @@ -139,14 +141,21 @@ python3 epub2md_merge.py output/meinbuch --force ### Roman (Teile und Kapitel) -Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Standardmäßig entsteht eine Datei -pro **Teil**. Das können sehr große Dateien werden. Das Programm warnt dann mit `chapter-large` und -empfiehlt die passende Einstellung. Eine Datei pro **Kapitel** bekommst du so: +Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Das Programm erkennt sehr große +Teile (über 30 000 Wörter) selbst und legt dann eine kurze Datei für den Teil und eine Datei pro +**Kapitel** an; kurze Abschnitte wie Vorwort oder Anhang bleiben eine Datei. Du musst dafür nichts +einstellen. Soll wirklich jeder Abschnitt mit Unterüberschrift eine eigene Datei werden, gilt: ```bash python3 epub2md.py roman.epub output/roman --chapter-level 2 ``` +### Roman (Szenenwechsel, Gedichte) + +Szenenwechsel, die im Buch nur als Leerzeile zwischen zwei Absätzen erscheinen, werden als `* * *` +übernommen; sonst würden zwei Szenen nahtlos ineinanderlaufen. Gedichte und Liedtexte bleiben Zeile für +Zeile erhalten (als eingerückter Zitatblock, Strophen durch Leerzeile getrennt). + ### Programmierbuch ```bash @@ -154,8 +163,9 @@ python3 epub2md.py python-buch.epub output/python-buch ``` Programmcode bleibt zeichengenau erhalten, inklusive Einrückung. Die Programmiersprache wird nur -eingetragen, wenn sie eindeutig erkennbar ist. Ist das ganze Buch in **einer** Sprache geschrieben, -kannst du sie für alle übrigen Codeblöcke vorgeben: +eingetragen, wenn sie eindeutig erkennbar ist. Ist ein Buch erkennbar in **einer** Sprache geschrieben, +erhalten auch kurze Codeschnipsel dieses Label (etwa `char ch = 65;` in einem C-Buch). Programmausgaben +bleiben ohne Label. Willst du wirklich alle übrigen Blöcke kennzeichnen, gib die Sprache vor: ```bash python3 epub2md.py python-buch.epub output/python-buch --code-language python @@ -177,7 +187,9 @@ python3 epub2md.py mathe.epub output/mathe ``` Formeln, die im E-Book als echte Formeln (MathML oder TeX) vorliegen, werden zu TeX: `$…$` im Text, -`$$…$$` als eigene Zeile. Formeln, die nur als Bild vorliegen, bleiben Bilder und erscheinen in +`$$…$$` als eigene Zeile. Viele Verlage legen das TeX einer Formel in den Alternativtext eines Formelbildes; +epub2md übernimmt es und passt Schreibweisen an, die Pandoc nicht versteht (z. B. `\"u` → `ü`), ohne den +Inhalt zu ändern. Formeln, die nur als Bild vorliegen, bleiben Bilder und erscheinen in `diagnostics/warnings.md`. | Gewünschte Formeldarstellung | Option | @@ -203,6 +215,55 @@ python3 epub2md.py bildband.epub output/bildband --chapter-level 3 - Bilder, die im E-Book zwar enthalten, aber nirgends eingebunden sind, werden trotzdem gespeichert. Im Bericht sind sie als `unreferenced` gekennzeichnet. +### Infokästen (Tipps, Hinweise, Warnungen) + +Kästen aus dem Buch, etwa „Tipp“, „Achtung“, „Exkurs“ oder „Die MATLAB-Minute“, werden als hervorgehobene +Blöcke übernommen: + +```markdown +> [!TIP] +> **Erwartungen an dieses Buch** +> +> Text des Kastens … +``` + +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“). +Andere Darstellungen wählst du mit `--boxes`: + +- `quote`: Zitatblock ohne `[!TIP]` +- `html`: HTML-`
` +- `plain`: wie normaler Text + +### Farben, Schriftarten und Schriftgrößen + +Markdown kennt keine Farben, Schriftarten oder Schriftgrößen. Standardmäßig übernimmt das Programm daher +nur, was eine Bedeutung trägt: + +- **fett** und *kursiv*, auch wenn das Buch sie nur über sein Stylesheet setzt +- unterstrichen, hoch- und tiefgestellt +- farbig hinterlegter Text als ``-Markierung + +Möchtest du das Aussehen möglichst erhalten, etwa für eine HTML-, EPUB- oder PDF-Ausgabe mit Pandoc oder zum +Lesen in Obsidian, wandle mit `--styles html` um. Dann bleiben Farbe, Hintergrund, Schriftgröße, +Schriftart, Kapitälchen und zentrierte oder rechtsbündige Absätze als HTML erhalten. Das gilt nur dort, wo +sie vom normalen Text abweichen. Fremdsprachige Passagen werden als solche gekennzeichnet. GitHub zeigt +diese Angaben nicht an. + +### Seitenzahlen der Druckausgabe (zum Zitieren) + +Viele E-Books enthalten unsichtbare Marken, wo im gedruckten Buch eine neue Seite beginnt. Standardmäßig +lässt das Programm sie weg. Mit `--page-markers visible` erscheinen sie im Text als `[S. 123]`, mit +`--page-markers comment` als unsichtbarer Kommentar ``. Den findest du über die Suche, er +stört aber beim Lesen nicht. + +```bash +python3 epub2md.py roman.epub roman-md --page-markers visible +``` + +In Codebeispielen stehen keine Seitenmarken, damit der Code unverändert bleibt. Enthält das E-Book keine +Seitenmarken, bleibt die Option ohne Wirkung; im Bericht steht dann `"page_markers": 0`. + ### Buch mit vielen Fußnoten Standardmäßig stehen die Fußnoten am Ende des jeweiligen Kapitels. Alternativen: @@ -254,28 +315,57 @@ cd output/meinbuch ``` `meinbuch.md` steht im Folgenden für den Namen der Gesamtdatei. Er entspricht dem Namen des -Original-E-Books. +Original-E-Books. `$P` ist der Ordner mit den Pandoc-Hilfsdateien von epub2md; lege ihn einmal fest: + +```bash +P=~/Python_Programs/epub2md/pandoc # Pfad zu deinem epub2md-Ordner anpassen +``` | Ziel | Befehl | | --- | --- | -| Webseite (HTML) | `pandoc meinbuch.md -s --toc -o meinbuch.html` | -| Webseite mit Formeln | `pandoc meinbuch.md -s --toc --mathml -o meinbuch.html` | -| E-Book (EPUB) | `pandoc meinbuch.md --toc -o meinbuch.epub` | -| Word | `pandoc meinbuch.md -o meinbuch.docx` | -| LibreOffice | `pandoc meinbuch.md -o meinbuch.odt` | -| PDF mit LaTeX | `pandoc meinbuch.md -s -o meinbuch.html && pandoc meinbuch.html --toc --pdf-engine=xelatex -o meinbuch.pdf` | -| PDF ohne LaTeX | `pandoc meinbuch.md --toc --pdf-engine=weasyprint -o meinbuch.pdf` | -| Kindle (AZW3) | `pandoc meinbuch.md --toc -o meinbuch.epub && ebook-convert meinbuch.epub meinbuch.azw3` | +| PDF (empfohlen) | `pandoc meinbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua --css $P/print.css --pdf-engine=weasyprint -o meinbuch.pdf` | +| Webseite (HTML) | `pandoc meinbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html` | +| Webseite mit Formeln | wie HTML, zusätzlich `--mathml` | +| E-Book (EPUB) | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.epub` | +| E-Book mit Formeln | wie EPUB, zusätzlich `--mathml` | +| Word | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.docx` | +| LibreOffice | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.odt` | +| PDF mit LaTeX (Bücher mit Formeln) | `pandoc meinbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html && pandoc meinbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex -o meinbuch.pdf` | +| Kindle (AZW3) | erst EPUB wie oben, dann `ebook-convert meinbuch.epub meinbuch.azw3` | + +Die drei Hilfsdateien: + +- **`book.lua`** ordnet den Buchanfang wie im gedruckten Buch: Cover, Titelseite, Impressum, dann das + Inhaltsverzeichnis, jeweils auf einer neuen Seite. Ohne den Filter setzt Pandoc mit `--toc` das + Inhaltsverzeichnis (und seinen eigenen Titelkopf) vor das Cover. Mit `book.lua` deshalb **kein** `--toc` + angeben. Hat das Buch keine eigene Titelseite, setzt der Filter Pandocs Titelkopf hinter das Cover. Das + gilt für HTML und für beide PDF-Wege (beim LaTeX-Weg den Filter in beiden Schritten angeben); EPUB, Word + und LibreOffice bauen ihr Verzeichnis mit `--toc` selbst. Kurze Seiten zwischen Titelseite und Impressum + (Auflagenvermerk, Verlagsbrief, „Über dieses Buch“) bleiben im Vorspann; das Inhaltsverzeichnis folgt vor + dem ersten Teil, Vorwort oder nummerierten Kapitel. +- **`callouts.lua`** zeichnet die Infokästen (`> [!TIP]`). Pandoc in der Ubuntu-Version kennt diese + Schreibweise nicht und druckt „[!TIP]“ sonst einfach mit. In HTML, EPUB und im PDF über weasyprint werden + daraus gerahmte, farbige Kästen mit Titel („Tipp: …“), im PDF über LaTeX und in Word/LibreOffice ein + eingerückter Block mit fettem Titel. +- **`print.css`** liefert das Druck-Layout für weasyprint: A4, Seitenzahlen (nicht auf dem Cover), jedes + Kapitel auf einer neuen Seite, Inhaltsverzeichnis mit Seitenzahlen, Silbentrennung und umbrechende lange + Codezeilen. Ohne sie + übernimmt weasyprint das Bildschirmlayout, und lange Codezeilen werden am Seitenrand abgeschnitten. + +Du musst die Befehle nicht abtippen: Die `README.md` im Ausgabeordner enthält sie fertig, mit allen Pfaden. **Zum PDF:** +- **Mit weasyprint** (empfohlen für Bücher ohne Formeln) genügt ein Schritt. Tabellen, Farben, + Infokästen und die Gestaltung aus `--styles html` bleiben erhalten. - **Mit LaTeX** immer den Zwischenschritt über HTML nehmen, wie in der Tabelle. Beim direkten Weg von - Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Bei - Büchern mit Formeln ist der LaTeX-Weg die beste Wahl. -- **Mit weasyprint** genügt ein Schritt; Tabellen bleiben erhalten. Für Bücher mit vielen Formeln ist der - LaTeX-Weg sicherer. + Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Für + Bücher mit Formeln ist das der richtige Weg, weil weasyprint keine Formeln setzen kann. Farben und + Kastenrahmen gehen dabei verloren; Kästen erscheinen als eingerückter Block mit fettem Titel. `book.lua` + stellt dabei A4 ein und wählt die Schrift DejaVu, die auch Zeichen wie ∈, λ oder ≠ im Fließtext enthält. -Bei Büchern mit Formeln für HTML zusätzlich `--mathml` angeben, sonst bleiben Matrizen als TeX-Text stehen. +Bei Büchern mit Formeln für HTML und EPUB zusätzlich `--mathml` angeben, sonst bleiben Matrizen und +ähnliche Formeln als TeX-Text stehen. #### Direkt aus den Kapiteldateien @@ -305,8 +395,8 @@ gilt hier derselbe Umweg über HTML. - Die einzelnen Kapiteldateien eignen sich als Abschnitte. Die Metadaten im Datenkopf (Buch, Kapitel, Herkunft) helfen beim Zuordnen von Fundstellen. - `manifest.json` listet alle Kapitel mit Titel, Datei, Wortanzahl und Ankern. -- Achte auf die Warnung `chapter-large`: Sehr große Kapiteldateien lassen sich schlecht zerlegen. Dann - mit höherem `--chapter-level` neu umwandeln. +- Sehr große Kapitel teilt das Programm selbst an ihren Unterkapiteln. Hast du `--chapter-level` fest + eingestellt und erscheint die Warnung `chapter-large`, dann mit dem empfohlenen Wert neu umwandeln. --- @@ -431,9 +521,14 @@ python3 epub2md_merge.py --help | `--dry-run` | nur anzeigen, nichts schreiben | | `--verbose` | ausführliche Ausgabe | | `--force` | frühere Ausgabe ersetzen | -| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) | +| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard `auto`: Ebene 1, sehr große Kapitel werden an ihren Unterkapiteln geteilt) | | `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache | | `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen | +| `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten | +| `--boxes callout/quote/html/plain` | Darstellung von Infokästen | +| `--styles semantic/html` | Farben, Schriften, Größen, Ausrichtung und Sprache als HTML übernehmen (`html`) | +| `--page-markers none/visible/comment` | Seitenzahlen der Druckausgabe als `[S. 123]` oder `` | +| `--no-css-emphasis` | Hervorhebungen nur aus HTML-Tags übernehmen, nicht aus dem Stylesheet des Buches (farbige Markierungen `` bleiben, sie gehören zu `--styles`) | | `--math auto/tex/mathml/image` | Formeldarstellung | | `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung | | `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..da79cba --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,124 @@ +# Änderungsprotokoll + +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. + +### Neu + +- **Hervorhebungen aus dem CSS des Buches** (`epub2md/css.py`): fett, kursiv, unterstrichen, durchgestrichen, + hoch-/tiefgestellt und Festbreitenschrift werden auch übernommen, wenn der Verlag sie nur per Stylesheet + setzt. Per CSS ausgeblendete Elemente (`display:none`) entfallen. Abschaltbar mit `--no-css-emphasis`. +- **Infokästen** (Tipp, Hinweis, Warnung, Exkurs …) als GitHub-/Obsidian-Callouts `> [!TIP]`, wahlweise als + Zitatblock, HTML-`div` oder normaler Text: `--boxes {callout,quote,html,plain}`. +- **Pandoc-Filter** `pandoc/callouts.lua`: zeichnet die Kästen in HTML/EPUB als gerahmte Kästen, in + LaTeX/PDF, Word und ODT als Zitatblock mit Titel. Die Buch-README nennt ihn in den Pandoc-Befehlen. +- **Farben, Schriftarten, Schriftgrößen, Kapitälchen, Ausrichtung und Fremdsprache** als HTML-Stil: + `--styles html`. Standard `semantic` übernimmt nur Hintergrundmarkierungen (``). +- **Szenenwechsel** (Leerabsätze, Ornamente) als `* * *`, **Gedichte** als Zitatblock mit Zeilenumbrüchen. +- **Seitenzahlen der Druckausgabe** als `[S. 123]` oder ``: `--page-markers {none,visible,comment}`. +- **Unsichtbare Zeichen** (Nullbreiten-Leerzeichen, weiche Trennstriche) werden im Fließtext entfernt, im + Code nicht. Abschaltbar mit `--keep-invisible-chars`. +- Verzeichnisse (Quellen, Literatur, Register, Glossar) unterhalb von `--chapter-level` werden eigene Dateien. +- Bericht: neue Abschnitte `text_cleanup`, `css` und `layout`. + +### Geändert + +- Kastentitel ohne dekorative Symbole wie `[»]`. Der Kastentyp aus der Verlagsklasse hat Vorrang vor einem + Typwort im Titel. +- Zwischen Feld-Spans wird kein Leerzeichen mehr eingefügt, wenn einer davon per CSS ausgeblendet ist. + +### Dokumentation + +- Bedienungsanleitung: Umwandlung in andere Formate (HTML, PDF, EPUB, Word, Kindle), Infokästen, + Farben/Schriften, Seitenzahlen. + +## [0.1.0] – 2026-10-06 + +Erste Fassung. + +- `epub2md.py`: DRM-freie EPUB 2/3 → kapitelweises Markdown mit Medien, `metadata.yaml`, `manifest.json`, + `conversion-report.json` und Diagnose-Dateien; Code zeichengetreu, MathML → TeX, Fußnoten-Modi, + sichere ZIP-/XML-Verarbeitung. +- `epub2md_merge.py`: Gesamtdatei `.md` aus einem Ausgabeordner. +- Optionen `--code-language`, `--keep-wrap-markers`; Warnung `chapter-large`; Titel überschriftsloser Seiten + aus dem Inhalt. +- 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 f48bfdd..e4974ce 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ YAML-Metadaten, Inhaltsverzeichnis, maschinenlesbarem Manifest und einem Konvert Gedacht für die Weiterverarbeitung mit Markdown-Renderern, Pandoc, Obsidian/Zettelkasten, Git, RAG-/Embedding-Pipelines, Volltextsuche und lokale LLMs. -Eine Schritt-für-Schritt-Anleitung für Anwender steht in der [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md). +Eine Schritt-für-Schritt-Anleitung für Anwender steht in der [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md). Änderungen je Version: [CHANGELOG.md](CHANGELOG.md). Das Projekt enthält zwei Programme: @@ -71,7 +71,7 @@ python3 epub2md.py \ --toc \ --verbose -# Roman mit Teilen (h1) und Kapiteln (h2): ein File pro Kapitel +# Jedes Kapitel unterhalb von h2 als eigene Datei (sonst teilt auto nur sehr große Kapitel) python3 epub2md.py roman.epub output/roman --chapter-level 2 # Nur analysieren, nichts schreiben @@ -88,7 +88,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json | `--format {gfm,commonmark}` | `gfm` | `gfm`: Pipe-Tabellen, `~~durchgestrichen~~`, `[^fn]`-Fußnoten. `commonmark`: Tabellen als HTML, ``, Fußnoten als Anker-Links | | `--extract-media` / `--no-extract-media` | an | Medien nach `media/` extrahieren. Ohne Extraktion stehen Bildreferenzen als Text `[Bild: alt – Pfad]` da (keine kaputten Links) | | `--copy-original` / `--no-copy-original` | aus | Original-EPUB nach `source/` kopieren | -| `--chapter-level N` | `1` | Überschriftenebene (1–6), ab der eine neue Kapiteldatei beginnt | +| `--chapter-level {auto,1-6}` | `auto` | Überschriftenebene, ab der eine neue Kapiteldatei beginnt. `auto`: Ebene 1; nur Kapitel mit über 30 000 Wörtern und mindestens zwei Unterkapiteln (z. B. Teile eines Romans) werden an ihren Unterkapiteln geteilt (Info `chapter-split`). Eine Zahl gilt ausnahmslos für das ganze Buch | | `--toc` / `--no-toc` | an | `README.md` mit Inhaltsverzeichnis, Statistik und Reproduktionsbefehl | | `--frontmatter` / `--no-frontmatter` | an | YAML-Frontmatter in jeder Kapiteldatei | | `--footnotes {keep-links,end-of-chapter,end-of-book,inline}` | `end-of-chapter` | siehe [Fußnoten](#fußnoten-modi) | @@ -97,6 +97,11 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json | `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt | | `--code-language SPRACHE` | – | Sprachlabel für Codeblöcke ohne erkannte Sprache (z. B. `python`, `c`; Aliase wie `py` erlaubt). Erkannte Labels haben Vorrang; im Bericht `detected_by: "option"` | | `--keep-wrap-markers` | aus | Druck-Umbruchzeichen `↪`/`➥` in Codeblöcken stehen lassen. Standard: Fortsetzungszeilen werden wieder zur ursprünglichen Codezeile zusammengefügt (Anzahl im Bericht: `wrap_lines_joined`) | +| `--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` | +| `--keep-invisible-chars` | aus | Nullbreiten-Zeichen und weiche Trennstriche im Fließtext behalten. Standard: entfernen, weil sie Suche und RAG stören (Anzahl im Bericht: `text_cleanup`). Code bleibt immer unverändert | | `--keep-html` | aus | Unbekannte/nicht sicher konvertierbare Elemente als bereinigtes HTML erhalten (sonst: Text übernommen + Eintrag in `diagnostics/unsupported-elements.md`) | | `--strict` | aus | Abbruch bei kritischen Fehlern (fehlende/unlesbare Spine-Dokumente, unsichere ZIP-Pfade, ungültige Spine-Verweise) | | `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) | @@ -153,9 +158,9 @@ output/mein-buch/ ## Wie Kapitel gebildet werden 1. Die **OPF-Spine** bestimmt die Lesereihenfolge (nicht Dateinamen, nicht das Inhaltsverzeichnis). -2. Überschriften `h1`…`hN` (N = `--chapter-level`) und semantische Abschnitte (`epub:type`/`role`: - chapter, part, appendix, glossary, bibliography, index, preface, …) beginnen neue Kapiteldateien, - auch mitten in einer XHTML-Datei. +2. Überschriften `h1`…`hN` (N = `--chapter-level`, bei `auto` 1 bzw. 2 in sehr großen Kapiteln) und + semantische Abschnitte (`epub:type`/`role`: chapter, part, appendix, glossary, bibliography, index, + preface, …) beginnen neue Kapiteldateien, auch mitten in einer XHTML-Datei. 3. Eine XHTML-Datei, die nur mit einer tieferen Überschrift beginnt (z. B. eine Datei pro Abschnitt `1.2`), wird an das laufende Kapitel angehängt. 4. Dateien ohne Überschrift (Cover, Titelei, Widmung) werden eigene Kapitel. Ihr Titel kommt aus @@ -165,6 +170,10 @@ output/mein-buch/ - ©/ISBN/„urheberrechtlich“ → „Impressum“ - sonst die ersten acht Wörter - erst als letzte Notlösung der Dateiname + + Unabhängig vom Titel gilt ein erstes Kapitel, das nur aus einem Bild besteht, als Cover (z. B. + „[Titelinfo]“); die Titel „Deckblatt“/„Umschlag“ ergeben ein Cover, „Titel“/„Titelei“ eine Titelseite. + Diese Typen stehen in `manifest.json` und bestimmen in der Gesamtdatei die Stelle des Inhaltsverzeichnisses. 5. Ein HTML-Inhaltsverzeichnis wird übersprungen, wenn NAV/NCX vorhanden ist (keine doppelte Ausgabe). Links darauf zeigen auf `README.md`. 6. Verzeichnisse erhalten immer eine eigene Datei, auch unterhalb von `--chapter-level`, z. B. ein @@ -179,10 +188,13 @@ Verlagsspezifische Heuristiken: Layout-Tabellen mit Klassen wie `Head1`/`Title_d Überschriften. `
` mit `
`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken. Literaturlisten mit eigenen Labels (`[BryL06]`) werden nicht doppelt nummeriert. -**Tipp:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), erzeugt -`--chapter-level 2` eine Datei pro Kapitel. Mit dem Standard `1` entsteht eine Datei pro Teil. -Ist eine Kapiteldatei größer als 30 000 Wörter und hat sie mindestens zwei `##`-Unterabschnitte, meldet -epub2md eine Warnung `chapter-large` mit der passenden `--chapter-level`-Empfehlung. +**Teile und Kapitel:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), teilt der +Standard `auto` jeden Teil mit über 30 000 Wörtern und mindestens zwei Kapiteln in eine kurze Datei für den +Teil (Überschrift, Einleitung) und eine Datei pro Kapitel; kleinere Kapitel (Vorwort, Anhang) bleiben ganz. +Verzeichnisse (Register, Literatur, Glossar) werden dabei nie zerlegt. `--chapter-level 2` teilt dagegen +jedes Kapitel des Buches an `h2`. Ist bei einer festen Zahl eine Kapiteldatei größer als 30 000 Wörter und +hat sie mindestens zwei `##`-Unterabschnitte, meldet epub2md eine Warnung `chapter-large` mit der +passenden `--chapter-level`-Empfehlung. ## Math-Modi @@ -234,9 +246,44 @@ weil CommonMark keine `[^…]`-Syntax kennt. - **Syntax-Highlighting:** Farb-Spans, ``/`` in Listings und Zeilennummern werden entfernt. Einrückung, Tabs, `<`, `>`, `&`, `$`, `|`, Backslashes und Backticks bleiben exakt erhalten. Die Sprache kommt aus Klassen/Attributen (`language-python`, `data-lang`, …) oder starken Inhaltssignalen - (`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. Bei einsprachigen - Büchern kann `--code-language python` (o. Ä.) alle übrigen Blöcke kennzeichnen. Achtung: Das betrifft - auch Programmausgaben und Shell-Befehle im Buch. + (`#include`, Shebang, `>>>`, Python-Code, der sich parsen lässt und `def`/`class`/Kontrollstrukturen + enthält, …). Gedruckte Zeilennummern als Text (`00 #include …`) stören die Erkennung nicht und bleiben + im Block stehen. Sind sich die sicher erkannten Blöcke einig (mindestens 5, davon 90 % eine Sprache), + genügen für die übrigen schwächere Signale: in einem Python-Buch Zuweisungen und Aufrufe, in einem + Buch mit C-artiger Sprache Anweisungen und Deklarationen mit `;` oder `{` (im Bericht + `detected_by: "book-context"`). Programmausgaben (Literale, Zahlen, Text) bleiben ohne Label. + `--code-language python` (o. Ä.) kennzeichnet alle übrigen Blöcke, auch Programmausgaben und + Shell-Befehle im Buch. +- **CSS-Formatierung:** Viele Verlage setzen Hervorhebungen nur über Stylesheets. epub2md wertet die + CSS-Dateien des Buches aus: Klassen-, Element- und ID-Selektoren, Nachfahren- und Kind-Kombinatoren, + Spezifität und `style`-Attribute. `@media`-Blöcke und Selektoren mit Pseudoklassen werden ignoriert. + Berücksichtigt werden nur Stylesheets, die ein Dokument per `` (oder `@import`) einbindet; + mitgelieferte Layouts anderer Buchreihen bleiben wie im E-Book-Reader wirkungslos (Info `css-unlinked`). + Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst (`*x**y*` → `*xy*`). +- **Farben und Schriften:** Markdown kennt keine Farben, Schriftarten oder -größen. Mit `--styles html` + bleiben sie als HTML-Stil erhalten. Übernommen wird nur, was vom Grundstil abweicht (häufigste Werte aller + Absätze; Schriftgröße erst ab ±15 %). Abgesetzte Formeln und Bildabsätze bekommen keine Ausrichtung. Werte + mit `url(...)`, `expression` usw. werden verworfen. +- **Seitenzahlen (`--page-markers`):** Erkannt werden `epub:type="pagebreak"`/`role="doc-pagebreak"` + (Nummer aus `aria-label`, `title` oder Text) und Seitenanker mit IDs wie `page_47`, `page37`, `seite-12` + oder – nur bei leeren ``/`` – `p17`. In Codeblöcken und Inline-Code stehen keine Marker; ein + Seitenwechsel in einer Überschrift steht als eigener Absatz davor. Die Anzahl steht im Bericht unter + `layout.page_markers`. +- **Infokästen:** + - **Erkennung:** `aside`/`epub:type` (sidebar, tip, warning …), Klassennamen (box, kasten, tipp, hinweis, + warnung, achtung, wichtig, merke, exkurs …) oder ein CSS-Rahmen bzw. -Hintergrund um kurzen Fließtext. + Folgen von Tipp-Absätzen ohne Rahmen werden zu einem Kasten zusammengefasst. + - **Titel und Symbole:** Kastenüberschriften werden zum fetten Titel und tauchen damit nicht mehr in der + Gliederung auf. Dekorative Symbolbilder entfallen. + - **Typ:** Er ergibt sich aus Klasse, Titel oder erstem Wort: TIP, IMPORTANT, WARNING, CAUTION, sonst NOTE. + - **Ausnahmen:** Umrahmte Codelistings und Bildrahmen gelten nicht als Kasten. +- **Szenenwechsel und Verse:** + - Leere Absätze zwischen zwei Textabsätzen werden zu `* * *`; mehrere hintereinander ergeben einen + Wechsel. Ebenso werden Ornament-Absätze wie `***` oder `⁂` zu `* * *`. + - Leere `div`s und Absätze, die nur Anker enthalten, gelten als Layout und nicht als Wechsel. + - Gedichte (`poem`, `verse`, `gedicht` … bzw. `z3998:poem`) werden zum Zitatblock mit einer Zeile pro Vers + und einer Leerzeile zwischen Strophen. + - Anzahl im Bericht unter `layout`. - **Gescannte Texte:** nur Bilder, kein Text (keine OCR). - **DRM:** wird erkannt und abgelehnt. - **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne @@ -263,14 +310,15 @@ verwendet und das Verzeichnis gibt den Namen vor. | --- | --- | | `-o`, `--output DATEI.md` | anderer Zielort; Medienpfade werden relativ zum Ziel umgeschrieben | | `--frontmatter` / `--no-frontmatter` | ein YAML-Kopf mit Buchmetadaten (`title`, `author`, `lang`, …) für Pandoc (Standard: an) | -| `--toc` | Inhaltsverzeichnis mit Sprunglinks auf die Kapitel einfügen | +| `--toc` | Inhaltsverzeichnis mit Sprunglinks auf die Kapitel einfügen, hinter Cover, Titelseite und Impressum (für Pandoc-PDFs besser `pandoc/book.lua`, das Seitenzahlen und Unterabschnitte liefert) | | `--force` | bestehende Zieldatei überschreiben (sonst Abbruch mit Exit 1) | | `--version` | Programmversion | Beim Zusammenfügen passiert Folgendes: - Die Frontmatter der einzelnen Kapitel wird entfernt. -- Jedes Kapitel erhält einen Startanker (``). +- Jedes Kapitel erhält einen Startanker (``). Er beginnt mit einem Buchstaben, + weil Pandocs EPUB-Ausgabe Links auf IDs mit führender Ziffer nicht auflöst. - Links zwischen Kapiteln (`003-x.md#anker`) werden zu Sprunglinks in der Gesamtdatei. - Anker, die in mehreren Kapiteln vorkommen (z. B. `Sec1` in jedem Kapitel eines Springer-Buchs), werden ab dem zweiten Vorkommen in `c012-Sec1` umbenannt, samt aller Links darauf. @@ -279,8 +327,15 @@ Beim Zusammenfügen passiert Folgendes: - Codeblöcke und Inline-Code werden nie verändert. - Kapitelüberschriften bleiben `#`, jedes Kapitel beginnt also mit einer H1. Pandoc behandelt sie als Kapitel. +- Hinter dem Vorspann (Cover, Titelseite, Impressum und kurze Seiten dazwischen, bis vor den ersten Teil, + das Vorwort oder das erste nummerierte Kapitel) steht ein unsichtbarer Platzhalter + `
`; hat das Buch keine eigene Titelseite, steht hinter dem Cover zusätzlich + `
`. `pandoc/book.lua` setzt dort Inhaltsverzeichnis und Titelkopf ein; + GitHub, Obsidian und andere Programme zeigen die leeren `div`s nicht an. - Der Buch-Kopf enthält nur Felder, die im EPUB vorhanden sind (`title`, `subtitle`, `author`, `lang`, - `publisher`, `date`, `identifier`, `rights`, `description`). + `publisher`, `date`, `identifier`, `rights`, `description`). Ein ISO-Zeitstempel als Datum wird gekürzt, + damit Pandoc auf der Titelseite nicht `2025-01-13T08:00:00+00:00` druckt: bei deutschen Büchern zu + `13.01.2025`, sonst zu `2025-01-13`. `metadata.yaml` behält den Originalwert. Am Ende werden alle Links der Gesamtdatei geprüft. Ein Link auf einen nicht vorhandenen Anker zeigt auf den Kapitelanfang und erzeugt eine Warnung auf stderr. Die Zusammenfassung nennt Kapitelanzahl, @@ -296,21 +351,54 @@ konsistente Anker: ```bash cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei -pandoc programmierbuch.md --toc -s -o programmierbuch.html -pandoc programmierbuch.md --toc -o programmierbuch.epub +P=~/Python_Programs/epub2md/pandoc # Pfad zum epub2md-Ordner anpassen +pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua \ + --css $P/print.css --pdf-engine=weasyprint -o programmierbuch.pdf # PDF (empfohlen) +pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o programmierbuch.html +pandoc programmierbuch.md --toc --lua-filter $P/callouts.lua -o programmierbuch.epub -cd output/mathematikbuch -pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html -# PDF über den Zwischenschritt HTML (siehe unten) -pandoc mathematikbuch.html --toc --pdf-engine=xelatex -o mathematikbuch.pdf +cd output/mathematikbuch # Formeln: PDF über HTML und LaTeX (weasyprint setzt kein MathML) +pandoc mathematikbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o mathematikbuch.html +pandoc mathematikbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex \ + -o mathematikbuch.pdf +pandoc mathematikbuch.md --toc --mathml --lua-filter $P/callouts.lua -o mathematikbuch.epub # EPUB: MathML ``` +Hilfsdateien in `pandoc/`: + +- **`book.lua`** (HTML und beide PDF-Wege; statt `--toc`): Pandocs `--toc` setzt Inhaltsverzeichnis und + Titelkopf immer an den Anfang, vor Cover, Titelseite und Impressum. `epub2md_merge.py` markiert deshalb das + Ende des Vorspanns (`
`, Kapiteltypen aus `manifest.json`) und, falls das Buch + keine eigene Titelseite hat, die Stelle hinter dem Cover (`
`). Der + Filter baut dort das Inhaltsverzeichnis (`--toc-depth` gilt), lässt Pandocs Titelkopf bei vorhandener + Titelseite weg, zeigt das Cover ohne Bildunterschrift und macht aus den Kapitelankern + `div.chapter-start` für Seitenumbrüche. Beim LaTeX-Weg (Filter in beiden Schritten) wird daraus + `\tableofcontents` an derselben Stelle, `\clearpage` vor jedem Kapitel und kein `\maketitle` vor dem Cover. + Außerdem A4 mit 2 cm Rand, das Cover in Seitengröße und – falls installiert und nicht selbst gewählt – + DejaVu-Schriften (`mainfont`, `monofont`, `mathfont`): Latin Modern fehlen Zeichen wie ∈ λ ≠ ≥ □ und + schmale Leerzeichen (im Mathebuch über 10.000 Stellen). ℓ, 〈 〉, ∖, ⋯ und „↩︎“ werden als Mathe-Symbole gesetzt. + Bei anderen Formaten entfernt er nur die Platzhalter. Wer selbst `--toc` angibt, bekommt Pandocs + Verzeichnis wie gewohnt. + - Der Vorspann reicht bis vor das erste Kapitel des Hauptteils (Teil, Vorwort, Einleitung, Prolog, + nummerierter Titel oder mehr als 1000 Wörter), sofern er Cover, Titelseite oder Impressum enthält. Ein + erstes Kapitel nur aus einem Bild gilt als Cover, ein Kapitel „Titel“ als Titelseite. +- **`print.css`** (weasyprint): A4, Seitenzahlen (nicht auf dem Cover), jedes Kapitel (auch Cover, + Titelseite, Impressum) und das Inhaltsverzeichnis auf neuer Seite, Inhaltsverzeichnis mit Punktlinie und + Seitenzahl, Silbentrennung (nicht in Code), umbrechende Codezeilen. Mit `--css` lässt Pandoc sein eigenes + Stylesheet weg, deshalb bringt `print.css` auch Grundschrift, Zeilenabstand und Titelkopf mit. Getestet + mit dem Python-Buch (362 Seiten). + +**Infokästen:** Pandoc kennt die Schreibweise `> [!TIP]` erst ab neueren Versionen (Erweiterung `alerts`); +3.1.3 (Ubuntu 24.04) zeigt „[!TIP]“ als Text. Der Filter `pandoc/callouts.lua` behebt das: In HTML und +EPUB entstehen gerahmte, farbige Kästen mit Titel („Tipp: …“, bei `lang: en` „Tip: …“), in LaTeX/PDF, Word +und ODT ein Zitatblock mit fettem Titel. Beim PDF-Weg über HTML den Filter in beiden Schritten angeben. +Die `README.md` eines konvertierten Buches mit Kästen enthält die Befehle bereits mit dem Filter. + **PDF immer über HTML erzeugen.** Beim direkten Weg Markdown → LaTeX verwirft Pandoc allen rohen HTML-Code. Dabei gehen HTML-Tabellen samt Inhalt verloren, und ``-Sprungziele fehlen, sodass interne Links im PDF ins Leere zeigen. Liest Pandoc dagegen die HTML-Fassung, macht es daraus echte Tabellen und Sprungziele. -Getestet mit dem C-Buch (405 Seiten, 34 Tabellen, 728 von 755 internen Links funktionieren; die übrigen -stehen in Codeblöcken innerhalb von Tabellen). Die `README.md` jedes konvertierten Buches enthält den -passenden Befehl. +Getestet mit dem C-Buch (322 Seiten, 34 Tabellen; alle 831 internen Links haben ein Ziel, kein Zeichen +fehlt). Die `README.md` jedes konvertierten Buches enthält den passenden Befehl. Alternativ direkt aus den Kapiteldateien: @@ -325,8 +413,10 @@ pandoc output/programmierbuch/chapters/*.md \ - Ausgaben mit `--math tex` (`\(…\)`) brauchen `-f markdown+tex_math_single_backslash`. - Das YAML-Frontmatter jeder Kapiteldatei wird von Pandoc als Metadaten gelesen. Wer die Kapiteldateien direkt zusammen verarbeitet, sollte mit `--no-frontmatter` konvertieren oder die Gesamtdatei verwenden. -- Einzelne TeX-Befehle aus EPUBs (z. B. `\O` oder `@`-Konstrukte) kennt Pandocs Formelparser nicht. Er - belässt sie als TeX, mit der Warnung „Could not convert TeX math“. +- TeX aus dem E-Book wird für Pandocs Formelparser angepasst, ohne den Inhalt zu ändern: `\"u` → `ü`, + `\O` (leere Menge) → `\emptyset`, keine `@{…}`-Spaltentrenner und kein `\arraycolsep` in `array`, kein + leeres `\displaystyle` vor einem Zellenende. Seltene Handkonstruktionen (z. B. `\mathinner{…\raise…}`) + bleiben als TeX stehen, mit der Warnung „Could not convert TeX math“ (Mathebuch: 1 von 4986 Formeln). ## Bericht und Diagnose @@ -370,7 +460,12 @@ Verlustfreiheit behauptet (`lossless: false`). - Audio/Video sind implementiert, aber nur mit synthetischen Daten geprüft. Bildbände sind mit einem 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 CSS nicht ausgewertet wird. + ü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 @@ -386,6 +481,7 @@ epub2md/ ├── chapters.py Kapitelbildung (Spine + Überschriften + epub:type), Klassifikation ├── references.py Ziel-IDs → Kapiteldatei + Anker, Link-Auflösung ├── html_converter.py rekursiver XHTML→Markdown-Renderer (Blöcke, Inline, Tabellen, Listen, Bilder) +├── css.py Mini-CSS-Auswertung (Selektoren, Spezifität) für Hervorhebungen und Stile ├── codeblocks.py Codeerkennung, exakte Textextraktion, Spracherkennung, Fences ├── mathml.py konservative MathML→TeX-Konvertierung, TeX-Erkennung ├── media.py Medienregistry mit stabilen Namen @@ -395,6 +491,12 @@ epub2md/ ├── diagnostics.py zentrale Sammlung von Warnungen/Fehlern ├── 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) ``` Statt BeautifulSoup wird direkt lxml verwendet: Es ist für die XHTML-Bäume ausreichend, schneller und @@ -412,7 +514,14 @@ ruff check epub2md tests && mypy epub2md --ignore-missing-imports # optional, Die Tests erzeugen synthetische Minimal-EPUBs (EPUB 2/3, NAV/NCX, Code, Tabellen, MathML, SVG, Fußnoten, Glossar/Literatur/Register, unsichere ZIP-Pfade, DRM, XXE, defekte Container, Kodierung) und prüfen die tatsächliche CLI-Ausgabe. `tests/test_merge.py` prüft die Gesamtdatei: Reihenfolge, -Anker-Umbenennung, Pfade, Fußnoten, unveränderter Code und Überschreibschutz. Liegen EPUBs unter `Beispiele/`, werden sie zusätzlich auf +Anker-Umbenennung, Pfade, Fußnoten, unveränderter Code, Überschreibschutz, Datumsformat und die Stelle des +Inhaltsverzeichnisses hinter dem Vorspann. `tests/test_styles.py` prüft die Typografie: CSS-Hervorhebungen, +unsichtbare Zeichen, Szenenwechsel, Gedichte, Infokästen, `--styles`, `--page-markers` und die Pandoc-Befehle +in der Buch-README. Liegen EPUBs unter `Beispiele/`, werden sie zusätzlich auf Struktur-Invarianten geprüft: keine kaputten Links, balancierte Code-Fences, gültiges YAML, alle Medien vorhanden. Dabei werden keine Buchtexte verglichen. `Beispiele/` ist per `.gitignore` vom Repository -ausgeschlossen. +ausgeschlossen.`tests/test_pandoc.py` prüft den Pandoc-Weg (nur mit installiertem Pandoc). + +Für gründlichere Prüfungen nach `Prompt_Kontrolle.md` gibt es `tools/qa/`: eine CLI-Matrix über alle Optionen +und Fehlercodes, einen unabhängigen Markdown-Validator, einen Wortvergleich gegen Datenverlust und die +Prüfung des Pandoc-Wegs (HTML, EPUB, PDF) für alle Beispielbücher. Details in `tools/qa/README.md`. diff --git a/epub2md/__init__.py b/epub2md/__init__.py index 9aada3e..292d78c 100644 --- a/epub2md/__init__.py +++ b/epub2md/__init__.py @@ -1,4 +1,4 @@ """epub2md – konvertiert DRM-freie EPUB-Dateien in kapitelweise Markdown-Sammlungen.""" -__version__ = "0.1.0" +__version__ = "0.3.0" TOOL_NAME = "epub2md" diff --git a/epub2md/chapters.py b/epub2md/chapters.py index 5845852..8716c3b 100644 --- a/epub2md/chapters.py +++ b/epub2md/chapters.py @@ -9,7 +9,17 @@ from typing import Any from .diagnostics import Diagnostics from .models import Chapter, NavEntry from .normalize import Doc, has_content -from .utils import classes, epub_types, is_element, iter_elements, local, norm_ws, roles, text_content +from .utils import ( + classes, + epub_types, + is_element, + iter_elements, + local, + norm_ws, + remove_invisible, + roles, + text_content, +) HEADINGS = {"h1": 1, "h2": 2, "h3": 3, "h4": 4, "h5": 5, "h6": 6} CONTAINERS = {"body", "div", "section", "article", "aside", "main", "header", "footer", "nav", "span", "hgroup"} @@ -35,8 +45,8 @@ KIND_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ("appendix", re.compile(r"^(anhang|appendix)", re.I)), ("colophon", re.compile(r"^(impressum|colophon|kolophon)", re.I)), ("dedication", re.compile(r"^(widmung|dedication)", re.I)), - ("cover", re.compile(r"^(cover|umschlag|titelbild)\b", re.I)), - ("titlepage", re.compile(r"^(titelei|titelseite|title ?page)", re.I)), + ("cover", re.compile(r"^(cover|umschlag|titelbild|deckblatt)\b", re.I)), + ("titlepage", re.compile(r"^(titelei|titelseite|title ?page|titel\s*$|title\s*$)", re.I)), ("part", re.compile(r"^(teil|part)\s+([IVXLC]+|\d+|[a-zäöü]+)\b", re.I)), ("epilogue", re.compile(r"^(epilog|nachwort|epilogue|afterword)", re.I)), ("prologue", re.compile(r"^(prolog|prologue)", re.I)), @@ -95,9 +105,11 @@ def semantic_types(el: Any) -> list[str]: class ChapterBuilder: def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics, - book_title: str = "", authors: list[str] | None = None) -> None: + book_title: str = "", authors: list[str] | None = None, deep_starts: set[Any] | None = None) -> None: self.level = chapter_level self.diag = diag + self.deep_starts = deep_starts or set() # chapter headings whose sections are split one level deeper + self._deep = False # current chapter is one of them self._special_level = 0 # level of a split-out special heading whose section is still open self._in_special = False # current chapter itself is a bibliography/index/glossary self.book_title = book_title @@ -119,6 +131,12 @@ class ChapterBuilder: if lvl and has_content(el): special = _special_heading(el) if lvl <= self.level: + if update: + self._special_level = 0 + self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS + self._deep = el in self.deep_starts + return Marker(el, "heading", lvl) + if self._deep and lvl == self.level + 1: if update: self._special_level = 0 self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS @@ -186,7 +204,7 @@ class ChapterBuilder: body_segments = segments[1:] if _meaningful(head_items): first = _first_meaningful(head_items) - if chapters and leading_heading_level(first) > self.level and not semantic_types(doc.body): + if chapters and leading_heading_level(first) > self.level + self._deep and not semantic_types(doc.body): chapters[-1].items.extend(pending + head_items) pending = [] if doc.path not in chapters[-1].docs: @@ -270,6 +288,7 @@ class ChapterBuilder: if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]): types.append("auxiliary") ch.types = list(dict.fromkeys(types)) + ch.title = remove_invisible(ch.title)[0] # titles feed file names, frontmatter and README return ch def _title_from_content(self, items: list[tuple[str, Any]], number: int) -> tuple[str, str]: @@ -296,12 +315,21 @@ class ChapterBuilder: kind = classify_title(ch.title) if kind and kind not in ch.types: ch.types.append(kind) + if ch.number == 1 and "cover" not in ch.types and _image_only(ch.items): + ch.types.append("cover") # first chapter with nothing but a picture, whatever its label ('[Titelinfo]') if "index" in ch.types and classify_title(ch.title) == "index-persons": ch.types.remove("index") if not ch.types: ch.types.append("chapter") +def _image_only(items: list[tuple[str, Any]]) -> bool: + """Content is one or more pictures without any text.""" + text = "".join(it if isinstance(it, str) else text_content(it) for _, it in items if isinstance(it, str) or is_element(it)) + return not text.strip() and any(is_element(it) and any(local(e.tag) in ("img", "svg", "image") for e in iter_elements(it)) + for _, it in items) + + @dataclass class _TypeCarrier: types: list[str] diff --git a/epub2md/cli.py b/epub2md/cli.py index 4679493..8066c45 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -46,6 +46,14 @@ def code_language(value: str) -> str: return lang +def _chapter_level(value: str) -> int | str: + if value == "auto": + return value + if value.isdigit() and 1 <= int(value) <= 6: + return int(value) + raise argparse.ArgumentTypeError("auto oder eine Zahl zwischen 1 und 6 erwartet") + + def build_parser() -> argparse.ArgumentParser: p = _Parser( prog="epub2md.py", @@ -61,9 +69,10 @@ def build_parser() -> argparse.ArgumentParser: help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)") p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False, help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)") - p.add_argument("--chapter-level", type=int, default=1, metavar="N", - help="Überschriftenebene 1–6, ab der neue Kapiteldateien entstehen (Standard: 1). " - "Für Bücher mit Teilen (h1) und Kapiteln (h2) ist 2 sinnvoll") + p.add_argument("--chapter-level", type=_chapter_level, default="auto", metavar="{auto,1-6}", + help="Überschriftenebene, ab der neue Kapiteldateien entstehen. auto (Standard): Ebene 1, " + "nur sehr große Kapitel (über 30000 Wörter, z. B. Teile eines Romans) werden an ihren " + "Unterkapiteln geteilt. Eine Zahl 1–6 gilt ausnahmslos für das ganze Buch") p.add_argument("--toc", action=argparse.BooleanOptionalAction, default=True, help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)") p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True, @@ -85,6 +94,23 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--keep-wrap-markers", action="store_true", help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte " "Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt") + p.add_argument("--boxes", choices=["callout", "quote", "html", "plain"], default="callout", + help="Infokästen (Tipp, Hinweis, Warnung, Exkurs …): callout = '> [!TIP]' (GitHub/Obsidian), " + "quote = Zitatblock mit fettem Titel, html =
, plain = normaler Text " + "(Standard: callout)") + p.add_argument("--styles", choices=["semantic", "html"], default="semantic", + help="semantic: nur Bedeutung übernehmen (Hintergrundmarkierung → ); html: zusätzlich Farbe, " + "Hintergrund, Schriftgröße/-art, Kapitälchen, Ausrichtung und Fremdsprache als /" + " (wo sie vom normalen Text abweichen; GitHub zeigt das nicht an) (Standard: semantic)") + p.add_argument("--page-markers", choices=["none", "visible", "comment"], default="none", + help="Seitenzahlen der Druckausgabe: visible = '[S. 123]' im Text, comment = '' " + "(unsichtbar, durchsuchbar), none = weglassen (Standard: none)") + p.add_argument("--css-emphasis", action=argparse.BooleanOptionalAction, default=True, + help="Fett/kursiv/unterstrichen/hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS " + "setzt; per CSS ausgeblendete Elemente weglassen (Standard: an)") + p.add_argument("--keep-invisible-chars", action="store_true", + help="Nullbreiten-Zeichen und weiche Trennstriche (U+00AD) im Fließtext behalten; Standard: entfernen " + "(stören Suche und Weiterverarbeitung). Code bleibt immer unverändert") p.add_argument("--strict", action="store_true", help="Bei kritischen Fehlern abbrechen: fehlende/unlesbare Spine-Dokumente, nicht dekodierbares " "XHTML, unsichere ZIP-Pfade, übersprungene (verdächtige) Medien") @@ -119,6 +145,11 @@ def main(argv: list[str] | None = None) -> int: keep_html=args.keep_html, code_language=args.code_language or "", keep_wrap_markers=args.keep_wrap_markers, + keep_invisible_chars=args.keep_invisible_chars, + css_emphasis=args.css_emphasis, + boxes=args.boxes, + styles=args.styles, + page_markers=args.page_markers, strict=args.strict, verbose=args.verbose, dry_run=args.dry_run, diff --git a/epub2md/codeblocks.py b/epub2md/codeblocks.py index 0fcc883..7731a9b 100644 --- a/epub2md/codeblocks.py +++ b/epub2md/codeblocks.py @@ -2,8 +2,10 @@ from __future__ import annotations +import ast import json import re +import warnings from typing import Any from .utils import classes, is_element, local @@ -127,9 +129,114 @@ def detect_language(el: Any) -> tuple[str, str]: return "", "" +LINE_NUMBER_PREFIX = re.compile(r"^(\d{1,4})( +|$)") + + +def strip_line_numbers(code: str) -> str: + """Remove printed line numbers ("00 #include …") – for language detection only, the block keeps them. + + Applies when at least 80 % of the non-blank lines (at least two) start with consecutive numbers. + """ + lines = code.split("\n") + filled = [ln for ln in lines if ln.strip()] + matches = [LINE_NUMBER_PREFIX.match(ln) for ln in filled] + numbers = [int(m.group(1)) for m in matches if m] + if len(numbers) < 2 or len(numbers) < 0.8 * len(filled): + return code + if any(b != a + 1 for a, b in zip(numbers, numbers[1:])): + return code + width = min((len(m.group(2)) for m in matches if m and m.group(2)), default=0) + out = [] + for ln in lines: + m = LINE_NUMBER_PREFIX.match(ln) if ln.strip() else None + out.append(ln[len(m.group(1)) + min(width, len(m.group(2))):] if m else ln) + return "\n".join(out) + + +def _python_module(text: str) -> ast.Module | None: + """The parsed module, or None when the text is not valid Python (never executed, warnings suppressed).""" + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return ast.parse(text) + except (SyntaxError, ValueError, RecursionError, MemoryError): + return None + + +PY_COMPOUND = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.If, ast.For, ast.AsyncFor, ast.While, + ast.With, ast.AsyncWith, ast.Try) +PY_CODE_EXPR = (ast.Call, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.Lambda, ast.Await) + + +def _python_strength(text: str) -> int: + """2 = surely Python (block statement, or import plus code), 1 = Python code (assignment, call, + comprehension …), 0 = not Python or only literals/names – which is what program output looks like.""" + if any(ln.rstrip().endswith((";", "{")) for ln in text.split("\n")): + return 0 + module = _python_module(text) + if module is None or not module.body: + return 0 + imports = sum(isinstance(n, (ast.Import, ast.ImportFrom)) for n in module.body) + code = 0 + for node in module.body: + if isinstance(node, PY_COMPOUND): + return 2 + if isinstance(node, ast.Expr): + value = node.value + if isinstance(value, ast.Constant) and isinstance(value.value, str): + continue # docstring + if not isinstance(value, PY_CODE_EXPR): + return 0 # a bare name, number or literal line is program output ("Jane", "42", "[1, 4]") + code += 1 + elif isinstance(node, ast.AnnAssign) and node.value is None: + pass # "name: value" is YAML or a Makefile rule as often as Python + elif not isinstance(node, (ast.Import, ast.ImportFrom, ast.Pass)): + code += 1 + if imports and code: + return 2 + return 1 if code else 0 + + +CURLY_STATEMENT = re.compile(r"[=(]|^\s*[A-Za-z_]\w*[\s*]+[A-Za-z_*]") # assignment/call or "type name" + + +def _curly_signal(text: str) -> bool: + """C-family syntax: a statement or declaration ending with ';' or '{', or a function body on one line.""" + lines = [re.sub(r"(^|\s)//.*$", "", ln).rstrip() for ln in text.split("\n")] + if any(ln.endswith((";", "{")) and CURLY_STATEMENT.search(ln) for ln in lines): + return True + return bool(re.search(r"\w\s*\([^)]*\)\s*\{.*\}", text)) + + +CURLY_LANGS = {"c", "cpp", "java", "csharp", "javascript", "typescript", "go", "rust", "php", "kotlin", "swift", "scala"} + + +def detect_language_from_book(code: str, book_language: str) -> str: + """Weak content signals, accepted only when they fit the book's dominant language.""" + if not book_language: + return "" + text = strip_line_numbers(code).strip() + if not text: + return "" + if book_language == "python" and _python_strength(text) >= 1: + return "python" + if book_language in CURLY_LANGS and _curly_signal(text): + return book_language + return "" + + +def dominant_language(languages: list[str]) -> str: + """The book's language when the reliably detected blocks agree (at least 5 blocks, 90 %).""" + detected = [lang for lang in languages if lang and lang != "text"] + if len(detected) < 5: + return "" + lang = max(set(detected), key=detected.count) + return lang if detected.count(lang) >= 0.9 * len(detected) else "" + + def detect_language_from_content(code: str) -> str: """Very conservative content heuristics – only strong signals.""" - text = code.strip() + text = strip_line_numbers(code).strip() if not text: return "" first = text.split("\n", 1)[0] @@ -167,6 +274,8 @@ def detect_language_from_content(code: str) -> str: c_like = sum(1 for ln in lines if ln.rstrip().endswith((";", "{"))) if py_signals >= 2 and c_like == 0: return "python" + if re.search(r"^>>> \S", text, re.M) or _python_strength(text) == 2: + return "python" if re.match(r"\s*(SELECT|INSERT\s+INTO|CREATE\s+(TABLE|INDEX|VIEW)|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b", text): return "sql" if re.match(r"(?m)^FROM\s+\S+", text) and re.search(r"(?m)^(RUN|CMD|COPY|ENTRYPOINT)\s", text): diff --git a/epub2md/css.py b/epub2md/css.py new file mode 100644 index 0000000..d385d28 --- /dev/null +++ b/epub2md/css.py @@ -0,0 +1,286 @@ +"""Minimal CSS evaluation for formatting that publishers express only through stylesheets. + +Supports type/class/id selectors, descendant (` `) and child (`>`) combinators, specificity and source +order, and `style` attributes. Selectors with pseudo-classes, attribute selectors or sibling combinators +are ignored (conservative: an unknown rule never adds formatting). @-blocks (@media, @font-face, @page …) +are skipped. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +from .utils import classes, local + +COMPOUND = re.compile(r"^(\*|[a-zA-Z][\w-]*)?((?:[.#][\w-]+)*)$") +MONOSPACE = re.compile(r"monospace|courier|consolas|menlo|monaco|lucida console|source code|dejavu sans mono|" + r"liberation mono|fira mono|fira code|inconsolata|andale mono|\bmono\b", re.I) + + +@dataclass +class _Compound: + tag: str | None + classes: frozenset[str] + ids: frozenset[str] + + def matches(self, el: Any) -> bool: + if self.tag and self.tag != "*" and local(el.tag) != self.tag: + return False + if self.ids and (el.get("id") or "").lower() not in self.ids: + return False + return not self.classes or self.classes <= set(classes(el)) + + +@dataclass +class _Rule: + parts: list[tuple[str, _Compound]] # (combinator to the previous part, compound); first combinator is "" + specificity: tuple[int, int, int] + order: int + decls: dict[str, str] + + def matches(self, el: Any) -> bool: + if not self.parts[-1][1].matches(el): + return False + node = el + for i in range(len(self.parts) - 1, 0, -1): + comb = self.parts[i][0] + compound = self.parts[i - 1][1] + node = node.getparent() + if comb == ">": + if node is None or not compound.matches(node): + return False + else: # descendant + while node is not None and not compound.matches(node): + node = node.getparent() + if node is None: + return False + return True + + +def _parse_declarations(text: str) -> dict[str, str]: + decls: dict[str, str] = {} + for part in text.split(";"): + if ":" not in part: + continue + prop, value = part.split(":", 1) + prop = prop.strip().lower() + value = re.sub(r"\s*!important\s*$", "", value.strip(), flags=re.I).strip() + if prop and value: + decls[prop] = value + return decls + + +def _parse_selector(sel: str) -> list[tuple[str, _Compound]] | None: + sel = sel.strip().lower() + if not sel or re.search(r"[:\[\]+~]", sel): + return None + tokens = re.split(r"\s*(>)\s*|\s+", sel) + parts: list[tuple[str, _Compound]] = [] + comb = "" + for tok in tokens: + if tok is None or tok == "": + continue + if tok == ">": + comb = ">" + continue + m = COMPOUND.match(tok) + if not m: + return None + rest = m.group(2) or "" + parts.append((comb if parts else "", _Compound( + tag=m.group(1), + classes=frozenset(re.findall(r"\.([\w-]+)", rest)), + ids=frozenset(re.findall(r"#([\w-]+)", rest)), + ))) + comb = " " + return parts or None + + +def _strip_at_blocks(css: str) -> str: + """Remove @-rules including nested blocks (@media, @font-face, @supports, @page …).""" + out: list[str] = [] + i = 0 + while i < len(css): + if css[i] == "@": + brace = css.find("{", i) + semi = css.find(";", i) + if semi != -1 and (brace == -1 or semi < brace): # @import/@charset …; + i = semi + 1 + continue + if brace == -1: + break + depth, j = 0, brace + while j < len(css): + if css[j] == "{": + depth += 1 + elif css[j] == "}": + depth -= 1 + if depth == 0: + break + j += 1 + i = j + 1 + continue + out.append(css[i]) + i += 1 + return "".join(out) + + +class StyleSheet: + def __init__(self, css_texts: list[str]) -> None: + self.rules: list[_Rule] = [] + self._index: dict[str, list[_Rule]] = {} + self._cache: dict[Any, dict[str, str]] = {} + order = 0 + for css in css_texts: + css = re.sub(r"/\*.*?\*/", "", css, flags=re.S) + css = _strip_at_blocks(css) + for selectors, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css): + decls = _parse_declarations(body) + if not decls: + continue + for sel in selectors.split(","): + parts = _parse_selector(sel) + if not parts: + continue + spec = (sum(len(c.ids) for _, c in parts), sum(len(c.classes) for _, c in parts), + sum(1 for _, c in parts if c.tag and c.tag != "*")) + rule = _Rule(parts, spec, order, decls) + order += 1 + self.rules.append(rule) + last = parts[-1][1] + keys = [f".{c}" for c in last.classes] or ([last.tag] if last.tag and last.tag != "*" else ["*"]) + for key in keys[:1]: # one index entry suffices: every key must match anyway + self._index.setdefault(key, []).append(rule) + + def computed(self, el: Any) -> dict[str, str]: + """Declared properties for ``el`` (no inheritance), including its ``style`` attribute.""" + cached = self._cache.get(el) + if cached is not None: + return cached + candidates: list[_Rule] = [] + for key in [f".{c}" for c in classes(el)] + [local(el.tag), "*"]: + candidates.extend(self._index.get(key, ())) + result: dict[str, str] = {} + for rule in sorted({id(r): r for r in candidates}.values(), key=lambda r: (r.specificity, r.order)): + if rule.matches(el): + result.update(rule.decls) + if el.get("style"): + result.update(_parse_declarations(el.get("style"))) + self._cache[el] = result + return result + + +def inline_style(el: Any) -> dict[str, str]: + """Only the ``style`` attribute (used when no stylesheet is available).""" + return _parse_declarations(el.get("style") or "") + + +def emphasis(style: dict[str, str]) -> dict[str, bool]: + """Semantic formatting expressed by a computed style.""" + weight = style.get("font-weight", "").lower() + deco = (style.get("text-decoration-line", "") + " " + style.get("text-decoration", "")).lower() + valign = style.get("vertical-align", "").lower() + return { + "bold": weight in ("bold", "bolder") or (weight.isdigit() and int(weight) >= 600), + "italic": style.get("font-style", "").lower() in ("italic", "oblique"), + "underline": "underline" in deco, + "strike": "line-through" in deco, + "sup": valign == "super", + "sub": valign == "sub", + "mono": bool(MONOSPACE.search(style.get("font-family", ""))), + "hidden": style.get("display", "").lower() == "none", + } + + +# ---------------------------------------------------------------------------- visual styles (--styles html) + +STYLE_PROPS = ("color", "background-color", "font-size", "font-family", "font-variant", "text-align") +UNSAFE_VALUE = re.compile(r"url\(|expression|javascript|[;<>{}\\]", re.I) +TRIVIAL_BG = re.compile(r"^(transparent|none|inherit|initial|unset|white|#fff|#ffffff|rgba?\(\s*255\s*,\s*255\s*,\s*255.*)$") +COLOR_TOKEN = re.compile(r"(#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|\b[a-z]+\b)", re.I) +KEYWORD_SIZES = {"xx-small": 0.6, "x-small": 0.75, "small": 0.89, "medium": 1.0, "large": 1.2, "x-large": 1.5, + "xx-large": 2.0, "smaller": 0.83, "larger": 1.2} + + +def _font_size_em(value: str) -> float | None: + v = value.strip().lower() + if v in KEYWORD_SIZES: + return KEYWORD_SIZES[v] + m = re.fullmatch(r"([\d.]+)\s*(em|rem|%|px|pt)?", v) + if not m: + return None + n = float(m.group(1)) + return {"em": n, "rem": n, "%": n / 100, "px": n / 16, "pt": n / 12, None: None}.get(m.group(2), None) + + +def _background_color(style: dict[str, str]) -> str: + bg = style.get("background-color", "") + if not bg and style.get("background"): + for tok in COLOR_TOKEN.findall(style["background"]): + if tok.lower() not in ("none", "repeat", "no", "fixed", "scroll", "top", "left", "center", "right", + "bottom", "url"): + bg = tok + break + bg = bg.strip() + return "" if not bg or TRIVIAL_BG.match(bg.lower()) else bg + + +def _family(value: str) -> str: + parts = [p.strip().strip("'\"") for p in value.split(",") if p.strip()] + return ", ".join(f"'{p}'" if " " in p else p for p in parts) + + +def _is_black(value: str) -> bool: + return value.strip().lower() in ("black", "#000", "#000000", "rgb(0,0,0)", "rgb(0, 0, 0)", "windowtext") + + +def has_highlight(style: dict[str, str]) -> bool: + return bool(_background_color(style)) + + +def base_style(stylesheet: StyleSheet, paragraphs: list[Any]) -> dict[str, str]: + """Most frequent value per visual property over the book's paragraphs (the 'normal' text style).""" + counters: dict[str, dict[str, int]] = {p: {} for p in STYLE_PROPS} + for el in paragraphs: + style = stylesheet.computed(el) + for prop in STYLE_PROPS: + value = _background_color(style) if prop == "background-color" else style.get(prop, "") + counters[prop][value] = counters[prop].get(value, 0) + 1 + return {p: max(c, key=lambda v: c[v]) for p, c in counters.items() if c} + + +def style_props(style: dict[str, str], base: dict[str, str], block: bool = False) -> list[tuple[str, str]]: + """Visual properties of ``style`` that differ from the book's base text style (sanitized).""" + out: list[tuple[str, str]] = [] + for prop in STYLE_PROPS: + if prop == "text-align" and not block: + continue + value = _background_color(style) if prop == "background-color" else style.get(prop, "").strip() + if not value or UNSAFE_VALUE.search(value) or value.lower() in ("inherit", "initial", "unset", "currentcolor"): + continue + reference = base.get(prop, "") + if prop == "color" and not reference: + reference = "black" # no colour declared = default text colour + if prop == "color" and _is_black(value) and _is_black(reference): + continue + if prop == "font-size": + size, ref = _font_size_em(value), _font_size_em(reference) if reference else 1.0 + if size is None or ref is None or abs(size / (ref or 1.0) - 1) < 0.15: + continue + elif prop == "font-family": + if _family(value).lower() == _family(reference).lower(): + continue + value = _family(value) + elif prop == "font-variant": + if "small-caps" not in value.lower() or "small-caps" in reference.lower(): + continue + value = "small-caps" + elif prop == "text-align": + if value.lower() not in ("center", "right", "end") or value.lower() == reference.lower(): + continue + elif value.lower() == reference.lower(): + continue + out.append((prop, value)) + return out diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index 68c0c4d..b8de5ad 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -9,6 +9,7 @@ from __future__ import annotations import html import re +from collections import Counter from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Any @@ -17,9 +18,11 @@ from urllib.parse import unquote from lxml import etree from .chapters import IdCarrier, Marker, heading_level +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, @@ -33,6 +36,7 @@ from .footnotes import NoteIndex from .mathml import Unsupported, is_display_tex, looks_like_tex, mathml_to_tex, strip_tex_delimiters from .media import MediaRegistry from .models import Chapter, Note +from .normalize import PAGE_ATTR from .references import LinkResolver from .utils import ( MATHML_NS, @@ -45,7 +49,9 @@ from .utils import ( local, norm_ws, rel_link, + remove_invisible, resolve_href, + roles, text_content, url_path, ) @@ -93,6 +99,11 @@ class RenderOptions: language: str = "de" code_language: str = "" # --code-language: label for blocks without a detected language keep_wrap_markers: bool = False # --keep-wrap-markers: do not join ↪-continued code lines + keep_invisible_chars: bool = False # --keep-invisible-chars: keep zero-width chars and soft hyphens + boxes: str = "callout" # --boxes {callout,quote,html,plain} + styles: str = "semantic" # --styles {semantic,html} + page_markers: str = "none" # --page-markers {none,visible,comment}: print page numbers + css_emphasis: bool = True # --no-css-emphasis: ignore bold/italic/… that only the stylesheet expresses def md_escape(text: str, table: bool = False) -> str: @@ -124,9 +135,23 @@ def escape_line_starts(text: str) -> str: return "\n".join(out) +EMPH_SEP = "\x1f" # separates open marker, content and close marker inside an ("e", …) piece + + def join_inline(pieces: list[Piece], hard_break: str = "\\\n") -> str: out = "" + last_e: tuple[int, str, str, str, int] | None = None # (start, open, core, close, end) of the last emphasis for kind, s in pieces: + if kind == "e": + open_, core, close = s.split(EMPH_SEP) + if last_e and last_e[4] == len(out) and last_e[1] == open_ and last_e[3] == close: + # directly adjacent identical emphasis (xy): merge, "*x**y*" would break + out = out[: last_e[0]] + core = last_e[2] + core + start = len(out) + out += open_ + core + close + last_e = (start, open_, core, close, len(out)) + continue if kind == "t": s = WS.sub(" ", s) if not out or out.endswith((" ", "\n")): @@ -176,6 +201,49 @@ def _collapse_blank_lines(text: str) -> str: return "\n".join(out) +CONSUMED = "data-e2m-consumed" +BOX_TAGS = {"div", "section", "aside", "p"} +BOX_CLASS = re.compile(r"(?:^|[-_])(box[a-z]?|kasten|infobox|infokasten|tipps?|tip|hint|hinweis|notiz|warn|warning|" + r"warnung|achtung|caution|vorsicht|important|wichtig|merke|merksatz|exkurs|sidebar|callout|" + r"alert)(?:[-_\d]|$)") +CALLOUT = {"note": "NOTE", "tip": "TIP", "important": "IMPORTANT", "warning": "WARNING", "caution": "CAUTION"} +TYPE_WORDS = {"tipp", "tip", "tipps", "hinweis", "note", "notiz", "info", "achtung", "warnung", "warning", + "wichtig", "important", "vorsicht", "caution", "gefahr", "merke"} + + +def _box_type(hint: str) -> str: + """Callout type from class names, epub:type, a title or the first words of the box.""" + h = hint.lower() + if re.search(r"vorsicht|caution|gefahr|danger", h): + return "caution" + if re.search(r"warn|achtung|attention", h): + return "warning" + if re.search(r"wichtig|important|merke", h): + return "important" + if re.search(r"tipp|\btip|hint", h): + return "tip" + if re.search(r"hinweis|\bnote|notiz|anmerkung|\binfo", h): + return "note" + return "" + + +ORNAMENT = re.compile(r"(?:[*∗⁂❖✻✱✳◆◇•·~][ \t]*){1,9}") +POEM_CLASS = re.compile(r"(?:^|[-_])(poem|poetry|verse|vers|lyrics|stanza|gedicht|lied|song)(?:$|[-_\d])") +ITALIC_TAGS = {"em", "i", "cite", "dfn", "var"} +BOLD_TAGS = {"strong", "b"} +CSS_SKIP_TAGS = {"br", "wbr", "img", "svg", "image", "math", "code", "samp", "tt", "kbd", "pre"} + + +def _style_attr(props: list[tuple[str, str]]) -> str: + return html.escape("; ".join(f"{k}: {v}" for k, v in props), quote=False).replace('"', "'") + + +def _wrap_text(text: str, marker: str) -> str: + """Wrap a finished paragraph in emphasis markers (whitespace stays outside).""" + core = text.strip() + return f"{marker}{core}{marker}" if core else text + + _BLOCK_SYNTAX = ("```", "~~~", "|", " ", "#", "$$", "\\[", "* * *") @@ -229,8 +297,20 @@ def serialize_mathml(math: Any) -> str: class Renderer: def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry, notes: NoteIndex, diag: Diagnostics, zip_names: set[str], - entry_problem: Callable[[str], str | None] | None = None) -> None: + entry_problem: Callable[[str], str | None] | None = None, + stylesheet: StyleSheet | None = None, base_style: dict[str, str] | None = None) -> None: + self.base_style = base_style or {} self.entry_problem = entry_problem or (lambda path: None) + 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 + self.css_applied: Counter[str] = Counter() + self.hidden_elements = 0 + self.invisible_removed = 0 self.opt = options self.zip_names = zip_names self.in_table_html = False @@ -244,6 +324,7 @@ class Renderer: self.consumed_formula_images: set[str] = set() self.formulas: list[dict[str, str]] = [] self.code_blocks: list[dict[str, Any]] = [] + self.book_code_language = "" # dominant language of the reliably detected blocks (pre-scan) self.endnotes_file = "" # per chapter state self.ch: Chapter | None = None @@ -293,13 +374,13 @@ class Renderer: if doc: self.doc = doc if isinstance(item, str): - buf.append(("t", md_escape(item, self.in_table))) + buf.append(("t", self._text(item))) elif isinstance(item, IdCarrier): anchor = self.resolver.anchor_for(self.doc, item.element) if anchor: buf.append(("r", self._anchor(anchor))) - elif isinstance(item, Marker) or not is_element(item): - continue + elif isinstance(item, Marker) or not is_element(item) or item.get(CONSUMED): + continue # markers, comments, siblings already rendered as part of a box elif self.is_block(item): flush() blocks.extend(self.block(item)) @@ -332,6 +413,19 @@ class Renderer: def _anchor(self, name: str) -> str: return f'' + def page_marker(self, el: Any) -> str: + """Visible '[S. 12]' or '' for a print page-break element (--page-markers).""" + page = el.get(PAGE_ATTR) + if not page or self.opt.page_markers == "none": + 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 + def anchors_of(self, el: Any, subtree: bool = False) -> str: nodes = iter_elements(el) if subtree else [el] names = [] @@ -361,6 +455,19 @@ class Renderer: def block(self, el: Any) -> list[str]: name = local(el.tag) + if self._hidden(el): + return [] + if name in ("p", "div") and not self.in_table: + if self._is_ornament(el) or self._is_scene_gap(el): + self.scene_breaks += 1 + anchor = self.anchors_of(el, subtree=True) + return ([anchor] if anchor else []) + ["* * *"] + if name in ("div", "section", "blockquote") and self._is_poem(el): + return self.poem(el) + if self.opt.boxes != "plain" and not self.in_box and not self.in_table and name in BOX_TAGS: + kind = self._box_kind(el) + if kind is not None: + return self.box(el, kind) ch = self.ch if ch is not None and el is ch.title_el and not heading_level(el): return self.heading(el, forced_level=1) @@ -395,15 +502,238 @@ class Renderer: return [self.clean_html(el, keep_root=True)] return self._flow_with_anchor(el) + # ------------------------------------------------------------------ scene breaks and verse (P4) + + @staticmethod + def _is_empty(el: Any) -> bool: + return not text_content(el).replace("\xa0", " ").strip() and \ + not any(local(e.tag) in ("img", "svg", "math", "image", "hr") for e in iter_elements(el)) + + def _is_blank_p(self, el: Any) -> bool: + """

with nothing but (non-breaking) spaces or
. Empty

s are layout helpers (Springer's + div.ClearBoth) and paragraphs holding only anchors are index targets – neither is a scene break.""" + return local(el.tag) == "p" and self._is_empty(el) and \ + all(local(c.tag) == "br" for c in el if is_element(c)) + + def _is_prose(self, el: Any) -> bool: + return local(el.tag) in ("p", "div") and not self._is_empty(el) and not self._is_ornament(el) \ + and not self._is_poem(el) + + @staticmethod + def _element_siblings(el: Any, forward: bool) -> Any: + node = el.getnext() if forward else el.getprevious() + while node is not None: + if is_element(node): + yield node + node = node.getnext() if forward else node.getprevious() + + def _is_ornament(self, el: Any) -> bool: + """Paragraph that only contains a typographic separator such as *** or ⁂.""" + return bool(ORNAMENT.fullmatch(text_content(el).replace("\xa0", " ").strip())) + + def _is_scene_gap(self, el: Any) -> bool: + """Empty paragraph between two prose paragraphs (first of a run of empties) → scene break.""" + if not self._is_blank_p(el): + return False + prev = next(self._element_siblings(el, forward=False), None) + if prev is None or not self._is_prose(prev): + return False + for nxt in self._element_siblings(el, forward=True): + if self._is_blank_p(nxt): + continue # further empty paragraphs of the same gap + return self._is_prose(nxt) + return False + + def _is_poem(self, el: Any) -> bool: + types = set(epub_types(el)) + return bool(types & {"z3998:poem", "z3998:verse", "z3998:song", "z3998:lyrics"}) or \ + any(POEM_CLASS.search(c) for c in classes(el)) + + def poem(self, el: Any) -> list[str]: + """Verse: one quoted line per verse line, stanzas separated by an empty quote line.""" + anchor = self.anchors_of(el) + stanzas: list[list[str]] = [[]] + + def new_stanza() -> None: + if stanzas[-1]: + stanzas.append([]) + + def walk(container: Any) -> None: + for child in container: + if not is_element(child) or self._hidden(child): + continue + if local(child.tag) in ("div", "section") and any( + is_element(c) and local(c.tag) in ("p", "div", "span") for c in child): + new_stanza() # stanza container + walk(child) + new_stanza() + elif self._is_empty(child): + new_stanza() + else: + line = join_inline([("r", self.anchors_of(child))] + self.inline_children(child)) + stanzas[-1].append(escape_line_starts(line).replace("\n", "\n> ")) + + walk(el) + stanzas = [s for s in stanzas if s] + if not stanzas: + return self._flow_with_anchor(el) + self.poems += 1 + text = "\n>\n".join("\\\n".join(f"> {line}" for line in stanza) for stanza in stanzas) + return ([anchor] if anchor else []) + [text] + + # ------------------------------------------------------------------ info boxes (P2) + + def _box_kind(self, el: Any) -> str | None: + """'' for a box without type hint, a type keyword, or None if ``el`` is no box.""" + name = local(el.tag) + types = set(epub_types(el)) | {r.removeprefix("doc-") for r in roles(el)} + hint = " ".join(sorted(types)) + " " + " ".join(classes(el)) + semantic = types & {"sidebar", "tip", "notice", "warning", "caution", "important", "help", "example"} + by_class = any(BOX_CLASS.search(c) for c in classes(el)) + if name == "p": + return _box_type(hint) if by_class and not self._is_empty(el) else None + if not (semantic or by_class or self._css_framed(el)): + return None + if not text_content(el).strip() or any(local(e.tag) == "pre" for e in iter_elements(el)) \ + and not any(local(e.tag) == "p" and text_content(e).strip() for e in iter_elements(el)): + return None # bordered code listings or image frames are no info boxes + if self.ch is not None and any(e is self.ch.title_el for e in iter_elements(el)): + return None # never swallow the chapter heading + return _box_type(hint) + + def _css_framed(self, el: Any) -> bool: + """Block with a visible border or background in the book's CSS, holding short prose.""" + if self.css is None or local(el.tag) not in ("div", "section", "aside"): + return False + style = self.css.computed(el) + border = any(k.startswith("border") and not re.search(r"\bnone\b|^0(px)?\b|hidden", v) + for k, v in style.items() if k in ("border", "border-top", "border-left", "border-style", + "border-width", "border-bottom", "border-right")) + bg = style.get("background-color") or style.get("background") or "" + background = bool(bg) and not re.search(r"transparent|none|inherit|initial|^#?fff(fff)?$|white", bg.lower()) + if not (border or background): + return False + text = norm_ws(text_content(el)) + return 0 < len(text) <= 3000 and any(local(e.tag) == "p" for e in iter_elements(el)) \ + and not any(heading_level(e) in (1, 2) for e in iter_elements(el)) + + def box(self, el: Any, kind: str) -> list[str]: + anchor = self.anchors_of(el) + members = [el] + if local(el.tag) == "p": # paragraph-style boxes: group following siblings of the same kind + for sib in self._element_siblings(el, forward=True): + if local(sib.tag) == "p" and any(BOX_CLASS.search(c) for c in classes(sib)) \ + and _box_type(" ".join(classes(sib))) == kind: + members.append(sib) + else: + 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 + title = join_inline(pieces, hard_break=" ").strip() + kind = kind or _box_type(norm_ws(text_content(title_el))) + saved = self.in_box + self.in_box = True + try: + blocks: list[str] = [] + for member in members: + if member is title_el: + continue + if local(member.tag) == "p": + blocks.extend(self.flow(member)) + else: + blocks.extend(self.render_items( + [(None, x) for x in self._items_without(member, title_el) if not self._box_icon(x)])) + finally: + self.in_box = saved + for sib in members[1:]: + sib.set(CONSUMED, "1") + if not kind and blocks: + kind = _box_type(norm_ws(re.sub(r"[*_\\]", "", blocks[0]))[:30]) + kind = kind or "note" + 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) + head = [f"**{title}**"] if title else [] + if self.opt.boxes == "html": + out.append(f'
\n\n' + "\n\n".join(head + blocks) + "\n\n
") + return out + body = "\n\n".join(head + blocks) + if self.opt.boxes == "callout": + body = f"[!{CALLOUT[kind]}]\n" + body + out.append("\n".join("> " + line if line.strip() else ">" for line in body.split("\n"))) + return out + + def _box_title(self, members: list[Any]) -> Any: + first = members[0] + if local(first.tag) == "p": + text = norm_ws(text_content(first)) + return first if len(members) > 1 and 0 < len(text) <= 40 and not text.endswith((".", "!", "?")) else None + for child in first: + if not is_element(child) or self._box_icon(child): + continue + if heading_level(child) or any(re.search(r"heading|title|head$|ueberschrift", c) for c in classes(child)): + return child + return None + return None + + @staticmethod + def _title_symbol(node: Any) -> bool: + """Decorative symbol in a box title, e.g. [»].""" + text = norm_ws(text_content(node)) + return any(re.search(r"icon|symbol", c) for c in classes(node)) and len(text) <= 5 \ + and not re.search(r"\w", text) + + @staticmethod + def _box_icon(node: Any) -> bool: + """Decorative icon container inside a box (e.g.
Image
).""" + if not is_element(node) or text_content(node).strip(): + return False + imgs = [e for e in iter_elements(node) if local(e.tag) == "img"] + return bool(imgs) and (any(re.search(r"icon|box_?img|symbol", c) for c in classes(node)) + or all((i.get("alt") or "").strip().lower() in ("", "image", "icon", "bild") for i in imgs)) + def _flow_with_anchor(self, el: Any) -> list[str]: anchor = self.anchors_of(el) if self.ch is not None and {"index", "index-persons", "glossary"} & set(self.ch.types): special = self._index_block(el, anchor) if special is not None: return special - blocks = self.flow(el) + italic = bold = False + if self.opt.css_emphasis and local(el.tag) in ("p", "div") and not self.in_heading \ + and not any(local(d.tag) in BLOCK_TAGS for d in iter_elements(el) if d is not el) \ + and text_content(el).strip(): + e = self._emphasis(el) + italic, bold = e["italic"] and not self.em, e["bold"] and not self.strong + self.em += italic + self.strong += bold + try: + blocks = self.flow(el) + finally: + self.em -= italic + self.strong -= bold + if (italic or bold) and len(blocks) == 1 and not _is_block_syntax(blocks[0]) and not blocks[0].startswith("<"): + for flag, marker, key in ((italic, "*", "italic"), (bold, "**", "bold")): + if flag: + blocks[0] = _wrap_text(blocks[0], marker) + self.css_applied[key] += 1 + blocks = self._block_styles(el, blocks) if anchor: - if blocks and not blocks[0].startswith(("#", "`", "|", " ", "- ", "$$", "\\[", "* * *")) \ + if blocks and not blocks[0].startswith(("#", "`", "|", " ", "- ", "$$", "\\[", "* * *")) \ and not re.match(r"\d+\. ", blocks[0]): blocks[0] = anchor + blocks[0] else: @@ -440,13 +770,14 @@ class Renderer: level = heading_level(el) - (ch.shift if ch else 0) level = max(self.min_level, min(6, level)) anchors = self.anchors_of(el, subtree=True) + markers = "".join(self.page_marker(e) for e in iter_elements(el)) self.in_heading = True try: text = join_inline(self.inline_children(el), hard_break=" ") finally: self.in_heading = False text = re.sub(r"", "", text).strip() - out = [] + out = [markers] if markers else [] if anchors: out.append(anchors) if not text: @@ -470,6 +801,9 @@ class Renderer: if not lang: lang = detect_language_from_content(code) how = "content" if lang else "" + if not lang: + lang = detect_language_from_book(code, self.book_code_language) + how = "book-context" if lang else "" if not lang and self.opt.code_language: lang, how = self.opt.code_language, "option" if self.ch: @@ -755,24 +1089,125 @@ class Renderer: def inline_children(self, el: Any) -> list[Piece]: pieces: list[Piece] = [] if el.text: - pieces.append(("t", md_escape(el.text, self.in_table))) + pieces.append(("t", self._text(el.text))) for child in el: if is_element(child): pieces.extend(self.inline(child)) if child.tail: - pieces.append(("t", md_escape(child.tail, self.in_table))) + pieces.append(("t", self._text(child.tail))) return pieces + def _emphasis(self, el: Any) -> dict[str, bool]: + style = self.css.computed(el) if self.css is not None else inline_style(el) + return emphasis(style) + + def _hidden(self, el: Any) -> bool: + """display:none in the book's CSS: readers do not show the element, so neither do we.""" + if not self.opt.css_emphasis or not is_element(el) or not self._emphasis(el)["hidden"]: + return False + self.hidden_elements += 1 + return True + def inline(self, el: Any) -> list[Piece]: + """Inline rendering plus formatting that only the stylesheet expresses (P1, P3, P7).""" + name = local(el.tag) + if name in CSS_SKIP_TAGS: + return self._inline_core(el) + if self._hidden(el): + return [] + if self.in_heading or is_inline_code(el) or not text_content(el).strip(): + return self._inline_core(el) + pieces = self._inline_emphasis(el) if self.opt.css_emphasis else self._inline_core(el) + return self._inline_styles(el, pieces) + + def _inline_styles(self, el: Any, pieces: list[Piece]) -> list[Piece]: + """--styles semantic: background highlight → ; --styles html: deviating visual properties and + foreign-language spans as /.""" + if self.css is None or (pieces and pieces[0][0] == "r" and pieces[0][1].startswith("`")): + return pieces # no stylesheet, or rendered as inline code + style = self.css.computed(el) + if self.opt.styles == "html": + props = style_props(style, self.base_style) + if props: + pieces = self._wrap(pieces, f'', "") + self.css_applied["style"] += 1 + lang = self._foreign_lang(el) + if lang: + pieces = self._wrap(pieces, f'', "") + self.css_applied["lang"] += 1 + elif has_highlight(style): + pieces = self._wrap(pieces, "", "") + self.css_applied["mark"] += 1 + return pieces + + def _foreign_lang(self, el: Any) -> str: + lang = (el.get("lang") or el.get("{http://www.w3.org/XML/1998/namespace}lang") or "").strip() + if lang and re.fullmatch(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*", lang) \ + and lang.split("-")[0].lower() != self.opt.language.split("-")[0].lower(): + return lang + return "" + + def _block_styles(self, el: Any, blocks: list[str]) -> list[str]: + """--styles html: paragraph alignment, colour, size, font and language as a wrapping
.""" + if self.opt.styles != "html" or self.css is None or self.in_table or not blocks or local(el.tag) not in ("p", "div") \ + or any(local(d.tag) in BLOCK_TAGS for d in iter_elements(el) if d is not el): + return blocks + if all(b.startswith(("$$", "\\[", "![", ""] + blocks + ["
"] + + def _inline_emphasis(self, el: Any) -> list[Piece]: + name = local(el.tag) + e = self._emphasis(el) + if e["mono"] and name != "a" and re.fullmatch(r"\S+", norm_ws(text_content(el))) \ + and not any(local(d.tag) in ("img", "svg", "math", "a") for d in iter_elements(el) if d is not el): + self.css_applied["code"] += 1 + anchor = self.anchors_of(el) + return ([("r", anchor)] if anchor else []) + [("r", inline_code(norm_ws(text_content(el))))] + has_link = name == "a" or any(local(d.tag) == "a" for d in iter_elements(el)) + italic = e["italic"] and name not in ITALIC_TAGS and not self.em + bold = e["bold"] and name not in BOLD_TAGS and not self.strong + strike_marker = ("~~", "~~") if self.opt.format == "gfm" else ("", "") + wrappers = [ + (e["sup"] and name != "sup", "", "", "sup"), + (e["sub"] and name != "sub", "", "", "sub"), + (e["underline"] and name not in ("u", "ins") and not has_link, "", "", "underline"), + (e["strike"] and name not in ("del", "s", "strike"), strike_marker[0], strike_marker[1], "strike"), + (italic, "*", "*", "italic"), + (bold, "**", "**", "bold"), + ] + self.em += italic + self.strong += bold + try: + pieces = self._inline_core(el) + finally: + self.em -= italic + self.strong -= bold + for flag, open_, close, key in wrappers: + if flag: + pieces = self._wrap(pieces, open_, close) + self.css_applied[key] += 1 + return pieces + + def _inline_core(self, el: Any) -> list[Piece]: name = local(el.tag) anchor = self.anchors_of(el) if name not in ("a",) else "" pre: list[Piece] = [("r", anchor)] if anchor and not self.in_heading else [] + marker = self.page_marker(el) if not self.in_heading else "" + if marker: + pre.insert(0, ("r", marker)) if name == "br": return pre + ([("t", " ")] if self.in_heading else [("br", "")]) if name == "wbr": return pre if name == "a": - return self.link(el) + return pre[:1] + self.link(el) if marker else self.link(el) if name == "img": return pre + self.image(el) if name == "image": @@ -803,8 +1238,8 @@ class Renderer: finally: self.strong -= 1 if name in ("del", "s", "strike"): - marker = ("~~", "~~") if self.opt.format == "gfm" else ("", "") - return pre + self._wrap(self.inline_children(el), marker[0], marker[1]) + del_open, del_close = ("~~", "~~") if self.opt.format == "gfm" else ("", "") + return pre + self._wrap(self.inline_children(el), del_open, del_close) if name in ("u", "ins", "mark", "kbd", "sup", "sub"): if name in ("sup", "sub"): elems = [c for c in el if is_element(c)] @@ -841,7 +1276,7 @@ class Renderer: core = core[: -len(hard)].rstrip() if not core: return [("t", lead or trail)] if (lead or trail) else [] - return [("t", lead), ("r", f"{open_}{core}{close}"), ("t", trail)] + return [("t", lead), ("e", f"{open_}{EMPH_SEP}{core}{EMPH_SEP}{close}"), ("t", trail)] # ------------------------------------------------------------------ links @@ -1046,7 +1481,16 @@ class Renderer: title_part = f' "{title.replace(chr(34), chr(39))}"' if title else "" return [("r", f"![{self._alt(alt)}]({url_path(rel_link(self.file, mf.output))}{title_part})")] + def _text(self, text: str) -> str: + """Escape prose text; zero-width characters and soft hyphens are dropped (P6).""" + if not self.opt.keep_invisible_chars: + text, n = remove_invisible(text) + self.invisible_removed += n + return md_escape(text, self.in_table) + def _alt(self, alt: str) -> str: + if not self.opt.keep_invisible_chars: + alt = remove_invisible(alt)[0] return WS.sub(" ", alt).replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]").strip() def _is_formula_image(self, el: Any, alt: str, path: str) -> bool: @@ -1189,6 +1633,12 @@ class Renderer: return out.strip() def _html(self, el: Any, in_pre: bool, keep: bool = False) -> str: + marker = self.page_marker(el) if not in_pre else "" + if marker.startswith("\\["): + marker = marker.replace("\\", "") # inside HTML no Markdown escapes + return marker + self._html_core(el, in_pre, keep) + + def _html_core(self, el: Any, in_pre: bool, keep: bool = False) -> str: name = local(el.tag) if name == "math": # Markdown math ($…$, \(…\)) is not rendered inside an HTML block; MathML is. @@ -1219,11 +1669,31 @@ class Renderer: if keep and re.fullmatch(r"[a-z][a-z0-9-]*", name): safe = [f'{k.rsplit("}", 1)[-1]}="{html.escape(v)}"' for k, v in el.attrib.items() if re.fullmatch(r"[A-Za-z_:][-A-Za-z0-9_:.]*", k.rsplit("}", 1)[-1]) - and not k.rsplit("}", 1)[-1].lower().startswith("on") and k.rsplit("}", 1)[-1] != "style" + and not k.rsplit("}", 1)[-1].lower().startswith(("on", "data-e2m-")) and k.rsplit("}", 1)[-1] != "style" and not v.strip().lower().startswith("javascript:")] return f"<{name}{(' ' + ' '.join(safe)) if safe else ''}>{content}" if name not in HTML_KEEP_TAGS or name == "span": anchor = self.anchors_of(el) + if self.opt.css_emphasis and content.strip(): + e = self._emphasis(el) + if e["hidden"]: + self.hidden_elements += 1 + return "" + for flag, tag, key in ((e["italic"], "em", "italic"), (e["bold"], "strong", "bold"), + (e["underline"] and "{content}" + self.css_applied[key] += 1 + if self.css is not None and content.strip(): + style = self.css.computed(el) + props = style_props(style, self.base_style) if self.opt.styles == "html" else [] + if props: + content = f'{content}' + self.css_applied["style"] += 1 + elif self.opt.styles != "html" and has_highlight(style): + content = f"{content}" + self.css_applied["mark"] += 1 return anchor + content attrs = [] for k, v in el.attrib.items(): diff --git a/epub2md/mathml.py b/epub2md/mathml.py index 4687944..fdee4d8 100644 --- a/epub2md/mathml.py +++ b/epub2md/mathml.py @@ -365,7 +365,54 @@ def strip_tex_delimiters(text: str) -> str: if t.startswith(o) and t.endswith(c) and len(t) >= len(o) + len(c): t = t[len(o) : len(t) - len(c)] break - return t.strip() + return portable_tex(t.strip()) + + +UMLAUTS = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"} + + +def portable_tex(tex: str) -> str: + """Publisher TeX → TeX that Pandoc's formula parser (texmath, MathJax, KaTeX) understands, still valid LaTeX: + text accents \\"u → ü, \\O (empty set in Springer books) → \\emptyset, no @{…} column separators in + array specifications, no \\arraycolsep assignments, no empty \\displaystyle before a cell end. Only changes + spacing, never content.""" + tex = re.sub(r'\\"(?:\{([aouAOU])\}|([aouAOU]))', lambda m: UMLAUTS[m.group(1) or m.group(2)], tex) + tex = re.sub(r"\\(?:text|mbox|mathrm)\s*\{\s*\\O\s*\}", r"\\emptyset", tex) + tex = re.sub(r"\\O(?![A-Za-z])", r"\\emptyset", tex) + tex = re.sub(r"\\arraycolsep\s*-?\d*\.?\d+\s*(?:pt|em|ex|mm|cm)", "", tex) + tex = re.sub(r"\\displaystyle\s*(?=\\\\|&|\\end\b)", "", tex) # no-op before a cell end; texmath fails + out: list[str] = [] + pos = 0 + for m in re.finditer(r"\\begin\{array\}\{", tex): + if m.start() < pos: + continue + end = _closing_brace(tex, m.end() - 1) + if end < 0: + break + spec = tex[m.end():end] + i = spec.find("@{") + while i >= 0: + close = _closing_brace(spec, i + 1) + if close < 0: + break + spec = spec[:i] + spec[close + 1:] + i = spec.find("@{") + out.append(tex[pos:m.end()] + spec) + pos = end + return "".join(out) + tex[pos:] + + +def _closing_brace(text: str, open_pos: int) -> int: + """Index of the '}' matching the '{' at ``open_pos`` (-1 if unbalanced).""" + depth = 0 + for i in range(open_pos, len(text)): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + return i + return -1 def is_display_tex(text: str) -> bool: diff --git a/epub2md/merge.py b/epub2md/merge.py index cba21ff..3652e4d 100644 --- a/epub2md/merge.py +++ b/epub2md/merge.py @@ -46,6 +46,8 @@ class ChapterFile: body: str = "" start_anchor: str = "" anchor_map: dict[str, str] = field(default_factory=dict) + types: list[str] = field(default_factory=list) + words: int = 0 @dataclass @@ -74,12 +76,13 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]: report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {} if manifest_path.is_file(): manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - entries = [(c["number"], c["title"], c["file"]) for c in manifest.get("chapters", [])] + entries = [(c["number"], c["title"], c["file"], c.get("types") or [], c.get("word_count", 0)) + for c in manifest.get("chapters", [])] else: files = sorted((out_dir / "chapters").glob("*.md")) - entries = [(i + 1, p.stem, f"chapters/{p.name}") for i, p in enumerate(files)] - chapters = [ChapterFile(n, t, rel) for n, t, rel in entries if (out_dir / rel).is_file()] - missing = [rel for _, _, rel in entries if not (out_dir / rel).is_file()] + entries = [(i + 1, p.stem, f"chapters/{p.name}", [], 0) for i, p in enumerate(files)] + chapters = [ChapterFile(n, t, rel, types=list(ty), words=w) for n, t, rel, ty, w in entries if (out_dir / rel).is_file()] + missing = [rel for _, _, rel, _, _ in entries if not (out_dir / rel).is_file()] if missing: raise MergeError(f"Kapiteldateien fehlen: {', '.join(missing[:5])}") if not chapters: @@ -89,6 +92,49 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]: return report, chapters +# Front matter that precedes the table of contents in a printed book (cover, title page, imprint …). +FRONT_TYPES = {"cover", "titlepage", "halftitlepage", "colophon", "copyright-page", "imprint", "dedication", + "epigraph", "frontispiece", "seriespage", "imprimatur"} +TOC_PLACEHOLDER = '
' # pandoc/book.lua puts the table of contents here +TITLE_PLACEHOLDER = '
' # … and Pandoc's title block here (no own title page) + + +# Where the main matter starts: a part, preface, introduction …, a numbered title or a longer text. +MAIN_TYPES = {"part", "preface", "foreword", "introduction", "prologue", "preamble", "appendix", "bodymatter", "toc"} +NUMBERED_TITLE = re.compile(r"^\s*(\d+|[IVXLC]+\.\s|teil\b|kapitel\b|part\b|chapter\b|buch\b|book\b)", re.I) +FRONT_MAX_WORDS = 1000 +FRONT_MAX_CHAPTERS = 12 + + +def front_matter_count(chapters: list[ChapterFile]) -> int: + """Number of leading chapters before the table of contents: cover, title page, imprint and the short + pages between them (series page, edition note, publisher's letter …) up to the start of the main matter.""" + k = 0 + while k < min(len(chapters), FRONT_MAX_CHAPTERS): + ch = chapters[k] + if MAIN_TYPES & set(ch.types) or NUMBERED_TITLE.match(ch.title) or ch.words > FRONT_MAX_WORDS: + break + k += 1 + if k < len(chapters) and any(FRONT_TYPES & set(c.types) for c in chapters[:k]): + return k + n = 0 # no clear start of the main matter: only the unbroken run of typed front matter + while n < len(chapters) and FRONT_TYPES & set(chapters[n].types): + n += 1 + return n + + +ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:[T ][\d:.]+(?:Z|[+-]\d{2}:?\d{2})?)?$") + + +def display_date(value: str, language: str) -> str: + """'2025-01-13T08:00:00+00:00' → '13.01.2025' (German) or '2025-01-13'; other formats unchanged.""" + m = ISO_DATE.match(value.strip()) + if not m: + return value + year, month, day = m.groups() + return f"{day}.{month}.{year}" if language.lower().startswith("de") else f"{year}-{month}-{day}" + + def default_target(out_dir: Path, report: dict) -> Path: name = (report.get("input") or {}).get("name") or (out_dir.name + ".epub") stem = name[:-5] if name.lower().endswith(".epub") else name @@ -142,7 +188,7 @@ def assign_anchors(chapters: list[ChapterFile]) -> int: for m in TOKEN.finditer(seg): if m.group("attr") and m.group("aname") == "id" and m.group("avalue") not in ids: ids.append(m.group("avalue")) - start = stems[ch.rel] + start = "kap-" + stems[ch.rel] # ids must start with a letter (Pandoc's EPUB writer breaks '#039-…') while start in used or start in ids: start = "kapitel-" + start ch.start_anchor = start @@ -225,18 +271,31 @@ def merge(out_dir: Path, target: Path | None = None, *, frontmatter: bool = True ("publisher", "publisher"), ("date", "published"), ("identifier", "identifier"), ("rights", "rights"), ("description", "description")): if md.get(src): - fm[key] = md[src] + fm[key] = display_date(md[src], md.get("language", "")) if key == "date" else md[src] if fm: parts.append("---\n" + to_yaml(fm) + "---") + front = front_matter_count(chapters) + has_title_page = any({"titlepage", "halftitlepage"} & set(ch.types) for ch in chapters[:front]) + cover_first = bool(chapters) and "cover" in chapters[0].types if toc: lines = ["## Inhalt" if (report.get("metadata") or {}).get("language", "de").startswith("de") else "## Contents", ""] lines += [f"{ch.number}. [{_escape_link_text(ch.title)}](#{ch.start_anchor})" for ch in chapters] - parts.append("\n".join(lines)) - for ch in chapters: + toc_block = "\n".join(lines) + else: + toc_block = TOC_PLACEHOLDER + if not has_title_page and not cover_first: + parts.append(TITLE_PLACEHOLDER) + if front == 0: + parts.append(toc_block) + for i, ch in enumerate(chapters): segments = [(is_code, seg if is_code else rewrite_segment(seg, ch)) for is_code, seg in _non_code_segments(ch.body)] body, n = _drop_duplicate_definitions(_join(segments), seen_defs) dropped += n parts.append(f'
\n\n{body.strip()}') + if i == 0 and cover_first and not has_title_page: + parts.append(TITLE_PLACEHOLDER) + if i + 1 == front: + parts.append(toc_block) text = "\n\n".join(parts).rstrip() + "\n" existing = {p.resolve().as_posix() for p in out_dir.rglob("*") if p.is_file()} diff --git a/epub2md/normalize.py b/epub2md/normalize.py index 6152766..78db4e1 100644 --- a/epub2md/normalize.py +++ b/epub2md/normalize.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from collections.abc import Callable from dataclasses import dataclass, field from typing import Any @@ -15,6 +16,9 @@ HEAD_CLASS_RE = re.compile(r"^(?:head|heading|h|ueberschrift|uberschrift)[_-]?(\ TITLE_CLASS_RE = re.compile(r"^(?:title[_-]?document|chapter[_-]?title|document[_-]?number|chaptertitle)$") TOC_TITLE_RE = re.compile(r"^\s*(inhalt|inhaltsverzeichnis|inhaltsübersicht|contents|table of contents|toc)\s*$", re.I) MEDIA_TAGS = {"img", "svg", "math", "audio", "video", "object", "embed", "iframe", "image"} +PAGE_ATTR = "data-e2m-page" # print page number of a page-break marker (rendered with --page-markers) +PAGE_ID_RE = re.compile(r"^(?:page|pg|seite)[_-]?(\d+|[ivxlcdm]+)$", re.I) # id="page_47", +PAGE_ID_SHORT_RE = re.compile(r"^p[_-]?(\d+)$", re.I) # id="p17": only on empty / @dataclass @@ -77,7 +81,8 @@ def book_uses_title_tables(docs: list[Doc]) -> bool: return False -def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None: +def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool, + hidden: Callable[[Any], bool] | None = None) -> None: body = doc.body for el in list(iter_elements(body)): if not _attached(el, body): @@ -88,9 +93,11 @@ def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None: continue types = epub_types(el) if "pagebreak" in types or "doc-pagebreak" in roles(el): - _replace_with_anchor(el) + _replace_with_anchor(el, _pagebreak_label(el)) doc.pagebreaks += 1 continue + if name in ("a", "span") and not el.get("href") and (page := _page_from_id(el)): + el.set(PAGE_ATTR, page) if name == "table": _maybe_heading_table(el, title_tables) elif name in ("p", "div") and len(classes(el)) == 1 and (m := HEAD_CLASS_RE.match(classes(el)[0])): @@ -102,19 +109,19 @@ def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None: doc.toc_ids.update(e.get("id") for e in iter_elements(el) if e.get("id")) remove_keep_tail(el) diag.info("html-toc-removed", "HTML-Inhaltsverzeichnis entfernt (ersetzt durch README.md).", doc.path) - separated = _separate_field_spans(body) + separated = _separate_field_spans(body, hidden or (lambda el: False)) if separated: diag.info("inline-separator-added", f"{separated} Leerzeichen zwischen aneinanderstoßenden Feld-Spans eingefügt.", doc.path) -def _separate_field_spans(body: Any) -> int: +def _separate_field_spans(body: Any, hidden: Callable[[Any], bool]) -> int: """Insert a space between adjacent class-styled "fields" that touch without whitespace. Publishers sometimes lay out metadata as sibling spans (author | title | series) separated only by CSS. Narrow rule to keep drop caps ("D|as") and split words ("Haupt|bahnhof") intact: both spans carry different classes, no text between them, both have >= 2 characters and the right one starts - with an uppercase letter or digit. + with an uppercase letter or digit. Spans hidden by the book's CSS (display:none) get no separator. """ count = 0 for parent in iter_elements(body): @@ -127,7 +134,7 @@ def _separate_field_spans(body: Any) -> int: lt, rt = text_content(left), text_content(right) if len(lt.strip()) < 2 or len(rt.strip()) < 2 or lt[-1].isspace() or rt[0].isspace(): continue - if rt[0].isupper() or rt[0].isdigit(): + if (rt[0].isupper() or rt[0].isdigit()) and not hidden(left) and not hidden(right): left.tail = " " count += 1 return count @@ -142,13 +149,35 @@ def _attached(el: Any, body: Any) -> bool: return False -def _replace_with_anchor(el: Any) -> None: +def _pagebreak_label(el: Any) -> str: + """Page number of an epub:type="pagebreak" element (aria-label, title, text or id).""" + for value in (el.get("aria-label"), el.get("title"), text_content(el)): + value = norm_ws(value or "") + if value and len(value) <= 12: + return re.sub(r"^(?:seite|page|s\.|p\.)\s*", "", value, flags=re.I) + m = PAGE_ID_RE.match(el.get("id") or "") or PAGE_ID_SHORT_RE.match(el.get("id") or "") + return m.group(1) if m else "" + + +def _page_from_id(el: Any) -> str: + el_id = el.get("id") or "" + if m := PAGE_ID_RE.match(el_id): + return m.group(1) + if (m := PAGE_ID_SHORT_RE.match(el_id)) and not norm_ws(text_content(el)) and len(el) == 0: + return m.group(1) + return "" + + +def _replace_with_anchor(el: Any, page: str = "") -> None: ids = [e.get("id") for e in iter_elements(el) if e.get("id")] - if not ids: + if not ids and not page: remove_keep_tail(el) return anchor = _make(el, "a") - anchor.set("id", ids[0]) + if ids: + anchor.set("id", ids[0]) + if page: + anchor.set(PAGE_ATTR, page) anchor.tail = el.tail el.getparent().replace(el, anchor) @@ -177,6 +206,7 @@ def _maybe_heading_table(table: Any, title_tables: bool) -> None: level = max(1, min(6, level)) texts = [] ids = [e.get("id") for e in iter_elements(table) if e.get("id")] + pages = {e.get("id"): page for e in iter_elements(table) if local(e.tag) in ("a", "span") and (page := _page_from_id(e))} for cell in cells: parts = [] for node in cell.iter(): @@ -193,6 +223,8 @@ def _maybe_heading_table(table: Any, title_tables: bool) -> None: for anchor_id in ids: a = _make(table, "a") a.set("id", anchor_id) + if anchor_id in pages: + a.set(PAGE_ATTR, pages[anchor_id]) heading.append(a) if len(heading): heading[-1].tail = " ".join(texts) diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index e5dd9dc..db5eae3 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -8,24 +8,30 @@ import re import shutil import sys import tempfile +from collections.abc import Callable from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any +from urllib.parse import unquote from . import TOOL_NAME, __version__ -from .chapters import ChapterBuilder +from .chapters import SPECIAL_KINDS, ChapterBuilder, heading_level +from .codeblocks import (code_text, detect_language, detect_language_from_content, dominant_language, + is_block_code_container, join_wrap_markers) from .diagnostics import Diagnostics -from .epub_reader import EpubReader, parse_xml +from .css import StyleSheet, base_style, emphasis +from .epub_reader import EpubReader, decode_markup, parse_xml from .errors import ContainerError, InputError, StructureError from .footnotes import collect_notes from .html_converter import RenderOptions, Renderer from .linkcheck import check_links from .media import MediaRegistry from .models import Chapter -from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail +from .normalize import (Doc, book_uses_title_tables, has_content, is_html_toc_doc, make_doc, normalize_doc, + remove_keep_tail) from .references import LinkResolver -from .utils import is_inside, iter_elements, sha256_file, slugify +from .utils import is_element, is_inside, iter_elements, local, resolve_href, sha256_file, slugify, text_content from . import writer MARKER_FILES = ("conversion-report.json", "manifest.json") @@ -37,7 +43,7 @@ class Options: format: str = "gfm" extract_media: bool = True copy_original: bool = False - chapter_level: int = 1 + chapter_level: int | str = "auto" # 1–6, or "auto": level 1 plus oversized chapters split one level deeper toc: bool = True frontmatter: bool = True footnotes: str = "end-of-chapter" @@ -47,6 +53,11 @@ class Options: keep_html: bool = False code_language: str = "" keep_wrap_markers: bool = False + keep_invisible_chars: bool = False + css_emphasis: bool = True + boxes: str = "callout" + styles: str = "semantic" + page_markers: str = "none" strict: bool = False verbose: bool = False dry_run: bool = False @@ -73,8 +84,8 @@ def check_paths(input_path: Path, output_dir: Path, opts: Options) -> None: raise InputError(f"Eingabedatei nicht gefunden oder keine Datei: {input_path}") if not os.access(input_path, os.R_OK): raise InputError(f"Eingabedatei nicht lesbar: {input_path}") - if not 1 <= opts.chapter_level <= 6: - raise InputError("--chapter-level muss zwischen 1 und 6 liegen.") + if opts.chapter_level != "auto" and not (isinstance(opts.chapter_level, int) and 1 <= opts.chapter_level <= 6): + raise InputError("--chapter-level muss auto oder eine Zahl zwischen 1 und 6 sein.") if opts.dry_run: return if output_dir.exists() and not output_dir.is_dir(): @@ -133,9 +144,11 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio package = reader.load_package() diag.step(f"EPUB {package.version}: {package.metadata.title!r}, {len(package.spine)} Spine-Dokumente") docs = _load_docs(reader, package, diag) - _prepare_docs(docs, package, diag) + stylesheet = _load_stylesheet(reader, package, docs, diag) # before normalization drops ")) + end + return doc +end + +return { + { Meta = Meta }, + { BlockQuote = BlockQuote, Div = Div }, + { Pandoc = Pandoc }, +} diff --git a/pandoc/print.css b/pandoc/print.css new file mode 100644 index 0000000..2bea7b8 --- /dev/null +++ b/pandoc/print.css @@ -0,0 +1,32 @@ +/* Print layout for PDFs from epub2md output via Pandoc + weasyprint: + pandoc buch.md -s --lua-filter pandoc/book.lua --lua-filter pandoc/callouts.lua --css pandoc/print.css \ + --pdf-engine=weasyprint -o buch.pdf + book.lua places the table of contents after cover, title page and imprint and marks chapter starts. */ +@page { size: A4; margin: 2cm 1.8cm; @bottom-center { content: counter(page); font-size: 9pt; } } +@page :first { @bottom-center { content: none; } } /* no page number on the cover / title page */ +body { max-width: none; font-size: 10.5pt; line-height: 1.4; hyphens: auto; text-align: justify; + font-family: "DejaVu Serif", "Liberation Serif", Georgia, serif; } +#title-block-header, #title-block-header h1.title { text-align: center; margin-bottom: 2em; } +h1, h2, h3, h4 { text-align: left; hyphens: manual; page-break-after: avoid; } +pre, code { font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; hyphens: none; } +pre { background: #f6f8fa; padding: 0.5em 0.8em; border-radius: 0.3em; page-break-inside: auto; } +pre, pre code { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 8.5pt; } /* long code lines wrap */ +pre { overflow: visible; } +blockquote { margin: 1em 0 1em 1.5em; padding-left: 0.8em; border-left: 2px solid #ccc; color: #444; } +a { color: inherit; text-decoration: none; } +img { max-width: 100%; max-height: 24cm; height: auto; } +table { font-size: 9pt; } +.cover { text-align: center; } +.cover img { max-height: 25cm; } + +/* New pages: every chapter (book.lua: div.chapter-start, also cover, title page, imprint), the title + block, the table of contents; without book.lua every

. No second break for the chapter's own

. */ +h1, .chapter-start, #title-block-header, #TOC { page-break-before: always; } +.chapter-start + h1, #title-block-header h1, #TOC h1 { page-break-before: auto; } +#TOC { page-break-after: always; } + +/* Table of contents with dotted leaders and page numbers. */ +#TOC ul { list-style: none; padding-left: 1.2em; margin: 0; } +#TOC > ul { padding-left: 0; } +#TOC li { text-align: left; } +#TOC a::after { content: leader(".") target-counter(attr(href), page); } diff --git a/tests/conftest.py b/tests/conftest.py index a08e5b7..5012e47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import posixpath import sys import zipfile from dataclasses import dataclass, field @@ -25,7 +26,7 @@ SVG = b' -{title} +{title}{head} {body} """ @@ -36,6 +37,7 @@ class Doc: body: str title: str = "" linear: bool = True + css: list[str] | None = None # linked stylesheets (OEBPS-relative) – default: every .css in Book.media @dataclass @@ -90,7 +92,7 @@ def build_epub(path: Path, book: Book) -> Path: {''.join(spine)}{book.spine_extra} """ nav_xhtml = XHTML.format( - title="Inhalt", + title="Inhalt", head="", body='", ) ncx = ( @@ -114,7 +116,10 @@ def build_epub(path: Path, book: Book) -> Path: if book.ncx: z.writestr("OEBPS/toc.ncx", ncx) for d in book.docs: - z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, body=d.body)) + css = d.css if d.css is not None else [n for n in book.media if n.lower().endswith(".css")] + head = "".join(f'' + for c in css) + z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, head=head, body=d.body)) for name, data in book.media.items(): z.writestr(f"OEBPS/{name}", data) for name, data in book.extra_entries.items(): diff --git a/tests/test_chapters.py b/tests/test_chapters.py index c98dd87..befe5d6 100644 --- a/tests/test_chapters.py +++ b/tests/test_chapters.py @@ -127,7 +127,7 @@ def test_glossary_anchor_is_placed_before_bold_term(convert): def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert): words = " ".join(["Wort"] * 11000) body = "

Teil 1

" + "".join(f"

Kapitel {i}

{words}

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

Vorwort

kurz

Dank

danke

Hinweise

lesen

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

Teil 1

Einleitung

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

Kapitel {i}

{words}

Abschnitt

x

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

Teil 2

Kapitel 4

kurz

Kapitel 5

kurz

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

A

")]), "--chapter-level", value).rc == 1 + + def test_titles_derived_from_content_when_no_other_source(convert): docs = [ Doc("ccover.xhtml", '
'), @@ -185,3 +212,18 @@ def test_special_chapter_is_not_split_further(convert): "

Register

A

Apfel

Index

x

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

Erika Musterfrau

Testbuch

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

Eins

Text

")] + run = convert(Book(docs=docs, nav=[("Eins", "c1.xhtml")], media={"c.png": PNG})) + types = {c["file"]: c["types"] for c in run.report["chapters"]} + first, second, third = sorted(types)[:3] + assert "cover" in types[first] + assert "titlepage" in types[second] + assert "cover" in types[third] # 'Deckblatt' by title + assert "cover" not in types[sorted(types)[3]] diff --git a/tests/test_codeblocks.py b/tests/test_codeblocks.py index 0cf0ca9..6084d45 100644 --- a/tests/test_codeblocks.py +++ b/tests/test_codeblocks.py @@ -118,3 +118,57 @@ def test_print_wrap_markers_can_be_kept(convert): run = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-wrap-markers") (_, _, code), = fenced_blocks(run.chapter("001")) assert code == "a = (1,\n↪ 2)" + + +def test_printed_line_numbers_do_not_hide_the_language(convert): + listing = ("00 // Kapitel1/hallo.c\n01 #include <stdio.h>\n \n02 int main(void) {\n" + "03 printf(\"Hallo\\n\");\n04 return 0;\n05 }") + run = convert(Book(docs=[Doc("c.xhtml", f"

N

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

P

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

C

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

P

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

M

" + "
def f(x):\n    return x
" * 3 + body += "
#include <stdio.h>\nint main(void) { return 0; }
" * 3 + "
x = compute();
" + run = convert(Book(docs=[Doc("c.xhtml", body)])) + assert fenced_blocks(run.chapter("001"))[-1][1] == "" diff --git a/tests/test_math.py b/tests/test_math.py index a89df9c..1266d5a 100644 --- a/tests/test_math.py +++ b/tests/test_math.py @@ -2,7 +2,7 @@ import pytest from lxml import etree from conftest import PNG, SVG, Book, Doc -from epub2md.mathml import Unsupported, mathml_to_tex +from epub2md.mathml import Unsupported, mathml_to_tex, portable_tex M = 'xmlns="http://www.w3.org/1998/Math/MathML"' @@ -111,6 +111,24 @@ def test_book_readme_suggests_mathml_for_pandoc_only_with_formulas(convert): readme = (run.out / "README.md").read_text(encoding="utf-8") assert "## Weiterverarbeitung" in readme and "epub2md_merge.py" in readme assert "--mathml" in readme + pandoc_lines = [ln for ln in readme.splitlines() if ln.startswith("pandoc ")] + assert pandoc_lines and all("--mathml" in ln for ln in pandoc_lines) # EPUB too, else TeX stays text (QA3-04) plain = convert(Book(docs=[Doc("a.xhtml", "

A

Text

")])) plain_readme = (plain.out / "README.md").read_text(encoding="utf-8") assert "## Weiterverarbeitung" in plain_readme and "--mathml" not in plain_readme + + +@pytest.mark.parametrize("tex,expected", [ + (r'\mbox{gr\"un}, \text{F\"{u}r}', r"\mbox{grün}, \text{Für}"), + (r'\text{ f{\"u}r } j', r"\text{ f{ü}r } j"), # braces stay balanced + (r"{\mathscr L}(A,b)={{\O }}", r"{\mathscr L}(A,b)={{\emptyset }}"), + (r"M\cap N=\text{\O }", r"M\cap N=\emptyset"), + (r"\Omega\neq\O", r"\Omega\neq\emptyset"), + (r"\begin{array}{@{}l@{\qquad \quad }l@{}} a & b\end{array}", r"\begin{array}{ll} a & b\end{array}"), + (r"\arraycolsep8pt\begin{array}{c@{\,}r} x\end{array}", r"\begin{array}{cr} x\end{array}"), + (r"a & =&\displaystyle b, \\ & \vdots &\displaystyle \\ c", r"a & =&\displaystyle b, \\ & \vdots &\\ c"), + (r"\frac{a}{b}+\{x\}", r"\frac{a}{b}+\{x\}"), +]) +def test_publisher_tex_is_made_portable(tex, expected): + """Pandoc's texmath rejects \\", \\O, @{…} and \\arraycolsep (Springer alt texts, QA3-06).""" + assert portable_tex(tex) == expected diff --git a/tests/test_merge.py b/tests/test_merge.py index 91898ae..8277259 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -34,10 +34,10 @@ def test_merge_complete_book(convert, tmp_path): # links between chapters point into the merged file; colliding id "sec" renamed in chapter B assert '' in text and '' in text assert "[B](#c002-sec)" in text and "[A](#sec)" in text and "[hier](#sec)" in text - assert "[Kapitel B](#002-kapitel-b)" in text and '' in text + assert "[Kapitel B](#kap-002-kapitel-b)" in text and '' in text # media paths relative to the merged file, also inside image links assert "![Bild](media/images/0001-bild.png)" in text - assert "[![Bildlink](media/images/0001-bild.png)](#002-kapitel-b)" in text + assert "[![Bildlink](media/images/0001-bild.png)](#kap-002-kapitel-b)" in text # code untouched assert '[kein](link.md) id="sec" `code`' in text # footnote defined in both chapters -> only once in the merged file @@ -63,11 +63,76 @@ def test_merge_to_other_location_rewrites_media_paths(convert, tmp_path): text = target.read_text(encoding="utf-8") assert not text.startswith("---") assert f"![Bild](../{run.out.name}/media/images/0001-bild.png)" in text - assert "1. [Kapitel A](#001-kapitel-a)" in text - assert "001-kapitel-a" in anchors_in(text) + assert "1. [Kapitel A](#kap-001-kapitel-a)" in text + assert "kap-001-kapitel-a" in anchors_in(text) def test_merge_invalid_directory(tmp_path): assert merge_main([str(tmp_path / "gibt-es-nicht")]) == 1 (tmp_path / "leer").mkdir() assert merge_main([str(tmp_path / "leer")]) == 1 + + +def test_merge_shortens_iso_date(convert): + from epub2md.merge import display_date + assert display_date("2025-01-13T08:00:00+00:00", "de") == "13.01.2025" + assert display_date("2025-01-13", "de-DE") == "13.01.2025" + assert display_date("2025-01-13T08:00:00Z", "en") == "2025-01-13" + assert display_date("2025", "de") == "2025" # year only / other formats stay + assert display_date("Januar 2025", "de") == "Januar 2025" + run = convert(book()) + assert merge_main([str(run.out)]) == 0 + text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8") + assert yaml.safe_load(text.split("---\n")[1])["date"] == "01.01.2024" + + +def front_book() -> Book: + docs = [Doc("cover.xhtml", '
Cover
'), + Doc("titel.xhtml", "

Erika Musterfrau

Vorspann

Roman

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

© 2024 Testverlag

Das Werk ist urheberrechtlich geschützt.

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

Kapitel A

a

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

Kapitel A

a

")] + run = convert(Book(title="Ohne Titelei", docs=docs, nav=[("Kapitel A", "a.xhtml")], media={"img/a.png": PNG})) + assert merge_main([str(run.out)]) == 0 + text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8") + assert text.index("![Cover]") < text.index('
') \ + < text.index('
') < text.index("# Kapitel A") + + +def test_merge_own_toc_replaces_placeholder(convert): + run = convert(front_book()) + assert merge_main([str(run.out), "--toc"]) == 0 + text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8") + assert "epub2md-toc" not in text and text.index("© 2024") < text.index("## Inhalt") < text.index("# Kapitel A") + + +def test_merge_front_matter_region_with_gaps(convert): + """Krooss/Blake-like: short untyped pages between title page and imprint; main matter starts at '1 …'.""" + docs = [Doc("cover.xhtml", '
Cover
'), + Doc("titel.xhtml", "

Erika Musterfrau

Vorspann

Roman

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

Vorspann

4. Auflage 2025

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

© 2024 Testverlag

Das Werk ist urheberrechtlich geschützt.

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

Liebe Leserin, lieber Leser,

Viel Spaß.

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

1 Der Einstieg

a

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

Zwischenspiel

b

")] + book = Book(title="Vorspann", docs=docs, nav=[("1 Der Einstieg", "k1.xhtml"), ("Zwischenspiel", "k2.xhtml")], + media={"img/a.png": PNG}) + run = convert(book) + assert merge_main([str(run.out)]) == 0 + text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8") + toc = text.index('
') + assert text.index("© 2024") < text.index("Viel Spaß.") < toc < text.index("# 1 Der Einstieg") + assert text.count("epub2md-toc") == 1 and "epub2md-titlepage" not in text diff --git a/tests/test_pandoc.py b/tests/test_pandoc.py new file mode 100644 index 0000000..4f4e00c --- /dev/null +++ b/tests/test_pandoc.py @@ -0,0 +1,60 @@ +"""Pandoc path: merged file → EPUB/HTML with the filters from pandoc/ (skipped without pandoc).""" + +import re +import shutil +import subprocess +import zipfile +from pathlib import Path + +import pytest + +from conftest import Book, Doc +from epub2md.merge import main as merge_main + +pytestmark = pytest.mark.skipif(shutil.which("pandoc") is None, reason="pandoc nicht installiert") +FILTERS = Path(__file__).resolve().parent.parent / "pandoc" + + +def merged(convert, book: Book) -> Path: + run = convert(book) + assert merge_main([str(run.out)]) == 0 + return run.out / f"{run.report['input']['name'][:-5]}.md" + + +def pandoc(md: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["pandoc", md.name, *args], cwd=md.parent, capture_output=True, text=True, check=True) + + +def links_and_ids(epub: Path) -> tuple[list[tuple[str, str]], dict[str, set[str]]]: + with zipfile.ZipFile(epub) as z: + docs = {n: z.read(n).decode("utf-8") for n in z.namelist() if n.endswith(".xhtml")} + ids = {n: set(re.findall(r'\bid="([^"]+)"', t)) for n, t in docs.items()} + links = [(n, h) for n, t in docs.items() for h in re.findall(r'href="([^"]+)"', t) + if not re.match(r"^[a-z]+:", h) and not h.endswith(".css")] + return links, ids + + +def test_epub_links_to_chapter_starts_resolve(convert): + """Chapter start anchors must begin with a letter: Pandoc's EPUB writer breaks '#039-…' (QA3-05).""" + docs = [Doc("a.xhtml", '

Kapitel A

Siehe Kapitel B und Bild.

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

Kapitel B

Text B.

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

Nur eine Bildseite ohne Überschrift.

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

Kapitel A

a

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

Kapitel B

b

")] + md = merged(convert, Book(docs=docs)) + html = pandoc(md, "-s", "--lua-filter", str(FILTERS / "book.lua"), "-t", "html").stdout + assert html.count('class="chapter-start"') == 2 + assert 'id="TOC"' in html and "Kapitel B" in html diff --git a/tests/test_qa2_open.py b/tests/test_qa2_open.py index 35f3e7b..7b003c2 100644 --- a/tests/test_qa2_open.py +++ b/tests/test_qa2_open.py @@ -32,12 +32,14 @@ def test_qa2_10_book_readme_recommends_pdf_via_html(convert): """Pandoc drops raw HTML (tables, ) when going Markdown -> LaTeX; the HTML route keeps both.""" run = convert(Book(docs=[Doc("a.xhtml", "

A

x

")])) readme = (run.out / "README.md").read_text(encoding="utf-8") - assert re.search(r"pandoc \S+\.md -s -o \S+\.html && pandoc \S+\.html --toc --pdf-engine=xelatex -o \S+\.pdf", readme) + book = r"--lua-filter \S+/pandoc/book\.lua" # table of contents after the front matter (both steps) + assert re.search(rf"pandoc \S+\.md -s {book} -o \S+\.html && pandoc \S+\.html {book} --pdf-engine=xelatex -o \S+\.pdf", + readme) def test_qa2_11_code_language_hint(convert): py = "def f(x):\n return x\n\nclass A:\n pass" - body = "

C

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

C

" + "".join(f"
{py}\n# {i}
Ergebnis: {i}
" for i in range(10)) run = convert(Book(docs=[Doc("c.xhtml", body)])) hints = [i for i in run.report["infos"] if i["code"] == "code-language-hint"] assert hints and "--code-language python" in hints[0]["message"] diff --git a/tests/test_styles.py b/tests/test_styles.py new file mode 100644 index 0000000..a62a99d --- /dev/null +++ b/tests/test_styles.py @@ -0,0 +1,232 @@ +"""Typography and layout: invisible characters, CSS emphasis, scene breaks, boxes, styles, page markers.""" + +from conftest import Book, Doc + +ZW, SHY = "​", "­" + + +def test_p6_invisible_characters_removed_outside_code(convert): + body = (f"

Kapitel 1.{ZW}1

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

" + f"
a{ZW}b

x{SHY}y

") + run = convert(Book(docs=[Doc("c.xhtml", body)])) + text = run.chapter("001") + assert "Leserinnen und 1.1 sowieWort." in text + assert "# Kapitel 1.1" in text and 'chapter_title: "Kapitel 1.1"' in text + assert f"a{ZW}b" in text and f"x{SHY}y" in text # code stays verbatim + assert run.report["text_cleanup"]["invisible_chars_removed"] == 4 + kept = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-invisible-chars").chapter("001") + assert f"Lese{SHY}rinnen" in kept + + +CSS = b""".i { font-style: italic } .b { font-weight: bold } span.u { text-decoration: underline } +.s { text-decoration: line-through } .hoch { vertical-align: super } .weg { display: none } +li p.first > span.b { font-weight: normal; font-family: monospace } .schrift-c { font-family: "Courier New", monospace } +p.zitat { font-style: italic } h2.t { font-weight: bold; font-style: italic } .url { text-decoration: underline } +@media amzn-kf8 { .i { font-style: normal } }""" + + +def styled(body: str, *args: str, convert=None): + return convert(Book(docs=[Doc("c.xhtml", body)], media={"style.css": CSS}), *args) + + +def test_p1_css_classes_become_markdown_emphasis(convert): + body = ('

K

Ein kursives und fettes Wort, ' + 'unterstrichen, gestrichen, x2, ' + '[»] sichtbar, printf und ' + 'zwei Wörter, inline, ' + 'example.org.

' + '
  • nicht fett im Listenkopf

' + '

Ein ganzer kursiver Absatz mit Betonung.

' + '

Überschrift bleibt

nur einmal

code
') + run = styled(body, convert=convert) + text = run.chapter("001") + assert "Ein *kursives* und **fettes** Wort, unterstrichen, ~~gestrichen~~, x2, sichtbar," in text + assert "`printf` und zwei Wörter, **inline**, [example.org](https://example.org)." in text + assert "[»]" not in text and "\\[»\\]" not in text + assert "- nicht fett im Listenkopf" in text + assert "*Ein ganzer kursiver Absatz mit Betonung.*" in text + assert "## Überschrift bleibt" in text and "*nur einmal*" in text and "**nur einmal**" not in text + assert "```\ncode\n```" in text + assert run.report["css"]["hidden_elements"] == 1 + assert run.report["css"]["applied"]["italic"] >= 2 + plain = styled(body, "--no-css-emphasis", convert=convert).chapter("001") + assert "Ein kursives und fettes Wort" in plain + + +def test_p1_css_emphasis_inside_html_tables(convert): + body = ('

T

' + '
kursiv und fett
ab
') + text = styled(body, convert=convert).chapter("001") + assert "kursiv und fett" in text + + +def test_adjacent_emphasis_is_merged(convert): + body = ('

K

Fläche (A=r2×π) und ' + 'xy sowie ab und c d.

') + text = styled(body, convert=convert).chapter("001") + assert "2*×π*" in text + assert "*xy*" in text and "**ab**" in text and "*c* *d*" in text + + +def test_p4_scene_breaks(convert): + body = ('

K

 

Erster Absatz.

 

' + '

 

Neue Szene.

Weiter.

***

' + '

Nach Ornament.

 

Abschnitt

Text

⁂

Ende.

') + text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001") + body_md = text.split("# K", 1)[1] + assert "Erster Absatz.\n\n* * *\n\nNeue Szene." in body_md + assert body_md.count("* * *") == 3 # two empties collapse into one; ***, ⁂ are ornaments; none before the heading + assert "Weiter.\n\n* * *\n\nNach Ornament." in body_md + assert "\\*" not in body_md + assert body_md.strip().startswith("Erster Absatz.") # no break at chapter start + + +def test_p4_poems_keep_lines_and_stanzas(convert): + body = ('

G

Davor.

»Erste Zeile

zweite Zeile

' + '

 

Zweite Strophe«

Danach.

') + text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001") + assert "> »Erste Zeile\\\n> zweite Zeile\n>\n> Zweite Strophe«" in text + assert "* * *" not in text + + +BOX_CSS = b""".rahmen { border: 1px solid #333; padding: 4px } .listing { border: 1px solid #ccc }""" + + +def boxed(body: str, *args: str, convert=None): + return convert(Book(docs=[Doc("c.xhtml", body)], media={"box.css": BOX_CSS}), *args) + + +def test_p2_boxes_become_callouts(convert): + body = ('

K

Vorher.

' + '

[»] Erwartungen

' + '

Kein Kompendium.

Zweiter Absatz.

' + '
Image
' + '

Wichtig: Funktionen sind Objekte.

' + '

Tipp

Gelb mit Blau mischen.

Mehr Weiß.

' + '' + '

Achtung, heiß!

' + '
code()

Nachher.

') + from conftest import PNG + run = convert(Book(docs=[Doc("c.xhtml", body)], media={"box.css": BOX_CSS, "tick.png": PNG})) + text = run.chapter("001") + assert "> [!NOTE]\n> **Erwartungen**\n>\n> Kein Kompendium.\n>\n> Zweiter Absatz." in text + assert "> [!IMPORTANT]\n> **Wichtig:** Funktionen sind Objekte." in text and "tick" not in text + assert "> [!TIP]\n> Gelb mit Blau mischen.\n>\n> Mehr Weiß." in text # label 'Tipp' = type word, no title + assert "> [!NOTE]\n> **Exkurs**\n>\n> Am Rande." in text + assert "> [!WARNING]\n> Achtung, heiß!" in text # box recognized via CSS border, type from text + assert "```\ncode()\n```" in text and "> ```" not in text # bordered code listing is no box + assert "### " not in text # box heading no longer pollutes the outline + assert run.report["layout"]["boxes"] == 5 + + +def test_p2_box_modes(convert): + body = '

K

' + quote = boxed(body, "--boxes", "quote", convert=convert).chapter("001") + assert "> **Hinweis zum Pinsel**\n>\n> Sauber halten." in quote and "[!" not in quote + htm = boxed(body, "--boxes", "html", convert=convert).chapter("001") + assert '
\n\n**Hinweis zum Pinsel**\n\nSauber halten.\n\n
' in htm + plain = boxed(body, "--boxes", "plain", convert=convert).chapter("001") + assert "## Hinweis zum Pinsel" in plain and "Sauber halten." in plain and ">" not in plain.split("---")[-1] + + +STYLE_CSS = b"""p { font-family: "Aldus", serif; font-size: 0.9em; color: #000 } +.rot { color: #942f6a } .gelb { background-color: #ffff00 } .gross { font-size: 1.5em } +.kap { font-variant: small-caps } p.mitte { text-align: center } .script { font-family: "Mulled Wine", cursive }""" + + +def styles_book(body: str, *args: str, convert=None): + return convert(Book(docs=[Doc("c.xhtml", body)], media={"s.css": STYLE_CSS}), *args) + + +BODY_STYLES = ('

S

Normal rot, markiert, ' + 'groß, Kapitälchen, Zierschrift, ' + 'English words und deutsch.

' + '

Zweiter Absatz.

Dritter Absatz.

Widmung

') + + +def test_p3_semantic_styles_default(convert): + text = styles_book(BODY_STYLES, convert=convert).chapter("001") + assert "Normal rot, markiert, groß, Kapitälchen, Zierschrift, English words und deutsch." in text + assert "style=" not in text and "lang=" not in text + + +def test_p3_p7_html_styles(convert): + run = styles_book(BODY_STYLES, "--styles", "html", convert=convert) + text = run.chapter("001") + assert 'rot' in text + assert 'markiert' in text + assert 'groß' in text + assert 'Kapitälchen' in text + assert "Zierschrift" in text + assert 'English words und deutsch.' in text + assert '
\n\nWidmung\n\n
' in text + assert "Zweiter Absatz." in text and "Aldus" not in text # base paragraph style is not repeated + assert run.report["css"]["applied"]["style"] >= 5 + + +PAGES = ('

K

Ende Seite 89.

' + '

Text weiter, Wort und fort.

' + '

Absatz mit Absatz-ID.

Kapitelanfang

x

code
' + '
1.2Tabellentitel
') + + +def test_p5_page_markers(convert): + none = convert(Book(docs=[Doc("c.xhtml", PAGES)])).chapter("001") + assert "S. 90" not in none and "S. 12" not in none + vis = convert(Book(docs=[Doc("c.xhtml", PAGES)]), "--page-markers", "visible") + text = vis.chapter("001") + assert "Ende Seite 89.\n\n\\[S. 90\\]" in text + assert "Text\\[S. 12\\] weiter, \\[S. 13\\]Wort und\\[S. 14\\] fort." in text + assert "S. 15" not in text # paragraph id, not a page + assert "\\[S. 16\\]\n\n## Kapitelanfang" in text # marker before the heading, not inside it + assert "```\ncode\n```" in text # nothing inside code + assert "\\[S. 18\\]\n\n## 1.2 Tabellentitel" in text # layout-table heading keeps the page + assert vis.report["layout"]["page_markers"] == 6 + com = convert(Book(docs=[Doc("c.xhtml", PAGES)]), "--page-markers", "comment").chapter("001") + assert "" in com and "Text weiter" in com + + +def test_book_readme_suggests_callout_filter(convert): + body = '

K

Merke

Wichtiger Satz.

' + readme = (convert(Book(docs=[Doc("c.xhtml", body)])).out / "README.md").read_text(encoding="utf-8") + assert "--lua-filter" in readme and "pandoc/callouts.lua" in readme and "1 Infokasten " in readme + 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") + + +def test_hidden_span_gets_no_field_separator_and_class_type_wins(convert): + css = b".u { text-decoration: underline; } .weg { display: none; }" + body = ('

K

unterstrichenVERSTECKT.

' + '

Wichtige Regel

Immer testen.

') + text = convert(Book(docs=[Doc("c.xhtml", body)], media={"s.css": css})).chapter("001") + assert "unterstrichen." in text and "VERSTECKT" not in text + assert "> [!NOTE]\n> **Wichtige Regel**" in text # class 'hinweis' beats the word in the title + + +def test_page_marker_in_box_title_goes_before_the_box(convert): + body = ('

K

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

K

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

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

K

kursiv normal.

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

Chapter One

Hello EPUB 2 world.

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

Erstes Kapitel

Eins.

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

Zweites Kapitel

Zwei.

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

Kapitel A

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

' + '

A.1 Abschnitt

Text A.1

' + '

Kapitel A2 in derselben Datei

Unten in A2.

' + '

A2.1 Unterabschnitt

Mehr.

') + b = ('

Kapitel B

B-Text.

B.1

Ziel in B.

' + '

zurück zu A.1

') + c = '

Kapitel C

Ende mit Link auf A.

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

Code und Tabellen

" + "

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

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

Unbekanntes Element: Widget-Inhalt.

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

Bilder

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

Nochmals dasselbe Foto: Wiederholung

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

Inline-SVG: Kreis' + '

' + '

Siehe Abbildung 1.1 und SVG direkt.

' + '

Fehlendes Bild: weg

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

Formeln

" + f"

Bruch inline: ab.

" + f"

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

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

Nach Gleichung (2.1) folgt …

' + f'

Nicht unterstützt: z.

' + f'

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

" + f'

alttext: 2.

' + '

SVG-Formel: Formel E=mc²

' + '

Bildformel: Summe über i

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

Kapitel mit Fußnoten

" + '

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

' + '

Endnote3 und kaputt9.

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

Anmerkungen

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

Hauptteil

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

') + glossary = ('

Glossar

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

Selbstaufruf einer Funktion.

Siehe auch Iteration.

") + lit = ('

Literatur

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

Sachregister

' + '

A

API Glossar

' + '

R

Rekursion 17, 42

' + '

endständige, 45

') + pers = ('

Personenregister

K

' + '

Knuth, Donald E. 17

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

Komplexe Tabelle

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

1,5

korrigiert

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

Böse Pfade

raus ' + 'escape abs

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

Größe & Übermaß

{text}

Rücksprung

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

Café crème

Ça va.

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

Gestaltung

' + '

Ein kursives und fettes Wort, ' + 'unterstrichenVERSTECKT.

' + '

Null\u200bbreite und wei\u00adches Trenn\u00adzeichen.

' + '

Szene eins endet hier.

Szene zwei beginnt hier.

' + '

Zeile eins

Zeile zwei

' + '

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

' + '

Zentrierte Widmung

' + '

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

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

Kästen

' + '

[»]Wichtige Regel

' + '

Immer testen.

' + '

Nachher.

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

A

x

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

A

x

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

Kaputt

nicht geschlossen   &unbekannt; fett"}) + print("\n".join(sorted(p.name for p in OUT.glob("*.epub")))) + + +if __name__ == "__main__": + main() diff --git a/tools/qa/pandoc_full.sh b/tools/qa/pandoc_full.sh new file mode 100755 index 0000000..36ef93a --- /dev/null +++ b/tools/qa/pandoc_full.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Kompletter Pandoc-Durchlauf: jedes EPUB aus Beispiele/ konvertieren, zusammenfügen, alle Pandoc-Befehle der +# Buch-README ausführen, beide PDF-Varianten erzeugen und prüfen. Aufruf im Repo-Wurzelverzeichnis: +# tools/qa/pandoc_full.sh Ergebnis in .qa/output/pandoc/ (check.json, pdf-check.txt) +# Python über $PYTHON (Standard: python3); braucht pandoc, weasyprint, xelatex, poppler-utils. +PY=${PYTHON:-python3} +Q=$(dirname "$0") +P=.qa/output/pandoc; rm -rf $P; mkdir -p $P +for f in Beispiele/*.epub; do + n=$(basename "$f" .epub | cut -d_ -f1 | cut -d- -f1); n=${n/Krooß/Krooss} + $PY epub2md.py "$f" "$P/$n" > /dev/null 2> "$P/$n.convert.err"; echo "$n convert rc=$?" + $PY epub2md_merge.py "$P/$n" > "$P/$n.merge.log" 2>&1; echo "$n merge rc=$? $(head -1 "$P/$n.merge.log" | cut -c1-120)" + SKIP_PDF=1 "$Q/pandoc_run.sh" "$P/$n" + "$Q/pandoc_pdfs.sh" "$P/$n" +done +$PY "$Q/check_pandoc.py" $P/*/ > $P/check.json +"$Q/check_pdf.sh" $P/*/*.pdf > $P/pdf-check.txt +echo FERTIG diff --git a/tools/qa/pandoc_pdfs.sh b/tools/qa/pandoc_pdfs.sh new file mode 100755 index 0000000..c248182 --- /dev/null +++ b/tools/qa/pandoc_pdfs.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Erzeugt beide PDF-Varianten aus der Buch-README unter getrennten Namen: *.weasy.pdf und *.latex.pdf +# (in der README schreiben beide Befehle dieselbe Datei). Aufruf im Repo-Wurzelverzeichnis. +mkdir -p .qa +for dir in "$@"; do + mkdir -p "$dir/pandoc-logs" + grep -E '^pandoc .*\.pdf' "$dir/README.md" | while IFS= read -r cmd; do + case "$cmd" in *weasyprint*) kind=weasy;; *) kind=latex;; esac + cmd=$(printf '%s' "$cmd" | sed -E "s/\.pdf('?)\$/.$kind.pdf\1/") + s=$(date +%s) + (cd "$dir" && timeout 1800 bash -c "$cmd") > "$dir/pandoc-logs/$kind.out" 2> "$dir/pandoc-logs/$kind.err"; rc=$? + printf '%s\t%s\trc=%s\t%ss\n' "$(basename "$dir")" $kind $rc $(( $(date +%s)-s )) + printf '%s\trc=%s\tpandoc %s-PDF (%s)\n' "$(date -Iseconds)" $rc $kind "$(basename "$dir")" >> .qa/commands.log + done +done diff --git a/tools/qa/pandoc_run.sh b/tools/qa/pandoc_run.sh new file mode 100755 index 0000000..319f845 --- /dev/null +++ b/tools/qa/pandoc_run.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Führt jede "pandoc …"-Zeile aus der README.md eines konvertierten Buches im Buchordner aus. +# Aufruf im Repo-Wurzelverzeichnis: tools/qa/pandoc_run.sh BUCHORDNER... (SKIP_PDF=1: ohne PDF-Befehle) +# Logs: BUCHORDNER/pandoc-logs/NN.{cmd,out,err} und summary.tsv; Kommandos mit rc in .qa/commands.log +mkdir -p .qa +for dir in "$@"; do + mkdir -p "$dir/pandoc-logs" + n=0 + grep -E '^pandoc ' "$dir/README.md" | { [ -n "$SKIP_PDF" ] && grep -v '\.pdf' || cat; } | while IFS= read -r cmd; do + n=$((n+1)); f=$(printf '%s/pandoc-logs/%02d' "$dir" $n) + printf '%s\n' "$cmd" > "$f.cmd" + s=$(date +%s) + (cd "$dir" && timeout 1800 bash -c "$cmd") > "$f.out" 2> "$f.err"; rc=$? + printf '%s\trc=%s\t%ss\t%s warn\t%s\n' "$(basename "$dir")" $rc $(( $(date +%s)-s )) "$(grep -c . "$f.err")" \ + "$(echo "$cmd" | grep -o -- '-o [^ ]*\.[a-z]*' | tail -1)" | tee -a "$dir/pandoc-logs/summary.tsv" + printf '%s\trc=%s\tpandoc (%s)\n' "$(date -Iseconds)" $rc "$(basename "$dir") #$n" >> .qa/commands.log + done +done diff --git a/tools/qa/pyblocks.py b/tools/qa/pyblocks.py new file mode 100755 index 0000000..0876dde --- /dev/null +++ b/tools/qa/pyblocks.py @@ -0,0 +1,36 @@ +"""Listet Python-Codeblöcke mit Einrückungsfehlern in Markdown-Dateien (mit Inhalt, zum Vergleich mit der Quelle). + +Aufruf: python tools/qa/pyblocks.py AUSGABE/chapters/*.md +""" + +from __future__ import annotations + +import ast +import re +import sys + + +def main() -> None: + for path in sys.argv[1:]: + with open(path, encoding="utf-8") as fh: + lines = fh.read().split("\n") + i = 0 + while i < len(lines): + m = re.match(r"^(`{3,})python\s*$", lines[i]) + if m: + j = i + 1 + while j < len(lines) and not lines[j].startswith(m.group(1)): + j += 1 + try: + ast.parse("\n".join(lines[i + 1:j])) + except IndentationError as exc: + print(f"== {path}:{i + 1} {exc.msg} (Blockzeile {exc.lineno})") + print("\n".join(f"{k + 1:3}|{line}" for k, line in enumerate(lines[i + 1:j]))) + except SyntaxError: + pass # fragments and program output are no indentation defects + i = j + i += 1 + + +if __name__ == "__main__": + main() diff --git a/tools/qa/requirements.txt b/tools/qa/requirements.txt new file mode 100644 index 0000000..78cae10 --- /dev/null +++ b/tools/qa/requirements.txt @@ -0,0 +1,5 @@ +# Zusätzlich zu requirements-dev.txt für die QA-Skripte (unabhängiger Markdown-Validator, Coverage) +-r ../../requirements-dev.txt +markdown-it-py>=3.0 +mdit-py-plugins>=0.4 +coverage>=7.0 diff --git a/tools/qa/run_matrix.py b/tools/qa/run_matrix.py new file mode 100755 index 0000000..f8eea23 --- /dev/null +++ b/tools/qa/run_matrix.py @@ -0,0 +1,418 @@ +"""CLI-Testmatrix gegen die QA-Fixtures. Jeder Lauf schreibt in ein neues Verzeichnis unter .qa/output/. + +Ergebnis: .qa/output/matrix-results.json, Kommandos mit Rückgabecode in .qa/commands.log +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import stat +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +QA = ROOT / ".qa" +FX = QA / "generated-fixtures" +OUT = QA / "output" / "matrix" +PY = sys.executable +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from validate_output import validate # noqa: E402 + +results: list[dict] = [] + + +def run(args: list[str]) -> subprocess.CompletedProcess: + cmd = [PY, "epub2md.py", *args] + cp = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600) + with open(QA / "commands.log", "a", encoding="utf-8") as fh: + rel = " ".join(a.replace(str(ROOT) + "/", "") for a in cmd[1:]) + fh.write(f"{datetime.now().isoformat(timespec='seconds')}\trc={cp.returncode}\tpython {rel}\n") + return cp + + +def merge_case(name: str, book_dir: Path, expect_rc: int, checks=None) -> dict: + """epub2md_merge.py on an existing output directory.""" + cmd = [PY, "epub2md_merge.py", str(book_dir)] + cp = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600) + with open(QA / "commands.log", "a", encoding="utf-8") as fh: + fh.write(f"{datetime.now().isoformat(timespec='seconds')}\trc={cp.returncode}\tpython epub2md_merge.py " + f"{str(book_dir).replace(str(ROOT) + '/', '')}\n") + entry: dict = {"case": name, "args": [str(book_dir)], "rc": cp.returncode, "expected_rc": expect_rc, + "ok": cp.returncode == expect_rc and "Traceback" not in cp.stderr, "notes": []} + for label, fn in checks or []: + good = bool(fn(book_dir, cp)) + entry["ok"] = entry["ok"] and good + entry["notes"].append(("OK: " if good else "FEHLER: ") + label) + results.append(entry) + print(("PASS " if entry["ok"] else "FAIL ") + name, "rc=", cp.returncode) + return entry + + +def read(out: Path, pattern: str) -> str: + return "\n".join(p.read_text(encoding="utf-8") for p in sorted(out.glob(pattern))) + + +def case(name: str, fixture: str, args: list[str], expect_rc: int, checks=None, validate_out: bool = True, + out: Path | None = None, keep: bool = False) -> dict: + out = out or OUT / name + if out.exists() and not keep: + shutil.rmtree(out) + src = str(FX / fixture) if fixture and not fixture.startswith("/") else fixture + argv = ([src] if src else []) + ([str(out)] if src else []) + args + cp = run(argv) + entry: dict = {"case": name, "fixture": fixture, "args": args, "rc": cp.returncode, "expected_rc": expect_rc, + "ok": cp.returncode == expect_rc, "notes": [], "stderr_tail": cp.stderr.strip().splitlines()[-3:]} + if not entry["ok"]: + entry["notes"].append(f"Rückgabecode {cp.returncode}, erwartet {expect_rc}") + if "Traceback (most recent call last)" in cp.stderr: + entry["ok"] = False + entry["notes"].append("Traceback im Normalmodus") + if checks: + for label, fn in checks: + try: + good = bool(fn(out, cp)) + except Exception as exc: # noqa: BLE001 + good = False + label = f"{label} (Exception {type(exc).__name__}: {exc})" + if not good: + entry["ok"] = False + entry["notes"].append(("OK: " if good else "FEHLER: ") + label) + if validate_out and cp.returncode == 0 and out.exists() and "--dry-run" not in args: + v = validate(out) + entry["validator"] = v["severity_counts"] + entry["validator_issues"] = [i for i in v["issues"] if i["severity"] != "info"][:15] + if any(i["severity"] in ("critical", "high") for i in v["issues"]): + entry["ok"] = False + entry["notes"].append("Validator: kritische/hohe Befunde") + results.append(entry) + print(("PASS " if entry["ok"] else "FAIL ") + name, "rc=", cp.returncode, "; ".join(n for n in entry["notes"] if n.startswith("FEHLER") or "erwartet" in n or "Validator" in n or "Traceback" in n)) + return entry + + +def ch(out: Path, prefix: str = "") -> str: + return read(out / "chapters", f"{prefix}*.md") + + +def report(out: Path) -> dict: + return json.loads((out / "conversion-report.json").read_text(encoding="utf-8")) + + +def main() -> None: + if OUT.exists(): + shutil.rmtree(OUT) + OUT.mkdir(parents=True) + # --- help + cp = run(["--help"]) + opts = ["--format", "--extract-media", "--no-extract-media", "--copy-original", "--chapter-level", "--toc", "--no-toc", + "--frontmatter", "--no-frontmatter", "--footnotes", "--math", "--images", "--slug-language", "--keep-html", + "--strict", "--force", "--verbose", "--dry-run", "--report", "--code-language", "--keep-wrap-markers", + "--keep-invisible-chars", "--css-emphasis", "--no-css-emphasis", "--boxes", "--styles", "--page-markers"] + missing = [o for o in opts if o not in cp.stdout] + results.append({"case": "help", "rc": cp.returncode, "expected_rc": 0, "ok": cp.returncode == 0 and not missing, + "notes": [f"fehlende Optionen in --help: {missing}"] if missing else ["alle Optionen dokumentiert"]}) + print("PASS help" if not missing else f"FAIL help {missing}") + + # --- basics on all good fixtures + for fx in ["01-minimal-epub2", "02-minimal-epub3-nav", "03-chapters-and-links", "04-code-and-tables", "05-images-and-svg", + "06-mathml-and-equations", "07-footnotes-and-endnotes", "08-glossary-bibliography-index", "09-complex-table", + "12-encoding-edge-cases"]: + case(f"basic-{fx[:2]}", f"{fx}.epub", [], 0) + + f03 = "03-chapters-and-links.epub" + case("dry-run", f03, ["--dry-run", "--report", str(OUT / "dry-report.json")], 0, [ + ("kein Ausgabeordner", lambda o, c: not o.exists()), + ("kein Report", lambda o, c: not (OUT / "dry-report.json").exists()), + ("Planung im stdout", lambda o, c: "chapters/001-" in c.stdout)]) + case("verbose", f03, ["--verbose"], 0, [("Schritte auf stderr", lambda o, c: "Rendere" in c.stderr and "Kapitel gebildet" in c.stderr)]) + case("chapter-level-1", f03, ["--chapter-level", "1"], 0, [ + ("3+1 Kapitel (A, A2, B, C)", lambda o, c: len(list((o / "chapters").glob("*.md"))) == 4)]) + case("chapter-level-2", f03, ["--chapter-level", "2"], 0, [ + ("h2 erzeugt eigene Dateien", lambda o, c: len(list((o / "chapters").glob("*.md"))) == 7)]) + case("links-03", f03, [], 0, [ + ("Link Kapitel→Kapitel", lambda o, c: "[Kapitel B](003-kapitel-b.md)" in ch(o, "001")), + ("Anker im selben EPUB-Dokument, anderes Kapitel", lambda o, c: "[Anker unten](002-kapitel-a2-in-derselben-datei.md#a-unten)" in ch(o, "001")), + ("Anker in anderem Kapitel", lambda o, c: "[Ziel in B](003-kapitel-b.md#b-ziel)" in ch(o, "001")), + ("nicht auflösbar → Text + Report", lambda o, c: "Nichts" in ch(o, "001") and "nirgendwo" not in ch(o, "001") + and any(u["href"] == "nirgendwo.xhtml#weg" for u in report(o)["unresolved_links"])), + ("fehlender Anker → Warnung", lambda o, c: any(w["code"] == "anchor-missing" for w in report(o)["warnings"]))]) + + f04 = "04-code-and-tables.epub" + case("format-gfm", f04, ["--format", "gfm"], 0, [ + ("Tabelle mit | in Code als HTML (QA2-01)", lambda o, c: "ls | wc -l" in ch(o) and "| Befehl |" not in ch(o))]) + case("format-commonmark", f04, ["--format", "commonmark"], 0, [("HTML-Tabelle statt Pipe", lambda o, c: "" in ch(o) and "| Befehl |" not in ch(o))]) + case("code-04", f04, [], 0, [ + ("Python-Einrückung + Tab exakt", lambda o, c: " def push(self, x):\n\tself.items.append(x) # Tab-Einrückung\n if len(self.items) > 10 and x < 0:" in ch(o)), + ("Sprachlabel python", lambda o, c: "```python\nclass Stack:" in ch(o)), + ("Shell exakt, Label aus class=shell", lambda o, c: "```bash\n$ find . -name '*.py' | xargs grep -n \"TODO\" \\\n | sort -u > todo.txt\n$ echo $HOME\n```" in ch(o)), + ("Fence länger als Backticks im Code", lambda o, c: "````\nMarkdown mit ```fences```" in ch(o)), + ("json via data-language", lambda o, c: "```json\n{" in ch(o)), + ("c via #include", lambda o, c: "```c\n#include " in ch(o)), + ("Inline-Code mit < & ", lambda o, c: "`len(x) < 3 && y`" in ch(o)), + ("Inline-Code mit Backtick", lambda o, c: "``a`b``" in ch(o)), + ("verschachtelte Listen", lambda o, c: "- Punkt 1\n\n - Punkt 1.1\n\n 1. Schritt a\n 2. Schritt b" in ch(o) or "- Punkt 1\n - Punkt 1.1" in ch(o)), + ("ol start=3", lambda o, c: "3. drei" in ch(o)), + ("unbekanntes Element gemeldet", lambda o, c: "x-widget" in report(o)["unsupported_elements"] and "Widget-Inhalt" in ch(o))]) + case("keep-html", f04, ["--keep-html"], 0, [ + ("unbekanntes Element als HTML, ohne onclick", lambda o, c: 'Widget-Inhalt' in ch(o) and "evil" not in ch(o))]) + + f05 = "05-images-and-svg.epub" + case("images-relative", f05, ["--images", "relative"], 0, [ + ("Foto einmal extrahiert", lambda o, c: len([p for p in (o / "media/images").iterdir() if "foto" in p.name]) == 1), + ("SVG bleibt SVG", lambda o, c: any(p.suffix == ".svg" for p in (o / "media/images").iterdir())), + ("alt + title", lambda o, c: '![Ein Foto](../media/images/' in ch(o) and '"Fototitel")' in ch(o)), + ("Bildunterschrift", lambda o, c: "*Abbildung 1.1: Ein Foto mit Unterschrift.*" in ch(o)), + ("Caption als Alt bei leerem alt", lambda o, c: "![Abbildung 1.2: Ein SVG-Diagramm.](" in ch(o)), + ("Inline-SVG extrahiert", lambda o, c: any(m["role"] == "inline-svg" for m in report(o)["media"]["files"])), + ("fehlendes Bild gewarnt", lambda o, c: any(w["code"] == "image-missing" for w in report(o)["warnings"])), + ("alles im Ausgabeordner", lambda o, c: all(o.resolve() in p.resolve().parents for p in o.rglob("*")))]) + case("images-flat", f05, ["--images", "flat"], 0, [("media/", lambda o, c: (o / "media" / "foto 1 ä.png").exists())]) + case("images-original-path", f05, ["--images", "original-path"], 0, [("media/images/", lambda o, c: (o / "media/images/foto 1 ä.png").exists())]) + case("no-extract-media", f05, ["--no-extract-media"], 0, [("kein media/", lambda o, c: not (o / "media").exists()), + ("Platzhalter statt Link", lambda o, c: "Bild: Ein Foto" in ch(o))]) + case("extract-media", f05, ["--extract-media"], 0, [("media/ vorhanden", lambda o, c: (o / "media").exists())]) + + f06 = "06-mathml-and-equations.epub" + math_common = [ + ("Gleichungsnummer sichtbar", lambda o, c: "(2.1)" in ch(o) and "(2.2)" in ch(o)), + ("SVG-Formel als SVG-Bild", lambda o, c: "![Formel E=mc²](" in ch(o)), + ("Bildformel als Bild", lambda o, c: "![Summe über i](" in ch(o)), + ("unbekanntes MathML nicht verloren", lambda o, c: "menclose" in ch(o))] + case("math-auto", f06, ["--math", "auto"], 0, math_common + [ + ("Bruch", lambda o, c: r"$\frac{a}{b}$" in ch(o)), + ("Hoch/Index", lambda o, c: "$x^{2}$" in ch(o) and "$a_{i}$" in ch(o) and "$x_{i}^{2}$" in ch(o)), + ("Block-Formel", lambda o, c: "$$\ne^{i \\pi } + 1 = 0\n$$" in ch(o) or "$$\ne^{i \\pi} + 1 = 0\n$$" in ch(o)), + ("TeX-Annotation bevorzugt", lambda o, c: r"$\vec{y}$" in ch(o)), + ("TeX aus alt-Bild", lambda o, c: r"\int_0^1 x\,dx = \frac{1}{2}" in ch(o)), + ("Warnungen im Report", lambda o, c: {"mathml-kept", "formula-image"} <= {w["code"] for w in report(o)["warnings"]}), + ("SVG-Formel als svg im Report", lambda o, c: "svg" in {f["origin"] for f in report(o)["formulas"]["items"]})]) + case("math-tex", f06, ["--math", "tex"], 0, math_common + [ + ("\\( \\) Begrenzer", lambda o, c: r"\(\frac{a}{b}\)" in ch(o)), + ("keine $-Begrenzer", lambda o, c: not re.search(r"(?ab" in ch(o))]) + case("math-image", f06, ["--math", "image"], 0, math_common + [("TeX-Bild als Bild", lambda o, c: "tex" in " ".join(m["source"] for m in report(o)["media"]["files"]))]) + + f07 = "07-footnotes-and-endnotes.epub" + fn_common = [("Fußnotentext vorhanden", lambda o, c: "Erster Absatz mit" in read(o, "chapters/*.md") and "Zweiter Absatz." in read(o, "chapters/*.md")), + ("Endnote vorhanden", lambda o, c: "Eine Endnote mit Rücklink." in read(o, "chapters/*.md")), + ("Formel in Fußnote", lambda o, c: "x^{3}" in read(o, "chapters/*.md")), + ("kaputter Verweis gewarnt", lambda o, c: any(w["code"] == "noteref-unresolved" for w in report(o)["warnings"]))] + case("footnotes-end-of-chapter", f07, ["--footnotes", "end-of-chapter"], 0, fn_common + [ + ("Mehrfachverweis auf eine Definition", lambda o, c: ch(o, "001").count("[^fn-001]") == 3), + ("keine Doppelung im Fließtext", lambda o, c: ch(o, "001").count("Erster Absatz") == 1)]) + case("footnotes-end-of-book", f07, ["--footnotes", "end-of-book"], 0, fn_common + [ + ("Anmerkungsdatei", lambda o, c: (o / "chapters" / "999-anmerkungen.md").exists()), + ("Rücklinks", lambda o, c: "[↩](001-" in ch(o, "999"))]) + case("footnotes-inline", f07, ["--footnotes", "inline"], 0, fn_common + [("inline", lambda o, c: "(Anmerkung: Erster Absatz" in ch(o, "001"))]) + case("footnotes-keep-links", f07, ["--footnotes", "keep-links"], 0, fn_common + [("Links", lambda o, c: "](#fn1)" in ch(o, "001"))]) + + f08 = "08-glossary-bibliography-index.epub" + case("special-sections", f08, [], 0, [ + ("Typen erkannt", lambda o, c: {s["type"] for s in report(o)["special_sections"]} >= {"glossary", "bibliography", "index", "index-persons"}), + ("Glossarbegriff", lambda o, c: "**API:** Application Programming Interface" in ch(o)), + ("mehrteilige Definition", lambda o, c: "Selbstaufruf einer Funktion." in ch(o) and "Siehe auch Iteration." in ch(o)), + ("Literatur vollständig", lambda o, c: all(s in ch(o) for s in ("ISBN 978-0-201-89683-1", "S. 1–650", "(Zugriff: 01.02.2024)"))), + ("DOI/URL als Link", lambda o, c: "](https://doi.org/10.1000/xyz123)" in ch(o) and "](https://example.org/seite?a=1&b=2)" in ch(o)), + ("Registerlink ins Kapitel", lambda o, c: "[17](001-hauptteil.md#idx-rekursion)" in ch(o)), + ("Seitenzahl als Text", lambda o, c: ", 42" in ch(o)), + ("Untereintrag eingerückt", lambda o, c: "– endständige, 45" in ch(o)), + ("Buchstabengruppen", lambda o, c: "## R" in ch(o)), + ("Querverweis ins Glossar", lambda o, c: "[API](002-glossar.md#g-api)" in ch(o, "001"))]) + + case("complex-table", "09-complex-table.epub", [], 0, [ + ("HTML-Tabelle mit colspan/rowspan", lambda o, c: 'rowspan="2"' in ch(o) and 'colspan="2"' in ch(o)), + ("Zellinhalte vollständig", lambda o, c: all(s in ch(o) for s in ("1,5", "korrigiert", "2,0", "ausgefallen", "siehe Text", "Messwerte"))), + ("Warnung", lambda o, c: any(w["code"] == "table-html" for w in report(o)["warnings"]))]) + + f12 = "12-encoding-edge-cases.epub" + enc_text = "Ärger über Öl und Straße – „deutsche Anführungszeichen“, ‚einfach‘ und «guillemets» — café, crème brûlée" + case("slug-de", f12, ["--slug-language", "de"], 0, [("ä→ae, ß→ss", lambda o, c: (o / "chapters/001-groesse-uebermass.md").exists()), + ("Text unverändert UTF-8", lambda o, c: enc_text in ch(o) and "αβγ ΔΣΩ" in ch(o) and "∀x∈ℝ: x² ≥ 0" in ch(o))]) + case("slug-en", f12, ["--slug-language", "en"], 0, [("ä→a", lambda o, c: (o / "chapters/001-grosse-ubermass.md").exists())]) + case("slug-none", f12, ["--slug-language", "none"], 0, [("Unicode", lambda o, c: (o / "chapters/001-größe-übermaß.md").exists())]) + + f02 = "02-minimal-epub3-nav.epub" + case("copy-original", f02, ["--copy-original"], 0, [("source/", lambda o, c: (o / "source" / f02).exists())]) + case("no-copy-original", f02, ["--no-copy-original"], 0, [("kein source/", lambda o, c: not (o / "source").exists())]) + case("toc", f02, ["--toc"], 0, [("README mit TOC", lambda o, c: "## Inhaltsverzeichnis" in (o / "README.md").read_text())]) + case("no-toc", f02, ["--no-toc"], 0, [("kein README", lambda o, c: not (o / "README.md").exists())]) + case("frontmatter", f02, ["--frontmatter"], 0, [("YAML", lambda o, c: ch(o).startswith("---\n"))]) + case("no-frontmatter", f02, ["--no-frontmatter"], 0, [("kein YAML", lambda o, c: not ch(o).startswith("---"))]) + rep = OUT / "reports" / "r.json" + case("report", f02, ["--report", str(rep)], 0, [("Report-Datei", lambda o, c: json.loads(rep.read_text())["tool"] == "epub2md")]) + + # --- force / existing directory + fo = OUT / "force-target" + case("force-1-initial", f02, [], 0, out=fo) + case("force-2-exists-no-force", f02, [], 1, [("Meldung", lambda o, c: "existiert bereits" in c.stderr)], out=fo, keep=True, validate_out=False) + (fo / "zusatz.txt").write_text("x") + case("force-3-with-force", f02, ["--force"], 0, [("ersetzt", lambda o, c: not (o / "zusatz.txt").exists())], out=fo, keep=True) + foreign = OUT / "fremd" + foreign.mkdir() + (foreign / "wichtig.txt").write_text("nicht löschen") + case("force-4-foreign-dir", f02, ["--force"], 1, [("fremder Ordner bleibt", lambda o, c: (o / "wichtig.txt").exists())], + out=foreign, keep=True, validate_out=False) + + # --- invalid options + case("invalid-math", f02, ["--math", "foo"], 1, [("Meldung", lambda o, c: "invalid choice" in c.stderr)], validate_out=False) + case("invalid-chapter-level", f02, ["--chapter-level", "0"], 1, validate_out=False) + case("invalid-chapter-level-text", f02, ["--chapter-level", "x"], 1, validate_out=False) + case("missing-input", "/nonexistent/datei.epub", [], 1, validate_out=False) + case("unknown-option", f02, ["--gibt-es-nicht"], 1, validate_out=False) + + # --- broken input + case("broken-container", "11-broken-container.epub", [], 2, [("verständlich", lambda o, c: "OPF" in c.stderr), ("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False) + case("broken-spine", "11b-broken-spine.epub", [], 0, [("Fehler im Report", lambda o, c: report(o)["status"] == "success-with-errors")]) + case("broken-spine-strict", "11b-broken-spine.epub", ["--strict"], 3, [("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False) + case("broken-opf-xml", "11c-broken-opf-xml.epub", [], 3, [("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False) + case("missing-spine-file", "11d-missing-spine-file.epub", [], 3, [("verständlich", lambda o, c: "Spine" in c.stderr)], validate_out=False) + case("not-a-zip", "11e-not-a-zip.epub", [], 2, validate_out=False) + case("missing-container", "11f-missing-container.epub", [], 2, validate_out=False) + case("broken-xhtml", "11g-broken-xhtml.epub", [], 0, [("Text gerettet", lambda o, c: "nicht geschlossen" in ch(o)), + ("Hinweis html-fallback/Entity", lambda o, c: True)]) + case("strict-ok", "01-minimal-epub2.epub", ["--strict"], 0) + + # --- security + escape = Path("/tmp/qa-epub2md-escape.txt") + escape.unlink(missing_ok=True) + case("malicious", "10-malicious-paths.epub", [], 0, [ + ("nichts außerhalb geschrieben", lambda o, c: not escape.exists() and not (ROOT / "outside.txt").exists() + and not (QA / "outside.txt").exists() and not (OUT / "outside.txt").exists()), + ("alle Dateien im Zielordner", lambda o, c: all(o.resolve() in p.resolve().parents for p in o.rglob("*"))), + ("unsichere Einträge gemeldet", lambda o, c: any(e["code"] == "unsafe-zip-path" for e in report(o)["errors"])), + ("unsicherer Manifest-Pfad gemeldet", lambda o, c: any(e["code"] == "unsafe-href" for e in report(o)["errors"]))]) + case("malicious-strict", "10-malicious-paths.epub", ["--strict"], 2, [("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False) + + # --- write error: parent directory not writable + ro = OUT / "readonly" + ro.mkdir() + os.chmod(ro, stat.S_IRUSR | stat.S_IXUSR) + try: + case("write-error", f02, [], 1, [("verständliche Meldung", lambda o, c: "Traceback" not in c.stderr and c.stderr.strip() != "")], + out=ro / "ziel", validate_out=False) + finally: + os.chmod(ro, stat.S_IRWXU) + + # --- typography (P1–P7): CSS emphasis, invisible chars, scene breaks, poems, boxes, styles, page markers + f14 = "14-typography.epub" + body = lambda o: ch(o, "001") # noqa: E731 + boxes = lambda o: ch(o, "002") # noqa: E731 + case("typo-default", f14, [], 0, [ + ("CSS-kursiv/fett/unterstrichen", lambda o, c: "Ein *kursives* und **fettes** Wort, unterstrichen." in body(o)), + ("display:none entfällt ohne Leerzeichen-Rest", lambda o, c: "VERSTECKT" not in body(o) and "." in body(o)), + ("@media print ignoriert", lambda o, c: "***kursives***" not in body(o)), + ("unsichtbare Zeichen im Text entfernt", lambda o, c: "Nullbreite und weiches Trennzeichen." in body(o)), + ("unsichtbare Zeichen im Code erhalten", lambda o, c: "x = 1\u200b # bleibt" in body(o)), + ("Szenenwechsel", lambda o, c: "Szene eins endet hier.\n\n* * *\n\nSzene zwei beginnt hier." in body(o)), + ("Gedicht als Zitatblock", lambda o, c: "> Zeile eins\\\n> Zeile zwei" in body(o)), + ("semantic: , keine Stile", lambda o, c: "markierter" in body(o) and "style=" not in body(o)), + ("Kasten mit Klassentyp und Titel ohne Symbol", lambda o, c: "> [!NOTE]\n> **Wichtige Regel**\n>\n> Immer testen." in boxes(o)), + ("aside tip", lambda o, c: "> [!TIP]\n> Zwischendurch speichern." in boxes(o)), + ("keine Seitenmarken", lambda o, c: "S. 12" not in body(o)), + ("Bericht", lambda o, c: report(o)["layout"] == {"scene_breaks": 1, "poems": 1, "boxes": 2, "page_markers": 0} + and report(o)["text_cleanup"]["invisible_chars_removed"] == 3 and report(o)["css"]["hidden_elements"] == 1), + ("Buch-README mit Pandoc-Filter", lambda o, c: "--lua-filter" in (o / "README.md").read_text(encoding="utf-8"))]) + case("typo-no-css-emphasis", f14, ["--no-css-emphasis"], 0, [ + ("keine CSS-Hervorhebung, Verstecktes bleibt", lambda o, c: "Ein kursives und fettes Wort" in body(o) and "VERSTECKT" in body(o))]) + case("typo-keep-invisible-chars", f14, ["--keep-invisible-chars"], 0, [ + ("Zeichen erhalten", lambda o, c: "Null\u200bbreite" in body(o) and "wei\u00adches" in body(o)), + ("Bericht 0", lambda o, c: report(o)["text_cleanup"]["invisible_chars_removed"] == 0)]) + case("typo-styles-html", f14, ["--styles", "html"], 0, [ + ("Farbe/Größe/Kapitälchen/Hintergrund", lambda o, c: all(x in body(o) for x in ( + 'roter', 'großer', + 'Kapitälchen', + 'markierter'))), + ("Sprache", lambda o, c: 'an English phrase' in body(o)), + ("Ausrichtung", lambda o, c: '
\n\nZentrierte Widmung\n\n
' in body(o)), + ("Grundschrift nicht wiederholt", lambda o, c: "Georgia" not in body(o))]) + case("typo-page-markers-visible", f14, ["--page-markers", "visible"], 0, [ + ("Marker", lambda o, c: "Seite elf endet\\[S. 12\\] und Seite zwölf geht weiter\\[S. 13\\] bis dreizehn." in body(o)), + ("Bericht", lambda o, c: report(o)["layout"]["page_markers"] == 2)]) + case("typo-page-markers-comment", f14, ["--page-markers", "comment"], 0, [ + ("Kommentar", lambda o, c: "endet und" in body(o) and "weiter bis" in body(o))]) + case("typo-boxes-quote", f14, ["--boxes", "quote"], 0, [ + ("Zitatblock ohne [!", lambda o, c: "> **Wichtige Regel**\n>\n> Immer testen." in boxes(o) and "[!" not in boxes(o))]) + case("typo-boxes-html", f14, ["--boxes", "html"], 0, [ + ("div mit Typklasse", lambda o, c: '
' in boxes(o) and '
' in boxes(o))]) + case("typo-boxes-plain", f14, ["--boxes", "plain"], 0, [ + ("normaler Text", lambda o, c: "Immer testen." in boxes(o) and "> " not in boxes(o) and " [!NOTE]", "Zwischendurch speichern.")))]) + merge_case("merge-again-no-force", OUT / "typo-default", 1, [("Meldung", lambda o, c: c.stderr.strip() != "")]) + merge_case("merge-missing-dir", OUT / "gibt-es-nicht", 1) + if shutil.which("pandoc"): + src = OUT / "typo-default" / "14-typography.md" + cp = subprocess.run(["pandoc", str(src), "-s", "--lua-filter", str(ROOT / "pandoc" / "callouts.lua"), "-o", "-", + "-t", "html"], capture_output=True, text=True, timeout=120) + good = cp.returncode == 0 and 'class="callout callout-note"' in cp.stdout and "[!NOTE]" not in cp.stdout \ + and "Tipp" in cp.stdout + results.append({"case": "pandoc-callouts-filter", "ok": good, "rc": cp.returncode, + "notes": ["Kästen als div.callout, keine [!…]-Marken" if good else cp.stderr[-300:]]}) + print(("PASS " if good else "FAIL ") + "pandoc-callouts-filter") + cp = subprocess.run(["pandoc", str(src), "-s", "--lua-filter", str(ROOT / "pandoc" / "book.lua"), "-t", "html"], + capture_output=True, text=True, timeout=120) + h = cp.stdout + pos = [h.find(x) for x in ('id="title-block-header"', 'id="TOC"', 'class="chapter-start"')] + good = cp.returncode == 0 and -1 not in pos and pos == sorted(pos) and h.count('id="title-block-header"') == 1 \ + and "epub2md-toc" not in h and 'href="#' in h[pos[1]:pos[2]] + results.append({"case": "pandoc-book-filter", "ok": good, "rc": cp.returncode, + "notes": ["Titelkopf → Inhaltsverzeichnis → Kapitel, Platzhalter ersetzt" if good else f"Reihenfolge {pos}"]}) + print(("PASS " if good else "FAIL ") + "pandoc-book-filter") + step1 = OUT / "typo-step1.html" + subprocess.run(["pandoc", str(src), "-s", "--lua-filter", str(ROOT / "pandoc" / "book.lua"), "-o", str(step1)], + capture_output=True, text=True, timeout=120) + cp = subprocess.run(["pandoc", str(step1), "-s", "--lua-filter", str(ROOT / "pandoc" / "book.lua"), "-t", "latex"], + capture_output=True, text=True, timeout=120, cwd=OUT) + t = cp.stdout + good = cp.returncode == 0 and t.count("\\tableofcontents") == 1 and "\\maketitle" not in t \ + and t.index("Typografie}") < t.index("\\tableofcontents") and "a4paper" in t + results.append({"case": "pandoc-book-filter-latex", "ok": good, "rc": cp.returncode, + "notes": ["LaTeX: Titel → \\tableofcontents, kein \\maketitle, A4" if good else "LaTeX-Aufbau falsch"]}) + print(("PASS " if good else "FAIL ") + "pandoc-book-filter-latex") + hdr = OUT / "header.html" + hdr.write_text("\n", encoding="utf-8") + cp = subprocess.run(["pandoc", str(src), "-s", "-H", str(hdr), "--lua-filter", str(ROOT / "pandoc" / "callouts.lua"), + "-t", "html"], capture_output=True, text=True, timeout=120) + good = cp.returncode == 0 and ".callout-tip {" in cp.stdout + results.append({"case": "pandoc-callouts-filter-with-H", "ok": good, "rc": cp.returncode, + "notes": ["Kasten-CSS trotz -H vorhanden" if good else "Kasten-CSS fehlt bei -H"]}) + print(("PASS " if good else "FAIL ") + "pandoc-callouts-filter-with-H") + epub = OUT / "typo.epub" + cp = subprocess.run(["pandoc", str(src), "--lua-filter", str(ROOT / "pandoc" / "callouts.lua"), "-o", str(epub)], + capture_output=True, text=True, timeout=120) + import zipfile + with zipfile.ZipFile(epub) as z: + css = "".join(z.read(n).decode() for n in z.namelist() if n.endswith(".css")) + chapters = [z.read(n).decode() for n in z.namelist() if "/ch0" in n] + good = cp.returncode == 0 and ".callout-tip {" in css and "font-family" in css \ + and all("stylesheet" in c for c in chapters) and any('class="callout' in c for c in chapters) + results.append({"case": "pandoc-callouts-filter-epub", "ok": good, "rc": cp.returncode, + "notes": ["EPUB: Kasten-CSS + Standard-CSS, in allen Kapiteln verlinkt" if good else "EPUB-CSS fehlt"]}) + print(("PASS " if good else "FAIL ") + "pandoc-callouts-filter-epub") + + # --- determinism + a = case("determinism-a", "06-mathml-and-equations.epub", [], 0, out=OUT / "det-a") + b = case("determinism-b", "06-mathml-and-equations.epub", [], 0, out=OUT / "det-b") + diff = [p.name for p in (OUT / "det-a/chapters").glob("*.md") if p.read_bytes() != (OUT / "det-b/chapters" / p.name).read_bytes()] + results.append({"case": "determinism", "ok": not diff and a["ok"] and b["ok"], "notes": [f"abweichende Dateien: {diff}"] if diff else ["Kapiteldateien identisch"]}) + + (QA / "output" / "matrix-results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") + failed = [r["case"] for r in results if not r["ok"]] + print(f"\n{len(results)} Fälle, {len(results) - len(failed)} ok, {len(failed)} fehlgeschlagen: {failed}") + + +if __name__ == "__main__": + main() diff --git a/tools/qa/validate_output.py b/tools/qa/validate_output.py new file mode 100755 index 0000000..3ef642f --- /dev/null +++ b/tools/qa/validate_output.py @@ -0,0 +1,218 @@ +"""Unabhängiger Validator für epub2md-Ausgaben (nutzt markdown-it-py, nicht epub2md/linkcheck.py). + +Aufruf: python tools/qa/validate_output.py AUSGABEVERZEICHNIS [...] -> JSON auf stdout +""" + +from __future__ import annotations + +import ast +import hashlib +import json +import posixpath +import re +import sys +import unicodedata +from pathlib import Path +from urllib.parse import unquote + +import yaml +from markdown_it import MarkdownIt +from mdit_py_plugins.dollarmath import dollarmath_plugin +from mdit_py_plugins.footnote import footnote_plugin + +MD = MarkdownIt("commonmark").enable(["table", "strikethrough"]).use(footnote_plugin).use(dollarmath_plugin) +ATTR = re.compile(r"""\b(href|src|id)\s*=\s*"([^"]*)\"""") +NAV_RESIDUE = re.compile(r"^\s*(next|previous|prev|zurück|weiter|vorherige seite|nächste seite|table of contents|back to top)\s*$", re.I) +ENTITY_IN_CODE = re.compile(r"&(lt|gt|amp|quot|#\d+|#x[0-9a-f]+);", re.I) + + +def gh_slug(text: str) -> str: + text = unicodedata.normalize("NFC", text).strip().lower() + text = re.sub(r"[^\w\- ]", "", text) + return text.replace(" ", "-") + + +def split_frontmatter(text: str) -> tuple[dict | None, str, str | None]: + if not text.startswith("---\n"): + return None, text, None + end = text.find("\n---\n", 4) + if end < 0: + return None, text, "Frontmatter nicht geschlossen" + try: + data = yaml.safe_load(text[4:end]) + except yaml.YAMLError as exc: + return None, text[end + 5 :], f"YAML ungültig: {exc}" + return data, text[end + 5 :], None + + +def walk(tokens): + for t in tokens: + yield t + if t.children: + yield from walk(t.children) + + +def analyse(rel: str, body: str) -> dict: + tokens = MD.parse(body) + info: dict = {"links": [], "anchors": set(), "fn_refs": set(), "fn_defs": set(), "code": [], "issues": []} + lines = body.split("\n") + heading_next = False + for t in walk(tokens): + if t.type == "heading_open": + heading_next = True + elif t.type == "inline" and heading_next: + heading_next = False + plain = re.sub(r"<[^>]+>", "", "".join(c.content for c in (t.children or []) if c.type in ("text", "code_inline"))) + info["anchors"].add(gh_slug(plain)) + if t.type == "link_open": + info["links"].append(t.attrs.get("href", "")) + elif t.type == "image": + info["links"].append(t.attrs.get("src", "")) + elif t.type in ("html_inline", "html_block"): + for kind, value in ATTR.findall(t.content): + if kind == "id": + info["anchors"].add(value) + else: + info["links"].append(value) + elif t.type == "footnote_ref": + info["fn_refs"].add(t.meta.get("label")) + elif t.type == "footnote_block_open": + pass + elif t.type == "footnote_open": + info["fn_defs"].add(t.meta.get("label")) + elif t.type == "fence": + info["code"].append((t.info.strip(), t.content, t.map)) + # unclosed fence: markdown-it lets it run to the end of the document + if t.map and t.map[1] >= len(lines) and not re.match(r"^\s*(`{3,}|~{3,})\s*$", lines[t.map[1] - 1] if t.map[1] - 1 < len(lines) else ""): + info["issues"].append(("high", "unclosed-fence", f"Code-Fence ab Zeile {t.map[0] + 1} nicht geschlossen")) + # raw footnote definitions that markdown-it did not pick up (e.g. broken indentation) + for m in re.finditer(r"^\[\^([^\]]+)\]:", body, re.M): + info["fn_defs"].add(m.group(1)) + for ln in lines: + if NAV_RESIDUE.match(ln): + info["issues"].append(("medium", "nav-residue", f"Navigationsrest: {ln.strip()!r}")) + for lang, code, rng in info["code"]: + if ENTITY_IN_CODE.search(code): + info["issues"].append(("high", "entity-in-code", f"HTML-Entity im Codeblock (Zeile {rng[0] + 1}): {ENTITY_IN_CODE.search(code).group(0)}")) + if lang == "python": + try: + ast.parse(code) + except TabError as exc: # mixed tabs/spaces: inherited from the source, not a conversion defect + info["issues"].append(("info", "python-tabs", f"Tab/Leerzeichen gemischt (Zeile {rng[0] + 1}): {exc.msg}")) + except IndentationError as exc: + info["issues"].append(("high", "python-indentation", f"Python-Einrückung kaputt (Zeile {rng[0] + 1}): {exc.msg}")) + except SyntaxError: + info["issues"].append(("info", "python-syntax", f"Python-Snippet nicht vollständig parsebar (Zeile {rng[0] + 1})")) + if "�" in body: + info["issues"].append(("medium", "replacement-char", "Ersatzzeichen U+FFFD in der Ausgabe")) + return info + + +def validate(out: Path) -> dict: + result: dict = {"output": str(out), "issues": [], "stats": {}} + + def issue(sev: str, code: str, msg: str, file: str = "") -> None: + result["issues"].append({"severity": sev, "code": code, "message": msg, "file": file}) + + md_files = sorted(p for p in out.rglob("*.md") if "diagnostics" not in p.parts) + analysed: dict[str, dict] = {} + bodies: dict[str, str] = {} + for p in md_files: + rel = p.relative_to(out).as_posix() + text = p.read_text(encoding="utf-8") + fm, body, err = split_frontmatter(text) + if err: + issue("high", "frontmatter", err, rel) + if rel.startswith("chapters/") and fm is not None: + for key in ("book_title", "chapter_number", "chapter_title", "source_epub", "conversion_status"): + if key not in fm: + issue("medium", "frontmatter-key", f"Frontmatter-Schlüssel fehlt: {key}", rel) + if rel.startswith("chapters/") and not body.strip(): + issue("high", "empty-chapter", "Kapiteldatei ohne Inhalt", rel) + bodies[rel] = body + a = analyse(rel, body) + analysed[rel] = a + for sev, code, msg in a["issues"]: + issue(sev, code, msg, rel) + # links + existing = {p.relative_to(out).as_posix() for p in out.rglob("*") if p.is_file()} + n_links = 0 + for rel, a in analysed.items(): + for link in a["links"]: + n_links += 1 + if not link or re.match(r"^[a-z][a-z0-9+.-]*:", link, re.I): + continue + target, _, frag = link.partition("#") + target, frag = unquote(target), unquote(frag) + resolved = posixpath.normpath(posixpath.join(posixpath.dirname(rel), target)) if target else rel + if resolved.startswith(".."): + issue("critical", "link-outside", f"Link verlässt Ausgabeordner: {link}", rel) + elif resolved not in existing: + issue("high", "broken-link", f"Ziel fehlt: {link}", rel) + elif frag and resolved in analysed and frag not in analysed[resolved]["anchors"] \ + and not frag.startswith("fn"): # markdown-it footnote ids are renderer-specific + issue("high", "broken-anchor", f"Anker fehlt: {link}", rel) + missing_defs = a["fn_refs"] - a["fn_defs"] + unused_defs = a["fn_defs"] - a["fn_refs"] + if missing_defs: + issue("high", "footnote-missing-def", f"Fußnotenverweise ohne Definition: {sorted(missing_defs)}", rel) + if unused_defs: + issue("medium", "footnote-unused-def", f"Fußnotendefinitionen ohne Verweis: {sorted(unused_defs)}", rel) + # duplicate chapter bodies + seen: dict[str, str] = {} + for rel, body in bodies.items(): + norm = re.sub(r"\s+", " ", body).strip() + if rel.startswith("chapters/") and len(norm) > 200: + h = hashlib.sha1(norm.encode()).hexdigest() + if h in seen: + issue("medium", "duplicate-chapter", f"Inhalt identisch mit {seen[h]}", rel) + seen[h] = rel + # JSON / YAML files + for name in ("manifest.json", "conversion-report.json"): + p = out / name + if not p.exists(): + issue("high", "missing-file", f"{name} fehlt") + continue + try: + data = json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + issue("high", "json-invalid", f"{name}: {exc}") + continue + for ch in data.get("chapters", []): + if not (out / ch["file"]).is_file(): + issue("high", "json-ref-missing", f"{name}: Kapitel fehlt {ch['file']}") + media = data.get("media", []) + media = media.get("files", []) if isinstance(media, dict) else media + for m in media: + if not (out / m["file"]).is_file(): + issue("high", "json-ref-missing", f"{name}: Medium fehlt {m['file']}") + if name == "conversion-report.json": + n_files = len([p for p in md_files if p.parts[-2] == "chapters"]) + if len(data["chapters"]) != n_files: + issue("medium", "report-mismatch", f"Bericht nennt {len(data['chapters'])} Kapitel, vorhanden {n_files}") + if data.get("warnings") and data.get("lossless") not in (False,): + issue("medium", "report-claims", "Bericht mit Warnungen behauptet nicht lossless=false") + result["report_status"] = data.get("status") + result["report_warnings"] = len(data.get("warnings", [])) + if (out / "metadata.yaml").exists(): + try: + yaml.safe_load((out / "metadata.yaml").read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + issue("high", "yaml-invalid", f"metadata.yaml: {exc}") + result["stats"] = { + "markdown_files": len(md_files), + "links_checked": n_links, + "code_blocks": sum(len(a["code"]) for a in analysed.values()), + "footnote_refs": sum(len(a["fn_refs"]) for a in analysed.values()), + "media_files": len([e for e in existing if e.startswith("media/")]), + } + counts: dict[str, int] = {} + for i in result["issues"]: + counts[i["severity"]] = counts.get(i["severity"], 0) + 1 + result["severity_counts"] = counts + return result + + +if __name__ == "__main__": + results = [validate(Path(p)) for p in sys.argv[1:]] + print(json.dumps(results, ensure_ascii=False, indent=2)) diff --git a/tools/qa/worddiff.py b/tools/qa/worddiff.py new file mode 100755 index 0000000..3799f27 --- /dev/null +++ b/tools/qa/worddiff.py @@ -0,0 +1,37 @@ +"""Vergleicht die Wörter zweier epub2md-Ausgaben (Datenverlust-Check nach einer Codeänderung). + +Aufruf: python tools/qa/worddiff.py ALT_AUSGABE NEU_AUSGABE +Gezählt werden die Wörter aller chapters/*.md ohne Frontmatter, Markup, Link- und Bildziele. Gibt die Wörter +aus, die nur in einer der beiden Ausgaben vorkommen (Multimengen-Differenz). +""" + +from __future__ import annotations + +import collections +import glob +import re +import sys + + +def words(out_dir: str) -> collections.Counter[str]: + counter: collections.Counter[str] = collections.Counter() + for path in sorted(glob.glob(out_dir + "/chapters/*.md")): + with open(path, encoding="utf-8") as fh: + text = fh.read() + text = re.sub(r"\A---\n.*?\n---\n", "", text, flags=re.S) + text = re.sub(r"\]\([^)]*\)|<[^>]+>|[*_`#>|\\\[\]↪➥]", " ", text) + counter.update(re.findall(r"\w+", text)) + return counter + + +def main() -> None: + old, new = words(sys.argv[1]), words(sys.argv[2]) + lost, gained = old - new, new - old + print(f"Wörter alt {sum(old.values())}, neu {sum(new.values())}; " + f"nur alt {sum(lost.values())}, nur neu {sum(gained.values())}") + print("nur alt:", lost.most_common(25)) + print("nur neu:", gained.most_common(25)) + + +if __name__ == "__main__": + main()