diff --git a/.gitignore b/.gitignore index 7ac2e8c..8924a81 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ htmlcov/ build/ dist/ -# QA-Läufe (Prompt_Kontrolle.md): erzeugte Daten von tools/qa/ +# QA-Läufe (Prompt_Kontrolle.md) .qa/ .qa-output/ .qa-fixtures/ diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index f1e3639..afb00f6 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -115,14 +115,12 @@ python3 epub2md_merge.py output/meinbuch ``` Ergebnis: `output/meinbuch/meinbuch.md`. Die Datei heißt wie das Original-E-Book, nur mit `.md`. -Bilder und Querverweise funktionieren darin weiter. Am Anfang stehen die Buchdaten (Titel, Autor, Verlag, -Datum als `13.01.2025`), die Pandoc für die Titelseite verwendet. +Bilder und Querverweise funktionieren darin weiter. Nützliche Varianten: ```bash -# mit Inhaltsverzeichnis (Liste der Kapitel) hinter Cover, Titelseite und Impressum – -# für PDFs mit Pandoc nicht nötig, dort baut book.lua ein Verzeichnis mit Seitenzahlen (Kapitel 7) +# mit Inhaltsverzeichnis am Anfang python3 epub2md_merge.py output/meinbuch --toc # an einen anderen Ort schreiben (Bildpfade werden automatisch angepasst) @@ -141,21 +139,14 @@ python3 epub2md_merge.py output/meinbuch --force ### Roman (Teile und Kapitel) -Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Das Programm erkennt sehr große -Teile (über 30 000 Wörter) selbst und legt dann eine kurze Datei für den Teil und eine Datei pro -**Kapitel** an; kurze Abschnitte wie Vorwort oder Anhang bleiben eine Datei. Du musst dafür nichts -einstellen. Soll wirklich jeder Abschnitt mit Unterüberschrift eine eigene Datei werden, gilt: +Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Standardmäßig entsteht eine Datei +pro **Teil**. Das können sehr große Dateien werden. Das Programm warnt dann mit `chapter-large` und +empfiehlt die passende Einstellung. Eine Datei pro **Kapitel** bekommst du so: ```bash python3 epub2md.py roman.epub output/roman --chapter-level 2 ``` -### 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 @@ -163,9 +154,8 @@ python3 epub2md.py python-buch.epub output/python-buch ``` Programmcode bleibt zeichengenau erhalten, inklusive Einrückung. Die Programmiersprache wird nur -eingetragen, wenn sie eindeutig erkennbar ist. Ist ein Buch erkennbar in **einer** Sprache geschrieben, -erhalten auch kurze Codeschnipsel dieses Label (etwa `char ch = 65;` in einem C-Buch). Programmausgaben -bleiben ohne Label. Willst du wirklich alle übrigen Blöcke kennzeichnen, gib die Sprache vor: +eingetragen, wenn sie eindeutig erkennbar ist. Ist das ganze Buch in **einer** Sprache geschrieben, +kannst du sie für alle übrigen Codeblöcke vorgeben: ```bash python3 epub2md.py python-buch.epub output/python-buch --code-language python @@ -187,9 +177,7 @@ python3 epub2md.py mathe.epub output/mathe ``` Formeln, die im E-Book als echte Formeln (MathML oder TeX) vorliegen, werden zu TeX: `$…$` im Text, -`$$…$$` als eigene Zeile. Viele Verlage legen das TeX einer Formel in den Alternativtext eines Formelbildes; -epub2md übernimmt es und passt Schreibweisen an, die Pandoc nicht versteht (z. B. `\"u` → `ü`), ohne den -Inhalt zu ändern. Formeln, die nur als Bild vorliegen, bleiben Bilder und erscheinen in +`$$…$$` als eigene Zeile. Formeln, die nur als Bild vorliegen, bleiben Bilder und erscheinen in `diagnostics/warnings.md`. | Gewünschte Formeldarstellung | Option | @@ -215,55 +203,6 @@ 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: @@ -315,57 +254,28 @@ cd output/meinbuch ``` `meinbuch.md` steht im Folgenden für den Namen der Gesamtdatei. Er entspricht dem Namen des -Original-E-Books. `$P` ist der Ordner mit den Pandoc-Hilfsdateien von epub2md; lege ihn einmal fest: - -```bash -P=~/Python_Programs/epub2md/pandoc # Pfad zu deinem epub2md-Ordner anpassen -``` +Original-E-Books. | Ziel | Befehl | | --- | --- | -| PDF (empfohlen) | `pandoc meinbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua --css $P/print.css --pdf-engine=weasyprint -o meinbuch.pdf` | -| Webseite (HTML) | `pandoc meinbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html` | -| Webseite mit Formeln | wie HTML, zusätzlich `--mathml` | -| E-Book (EPUB) | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.epub` | -| E-Book mit Formeln | wie EPUB, zusätzlich `--mathml` | -| Word | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.docx` | -| LibreOffice | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.odt` | -| PDF mit LaTeX (Bücher mit Formeln) | `pandoc meinbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html && pandoc meinbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex -o meinbuch.pdf` | -| Kindle (AZW3) | erst EPUB wie oben, dann `ebook-convert meinbuch.epub meinbuch.azw3` | - -Die drei Hilfsdateien: - -- **`book.lua`** ordnet den Buchanfang wie im gedruckten Buch: Cover, Titelseite, Impressum, dann das - Inhaltsverzeichnis, jeweils auf einer neuen Seite. Ohne den Filter setzt Pandoc mit `--toc` das - Inhaltsverzeichnis (und seinen eigenen Titelkopf) vor das Cover. Mit `book.lua` deshalb **kein** `--toc` - angeben. Hat das Buch keine eigene Titelseite, setzt der Filter Pandocs Titelkopf hinter das Cover. Das - gilt für HTML und für beide PDF-Wege (beim LaTeX-Weg den Filter in beiden Schritten angeben); EPUB, Word - und LibreOffice bauen ihr Verzeichnis mit `--toc` selbst. Kurze Seiten zwischen Titelseite und Impressum - (Auflagenvermerk, Verlagsbrief, „Über dieses Buch“) bleiben im Vorspann; das Inhaltsverzeichnis folgt vor - dem ersten Teil, Vorwort oder nummerierten Kapitel. -- **`callouts.lua`** zeichnet die Infokästen (`> [!TIP]`). Pandoc in der Ubuntu-Version kennt diese - Schreibweise nicht und druckt „[!TIP]“ sonst einfach mit. In HTML, EPUB und im PDF über weasyprint werden - daraus gerahmte, farbige Kästen mit Titel („Tipp: …“), im PDF über LaTeX und in Word/LibreOffice ein - eingerückter Block mit fettem Titel. -- **`print.css`** liefert das Druck-Layout für weasyprint: A4, Seitenzahlen (nicht auf dem Cover), jedes - Kapitel auf einer neuen Seite, Inhaltsverzeichnis mit Seitenzahlen, Silbentrennung und umbrechende lange - Codezeilen. Ohne sie - übernimmt weasyprint das Bildschirmlayout, und lange Codezeilen werden am Seitenrand abgeschnitten. - -Du musst die Befehle nicht abtippen: Die `README.md` im Ausgabeordner enthält sie fertig, mit allen Pfaden. +| Webseite (HTML) | `pandoc meinbuch.md -s --toc -o meinbuch.html` | +| Webseite mit Formeln | `pandoc meinbuch.md -s --toc --mathml -o meinbuch.html` | +| E-Book (EPUB) | `pandoc meinbuch.md --toc -o meinbuch.epub` | +| Word | `pandoc meinbuch.md -o meinbuch.docx` | +| LibreOffice | `pandoc meinbuch.md -o meinbuch.odt` | +| PDF mit LaTeX | `pandoc meinbuch.md -s -o meinbuch.html && pandoc meinbuch.html --toc --pdf-engine=xelatex -o meinbuch.pdf` | +| PDF ohne LaTeX | `pandoc meinbuch.md --toc --pdf-engine=weasyprint -o meinbuch.pdf` | +| Kindle (AZW3) | `pandoc meinbuch.md --toc -o meinbuch.epub && ebook-convert meinbuch.epub meinbuch.azw3` | **Zum PDF:** -- **Mit weasyprint** (empfohlen für Bücher ohne Formeln) genügt ein Schritt. Tabellen, Farben, - Infokästen und die Gestaltung aus `--styles html` bleiben erhalten. - **Mit LaTeX** immer den Zwischenschritt über HTML nehmen, wie in der Tabelle. Beim direkten Weg von - Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Für - Bücher mit Formeln ist das der richtige Weg, weil weasyprint keine Formeln setzen kann. Farben und - Kastenrahmen gehen dabei verloren; Kästen erscheinen als eingerückter Block mit fettem Titel. `book.lua` - stellt dabei A4 ein und wählt die Schrift DejaVu, die auch Zeichen wie ∈, λ oder ≠ im Fließtext enthält. + Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Bei + Büchern mit Formeln ist der LaTeX-Weg die beste Wahl. +- **Mit weasyprint** genügt ein Schritt; Tabellen bleiben erhalten. Für Bücher mit vielen Formeln ist der + LaTeX-Weg sicherer. -Bei Büchern mit Formeln für HTML und EPUB zusätzlich `--mathml` angeben, sonst bleiben Matrizen und -ähnliche Formeln als TeX-Text stehen. +Bei Büchern mit Formeln für HTML zusätzlich `--mathml` angeben, sonst bleiben Matrizen als TeX-Text stehen. #### Direkt aus den Kapiteldateien @@ -395,8 +305,8 @@ gilt hier derselbe Umweg über HTML. - Die einzelnen Kapiteldateien eignen sich als Abschnitte. Die Metadaten im Datenkopf (Buch, Kapitel, Herkunft) helfen beim Zuordnen von Fundstellen. - `manifest.json` listet alle Kapitel mit Titel, Datei, Wortanzahl und Ankern. -- Sehr große Kapitel teilt das Programm selbst an ihren Unterkapiteln. Hast du `--chapter-level` fest - eingestellt und erscheint die Warnung `chapter-large`, dann mit dem empfohlenen Wert neu umwandeln. +- Achte auf die Warnung `chapter-large`: Sehr große Kapiteldateien lassen sich schlecht zerlegen. Dann + mit höherem `--chapter-level` neu umwandeln. --- @@ -521,14 +431,9 @@ python3 epub2md_merge.py --help | `--dry-run` | nur anzeigen, nichts schreiben | | `--verbose` | ausführliche Ausgabe | | `--force` | frühere Ausgabe ersetzen | -| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard `auto`: Ebene 1, sehr große Kapitel werden an ihren Unterkapiteln geteilt) | +| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) | | `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache | | `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen | -| `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten | -| `--boxes callout/quote/html/plain` | Darstellung von Infokästen | -| `--styles semantic/html` | Farben, Schriften, Größen, Ausrichtung und Sprache als HTML übernehmen (`html`) | -| `--page-markers none/visible/comment` | Seitenzahlen der Druckausgabe als `[S. 123]` oder `` | -| `--no-css-emphasis` | Hervorhebungen nur aus HTML-Tags übernehmen, nicht aus dem Stylesheet des Buches (farbige Markierungen `` bleiben, sie gehören zu `--styles`) | | `--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 deleted file mode 100644 index da79cba..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,124 +0,0 @@ -# Ä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 e4974ce..f48bfdd 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). Änderungen je Version: [CHANGELOG.md](CHANGELOG.md). +Eine Schritt-für-Schritt-Anleitung für Anwender steht in der [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md). Das Projekt enthält zwei Programme: @@ -71,7 +71,7 @@ python3 epub2md.py \ --toc \ --verbose -# Jedes Kapitel unterhalb von h2 als eigene Datei (sonst teilt auto nur sehr große Kapitel) +# Roman mit Teilen (h1) und Kapiteln (h2): ein File pro Kapitel python3 epub2md.py roman.epub output/roman --chapter-level 2 # Nur analysieren, nichts schreiben @@ -88,7 +88,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json | `--format {gfm,commonmark}` | `gfm` | `gfm`: Pipe-Tabellen, `~~durchgestrichen~~`, `[^fn]`-Fußnoten. `commonmark`: Tabellen als HTML, ``, Fußnoten als Anker-Links | | `--extract-media` / `--no-extract-media` | an | Medien nach `media/` extrahieren. Ohne Extraktion stehen Bildreferenzen als Text `[Bild: alt – Pfad]` da (keine kaputten Links) | | `--copy-original` / `--no-copy-original` | aus | Original-EPUB nach `source/` kopieren | -| `--chapter-level {auto,1-6}` | `auto` | Überschriftenebene, ab der eine neue Kapiteldatei beginnt. `auto`: Ebene 1; nur Kapitel mit über 30 000 Wörtern und mindestens zwei Unterkapiteln (z. B. Teile eines Romans) werden an ihren Unterkapiteln geteilt (Info `chapter-split`). Eine Zahl gilt ausnahmslos für das ganze Buch | +| `--chapter-level N` | `1` | Überschriftenebene (1–6), ab der eine neue Kapiteldatei beginnt | | `--toc` / `--no-toc` | an | `README.md` mit Inhaltsverzeichnis, Statistik und Reproduktionsbefehl | | `--frontmatter` / `--no-frontmatter` | an | YAML-Frontmatter in jeder Kapiteldatei | | `--footnotes {keep-links,end-of-chapter,end-of-book,inline}` | `end-of-chapter` | siehe [Fußnoten](#fußnoten-modi) | @@ -97,11 +97,6 @@ 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) | @@ -158,9 +153,9 @@ output/mein-buch/ ## Wie Kapitel gebildet werden 1. Die **OPF-Spine** bestimmt die Lesereihenfolge (nicht Dateinamen, nicht das Inhaltsverzeichnis). -2. Überschriften `h1`…`hN` (N = `--chapter-level`, bei `auto` 1 bzw. 2 in sehr großen Kapiteln) und - semantische Abschnitte (`epub:type`/`role`: chapter, part, appendix, glossary, bibliography, index, - preface, …) beginnen neue Kapiteldateien, auch mitten in einer XHTML-Datei. +2. Überschriften `h1`…`hN` (N = `--chapter-level`) und semantische Abschnitte (`epub:type`/`role`: + chapter, part, appendix, glossary, bibliography, index, preface, …) beginnen neue Kapiteldateien, + auch mitten in einer XHTML-Datei. 3. Eine XHTML-Datei, die nur mit einer tieferen Überschrift beginnt (z. B. eine Datei pro Abschnitt `1.2`), wird an das laufende Kapitel angehängt. 4. Dateien ohne Überschrift (Cover, Titelei, Widmung) werden eigene Kapitel. Ihr Titel kommt aus @@ -170,10 +165,6 @@ output/mein-buch/ - ©/ISBN/„urheberrechtlich“ → „Impressum“ - sonst die ersten acht Wörter - erst als letzte Notlösung der Dateiname - - Unabhängig vom Titel gilt ein erstes Kapitel, das nur aus einem Bild besteht, als Cover (z. B. - „[Titelinfo]“); die Titel „Deckblatt“/„Umschlag“ ergeben ein Cover, „Titel“/„Titelei“ eine Titelseite. - Diese Typen stehen in `manifest.json` und bestimmen in der Gesamtdatei die Stelle des Inhaltsverzeichnisses. 5. Ein HTML-Inhaltsverzeichnis wird übersprungen, wenn NAV/NCX vorhanden ist (keine doppelte Ausgabe). Links darauf zeigen auf `README.md`. 6. Verzeichnisse erhalten immer eine eigene Datei, auch unterhalb von `--chapter-level`, z. B. ein @@ -188,13 +179,10 @@ Verlagsspezifische Heuristiken: Layout-Tabellen mit Klassen wie `Head1`/`Title_d Überschriften. `
` mit `
`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken. Literaturlisten mit eigenen Labels (`[BryL06]`) werden nicht doppelt nummeriert. -**Teile und Kapitel:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), teilt der -Standard `auto` jeden Teil mit über 30 000 Wörtern und mindestens zwei Kapiteln in eine kurze Datei für den -Teil (Überschrift, Einleitung) und eine Datei pro Kapitel; kleinere Kapitel (Vorwort, Anhang) bleiben ganz. -Verzeichnisse (Register, Literatur, Glossar) werden dabei nie zerlegt. `--chapter-level 2` teilt dagegen -jedes Kapitel des Buches an `h2`. Ist bei einer festen Zahl eine Kapiteldatei größer als 30 000 Wörter und -hat sie mindestens zwei `##`-Unterabschnitte, meldet epub2md eine Warnung `chapter-large` mit der -passenden `--chapter-level`-Empfehlung. +**Tipp:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), erzeugt +`--chapter-level 2` eine Datei pro Kapitel. Mit dem Standard `1` entsteht eine Datei pro Teil. +Ist eine Kapiteldatei größer als 30 000 Wörter und hat sie mindestens zwei `##`-Unterabschnitte, meldet +epub2md eine Warnung `chapter-large` mit der passenden `--chapter-level`-Empfehlung. ## Math-Modi @@ -246,44 +234,9 @@ weil CommonMark keine `[^…]`-Syntax kennt. - **Syntax-Highlighting:** Farb-Spans, ``/`` in Listings und Zeilennummern werden entfernt. Einrückung, Tabs, `<`, `>`, `&`, `$`, `|`, Backslashes und Backticks bleiben exakt erhalten. Die Sprache kommt aus Klassen/Attributen (`language-python`, `data-lang`, …) oder starken Inhaltssignalen - (`#include`, Shebang, `>>>`, Python-Code, der sich parsen lässt und `def`/`class`/Kontrollstrukturen - enthält, …). Gedruckte Zeilennummern als Text (`00 #include …`) stören die Erkennung nicht und bleiben - im Block stehen. Sind sich die sicher erkannten Blöcke einig (mindestens 5, davon 90 % eine Sprache), - genügen für die übrigen schwächere Signale: in einem Python-Buch Zuweisungen und Aufrufe, in einem - Buch mit C-artiger Sprache Anweisungen und Deklarationen mit `;` oder `{` (im Bericht - `detected_by: "book-context"`). Programmausgaben (Literale, Zahlen, Text) bleiben ohne Label. - `--code-language python` (o. Ä.) kennzeichnet alle übrigen Blöcke, auch Programmausgaben und - Shell-Befehle im Buch. -- **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`. + (`#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. - **Gescannte Texte:** nur Bilder, kein Text (keine OCR). - **DRM:** wird erkannt und abgelehnt. - **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne @@ -310,15 +263,14 @@ verwendet und das Verzeichnis gibt den Namen vor. | --- | --- | | `-o`, `--output DATEI.md` | anderer Zielort; Medienpfade werden relativ zum Ziel umgeschrieben | | `--frontmatter` / `--no-frontmatter` | ein YAML-Kopf mit Buchmetadaten (`title`, `author`, `lang`, …) für Pandoc (Standard: an) | -| `--toc` | Inhaltsverzeichnis mit Sprunglinks auf die Kapitel einfügen, hinter Cover, Titelseite und Impressum (für Pandoc-PDFs besser `pandoc/book.lua`, das Seitenzahlen und Unterabschnitte liefert) | +| `--toc` | Inhaltsverzeichnis mit Sprunglinks auf die Kapitel einfügen | | `--force` | bestehende Zieldatei überschreiben (sonst Abbruch mit Exit 1) | | `--version` | Programmversion | Beim Zusammenfügen passiert Folgendes: - Die Frontmatter der einzelnen Kapitel wird entfernt. -- Jedes Kapitel erhält einen Startanker (``). Er beginnt mit einem Buchstaben, - weil Pandocs EPUB-Ausgabe Links auf IDs mit führender Ziffer nicht auflöst. +- Jedes Kapitel erhält einen Startanker (``). - Links zwischen Kapiteln (`003-x.md#anker`) werden zu Sprunglinks in der Gesamtdatei. - Anker, die in mehreren Kapiteln vorkommen (z. B. `Sec1` in jedem Kapitel eines Springer-Buchs), werden ab dem zweiten Vorkommen in `c012-Sec1` umbenannt, samt aller Links darauf. @@ -327,15 +279,8 @@ Beim Zusammenfügen passiert Folgendes: - Codeblöcke und Inline-Code werden nie verändert. - Kapitelüberschriften bleiben `#`, jedes Kapitel beginnt also mit einer H1. Pandoc behandelt sie als Kapitel. -- Hinter dem Vorspann (Cover, Titelseite, Impressum und kurze Seiten dazwischen, bis vor den ersten Teil, - das Vorwort oder das erste nummerierte Kapitel) steht ein unsichtbarer Platzhalter - `
`; hat das Buch keine eigene Titelseite, steht hinter dem Cover zusätzlich - `
`. `pandoc/book.lua` setzt dort Inhaltsverzeichnis und Titelkopf ein; - GitHub, Obsidian und andere Programme zeigen die leeren `div`s nicht an. - Der Buch-Kopf enthält nur Felder, die im EPUB vorhanden sind (`title`, `subtitle`, `author`, `lang`, - `publisher`, `date`, `identifier`, `rights`, `description`). Ein ISO-Zeitstempel als Datum wird gekürzt, - damit Pandoc auf der Titelseite nicht `2025-01-13T08:00:00+00:00` druckt: bei deutschen Büchern zu - `13.01.2025`, sonst zu `2025-01-13`. `metadata.yaml` behält den Originalwert. + `publisher`, `date`, `identifier`, `rights`, `description`). Am Ende werden alle Links der Gesamtdatei geprüft. Ein Link auf einen nicht vorhandenen Anker zeigt auf den Kapitelanfang und erzeugt eine Warnung auf stderr. Die Zusammenfassung nennt Kapitelanzahl, @@ -351,54 +296,21 @@ konsistente Anker: ```bash cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei -P=~/Python_Programs/epub2md/pandoc # Pfad zum epub2md-Ordner anpassen -pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua \ - --css $P/print.css --pdf-engine=weasyprint -o programmierbuch.pdf # PDF (empfohlen) -pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o programmierbuch.html -pandoc programmierbuch.md --toc --lua-filter $P/callouts.lua -o programmierbuch.epub +pandoc programmierbuch.md --toc -s -o programmierbuch.html +pandoc programmierbuch.md --toc -o programmierbuch.epub -cd output/mathematikbuch # Formeln: PDF über HTML und LaTeX (weasyprint setzt kein MathML) -pandoc mathematikbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o mathematikbuch.html -pandoc mathematikbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex \ - -o mathematikbuch.pdf -pandoc mathematikbuch.md --toc --mathml --lua-filter $P/callouts.lua -o mathematikbuch.epub # EPUB: MathML +cd output/mathematikbuch +pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html +# PDF über den Zwischenschritt HTML (siehe unten) +pandoc mathematikbuch.html --toc --pdf-engine=xelatex -o mathematikbuch.pdf ``` -Hilfsdateien in `pandoc/`: - -- **`book.lua`** (HTML und beide PDF-Wege; statt `--toc`): Pandocs `--toc` setzt Inhaltsverzeichnis und - Titelkopf immer an den Anfang, vor Cover, Titelseite und Impressum. `epub2md_merge.py` markiert deshalb das - Ende des Vorspanns (`
`, Kapiteltypen aus `manifest.json`) und, falls das Buch - keine eigene Titelseite hat, die Stelle hinter dem Cover (`
`). Der - Filter baut dort das Inhaltsverzeichnis (`--toc-depth` gilt), lässt Pandocs Titelkopf bei vorhandener - Titelseite weg, zeigt das Cover ohne Bildunterschrift und macht aus den Kapitelankern - `div.chapter-start` für Seitenumbrüche. Beim LaTeX-Weg (Filter in beiden Schritten) wird daraus - `\tableofcontents` an derselben Stelle, `\clearpage` vor jedem Kapitel und kein `\maketitle` vor dem Cover. - Außerdem A4 mit 2 cm Rand, das Cover in Seitengröße und – falls installiert und nicht selbst gewählt – - DejaVu-Schriften (`mainfont`, `monofont`, `mathfont`): Latin Modern fehlen Zeichen wie ∈ λ ≠ ≥ □ und - schmale Leerzeichen (im Mathebuch über 10.000 Stellen). ℓ, 〈 〉, ∖, ⋯ und „↩︎“ werden als Mathe-Symbole gesetzt. - Bei anderen Formaten entfernt er nur die Platzhalter. Wer selbst `--toc` angibt, bekommt Pandocs - Verzeichnis wie gewohnt. - - Der Vorspann reicht bis vor das erste Kapitel des Hauptteils (Teil, Vorwort, Einleitung, Prolog, - nummerierter Titel oder mehr als 1000 Wörter), sofern er Cover, Titelseite oder Impressum enthält. Ein - erstes Kapitel nur aus einem Bild gilt als Cover, ein Kapitel „Titel“ als Titelseite. -- **`print.css`** (weasyprint): A4, Seitenzahlen (nicht auf dem Cover), jedes Kapitel (auch Cover, - Titelseite, Impressum) und das Inhaltsverzeichnis auf neuer Seite, Inhaltsverzeichnis mit Punktlinie und - Seitenzahl, Silbentrennung (nicht in Code), umbrechende Codezeilen. Mit `--css` lässt Pandoc sein eigenes - Stylesheet weg, deshalb bringt `print.css` auch Grundschrift, Zeilenabstand und Titelkopf mit. Getestet - mit dem Python-Buch (362 Seiten). - -**Infokästen:** Pandoc kennt die Schreibweise `> [!TIP]` erst ab neueren Versionen (Erweiterung `alerts`); -3.1.3 (Ubuntu 24.04) zeigt „[!TIP]“ als Text. Der Filter `pandoc/callouts.lua` behebt das: In HTML und -EPUB entstehen gerahmte, farbige Kästen mit Titel („Tipp: …“, bei `lang: en` „Tip: …“), in LaTeX/PDF, Word -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 (322 Seiten, 34 Tabellen; alle 831 internen Links haben ein Ziel, kein Zeichen -fehlt). Die `README.md` jedes konvertierten Buches enthält den passenden Befehl. +Getestet mit dem C-Buch (405 Seiten, 34 Tabellen, 728 von 755 internen Links funktionieren; die übrigen +stehen in Codeblöcken innerhalb von Tabellen). Die `README.md` jedes konvertierten Buches enthält den +passenden Befehl. Alternativ direkt aus den Kapiteldateien: @@ -413,10 +325,8 @@ pandoc output/programmierbuch/chapters/*.md \ - Ausgaben mit `--math tex` (`\(…\)`) brauchen `-f markdown+tex_math_single_backslash`. - Das YAML-Frontmatter jeder Kapiteldatei wird von Pandoc als Metadaten gelesen. Wer die Kapiteldateien direkt zusammen verarbeitet, sollte mit `--no-frontmatter` konvertieren oder die Gesamtdatei verwenden. -- TeX aus dem E-Book wird für Pandocs Formelparser angepasst, ohne den Inhalt zu ändern: `\"u` → `ü`, - `\O` (leere Menge) → `\emptyset`, keine `@{…}`-Spaltentrenner und kein `\arraycolsep` in `array`, kein - leeres `\displaystyle` vor einem Zellenende. Seltene Handkonstruktionen (z. B. `\mathinner{…\raise…}`) - bleiben als TeX stehen, mit der Warnung „Could not convert TeX math“ (Mathebuch: 1 von 4986 Formeln). +- Einzelne TeX-Befehle aus EPUBs (z. B. `\O` oder `@`-Konstrukte) kennt Pandocs Formelparser nicht. Er + belässt sie als TeX, mit der Warnung „Could not convert TeX math“. ## Bericht und Diagnose @@ -460,12 +370,7 @@ 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 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). + übernommen, weil CSS nicht ausgewertet wird. ## Architektur @@ -481,7 +386,6 @@ 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 @@ -491,12 +395,6 @@ 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 @@ -514,14 +412,7 @@ ruff check epub2md tests && mypy epub2md --ignore-missing-imports # optional, Die Tests erzeugen synthetische Minimal-EPUBs (EPUB 2/3, NAV/NCX, Code, Tabellen, MathML, SVG, Fußnoten, Glossar/Literatur/Register, unsichere ZIP-Pfade, DRM, XXE, defekte Container, Kodierung) und prüfen die tatsächliche CLI-Ausgabe. `tests/test_merge.py` prüft die Gesamtdatei: Reihenfolge, -Anker-Umbenennung, Pfade, Fußnoten, unveränderter Code, Überschreibschutz, Datumsformat und die Stelle des -Inhaltsverzeichnisses hinter dem Vorspann. `tests/test_styles.py` prüft die Typografie: CSS-Hervorhebungen, -unsichtbare Zeichen, Szenenwechsel, Gedichte, Infokästen, `--styles`, `--page-markers` und die Pandoc-Befehle -in der Buch-README. Liegen EPUBs unter `Beispiele/`, werden sie zusätzlich auf +Anker-Umbenennung, Pfade, Fußnoten, unveränderter Code und Überschreibschutz. Liegen EPUBs unter `Beispiele/`, werden sie zusätzlich auf Struktur-Invarianten geprüft: keine kaputten Links, balancierte Code-Fences, gültiges YAML, alle Medien vorhanden. Dabei werden keine Buchtexte verglichen. `Beispiele/` ist per `.gitignore` vom Repository -ausgeschlossen.`tests/test_pandoc.py` prüft den Pandoc-Weg (nur mit installiertem Pandoc). - -Für gründlichere Prüfungen nach `Prompt_Kontrolle.md` gibt es `tools/qa/`: eine CLI-Matrix über alle Optionen -und Fehlercodes, einen unabhängigen Markdown-Validator, einen Wortvergleich gegen Datenverlust und die -Prüfung des Pandoc-Wegs (HTML, EPUB, PDF) für alle Beispielbücher. Details in `tools/qa/README.md`. +ausgeschlossen. diff --git a/epub2md/__init__.py b/epub2md/__init__.py index 292d78c..9aada3e 100644 --- a/epub2md/__init__.py +++ b/epub2md/__init__.py @@ -1,4 +1,4 @@ """epub2md – konvertiert DRM-freie EPUB-Dateien in kapitelweise Markdown-Sammlungen.""" -__version__ = "0.3.0" +__version__ = "0.1.0" TOOL_NAME = "epub2md" diff --git a/epub2md/chapters.py b/epub2md/chapters.py index 8716c3b..5845852 100644 --- a/epub2md/chapters.py +++ b/epub2md/chapters.py @@ -9,17 +9,7 @@ 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, - remove_invisible, - roles, - text_content, -) +from .utils import classes, epub_types, is_element, iter_elements, local, norm_ws, 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"} @@ -45,8 +35,8 @@ KIND_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ("appendix", re.compile(r"^(anhang|appendix)", re.I)), ("colophon", re.compile(r"^(impressum|colophon|kolophon)", re.I)), ("dedication", re.compile(r"^(widmung|dedication)", re.I)), - ("cover", re.compile(r"^(cover|umschlag|titelbild|deckblatt)\b", re.I)), - ("titlepage", re.compile(r"^(titelei|titelseite|title ?page|titel\s*$|title\s*$)", re.I)), + ("cover", re.compile(r"^(cover|umschlag|titelbild)\b", re.I)), + ("titlepage", re.compile(r"^(titelei|titelseite|title ?page)", re.I)), ("part", re.compile(r"^(teil|part)\s+([IVXLC]+|\d+|[a-zäöü]+)\b", re.I)), ("epilogue", re.compile(r"^(epilog|nachwort|epilogue|afterword)", re.I)), ("prologue", re.compile(r"^(prolog|prologue)", re.I)), @@ -105,11 +95,9 @@ def semantic_types(el: Any) -> list[str]: class ChapterBuilder: def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics, - book_title: str = "", authors: list[str] | None = None, deep_starts: set[Any] | None = None) -> None: + book_title: str = "", authors: list[str] | None = None) -> None: self.level = chapter_level self.diag = diag - self.deep_starts = deep_starts or set() # chapter headings whose sections are split one level deeper - self._deep = False # current chapter is one of them self._special_level = 0 # level of a split-out special heading whose section is still open self._in_special = False # current chapter itself is a bibliography/index/glossary self.book_title = book_title @@ -131,12 +119,6 @@ class ChapterBuilder: if lvl and has_content(el): special = _special_heading(el) if lvl <= self.level: - if update: - self._special_level = 0 - self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS - self._deep = el in self.deep_starts - return Marker(el, "heading", lvl) - if self._deep and lvl == self.level + 1: if update: self._special_level = 0 self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS @@ -204,7 +186,7 @@ class ChapterBuilder: body_segments = segments[1:] if _meaningful(head_items): first = _first_meaningful(head_items) - if chapters and leading_heading_level(first) > self.level + self._deep and not semantic_types(doc.body): + if chapters and leading_heading_level(first) > self.level and not semantic_types(doc.body): chapters[-1].items.extend(pending + head_items) pending = [] if doc.path not in chapters[-1].docs: @@ -288,7 +270,6 @@ 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]: @@ -315,21 +296,12 @@ class ChapterBuilder: kind = classify_title(ch.title) if kind and kind not in ch.types: ch.types.append(kind) - if ch.number == 1 and "cover" not in ch.types and _image_only(ch.items): - ch.types.append("cover") # first chapter with nothing but a picture, whatever its label ('[Titelinfo]') if "index" in ch.types and classify_title(ch.title) == "index-persons": ch.types.remove("index") if not ch.types: ch.types.append("chapter") -def _image_only(items: list[tuple[str, Any]]) -> bool: - """Content is one or more pictures without any text.""" - text = "".join(it if isinstance(it, str) else text_content(it) for _, it in items if isinstance(it, str) or is_element(it)) - return not text.strip() and any(is_element(it) and any(local(e.tag) in ("img", "svg", "image") for e in iter_elements(it)) - for _, it in items) - - @dataclass class _TypeCarrier: types: list[str] diff --git a/epub2md/cli.py b/epub2md/cli.py index 8066c45..4679493 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -46,14 +46,6 @@ def code_language(value: str) -> str: return lang -def _chapter_level(value: str) -> int | str: - if value == "auto": - return value - if value.isdigit() and 1 <= int(value) <= 6: - return int(value) - raise argparse.ArgumentTypeError("auto oder eine Zahl zwischen 1 und 6 erwartet") - - def build_parser() -> argparse.ArgumentParser: p = _Parser( prog="epub2md.py", @@ -69,10 +61,9 @@ def build_parser() -> argparse.ArgumentParser: help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)") p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False, help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)") - p.add_argument("--chapter-level", type=_chapter_level, default="auto", metavar="{auto,1-6}", - help="Überschriftenebene, ab der neue Kapiteldateien entstehen. auto (Standard): Ebene 1, " - "nur sehr große Kapitel (über 30000 Wörter, z. B. Teile eines Romans) werden an ihren " - "Unterkapiteln geteilt. Eine Zahl 1–6 gilt ausnahmslos für das ganze Buch") + p.add_argument("--chapter-level", type=int, default=1, metavar="N", + help="Überschriftenebene 1–6, ab der neue Kapiteldateien entstehen (Standard: 1). " + "Für Bücher mit Teilen (h1) und Kapiteln (h2) ist 2 sinnvoll") p.add_argument("--toc", action=argparse.BooleanOptionalAction, default=True, help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)") p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True, @@ -94,23 +85,6 @@ 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") @@ -145,11 +119,6 @@ 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 7731a9b..0fcc883 100644 --- a/epub2md/codeblocks.py +++ b/epub2md/codeblocks.py @@ -2,10 +2,8 @@ from __future__ import annotations -import ast import json import re -import warnings from typing import Any from .utils import classes, is_element, local @@ -129,114 +127,9 @@ def detect_language(el: Any) -> tuple[str, str]: return "", "" -LINE_NUMBER_PREFIX = re.compile(r"^(\d{1,4})( +|$)") - - -def strip_line_numbers(code: str) -> str: - """Remove printed line numbers ("00 #include …") – for language detection only, the block keeps them. - - Applies when at least 80 % of the non-blank lines (at least two) start with consecutive numbers. - """ - lines = code.split("\n") - filled = [ln for ln in lines if ln.strip()] - matches = [LINE_NUMBER_PREFIX.match(ln) for ln in filled] - numbers = [int(m.group(1)) for m in matches if m] - if len(numbers) < 2 or len(numbers) < 0.8 * len(filled): - return code - if any(b != a + 1 for a, b in zip(numbers, numbers[1:])): - return code - width = min((len(m.group(2)) for m in matches if m and m.group(2)), default=0) - out = [] - for ln in lines: - m = LINE_NUMBER_PREFIX.match(ln) if ln.strip() else None - out.append(ln[len(m.group(1)) + min(width, len(m.group(2))):] if m else ln) - return "\n".join(out) - - -def _python_module(text: str) -> ast.Module | None: - """The parsed module, or None when the text is not valid Python (never executed, warnings suppressed).""" - try: - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - return ast.parse(text) - except (SyntaxError, ValueError, RecursionError, MemoryError): - return None - - -PY_COMPOUND = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.If, ast.For, ast.AsyncFor, ast.While, - ast.With, ast.AsyncWith, ast.Try) -PY_CODE_EXPR = (ast.Call, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.Lambda, ast.Await) - - -def _python_strength(text: str) -> int: - """2 = surely Python (block statement, or import plus code), 1 = Python code (assignment, call, - comprehension …), 0 = not Python or only literals/names – which is what program output looks like.""" - if any(ln.rstrip().endswith((";", "{")) for ln in text.split("\n")): - return 0 - module = _python_module(text) - if module is None or not module.body: - return 0 - imports = sum(isinstance(n, (ast.Import, ast.ImportFrom)) for n in module.body) - code = 0 - for node in module.body: - if isinstance(node, PY_COMPOUND): - return 2 - if isinstance(node, ast.Expr): - value = node.value - if isinstance(value, ast.Constant) and isinstance(value.value, str): - continue # docstring - if not isinstance(value, PY_CODE_EXPR): - return 0 # a bare name, number or literal line is program output ("Jane", "42", "[1, 4]") - code += 1 - elif isinstance(node, ast.AnnAssign) and node.value is None: - pass # "name: value" is YAML or a Makefile rule as often as Python - elif not isinstance(node, (ast.Import, ast.ImportFrom, ast.Pass)): - code += 1 - if imports and code: - return 2 - return 1 if code else 0 - - -CURLY_STATEMENT = re.compile(r"[=(]|^\s*[A-Za-z_]\w*[\s*]+[A-Za-z_*]") # assignment/call or "type name" - - -def _curly_signal(text: str) -> bool: - """C-family syntax: a statement or declaration ending with ';' or '{', or a function body on one line.""" - lines = [re.sub(r"(^|\s)//.*$", "", ln).rstrip() for ln in text.split("\n")] - if any(ln.endswith((";", "{")) and CURLY_STATEMENT.search(ln) for ln in lines): - return True - return bool(re.search(r"\w\s*\([^)]*\)\s*\{.*\}", text)) - - -CURLY_LANGS = {"c", "cpp", "java", "csharp", "javascript", "typescript", "go", "rust", "php", "kotlin", "swift", "scala"} - - -def detect_language_from_book(code: str, book_language: str) -> str: - """Weak content signals, accepted only when they fit the book's dominant language.""" - if not book_language: - return "" - text = strip_line_numbers(code).strip() - if not text: - return "" - if book_language == "python" and _python_strength(text) >= 1: - return "python" - if book_language in CURLY_LANGS and _curly_signal(text): - return book_language - return "" - - -def dominant_language(languages: list[str]) -> str: - """The book's language when the reliably detected blocks agree (at least 5 blocks, 90 %).""" - detected = [lang for lang in languages if lang and lang != "text"] - if len(detected) < 5: - return "" - lang = max(set(detected), key=detected.count) - return lang if detected.count(lang) >= 0.9 * len(detected) else "" - - def detect_language_from_content(code: str) -> str: """Very conservative content heuristics – only strong signals.""" - text = strip_line_numbers(code).strip() + text = code.strip() if not text: return "" first = text.split("\n", 1)[0] @@ -274,8 +167,6 @@ def detect_language_from_content(code: str) -> str: c_like = sum(1 for ln in lines if ln.rstrip().endswith((";", "{"))) if py_signals >= 2 and c_like == 0: return "python" - if re.search(r"^>>> \S", text, re.M) or _python_strength(text) == 2: - return "python" if re.match(r"\s*(SELECT|INSERT\s+INTO|CREATE\s+(TABLE|INDEX|VIEW)|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b", text): return "sql" if re.match(r"(?m)^FROM\s+\S+", text) and re.search(r"(?m)^(RUN|CMD|COPY|ENTRYPOINT)\s", text): diff --git a/epub2md/css.py b/epub2md/css.py deleted file mode 100644 index d385d28..0000000 --- a/epub2md/css.py +++ /dev/null @@ -1,286 +0,0 @@ -"""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 b8de5ad..68c0c4d 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -9,7 +9,6 @@ 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 @@ -18,11 +17,9 @@ 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, @@ -36,7 +33,6 @@ 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, @@ -49,9 +45,7 @@ from .utils import ( local, norm_ws, rel_link, - remove_invisible, resolve_href, - roles, text_content, url_path, ) @@ -99,11 +93,6 @@ 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: @@ -135,23 +124,9 @@ 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")): @@ -201,49 +176,6 @@ 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 = ("```", "~~~", "|", " ", "#", "$$", "\\[", "* * *") @@ -297,20 +229,8 @@ 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, - stylesheet: StyleSheet | None = None, base_style: dict[str, str] | None = None) -> None: - self.base_style = base_style or {} + entry_problem: Callable[[str], str | None] | None = None) -> None: 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 @@ -324,7 +244,6 @@ class Renderer: self.consumed_formula_images: set[str] = set() self.formulas: list[dict[str, str]] = [] self.code_blocks: list[dict[str, Any]] = [] - self.book_code_language = "" # dominant language of the reliably detected blocks (pre-scan) self.endnotes_file = "" # per chapter state self.ch: Chapter | None = None @@ -374,13 +293,13 @@ class Renderer: if doc: self.doc = doc if isinstance(item, str): - buf.append(("t", self._text(item))) + buf.append(("t", md_escape(item, self.in_table))) 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) or item.get(CONSUMED): - continue # markers, comments, siblings already rendered as part of a box + elif isinstance(item, Marker) or not is_element(item): + continue elif self.is_block(item): flush() blocks.extend(self.block(item)) @@ -413,19 +332,6 @@ 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 = [] @@ -455,19 +361,6 @@ 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) @@ -502,238 +395,15 @@ 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 - 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) + blocks = self.flow(el) 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: @@ -770,14 +440,13 @@ 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 = [markers] if markers else [] + out = [] if anchors: out.append(anchors) if not text: @@ -801,9 +470,6 @@ class Renderer: if not lang: lang = detect_language_from_content(code) how = "content" if lang else "" - if not lang: - lang = detect_language_from_book(code, self.book_code_language) - how = "book-context" if lang else "" if not lang and self.opt.code_language: lang, how = self.opt.code_language, "option" if self.ch: @@ -1089,125 +755,24 @@ class Renderer: def inline_children(self, el: Any) -> list[Piece]: pieces: list[Piece] = [] if el.text: - pieces.append(("t", self._text(el.text))) + pieces.append(("t", md_escape(el.text, self.in_table))) for child in el: if is_element(child): pieces.extend(self.inline(child)) if child.tail: - pieces.append(("t", self._text(child.tail))) + pieces.append(("t", md_escape(child.tail, self.in_table))) 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 pre[:1] + self.link(el) if marker else self.link(el) + return self.link(el) if name == "img": return pre + self.image(el) if name == "image": @@ -1238,8 +803,8 @@ class Renderer: finally: self.strong -= 1 if name in ("del", "s", "strike"): - del_open, del_close = ("~~", "~~") if self.opt.format == "gfm" else ("", "") - return pre + self._wrap(self.inline_children(el), del_open, del_close) + marker = ("~~", "~~") if self.opt.format == "gfm" else ("", "") + return pre + self._wrap(self.inline_children(el), marker[0], marker[1]) if name in ("u", "ins", "mark", "kbd", "sup", "sub"): if name in ("sup", "sub"): elems = [c for c in el if is_element(c)] @@ -1276,7 +841,7 @@ class Renderer: core = core[: -len(hard)].rstrip() if not core: return [("t", lead or trail)] if (lead or trail) else [] - return [("t", lead), ("e", f"{open_}{EMPH_SEP}{core}{EMPH_SEP}{close}"), ("t", trail)] + return [("t", lead), ("r", f"{open_}{core}{close}"), ("t", trail)] # ------------------------------------------------------------------ links @@ -1481,16 +1046,7 @@ 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: @@ -1633,12 +1189,6 @@ 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. @@ -1669,31 +1219,11 @@ 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", "data-e2m-")) and k.rsplit("}", 1)[-1] != "style" + and not k.rsplit("}", 1)[-1].lower().startswith("on") 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 fdee4d8..4687944 100644 --- a/epub2md/mathml.py +++ b/epub2md/mathml.py @@ -365,54 +365,7 @@ def strip_tex_delimiters(text: str) -> str: if t.startswith(o) and t.endswith(c) and len(t) >= len(o) + len(c): t = t[len(o) : len(t) - len(c)] break - return portable_tex(t.strip()) - - -UMLAUTS = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"} - - -def portable_tex(tex: str) -> str: - """Publisher TeX → TeX that Pandoc's formula parser (texmath, MathJax, KaTeX) understands, still valid LaTeX: - text accents \\"u → ü, \\O (empty set in Springer books) → \\emptyset, no @{…} column separators in - array specifications, no \\arraycolsep assignments, no empty \\displaystyle before a cell end. Only changes - spacing, never content.""" - tex = re.sub(r'\\"(?:\{([aouAOU])\}|([aouAOU]))', lambda m: UMLAUTS[m.group(1) or m.group(2)], tex) - tex = re.sub(r"\\(?:text|mbox|mathrm)\s*\{\s*\\O\s*\}", r"\\emptyset", tex) - tex = re.sub(r"\\O(?![A-Za-z])", r"\\emptyset", tex) - tex = re.sub(r"\\arraycolsep\s*-?\d*\.?\d+\s*(?:pt|em|ex|mm|cm)", "", tex) - tex = re.sub(r"\\displaystyle\s*(?=\\\\|&|\\end\b)", "", tex) # no-op before a cell end; texmath fails - out: list[str] = [] - pos = 0 - for m in re.finditer(r"\\begin\{array\}\{", tex): - if m.start() < pos: - continue - end = _closing_brace(tex, m.end() - 1) - if end < 0: - break - spec = tex[m.end():end] - i = spec.find("@{") - while i >= 0: - close = _closing_brace(spec, i + 1) - if close < 0: - break - spec = spec[:i] + spec[close + 1:] - i = spec.find("@{") - out.append(tex[pos:m.end()] + spec) - pos = end - return "".join(out) + tex[pos:] - - -def _closing_brace(text: str, open_pos: int) -> int: - """Index of the '}' matching the '{' at ``open_pos`` (-1 if unbalanced).""" - depth = 0 - for i in range(open_pos, len(text)): - if text[i] == "{": - depth += 1 - elif text[i] == "}": - depth -= 1 - if depth == 0: - return i - return -1 + return t.strip() def is_display_tex(text: str) -> bool: diff --git a/epub2md/merge.py b/epub2md/merge.py index 3652e4d..cba21ff 100644 --- a/epub2md/merge.py +++ b/epub2md/merge.py @@ -46,8 +46,6 @@ class ChapterFile: body: str = "" start_anchor: str = "" anchor_map: dict[str, str] = field(default_factory=dict) - types: list[str] = field(default_factory=list) - words: int = 0 @dataclass @@ -76,13 +74,12 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]: report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {} if manifest_path.is_file(): manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - entries = [(c["number"], c["title"], c["file"], c.get("types") or [], c.get("word_count", 0)) - for c in manifest.get("chapters", [])] + entries = [(c["number"], c["title"], c["file"]) for c in manifest.get("chapters", [])] else: files = sorted((out_dir / "chapters").glob("*.md")) - entries = [(i + 1, p.stem, f"chapters/{p.name}", [], 0) for i, p in enumerate(files)] - chapters = [ChapterFile(n, t, rel, types=list(ty), words=w) for n, t, rel, ty, w in entries if (out_dir / rel).is_file()] - missing = [rel for _, _, rel, _, _ in entries if not (out_dir / rel).is_file()] + entries = [(i + 1, p.stem, f"chapters/{p.name}") for i, p in enumerate(files)] + chapters = [ChapterFile(n, t, rel) for n, t, rel in entries if (out_dir / rel).is_file()] + missing = [rel for _, _, rel in entries if not (out_dir / rel).is_file()] if missing: raise MergeError(f"Kapiteldateien fehlen: {', '.join(missing[:5])}") if not chapters: @@ -92,49 +89,6 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]: return report, chapters -# Front matter that precedes the table of contents in a printed book (cover, title page, imprint …). -FRONT_TYPES = {"cover", "titlepage", "halftitlepage", "colophon", "copyright-page", "imprint", "dedication", - "epigraph", "frontispiece", "seriespage", "imprimatur"} -TOC_PLACEHOLDER = '
' # pandoc/book.lua puts the table of contents here -TITLE_PLACEHOLDER = '
' # … and Pandoc's title block here (no own title page) - - -# Where the main matter starts: a part, preface, introduction …, a numbered title or a longer text. -MAIN_TYPES = {"part", "preface", "foreword", "introduction", "prologue", "preamble", "appendix", "bodymatter", "toc"} -NUMBERED_TITLE = re.compile(r"^\s*(\d+|[IVXLC]+\.\s|teil\b|kapitel\b|part\b|chapter\b|buch\b|book\b)", re.I) -FRONT_MAX_WORDS = 1000 -FRONT_MAX_CHAPTERS = 12 - - -def front_matter_count(chapters: list[ChapterFile]) -> int: - """Number of leading chapters before the table of contents: cover, title page, imprint and the short - pages between them (series page, edition note, publisher's letter …) up to the start of the main matter.""" - k = 0 - while k < min(len(chapters), FRONT_MAX_CHAPTERS): - ch = chapters[k] - if MAIN_TYPES & set(ch.types) or NUMBERED_TITLE.match(ch.title) or ch.words > FRONT_MAX_WORDS: - break - k += 1 - if k < len(chapters) and any(FRONT_TYPES & set(c.types) for c in chapters[:k]): - return k - n = 0 # no clear start of the main matter: only the unbroken run of typed front matter - while n < len(chapters) and FRONT_TYPES & set(chapters[n].types): - n += 1 - return n - - -ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:[T ][\d:.]+(?:Z|[+-]\d{2}:?\d{2})?)?$") - - -def display_date(value: str, language: str) -> str: - """'2025-01-13T08:00:00+00:00' → '13.01.2025' (German) or '2025-01-13'; other formats unchanged.""" - m = ISO_DATE.match(value.strip()) - if not m: - return value - year, month, day = m.groups() - return f"{day}.{month}.{year}" if language.lower().startswith("de") else f"{year}-{month}-{day}" - - def default_target(out_dir: Path, report: dict) -> Path: name = (report.get("input") or {}).get("name") or (out_dir.name + ".epub") stem = name[:-5] if name.lower().endswith(".epub") else name @@ -188,7 +142,7 @@ def assign_anchors(chapters: list[ChapterFile]) -> int: for m in TOKEN.finditer(seg): if m.group("attr") and m.group("aname") == "id" and m.group("avalue") not in ids: ids.append(m.group("avalue")) - start = "kap-" + stems[ch.rel] # ids must start with a letter (Pandoc's EPUB writer breaks '#039-…') + start = stems[ch.rel] while start in used or start in ids: start = "kapitel-" + start ch.start_anchor = start @@ -271,31 +225,18 @@ def merge(out_dir: Path, target: Path | None = None, *, frontmatter: bool = True ("publisher", "publisher"), ("date", "published"), ("identifier", "identifier"), ("rights", "rights"), ("description", "description")): if md.get(src): - fm[key] = display_date(md[src], md.get("language", "")) if key == "date" else md[src] + fm[key] = md[src] if fm: parts.append("---\n" + to_yaml(fm) + "---") - front = front_matter_count(chapters) - has_title_page = any({"titlepage", "halftitlepage"} & set(ch.types) for ch in chapters[:front]) - cover_first = bool(chapters) and "cover" in chapters[0].types if toc: lines = ["## Inhalt" if (report.get("metadata") or {}).get("language", "de").startswith("de") else "## Contents", ""] lines += [f"{ch.number}. [{_escape_link_text(ch.title)}](#{ch.start_anchor})" for ch in chapters] - toc_block = "\n".join(lines) - else: - toc_block = TOC_PLACEHOLDER - if not has_title_page and not cover_first: - parts.append(TITLE_PLACEHOLDER) - if front == 0: - parts.append(toc_block) - for i, ch in enumerate(chapters): + parts.append("\n".join(lines)) + for ch in chapters: segments = [(is_code, seg if is_code else rewrite_segment(seg, ch)) for is_code, seg in _non_code_segments(ch.body)] body, n = _drop_duplicate_definitions(_join(segments), seen_defs) dropped += n parts.append(f'
\n\n{body.strip()}') - if i == 0 and cover_first and not has_title_page: - parts.append(TITLE_PLACEHOLDER) - if i + 1 == front: - parts.append(toc_block) text = "\n\n".join(parts).rstrip() + "\n" existing = {p.resolve().as_posix() for p in out_dir.rglob("*") if p.is_file()} diff --git a/epub2md/normalize.py b/epub2md/normalize.py index 78db4e1..6152766 100644 --- a/epub2md/normalize.py +++ b/epub2md/normalize.py @@ -3,7 +3,6 @@ from __future__ import annotations import re -from collections.abc import Callable from dataclasses import dataclass, field from typing import Any @@ -16,9 +15,6 @@ 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 @@ -81,8 +77,7 @@ def book_uses_title_tables(docs: list[Doc]) -> bool: return False -def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool, - hidden: Callable[[Any], bool] | None = None) -> None: +def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None: body = doc.body for el in list(iter_elements(body)): if not _attached(el, body): @@ -93,11 +88,9 @@ def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool, continue types = epub_types(el) if "pagebreak" in types or "doc-pagebreak" in roles(el): - _replace_with_anchor(el, _pagebreak_label(el)) + _replace_with_anchor(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])): @@ -109,19 +102,19 @@ def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool, 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, hidden or (lambda el: False)) + separated = _separate_field_spans(body) 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, hidden: Callable[[Any], bool]) -> int: +def _separate_field_spans(body: Any) -> 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. Spans hidden by the book's CSS (display:none) get no separator. + with an uppercase letter or digit. """ count = 0 for parent in iter_elements(body): @@ -134,7 +127,7 @@ def _separate_field_spans(body: Any, hidden: Callable[[Any], bool]) -> 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()) and not hidden(left) and not hidden(right): + if rt[0].isupper() or rt[0].isdigit(): left.tail = " " count += 1 return count @@ -149,35 +142,13 @@ def _attached(el: Any, body: Any) -> bool: return False -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: +def _replace_with_anchor(el: Any) -> None: ids = [e.get("id") for e in iter_elements(el) if e.get("id")] - if not ids and not page: + if not ids: remove_keep_tail(el) return anchor = _make(el, "a") - if ids: - anchor.set("id", ids[0]) - if page: - anchor.set(PAGE_ATTR, page) + anchor.set("id", ids[0]) anchor.tail = el.tail el.getparent().replace(el, anchor) @@ -206,7 +177,6 @@ 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(): @@ -223,8 +193,6 @@ 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 db5eae3..e5dd9dc 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -8,30 +8,24 @@ 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 SPECIAL_KINDS, ChapterBuilder, heading_level -from .codeblocks import (code_text, detect_language, detect_language_from_content, dominant_language, - is_block_code_container, join_wrap_markers) +from .chapters import ChapterBuilder from .diagnostics import Diagnostics -from .css import StyleSheet, base_style, emphasis -from .epub_reader import EpubReader, decode_markup, parse_xml +from .epub_reader import EpubReader, 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, has_content, is_html_toc_doc, make_doc, normalize_doc, - remove_keep_tail) +from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail from .references import LinkResolver -from .utils import is_element, is_inside, iter_elements, local, resolve_href, sha256_file, slugify, text_content +from .utils import is_inside, iter_elements, sha256_file, slugify from . import writer MARKER_FILES = ("conversion-report.json", "manifest.json") @@ -43,7 +37,7 @@ class Options: format: str = "gfm" extract_media: bool = True copy_original: bool = False - chapter_level: int | str = "auto" # 1–6, or "auto": level 1 plus oversized chapters split one level deeper + chapter_level: int = 1 toc: bool = True frontmatter: bool = True footnotes: str = "end-of-chapter" @@ -53,11 +47,6 @@ 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 @@ -84,8 +73,8 @@ def check_paths(input_path: Path, output_dir: Path, opts: Options) -> None: raise InputError(f"Eingabedatei nicht gefunden oder keine Datei: {input_path}") if not os.access(input_path, os.R_OK): raise InputError(f"Eingabedatei nicht lesbar: {input_path}") - if opts.chapter_level != "auto" and not (isinstance(opts.chapter_level, int) and 1 <= opts.chapter_level <= 6): - raise InputError("--chapter-level muss auto oder eine Zahl zwischen 1 und 6 sein.") + if not 1 <= opts.chapter_level <= 6: + raise InputError("--chapter-level muss zwischen 1 und 6 liegen.") if opts.dry_run: return if output_dir.exists() and not output_dir.is_dir(): @@ -144,11 +133,9 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio package = reader.load_package() diag.step(f"EPUB {package.version}: {package.metadata.title!r}, {len(package.spine)} Spine-Dokumente") docs = _load_docs(reader, package, diag) - stylesheet = _load_stylesheet(reader, package, docs, diag) # before normalization drops ")) - end - return doc -end - -return { - { Meta = Meta }, - { BlockQuote = BlockQuote, Div = Div }, - { Pandoc = Pandoc }, -} diff --git a/pandoc/print.css b/pandoc/print.css deleted file mode 100644 index 2bea7b8..0000000 --- a/pandoc/print.css +++ /dev/null @@ -1,32 +0,0 @@ -/* Print layout for PDFs from epub2md output via Pandoc + weasyprint: - pandoc buch.md -s --lua-filter pandoc/book.lua --lua-filter pandoc/callouts.lua --css pandoc/print.css \ - --pdf-engine=weasyprint -o buch.pdf - book.lua places the table of contents after cover, title page and imprint and marks chapter starts. */ -@page { size: A4; margin: 2cm 1.8cm; @bottom-center { content: counter(page); font-size: 9pt; } } -@page :first { @bottom-center { content: none; } } /* no page number on the cover / title page */ -body { max-width: none; font-size: 10.5pt; line-height: 1.4; hyphens: auto; text-align: justify; - font-family: "DejaVu Serif", "Liberation Serif", Georgia, serif; } -#title-block-header, #title-block-header h1.title { text-align: center; margin-bottom: 2em; } -h1, h2, h3, h4 { text-align: left; hyphens: manual; page-break-after: avoid; } -pre, code { font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; hyphens: none; } -pre { background: #f6f8fa; padding: 0.5em 0.8em; border-radius: 0.3em; page-break-inside: auto; } -pre, pre code { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 8.5pt; } /* long code lines wrap */ -pre { overflow: visible; } -blockquote { margin: 1em 0 1em 1.5em; padding-left: 0.8em; border-left: 2px solid #ccc; color: #444; } -a { color: inherit; text-decoration: none; } -img { max-width: 100%; max-height: 24cm; height: auto; } -table { font-size: 9pt; } -.cover { text-align: center; } -.cover img { max-height: 25cm; } - -/* New pages: every chapter (book.lua: div.chapter-start, also cover, title page, imprint), the title - block, the table of contents; without book.lua every

. No second break for the chapter's own

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

Teil 1

" + "".join(f"

Kapitel {i}

{words}

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

Vorwort

kurz

Dank

danke

Hinweise

lesen

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

Teil 1

Einleitung

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

Kapitel {i}

{words}

Abschnitt

x

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

Teil 2

Kapitel 4

kurz

Kapitel 5

kurz

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

A

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

Register

A

Apfel

Index

x

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

Erika Musterfrau

Testbuch

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

Eins

Text

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

N

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

P

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

C

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

P

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

M

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

A

Text

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

Erika Musterfrau

Vorspann

Roman

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

© 2024 Testverlag

Das Werk ist urheberrechtlich geschützt.

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

Kapitel A

a

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

Kapitel A

a

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

Erika Musterfrau

Vorspann

Roman

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

Vorspann

4. Auflage 2025

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

© 2024 Testverlag

Das Werk ist urheberrechtlich geschützt.

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

Liebe Leserin, lieber Leser,

Viel Spaß.

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

1 Der Einstieg

a

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

Zwischenspiel

b

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

Kapitel A

Siehe Kapitel B und Bild.

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

Kapitel B

Text B.

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

Nur eine Bildseite ohne Überschrift.

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

Kapitel A

a

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

Kapitel B

b

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

A

x

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

C

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

C

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

Chapter One

Hello EPUB 2 world.

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

Erstes Kapitel

Eins.

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

Zweites Kapitel

Zwei.

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

Kapitel A

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

' - '

A.1 Abschnitt

Text A.1

' - '

Kapitel A2 in derselben Datei

Unten in A2.

' - '

A2.1 Unterabschnitt

Mehr.

') - b = ('

Kapitel B

B-Text.

B.1

Ziel in B.

' - '

zurück zu A.1

') - c = '

Kapitel C

Ende mit Link auf A.

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

Code und Tabellen

" - "

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

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

Unbekanntes Element: Widget-Inhalt.

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

Bilder

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

Nochmals dasselbe Foto: Wiederholung

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

Inline-SVG: Kreis' - '

' - '

Siehe Abbildung 1.1 und SVG direkt.

' - '

Fehlendes Bild: weg

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

Formeln

" - f"

Bruch inline: ab.

" - f"

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

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

Nach Gleichung (2.1) folgt …

' - f'

Nicht unterstützt: z.

' - f'

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

" - f'

alttext: 2.

' - '

SVG-Formel: Formel E=mc²

' - '

Bildformel: Summe über i

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

Kapitel mit Fußnoten

" - '

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

' - '

Endnote3 und kaputt9.

' - '' - f'" - ) - notes = ( - '

Anmerkungen

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

Hauptteil

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

') - glossary = ('

Glossar

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

Selbstaufruf einer Funktion.

Siehe auch Iteration.

") - lit = ('

Literatur

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

Sachregister

' - '

A

API Glossar

' - '

R

Rekursion 17, 42

' - '

endständige, 45

') - pers = ('

Personenregister

K

' - '

Knuth, Donald E. 17

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

Komplexe Tabelle

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

1,5

korrigiert

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

Böse Pfade

raus ' - 'escape abs

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

Größe & Übermaß

{text}

Rücksprung

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

Café crème

Ça va.

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

Gestaltung

' - '

Ein kursives und fettes Wort, ' - 'unterstrichenVERSTECKT.

' - '

Null\u200bbreite und wei\u00adches Trenn\u00adzeichen.

' - '

Szene eins endet hier.

Szene zwei beginnt hier.

' - '

Zeile eins

Zeile zwei

' - '

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

' - '

Zentrierte Widmung

' - '

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

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

Kästen

' - '

[»]Wichtige Regel

' - '

Immer testen.

' - '

Nachher.

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

A

x

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

A

x

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

Kaputt

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