commit 02449734a210b0d66b525daf26682b41e45fda24 Author: dschlueter Date: Mon Oct 5 19:33:42 2026 +0200 feat: epub2md – EPUB nach kapitelweisem Markdown Kommandozeilenwerkzeug, das DRM-freie EPUB-2/3-Dateien offline in kapitelweise Markdown-Dateien umwandelt. Erzeugt werden außerdem Medien, metadata.yaml, manifest.json, conversion-report.json und Diagnose-Dateien. - Spine-basierte Kapitelbildung mit --chapter-level, epub:type-Abschnitten, Fortsetzungsdateien und Erkennung von Layout-Überschriften - Code mit exakter Einrückung, sicheren Fences und konservativer Spracherkennung - MathML->TeX (konservativ), TeX aus alt-Texten, SVG-/Bildformeln bleiben Bilder - Fußnoten-Modi end-of-chapter, end-of-book, inline und keep-links - Interne Links/Anker werden umgeschrieben, Linkcheck über die Ausgabe - Sicher: kein Entpacken, Schutz vor Path Traversal und XXE, DRM-Erkennung, atomares Schreiben, --force nur für frühere Ausgaben - 82 pytest-Tests mit synthetischen EPUBs und Invariantentests für Beispiele/ Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28ec9b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +build/ +dist/ + +# QA-Läufe (Prompt_Kontrolle.md) +.qa/ +.qa-output/ +.qa-fixtures/ +.qa-reports/ + +# Urheberrechtlich geschützte Beispiel-Bücher und Konvertierungsausgaben +Beispiele/ +buch.epub +buch.md +buch-md/ +medien/ + +# Editor/OS +.idea/ +.vscode/ +*.swp +.DS_Store + +# Konvertierungsausgaben +output/ diff --git a/Prompt.md b/Prompt.md new file mode 100644 index 0000000..1d3bee2 --- /dev/null +++ b/Prompt.md @@ -0,0 +1,928 @@ +Du bist ein erfahrener Python-Entwickler für Dokumentkonvertierung, EPUB-Interna, XML/HTML-Verarbeitung und semantische Markdown-Pipelines. Entwickle ein vollständiges, robustes Commandline-Programm namens `epub2md.py`. + +## Ziel + +Das Programm soll DRM-freie EPUB-Dateien lokal in eine strukturierte Sammlung von Markdown-Dateien konvertieren. Es muss für sehr unterschiedliche Buchtypen geeignet sein: + +- Romane und Sachbücher +- Bildbände mit Bildern, Bildunterschriften und Bildreferenzen +- Programmierbücher mit Codeblöcken, Einrückungen, Quellcode und Tabellen +- Mathematik- und Naturwissenschaftsbücher mit Formeln, Gleichungen, MathML, TeX/LaTeX, SVG-Formeln und Grafiken +- Technische Fachbücher mit Fußnoten, Endnoten, Zitaten, Quellen, Glossar, Register und Inhaltsverzeichnis + +Die Ausgabe soll für folgende Zwecke gut weiterverarbeitbar sein: + +- Markdown-Renderer und statische Webseiten +- Pandoc-Konvertierung nach HTML, PDF, DOCX oder EPUB +- Obsidian, Zettelkasten- und Wissensdatenbanken +- Git-Versionierung +- RAG-, Embedding- und Chunking-Pipelines +- Volltextsuche und lokale LLM-Verarbeitung + +Die Konvertierung darf keine DRM-Schutzmechanismen umgehen. Unterstütze nur lesbare, DRM-freie EPUB-Dateien. + +## Arbeitsverzeichnis und Beispieldaten + +Im aktuellen Projektverzeichnis befindet sich ein Verzeichnis: + +```text +Beispiele/ +``` + +Darin liegen mehrere Beispiel-EPUB-Dateien unterschiedlicher Art. Nutze diese EPUBs ausschließlich lokal zur Analyse und zum Testen des Programms. Lies zunächst die Verzeichnisstruktur ein und identifiziere vorhandene EPUB-Dateien. + +Beispielhafte Zielgruppen der Testbücher können sein: + +- Roman mit Kapiteln, Fußnoten und Inhaltsverzeichnis +- Programmierbuch mit Python-, Shell-, C-, JavaScript-, SQL- oder Konfigurationscode +- Mathematikbuch mit Formeln in MathML, TeX, SVG oder als Bild +- Bildband mit JPEG-, PNG-, WebP- oder SVG-Grafiken +- Buch mit Glossar, Bibliografie, Endnoten sowie Personen- und Sachregister + +Falls keine EPUB-Beispiele vorhanden sind, implementiere das Programm trotzdem vollständig und lege zusätzlich minimalistische synthetische Testdaten oder Tests an. + +## Wichtige technische Anforderungen + +Schreibe kein bloßes Proof-of-Concept. Das Ergebnis soll ein benutzbares, nachvollziehbares und fehlertolerantes Werkzeug sein. + +Verwende bevorzugt: + +- Python 3.12 oder kompatibel mit Ubuntu 24.04 +- Standardbibliothek, soweit sinnvoll +- `beautifulsoup4` +- `lxml` +- optional `markdownify`, aber nicht als alleinige Konvertierungslogik +- optional `Pillow` nur für Metadaten oder Bildprüfung +- `pytest` für Tests + +Vermeide schwere externe Laufzeitabhängigkeiten, sofern sie nicht klaren Mehrwert bringen. Insbesondere darf das Programm nicht zwingend Calibre, Pandoc, Java, Node.js oder einen Cloud-Dienst benötigen. Eine optionale Integration mit Pandoc darf angeboten werden, aber die Kernfunktion muss ohne Pandoc funktionieren. + +Nutze für die EPUB-Verarbeitung die Tatsache, dass EPUB ein ZIP-Archiv mit OPF-, XHTML/HTML-, NCX-, NAV-, CSS-, Bild- und Mediendateien ist. Implementiere die Verarbeitung stabil anhand der OPF-Manifest- und Spine-Reihenfolge. Verlasse dich nicht nur auf Dateinamen oder ein vorhandenes Inhaltsverzeichnis. + +## Kommandozeilenschnittstelle + +Implementiere mindestens diese Schnittstelle: + +```bash +python3 epub2md.py INPUT.epub OUTPUT_VERZEICHNIS +``` + +Beispiel: + +```bash +python3 epub2md.py Beispiele/mathematikbuch.epub output/mathematikbuch +``` + +Zusätzliche Optionen: + +```bash +python3 epub2md.py --help + +python3 epub2md.py INPUT.epub OUTPUT_VERZEICHNIS \ + --format gfm \ + --extract-media \ + --copy-original \ + --chapter-level 1 \ + --toc \ + --frontmatter \ + --footnotes end-of-chapter \ + --math auto \ + --images relative \ + --slug-language de \ + --verbose +``` + +Implementiere mindestens diese Optionen: + +```text +--format {gfm,commonmark} + Ziel-Markdown-Variante, Standard: gfm. + +--extract-media / --no-extract-media + Extrahiert Bilder und Medien nach OUTPUT_VERZEICHNIS/media/. + Standard: aktiviert. + +--copy-original / --no-copy-original + Kopiert die originale EPUB-Datei optional nach OUTPUT_VERZEICHNIS/source/. + Standard: deaktiviert. + +--chapter-level N + Überschriftenebene, ab der neue Markdown-Kapiteldateien erzeugt werden. + Standard: 1. + +--toc / --no-toc + Erzeugt eine README.md bzw. index.md mit Inhaltsverzeichnis und Links auf Kapitel. + Standard: aktiviert. + +--frontmatter / --no-frontmatter + Fügt YAML-Frontmatter in jede Kapiteldatei ein. + Standard: aktiviert. + +--footnotes {keep-links,end-of-chapter,end-of-book,inline} + Strategie zur Ausgabe von Fußnoten und Endnoten. + Standard: end-of-chapter. + +--math {auto,tex,mathml,image} + Strategie für Mathematik. + Standard: auto. + +--images {relative,flat,original-path} + Strategie für Bildpfade. + Standard: relative. + +--slug-language {de,en,none} + Regeln für lesbare Dateinamen und Umlautbehandlung. + Standard: de. + +--keep-html + Erhalte nicht zuverlässig konvertierbare HTML-Fragmente als HTML im Markdown, + statt Informationen zu verlieren. + +--strict + Brich bei nicht verarbeitbaren kritischen Fehlern ab. + Ohne --strict sollen Warnungen gesammelt und die Konvertierung soweit möglich fortgesetzt werden. + +--verbose + Ausführliche Verarbeitungsschritte ausgeben. + +--dry-run + Analysiert EPUB-Struktur und geplante Ausgabedateien, schreibt aber nichts. + +--report DATEI.json + Schreibt einen maschinenlesbaren Konvertierungsbericht als JSON. +``` + +Nutze `argparse`. Rückgabecodes: + +```text +0 Erfolgreich, möglicherweise mit nichtkritischen Warnungen +1 Ungültige Parameter oder Eingabedatei nicht lesbar +2 EPUB-/ZIP-/Containerfehler +3 OPF-/Spine-/XHTML-Verarbeitungsfehler +4 Unerwarteter interner Fehler +``` + +## Erwartete Ausgabestruktur + +Für eine Eingabe `mein-buch.epub` soll beispielsweise Folgendes entstehen: + +```text +output/mein-buch/ +├── README.md +├── metadata.yaml +├── conversion-report.json +├── source/ +│ └── mein-buch.epub # nur mit --copy-original +├── media/ +│ ├── images/ +│ │ ├── 0001-cover.jpg +│ │ ├── 0002-abbildung-algorithmus.png +│ │ └── 0003-formel-1.svg +│ ├── audio/ +│ └── other/ +├── chapters/ +│ ├── 000-frontmatter.md +│ ├── 001-vorwort.md +│ ├── 002-kapitel-1-einleitung.md +│ ├── 003-kapitel-2-grundlagen.md +│ ├── 004-anhang.md +│ ├── 005-glossar.md +│ ├── 006-quellen-und-literatur.md +│ ├── 007-sachregister.md +│ └── 008-personenregister.md +└── diagnostics/ + ├── warnings.md + └── unsupported-elements.md +``` + +Die genaue Struktur darf verbessert werden, aber sie soll stabil, dokumentiert und skriptfreundlich bleiben. + +## EPUB-Analyse + +Implementiere eine saubere EPUB-Analyse: + +1. Lies `META-INF/container.xml`. +2. Ermittle darüber die OPF-Paketdatei. +3. Lies aus der OPF-Datei mindestens: + - Titel + - Untertitel, falls vorhanden + - Autorinnen/Autoren + - Sprache + - Identifier/ISBN, wenn vorhanden + - Verlag + - Veröffentlichungsdatum + - Beschreibung + - Rechteangaben + - Cover-Referenz + - Manifest + - Spine-Reihenfolge +4. Unterstütze EPUB 2 und EPUB 3. +5. Lies, falls vorhanden: + - EPUB-3-Navigation (`nav.xhtml`) + - NCX-Inhaltsverzeichnis (`.ncx`) + - Guide-Landmarks + - Dokumenteigenschaften wie `epub:type` +6. Verwende die Spine als verbindliche Lesereihenfolge. +7. Nutze NAV/NCX und semantische EPub-Typen zur Erkennung von: + - Titelblatt + - Inhaltsverzeichnis + - Vorwort + - Kapitel + - Teil + - Anhang + - Glossar + - Bibliografie + - Endnoten + - Sachregister + - Personenregister + - Danksagung + - Kolophon + +## Kapitelbildung + +Die Kapitelbildung muss robust sein. Viele EPUBs enthalten bereits eine XHTML-Datei je Kapitel; andere enthalten ein gesamtes Buch in wenigen Dateien. + +Implementiere diese Priorität: + +1. Berücksichtige die OPF-Spine-Reihenfolge. +2. Erkenne innerhalb jeder XHTML-Datei Überschriften `h1` bis `h6`. +3. Teile Inhalte anhand von `--chapter-level`. +4. Falls keine geeigneten Überschriften vorhanden sind: + - nutze die XHTML-Datei als Kapitel, + - oder leite einen sinnvollen Titel aus ``, `epub:type`, Dateiname oder NAV/NCX ab. +5. Erhalte die Reihenfolge exakt. +6. Verwende fortlaufende Dateinummern mit mindestens drei Stellen: + - `001-...md` + - `002-...md` +7. Erzeuge konfliktfreie, stabile Slugs. +8. Entferne keine Kapitel, nur weil sie kurz sind. Titelblatt, Widmung oder Epigraph dürfen als eigene Markdown-Datei erscheinen. +9. Vermeide doppelte Ausgabe desselben Inhalts, etwa wenn das EPUB ein HTML-Inhaltsverzeichnis enthält und zusätzlich NAV/NCX besitzt. + +## Markdown-Konvertierung + +Erzeuge sauberes, semantisch brauchbares Markdown. + +### Überschriften + +- XHTML `h1` bis `h6` müssen zu Markdown-Überschriften werden. +- Passe die Überschriftenebenen sinnvoll an die Kapiteldatei an. +- Vermeide mehrere identische H1-Überschriften in derselben Kapiteldatei. +- Gib Kapitelmetadaten im YAML-Frontmatter aus. + +Beispiel: + +```markdown +*** +book_title: "Beispieltitel" +book_author: + - "Max Mustermann" +language: "de" +chapter_number: 3 +chapter_title: "Graphenalgorithmen" +source_epub: "beispieltitel.epub" +source_href: "Text/ch03.xhtml" +epub_type: + - "chapter" +conversion_status: "converted" +*** + +# Graphenalgorithmen +``` + +### Fließtext und semantische Elemente + +Konvertiere mindestens: + +```text +p, div, span, section, article, header, footer, aside, +blockquote, q, cite, em, i, strong, b, u, del, s, +br, hr, sup, sub, abbr, kbd, mark +``` + +Regeln: + +- Normaler Fließtext soll keine künstlichen Zeilenumbrüche innerhalb eines Absatzes erhalten. +- `<br>` soll als echter Markdown-Zeilenumbruch oder, falls semantisch geeigneter, als Absatztrennung erscheinen. +- `blockquote` soll als `>` ausgegeben werden. +- Hervorhebungen sollen semantisch erhalten bleiben. +- `<sup>` und `<sub>` müssen erhalten werden; falls normales Markdown nicht ausreicht, nutze HTML-Tags innerhalb der Markdown-Datei. +- Unbekannte oder komplexe Elemente sollen nicht stillschweigend verloren gehen. Mit `--keep-html` sollen sie als bereinigtes HTML im Markdown erhalten bleiben. +- Entferne Navigationsreste wie wiederholte „Next“, „Previous“, Seitenzahlen oder Reader-Bedienelemente, aber nur dann, wenn sie zuverlässig als Navigation identifizierbar sind. + +### Listen + +Unterstütze: + +```text +ul, ol, li, dl, dt, dd +``` + +- Verschachtelte Listen müssen korrekt eingerückt sein. +- Nummerierte Listen müssen ihre Reihenfolge behalten. +- Definitionslisten sollen als gut lesbares Markdown oder gegebenenfalls HTML ausgegeben werden. + +### Tabellen + +Unterstütze HTML-Tabellen: + +```text +table, thead, tbody, tfoot, tr, th, td, caption +``` + +Regeln: + +- Einfache Tabellen sollen als GFM-Tabellen ausgegeben werden. +- Bei `rowspan`, `colspan`, verschachtelten Elementen, mehrzeiligen Zellen oder komplexen Layouttabellen: + - sichere den Inhalt, + - verwende bevorzugt HTML im Markdown, + - vermerke im Report eine Warnung, + - verliere keine Daten. +- Tabellenbeschriftungen müssen erhalten bleiben. + +### Links und Anker + +- Konvertiere externe Links vollständig. +- Konvertiere interne EPUB-Links in funktionierende relative Links zu Zielkapiteln und, soweit möglich, Zielankern. +- Erzeuge stabile Markdown-Anker oder verwende HTML-Anker, wenn erforderlich. +- Interne Referenzen wie „siehe Kapitel 4“, „siehe Abbildung 3.2“ oder Fußnoten-Backlinks sollen möglichst erhalten bleiben. +- Dokumentiere nicht auflösbare interne Ziele im Report. + +## Programmcode + +Programmierbücher sind ein zentraler Anwendungsfall. + +Erkenne unter anderem: + +```text +pre, code, pre > code, +div mit Klassen wie code, programlisting, sourcecode, listing, highlight, +Syntax-Highlighting-Spans von Pygments, Prism, highlight.js oder ähnlichen Systemen. +``` + +Anforderungen: + +1. Ausgabe als fenced code blocks mit dreifachen Backticks oder einer ausreichend langen Backtick-Sequenz, falls der Code selbst Backticks enthält. +2. Erhalte Leerzeichen, Tabs, Einrückung und Zeilenumbrüche exakt. +3. Entferne keine relevanten Zeichen aus Code: + - Backslashes + - Anführungszeichen + - `<`, `>`, `&` + - Pipe-Zeichen + - Hashes + - Dollarzeichen + - Unicode-Zeichen +4. Versuche die Programmiersprache aus Klassen, `type`, `data-language`, Dateinamen oder bekannten Hinweisen zu erkennen. +5. Unterstütze mindestens Sprachlabels wie: + - `python` + - `bash` + - `sh` + - `zsh` + - `c` + - `cpp` + - `java` + - `javascript` + - `typescript` + - `json` + - `yaml` + - `toml` + - `xml` + - `html` + - `css` + - `sql` + - `dockerfile` + - `makefile` + - `ini` + - `text` +6. Wenn die Sprache nicht sicher erkennbar ist, verwende keinen falschen Sprachbezeichner. +7. Inline-Code muss ebenfalls korrekt nach Markdown übersetzt werden. +8. Zeilennummern, Copy-Buttons, Prompt-Dekorationen und rein visuelle Syntax-Highlighting-Tags sollen entfernt werden, sofern sie eindeutig als Präsentationsartefakte erkennbar sind. +9. Wenn Text nicht sicher als Code erkennbar ist, darf er nicht aggressiv in einen Codeblock gezwungen werden. + +Beispiel: + +````markdown +```python +from pathlib import Path + +def read_book(path: Path) -> str: + return path.read_text(encoding="utf-8") +``` +```` + +## Bilder, SVG und Medien + +Extrahiere lokale Medien aus dem EPUB. + +Unterstütze mindestens: + +```text +JPEG, PNG, GIF, WebP, SVG, AVIF, MP3, MP4, WebM, CSS-Dateien, eingebettete Schriftarten +``` + +Regeln: + +1. Speichere Bilder standardmäßig in `media/images/`. +2. Erhalte die ursprüngliche Dateiendung, sofern möglich. +3. Nutze stabile, kollisionsfreie Namen, beispielsweise: + +```text +0001-cover.jpg +0002-abbildung-graph.png +0003-formel-01.svg +``` + +4. Erhalte Bildreferenzen in Markdown relativ: + +```markdown +![Alternativtext](../media/images/0002-abbildung-graph.png "Bildtitel") +``` + +5. Übernimm: + - `alt` + - `title` + - `figcaption` + - Bildnummer und Bildunterschrift + - interne Bildanker +6. Nutze für `figure` und `figcaption` eine gut lesbare Struktur, beispielsweise: + +```markdown + + +*Abbildung 3.2: Darstellung eines Graphen.* +``` + +7. SVG darf nicht gerastert werden. Bewahre SVG möglichst als SVG. +8. Wenn ein SVG eine mathematische Formel darstellt, markiere dies im Report, aber behandle sie visuell korrekt als Bild. +9. Extrahiere Medien nur einmal, auch wenn sie mehrfach referenziert werden. +10. Erhalte Audio- und Video-Referenzen nach Möglichkeit als Links; dokumentiere nicht direkt in Markdown darstellbare Medien im Report. +11. CSS muss nicht zwingend vollständig übernommen werden. Es darf aber für Heuristiken zur Code-, Tabellen- oder Formelklassifikation analysiert werden. + +## Mathematik und Formeln + +Mathematik ist ein zentraler Anwendungsfall. Formeln dürfen nicht stillschweigend verschwinden. + +Erkenne und behandle mindestens: + +```text +MathML: <math>, <mrow>, <mi>, <mo>, <mn>, <msup>, <msub>, <mfrac>, ... +LaTeX/TeX in Textknoten, data-Attributen oder Klassen +Asciimath, sofern erkennbar +SVG-Formeln +Rastergrafiken mit Formeln +HTML-Elemente mit Klassen wie math, equation, formula, MathJax, katex +``` + +Implementiere die Modi: + +### `--math auto` + +Priorität: + +1. Wenn eine Formel als TeX/LaTeX vorhanden ist: übernehme sie als TeX. +2. Wenn MathML vorhanden ist: + - versuche eine verlustarme Konvertierung nach TeX; + - falls keine sichere Konvertierung möglich ist, behalte MathML als HTML/XML im Markdown oder sichere es als separate `.mathml`-Datei und verlinke sie; + - verliere die Formel nicht. +3. Bei SVG- oder Rasterformeln: + - behalte die Grafikreferenz; + - verwende vorhandenen Alternativtext, Bildunterschrift oder zugänglichen Text; + - schreibe eine Warnung in den Report, dass keine semantische Textextraktion erfolgte. + +### `--math tex` + +- Gib möglichst \( ... \) für Inline-Mathematik aus. +- Gib \[ ... \] für abgesetzte Mathematik aus. +- Verwende keine Dollarzeichen als Mathe-Begrenzer. +- Wenn eine Formel nicht zuverlässig nach TeX konvertierbar ist, behalte die Originalrepräsentation statt fehlerhaften TeX zu erzeugen. + +### `--math mathml` + +- Erhalte MathML als eingebettetes XML/HTML innerhalb der Markdown-Datei oder als referenzierte Datei. +- Stelle sicher, dass die Formel maschinenlesbar bleibt. + +### `--math image` + +- Repräsentiere alle nichttrivialen Formeln als Bild/SVG-Referenz, sofern ein Bild vorhanden ist. +- Bei reinem MathML ohne Bild darf die Formel nicht verschwinden; sichere sie dann als MathML. + +Zusätzliche Regeln: + +- Unterscheide Inline- und Blockformeln. +- Erhalte Gleichungsnummern wie `(3.7)` und Gleichungsreferenzen. +- Erhalte mathematische Sonderzeichen, Mengenlehre, griechische Buchstaben, Indizes, Exponenten und Matrizen soweit möglich. +- Behalte Formeln in Tabellen und Fußnoten korrekt bei. +- Dokumentiere jede nicht sicher konvertierte Formel im JSON-Report. + +Falls sinnvoll, darfst du eine kleine MathML-nach-TeX-Konvertierung selbst implementieren. Unterstütze dabei mindestens häufige Elemente wie: + +```text +mi, mn, mo, mrow, mfrac, msup, msub, msubsup, +msqrt, mroot, mfenced, mtext, mover, munder, munderover, +mtable, mtr, mtd +``` + +Die Konvertierung muss konservativ sein: Bei Unklarheit lieber MathML erhalten als mathematisch falsches TeX erzeugen. + +## Fußnoten, Endnoten und Anmerkungen + +Erkenne Fußnoten und Endnoten, unter anderem über: + +```text +epub:type="noteref" +epub:type="footnote" +epub:type="endnote" +role="doc-noteref" +role="doc-footnote" +role="doc-endnote" +Klassen wie footnote, footnotes, note, endnote, endnotes, fnref +Links mit Fragment-IDs zu Anmerkungsabschnitten +``` + +Implementiere folgende Modi: + +### `--footnotes end-of-chapter` + +- Fußnotenreferenzen im Text als Markdown-Fußnotenreferenz: + +```markdown +Text mit Anmerkung.[^fn-001] +``` + +- Die Fußnoten am Ende der jeweiligen Kapiteldatei: + +```markdown +[^fn-001]: Vollständiger Fußnotentext. +``` + +### `--footnotes end-of-book` + +- Erzeuge eine eigene Datei, etwa: + +```text +chapters/999-anmerkungen.md +``` + +- Interne Referenzen müssen zu dieser Datei zeigen. + +### `--footnotes inline` + +- Ersetze Fußnotenreferenzen nachvollziehbar inline, beispielsweise: + +```markdown +Text mit Anmerkung (Anmerkung: Vollständiger Fußnotentext). +``` + +### `--footnotes keep-links` + +- Erhalte die vorhandene EPUB-Linkstruktur so weit wie möglich. + +Zusätzliche Anforderungen: + +- Erhalte Rückverweise, soweit sinnvoll. +- Mehrfachreferenzen auf dieselbe Fußnote dürfen nicht zu Datenverlust führen. +- Fußnoten können Absätze, Hervorhebungen, Links, Code, Bilder, Tabellen oder Formeln enthalten. +- Konvertiere diese Inhalte rekursiv mit derselben Qualität wie den Haupttext. +- Fußnoten dürfen nicht versehentlich auch als regulärer Fließtext im Kapitel erscheinen. + +## Glossar, Register und Quellen + +Erkenne semantische Dokumentteile über EPUB-3-Attribute, NAV/NCX, OPF-Guide, IDs, Klassen, Überschriften und Dateinamen. + +### Glossar + +- Erzeuge eine eigene Markdown-Datei, wenn ein Glossar vorhanden ist. +- Bewahre Begriffe, Definitionen, Verweise und alphabetische Sortierung. +- Konvertiere Definitionslisten sinnvoll, zum Beispiel: + +```markdown +## Begriff + +Definition des Begriffs. +``` + +oder bei kurzer Form: + +```markdown +**Begriff:** Definition. +``` + +- Interne Verweise auf Glossarbegriffe sollen möglichst als Links erhalten bleiben. + +### Quellen, Literatur und Bibliografie + +- Erzeuge eine separate Datei für Literatur, Quellen, Weblinks oder Bibliografie. +- Bewahre Autoren, Titel, Herausgeber, Jahr, Verlag, DOI, ISBN, URLs, Zugriffsdatum und Seitenangaben. +- Zerstöre keine bestehende Zitierstruktur. +- Wenn Einträge semantisch erkennbar sind, kann eine strukturierte Darstellung ergänzt werden; der Originaltext muss jedoch erhalten bleiben. +- Verlinke URLs korrekt. + +### Sachregister und Personenregister + +- Erzeuge eigene Markdown-Dateien für: + - Sachregister + - Personenregister +- Erhalte alphabetische Abschnitte. +- Erhalte Seiten-/Kapitelreferenzen als Text, auch wenn eine präzise Zuordnung zu EPUB-Locations nicht möglich ist. +- Versuche interne Registerlinks auf Kapiteldateien und Anker umzuschreiben. +- Wenn Seitenzahlen aus dem Drucksatz keine sinnvolle digitale Zielposition besitzen, behalte sie sichtbar, entferne sie nicht und vermerke dies gegebenenfalls im Report. + +## YAML-Metadaten + +Erzeuge eine globale Datei `metadata.yaml`. + +Beispiel: + +```yaml +title: "Titel des Buches" +subtitle: "Untertitel" +authors: + - "Autorin Eins" + - "Autor Zwei" +language: "de" +publisher: "Beispielverlag" +published: "2024-01-01" +identifier: "urn:isbn:978..." +source_file: "mein-buch.epub" +source_sha256: "..." +epub_version: "3.0" +description: "..." +rights: "..." +subjects: + - "Informatik" + - "Algorithmen" +cover_image: "media/images/0001-cover.jpg" +conversion: + tool: "epub2md" + version: "0.1.0" + generated_at: "ISO-8601-Zeitstempel" + options: {} +``` + +Behandle fehlende Daten sauber. Erfinde keine Metadaten. + +## README und maschinenlesbare Manifestdatei + +Erzeuge eine `README.md` mit: + +- Buchtitel und Autorinnen/Autoren +- kurzer Metadatenübersicht +- Inhaltsverzeichnis mit Links zu allen Kapiteldateien +- Hinweisen auf Glossar, Quellen, Register und Anmerkungen +- Statistik: + - Anzahl Kapitel + - Anzahl Bilder + - Anzahl SVGs + - Anzahl extrahierter Medien + - Anzahl erkannter Codeblöcke + - Anzahl erkannter Formeln + - Anzahl als Bild/SVG belassener Formeln + - Anzahl Fußnoten + - Anzahl Warnungen +- Reproduktionskommando mit den verwendeten Optionen + +Erzeuge zusätzlich eine maschinenlesbare Datei, beispielsweise `manifest.json`: + +```json +{ + "book": { + "title": "..." + }, + "chapters": [ + { + "number": 1, + "title": "Einleitung", + "file": "chapters/001-einleitung.md", + "source_href": "Text/ch01.xhtml", + "anchors": ["einleitung", "motivation"], + "word_count": 1234, + "code_blocks": 4, + "images": 2, + "math_blocks": 3, + "footnotes": 5 + } + ], + "media": [], + "warnings": [] +} +``` + +Diese Datei soll besonders für RAG- und Indexierungsprozesse nutzbar sein. + +## Konvertierungsbericht und Diagnostik + +Schreibe standardmäßig einen JSON-Bericht, z. B. `conversion-report.json`. + +Er muss mindestens enthalten: + +```text +- Programmversion +- Zeitstempel +- Eingabedatei +- SHA-256-Prüfsumme der Eingabe +- erkannte EPUB-Version +- verwendete Optionen +- erkannte Kapitel +- extrahierte Medien +- erkannte und konvertierte Codeblöcke +- erkannte Formeln und gewählte Formelrepräsentation +- erkannte Fußnoten und Verarbeitungsmodus +- erkannte Glossar-/Quellen-/Registerabschnitte +- Warnungen +- Fehler, die nicht zum Abbruch geführt haben +- nicht aufgelöste interne Links +- nicht unterstützte Elemente +``` + +Zusätzlich soll `diagnostics/warnings.md` für Menschen lesbar sein. + +Beispiele für Warnungen: + +```text +- Formel in Text/ch05.xhtml#eq-13 liegt nur als SVG vor; als Bild referenziert. +- Tabelle in Text/ch07.xhtml enthält colspan; als HTML erhalten. +- Interner Link Text/ch08.xhtml#unknown-target konnte nicht aufgelöst werden. +- Codeblock in Text/ch03.xhtml besitzt keine erkannte Programmiersprache. +- Fußnote fn-42 verweist auf ein nicht existentes Ziel. +``` + +## Fehlerbehandlung und Sicherheit + +- Behandle defekte oder unvollständige EPUBs robust. +- Verhindere Path Traversal beim Entpacken von ZIP-Dateien. +- Schreibe niemals Dateien außerhalb des angegebenen Ausgabeverzeichnisses. +- Behandle ungültiges UTF-8 robust und dokumentiere Ersatzzeichen oder Dekodierungsprobleme. +- Verwende temporäre Arbeitsverzeichnisse. +- Bereinige temporäre Daten auch bei Fehlern. +- Überschreibe ein existierendes Ausgabeverzeichnis nicht stillschweigend. +- Implementiere entweder: + - Abbruch, wenn das Ziel existiert, oder + - explizite Option `--force` zum Überschreiben. +- Dokumentiere die Entscheidung. +- Behandle XML External Entities sicher. Deaktiviere externe Entitäten und vermeide XXE-Risiken. +- Benutze keine Shell-Aufrufe mit unkontrollierten Eingaben. +- Verwende keine Netzwerkzugriffe. + +## Architektur + +Strukturiere den Code modular und testbar. Eine mögliche, aber nicht zwingende Struktur: + +```text +epub2md.py +epub2md/ +├── __init__.py +├── cli.py +├── epub_reader.py +├── metadata.py +├── spine.py +├── navigation.py +├── html_converter.py +├── markdown_writer.py +├── chapters.py +├── footnotes.py +├── math.py +├── codeblocks.py +├── media.py +├── references.py +├── diagnostics.py +├── models.py +└── utils.py +tests/ +├── test_epub_reader.py +├── test_chapters.py +├── test_codeblocks.py +├── test_math.py +├── test_footnotes.py +├── test_links.py +└── test_integration.py +``` + +Wenn du dich für eine kleinere Struktur entscheidest, begründe sie kurz in der Dokumentation. Wichtig ist, dass die Kernlogik nicht in einer einzigen unwartbaren Funktion endet. + +Nutze Dataclasses oder vergleichbare Datenmodelle für Kapitel, Medien, Fußnoten, interne Links, Warnungen und Konvertierungsresultate. + +## Tests + +Schreibe aussagekräftige Tests mit `pytest`. + +Mindestens testen: + +1. Ein minimales EPUB 2 wird gelesen. +2. Ein minimales EPUB 3 mit `nav.xhtml` wird gelesen. +3. Spine-Reihenfolge wird eingehalten. +4. Kapiteldateien werden korrekt benannt und nummeriert. +5. H1- und H2-basierte Kapitelteilung funktioniert. +6. Bilder werden extrahiert und mit funktionierenden relativen Links referenziert. +7. Codeblöcke behalten Einrückung und Sonderzeichen. +8. Verschachtelte Listen bleiben korrekt. +9. Einfache Tabellen werden als GFM-Tabelle ausgegeben. +10. Komplexe Tabellen bleiben als HTML erhalten. +11. MathML wird mindestens für einfache Brüche, Hochstellungen und Indizes sinnvoll behandelt. +12. Nicht konvertierbares MathML geht nicht verloren. +13. SVG-Formeln bleiben als Bild erhalten. +14. Fußnoten werden an das Kapitelende verschoben. +15. Glossar und Bibliografie werden erkannt oder anhand bekannter Kennzeichnungen verarbeitet. +16. Interne Links zwischen Kapiteln werden umgeschrieben. +17. Warnungen landen im JSON-Report. +18. `--dry-run` schreibt keine Ausgabedateien. +19. Unsichere ZIP-Pfade werden abgelehnt. +20. Bereits vorhandenes Ausgabeverzeichnis wird ohne `--force` nicht überschrieben. + +Nutze, falls möglich, die EPUB-Dateien aus `Beispiele/` in Integrationstests. Tests dürfen aber nicht von urheberrechtlich geschützten Volltexten oder exakten Inhalten abhängen. Prüfe Struktur, Dateien, Links und Konvertierungsinvarianten statt konkreter Buchtexte. + +## Dokumentation + +Erstelle eine ausführliche `README.md` für das Projekt mit: + +- Zweck und Grenzen des Werkzeugs +- Installation unter Ubuntu 24.04 +- Abhängigkeiten +- Virtuelle Python-Umgebung +- Beispiele für Aufrufe +- Beschreibung aller Optionen +- Erklärung der Verzeichnisstruktur +- Erklärung der Math-Modi +- Erklärung der Fußnoten-Modi +- Hinweise zur Qualität bei: + - formelbasierten EPUBs + - SVG-Formeln + - Layouttabellen + - Syntax-Highlighting + - gescannten Texten + - DRM +- Hinweise zur Weiterverarbeitung mit Pandoc + +Füge konkrete Beispiele hinzu: + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +python3 epub2md.py \ + Beispiele/programmierbuch.epub \ + output/programmierbuch \ + --format gfm \ + --math auto \ + --footnotes end-of-chapter \ + --extract-media \ + --frontmatter \ + --toc \ + --verbose +``` + +Pandoc-Beispiele für die Weiterverarbeitung: + +```bash +pandoc output/programmierbuch/chapters/*.md \ + --metadata-file=output/programmierbuch/metadata.yaml \ + -o programmierbuch.html +``` + +```bash +pandoc output/mathematikbuch/chapters/*.md \ + --metadata-file=output/mathematikbuch/metadata.yaml \ + --pdf-engine=xelatex \ + -o mathematikbuch.pdf +``` + +Erkläre dabei klar: Die Qualität der Formelkonvertierung hängt stark davon ab, ob das EPUB Formeln als echtes MathML/TeX oder nur als SVG/Bitmap enthält. SVG- und Bitmap-Formeln dürfen bei Unsicherheit nicht in fehlerhaftes TeX „halluziniert“ werden. + +## Vorgehensweise + +Arbeite in dieser Reihenfolge: + +1. Untersuche die vorhandene Projektstruktur und `Beispiele/`. +2. Gib kurz wieder, welche EPUBs und welche wahrscheinlichen Dokumenttypen du gefunden hast. +3. Plane Architektur und Abhängigkeiten. +4. Implementiere das Programm vollständig. +5. Implementiere Tests. +6. Führe Tests aus. +7. Führe das Programm testweise auf den Beispiel-EPUBs aus. +8. Prüfe die erzeugten Markdown-Dateien stichprobenartig: + - Kapitelstruktur + - Bildlinks + - Codeblöcke + - Formeln + - Fußnoten + - Glossar/Register/Quellen, falls vorhanden +9. Behebe offensichtliche Probleme. +10. Gib am Ende eine präzise Zusammenfassung: + - welche Dateien du erstellt oder geändert hast, + - wie man das Programm startet, + - welche Tests erfolgreich waren, + - welche Einschränkungen bei den konkreten Beispielbüchern bestehen, + - welche Warnungen oder nicht verlustfrei konvertierbaren Inhalte gefunden wurden. + +## Qualitätsregeln + +- Keine Pseudocode-Antwort. +- Schreibe wirklich ausführbaren Python-Code. +- Erfinde keine Testergebnisse und behaupte keine Ausführung, die nicht stattgefunden hat. +- Keine Cloud-APIs, keine Telemetrie, keine Netzwerkzugriffe. +- Konvertierung muss offline funktionieren. +- Bei unsicherer Semantik: Originalinhalt erhalten, Warnung dokumentieren, keine stillschweigende Datenvernichtung. +- Keine mathematisch falsche TeX-Erzeugung. +- Keine aggressive Bereinigung, die Code, Zitate, Formeln, Fußnoten oder Quellen zerstört. +- Priorität: semantischer Erhalt, Reproduzierbarkeit, robuste Diagnose und Nachbearbeitbarkeit. + + diff --git a/Prompt_Kontrolle.md b/Prompt_Kontrolle.md new file mode 100644 index 0000000..ebacdde --- /dev/null +++ b/Prompt_Kontrolle.md @@ -0,0 +1,978 @@ +# Qualitätskontrolle für `epub2md.py` + +Du bist ein sehr sorgfältiger Senior-Python-Engineer mit Schwerpunkt auf Testautomatisierung, EPUB-Interna, XML/XHTML, Markdown, Dokumentkonvertierung und Software-Qualität. + +Im aktuellen Projektverzeichnis befindet sich ein bereits von Claude Code implementiertes Python-Programm zur Konvertierung von DRM-freien EPUB-Dateien nach kapitelweise gespeichertem Markdown. Das Programm heißt voraussichtlich: + +```text +epub2md.py +``` + +Möglicherweise existiert zusätzlich ein Python-Paket, beispielsweise: + +```text +epub2md/ +``` + +Außerdem können folgende Verzeichnisse und Dateien vorhanden sein: + +```text +Beispiele/ +tests/ +README.md +requirements.txt +pyproject.toml +setup.py +``` + +Deine Aufgabe ist nicht, das Programm neu zu entwerfen oder pauschal umzuschreiben. Führe stattdessen eine gründliche, nachvollziehbare Qualitätskontrolle durch und belege jede Feststellung durch tatsächliche Codeanalyse, Testausführung oder konkrete erzeugte Ausgaben. + +## Oberstes Ziel + +Stelle fest, ob das vorhandene Programm tatsächlich zuverlässig EPUB-Dateien in eine strukturierte, kapitelweise gespeicherte Markdown-Ausgabe konvertiert und dabei insbesondere die folgenden Inhalte korrekt oder zumindest verlustarm verarbeitet: + +- Kapitelstruktur und Spine-Reihenfolge +- EPUB-2- und EPUB-3-Metadaten +- Überschriften, Fließtext, Hervorhebungen und Listen +- Tabellen +- Programmcode und Einrückungen +- Bilder, SVGs und andere Medien +- MathML, TeX/LaTeX, SVG- und Bildformeln +- Fußnoten, Endnoten und Rückverweise +- interne EPUB-Links und Anker +- Glossare +- Quellen, Bibliografien und Literaturverzeichnisse +- Sachregister und Personenregister +- YAML-Metadaten +- Inhaltsverzeichnis, Manifest und Konvertierungsbericht +- Fehlerbehandlung, sichere ZIP-Verarbeitung und Offline-Betrieb + +Die Prüfung muss praxisnah sein. Prüfe nicht nur, ob Funktionen existieren oder Tests grün sind, sondern ob die erzeugten Markdown-Dateien tatsächlich brauchbar und untereinander korrekt verlinkt sind. + +## Verbindliche Arbeitsweise + +Halte dich exakt an diese Regeln: + +1. Lies zuerst die komplette Projektstruktur ein. +2. Lies die vorhandene Dokumentation, insbesondere `README.md`. +3. Ermittle den tatsächlichen Einstiegspunkt und die tatsächlich unterstützten CLI-Optionen über: + + ```bash + python3 epub2md.py --help + ``` + + Falls das Programm als Python-Modul ausgeführt werden muss, ermittle den richtigen Aufruf aus der Projektstruktur und Dokumentation. + +4. Verlasse dich nicht auf Behauptungen im README oder auf bereits vorhandene Tests. +5. Führe vorhandene Tests tatsächlich aus. +6. Erstelle bei Lücken eigene Testfälle oder temporäre Minimal-EPUB-Dateien. +7. Teste echte EPUB-Dateien aus `Beispiele/`, falls dort Dateien vorhanden sind. +8. Verwende für jeden Testlauf ein neues, leeres Ausgabeverzeichnis. +9. Verwende niemals `--force` gegen ein echtes wichtiges Verzeichnis. Nutze ausschließlich Testausgabeordner, zum Beispiel: + + ```text + .qa-output/ + .qa-fixtures/ + .qa-reports/ + ``` + +10. Nimm keine Änderungen am Produktivcode vor, bevor du: + - den Fehler nachvollziehbar reproduziert hast, + - den erwarteten Sollzustand beschrieben hast, + - eine möglichst kleine Korrektur geplant hast. + +11. Wenn du Produktivcode änderst: + - ändere nur das Notwendige, + - schreibe oder ergänze zuerst einen Test, der den Fehler reproduziert, + - führe danach den neuen Test und die vollständige Testsuite aus, + - führe mindestens einen erneuten CLI-Integrationstest aus, + - dokumentiere exakt, welche Dateien geändert wurden und warum. + +12. Erfinde keine Resultate. Wenn ein Test nicht ausgeführt werden kann, erkläre: + - den konkreten Grund, + - die Fehlermeldung, + - die Auswirkung auf die Qualitätseinschätzung. + +13. Keine Netzwerkzugriffe, keine Cloud-Dienste, keine Telemetrie und keine DRM-Umgehung. + +14. Lösche keine Originaldateien unter `Beispiele/` und bearbeite sie nicht. + +15. Verwende keine oberflächlichen Aussagen wie „scheint zu funktionieren“. Jede Aussage muss durch: + - einen Test, + - eine Code-Stelle, + - eine erzeugte Datei, + - oder einen reproduzierbaren Fehler + gestützt sein. + +## Prüfprotokoll + +Lege im Projektverzeichnis ein Verzeichnis für die Qualitätsprüfung an: + +```text +.qa/ +``` + +Lege darin mindestens diese Dateien an: + +```text +.qa/ +├── QA_REPORT.md +├── QA_REPORT.json +├── commands.log +├── test-results.txt +├── findings.md +├── generated-fixtures/ +├── output/ +└── diff-summary.md +``` + +Falls die Projektregeln temporäre Dateien an anderer Stelle verlangen, passe die Pfade an. Die Ergebnisse der Qualitätskontrolle müssen aber klar von der normalen Programmausgabe getrennt sein. + +Protokolliere alle wichtigen ausgeführten Kommandos inklusive Rückgabecode in: + +```text +.qa/commands.log +``` + +Die Datei darf keine geheimen Daten enthalten. Es sollen in erster Linie lokal reproduzierbare Befehle, Testnamen und relevante Fehlermeldungen enthalten sein. + +## Phase 1: Bestandsaufnahme + +Führe zunächst eine Bestandsaufnahme durch. + +Prüfe: + +- Welche Python-Dateien und Module existieren? +- Gibt es eine `requirements.txt`, `pyproject.toml`, `setup.py` oder `tox.ini`? +- Welche Drittbibliotheken sind erforderlich? +- Gibt es Tests? +- Gibt es Beispiel-EPUBs? +- Ist das Programm monolithisch oder modular aufgebaut? +- Welche CLI-Optionen werden tatsächlich angeboten? +- Welche Ausgabestruktur behauptet die Dokumentation? +- Existieren Dokumentation und Implementierung im Einklang? + +Erstelle in `.qa/QA_REPORT.md` einen Abschnitt: + +```markdown +## Bestandsaufnahme +``` + +Dokumentiere dort: + +- Projektstruktur +- Einstiegspunkt +- Python-Version +- erkannte Abhängigkeiten +- vorhandene Tests +- Anzahl und Namen vorhandener Beispiel-EPUBs +- tatsächlich vorhandene CLI-Optionen +- offensichtliche Diskrepanzen zwischen README und Implementierung + +## Phase 2: Installations- und Startprüfung + +Prüfe die Installation beziehungsweise Ausführbarkeit unter einer lokalen virtuellen Umgebung, sofern sinnvoll. + +Nutze beispielsweise: + +```bash +python3 -m venv .qa/venv +. .qa/venv/bin/activate +python -m pip install --upgrade pip +``` + +Installiere Abhängigkeiten anhand der tatsächlich vorhandenen Projektdateien, zum Beispiel: + +```bash +pip install -r requirements.txt +``` + +oder: + +```bash +pip install -e . +``` + +Führe anschließend aus: + +```bash +python3 epub2md.py --help +``` + +Prüfkriterien: + +- Das Programm startet ohne Traceback. +- `--help` funktioniert. +- Die Nutzungsbeschreibung ist verständlich. +- Positionsargumente für Eingabe-EPUB und Ausgabeordner sind dokumentiert. +- Implementierte Optionen sind sichtbar. +- Nicht implementierte, aber dokumentierte Optionen müssen als Dokumentationsfehler markiert werden. +- Nicht dokumentierte, aber implementierte Optionen müssen ebenfalls dokumentiert werden. + +Wenn keine isolierte virtuelle Umgebung aufgebaut werden kann, teste mit der vorhandenen Python-Umgebung und dokumentiere dies klar. + +## Phase 3: Vorhandene Tests ausführen + +Ermittle das verwendete Testframework. Führe mindestens die vollständige Testsuite aus, bevorzugt mit: + +```bash +pytest -q +``` + +Falls keine Tests vorhanden sind, markiere dies als schwerwiegende Qualitätslücke. + +Prüfe zusätzlich, ob bei vorhandenem `pytest` eine Coverage-Auswertung möglich ist. Wenn ein Coverage-Tool nicht installiert ist, installiere es nur dann, wenn dies mit dem vorhandenen Abhängigkeitskonzept vereinbar ist. + +Beispiel: + +```bash +pytest -q --disable-warnings --maxfail=1 +``` + +Falls möglich: + +```bash +coverage run -m pytest +coverage report -m +``` + +Beurteile nicht nur die Zahl erfolgreicher Tests, sondern auch: + +- Ob zentrale Funktionen tatsächlich getestet werden. +- Ob Tests nur Hilfsfunktionen statt CLI-Integration testen. +- Ob Fehlerfälle getestet werden. +- Ob Tests echte EPUB-Strukturen oder nur Mock-Objekte nutzen. +- Ob Tests kritische Spezialfälle aus diesem Prüfauftrag abdecken. + +## Phase 4: Erzeuge eigene Minimal-EPUB-Testdaten + +Falls das Projekt nicht bereits ausreichende, rechtlich unproblematische Testfixtures besitzt, erstelle selbst kleine, synthetische und DRM-freie EPUB-2- und EPUB-3-Dateien unter: + +```text +.qa/generated-fixtures/ +``` + +Erzeuge die Test-EPUBs ausschließlich lokal mit Python und ZIP-Dateien. Die EPUBs müssen valide genug sein, um vom Zielprogramm verarbeitet zu werden. + +Erzeuge mindestens folgende Fixtures: + +```text +01-minimal-epub2.epub +02-minimal-epub3-nav.epub +03-chapters-and-links.epub +04-code-and-tables.epub +05-images-and-svg.epub +06-mathml-and-equations.epub +07-footnotes-and-endnotes.epub +08-glossary-bibliography-index.epub +09-complex-table.epub +10-malicious-paths.epub +11-broken-container.epub +12-encoding-edge-cases.epub +``` + +### Anforderungen an die Fixtures + +#### `01-minimal-epub2.epub` + +Muss enthalten: + +- `META-INF/container.xml` +- OPF-Paketdatei +- Metadaten mit Titel, Autor, Sprache und Identifier +- mindestens ein XHTML-Kapitel +- Spine-Reihenfolge +- NCX-Datei, falls sinnvoll + +#### `02-minimal-epub3-nav.epub` + +Muss enthalten: + +- EPUB-3-OPF +- `nav.xhtml` +- mindestens zwei Kapitel +- valide Spine-Reihenfolge +- Dokumenttitel und Metadaten + +#### `03-chapters-and-links.epub` + +Muss enthalten: + +- mehrere Kapitel +- mindestens zwei H1-Überschriften in einer XHTML-Datei +- interne Links: + - zu einem anderen Kapitel + - zu einem Anker im selben Kapitel + - zu einem Anker in einem anderen Kapitel +- eine absichtlich nicht auflösbare interne Referenz + +Prüfe: + +- Kapitelaufteilung anhand von `--chapter-level` +- funktionierende relative Links +- Warnung für nicht auflösbare Links + +#### `04-code-and-tables.epub` + +Muss enthalten: + +- Inline-Code +- Python-Code mit signifikanter Einrückung +- Shell-Code mit `$`, Pipes und Backslashes +- Code mit Backticks +- Code mit `<`, `>` und `&` +- Codeblöcke mit und ohne Sprachklasse +- verschachtelte ungeordnete und geordnete Listen +- einfache Tabelle + +Prüfe: + +- Code wird nicht HTML-escaped zerstört. +- Python-Einrückungen bleiben erhalten. +- Der Codeblock nutzt genügend Backticks. +- Sprachlabels sind korrekt oder bei Unsicherheit leer. +- Listenstruktur bleibt erhalten. +- Die einfache Tabelle wird als GFM-Tabelle oder zumindest in valider, lesbarer Form ausgegeben. + +#### `05-images-and-svg.epub` + +Muss enthalten: + +- PNG oder JPEG +- SVG +- `figure` mit `figcaption` +- `img` mit `alt` und `title` +- mehrfach referenziertes Bild +- Bild mit Leerzeichen oder Sonderzeichen im Quellpfad + +Prüfe: + +- Bilder werden nur einmal extrahiert. +- Ausgabedateien bleiben im Ausgabeordner. +- Relative Bildlinks funktionieren aus den Kapiteldateien. +- SVG bleibt SVG und wird nicht ungewollt gerastert. +- Bildunterschriften und Alternativtexte bleiben erhalten. + +#### `06-mathml-and-equations.epub` + +Muss mindestens folgende MathML-Beispiele enthalten: + +```xml +<math> + <mfrac><mi>a</mi><mi>b</mi></mfrac> +</math> +``` + +```xml +<math> + <msup><mi>x</mi><mn>2</mn></msup> +</math> +``` + +```xml +<math> + <msub><mi>a</mi><mi>i</mi></msub> +</math> +``` + +```xml +<math> + <msubsup> + <mi>x</mi><mi>i</mi><mn>2</mn> + </msubsup> +</math> +``` + +Zusätzlich: + +- Inline-Formel +- Blockformel mit Gleichungsnummer +- nicht vollständig unterstütztes MathML-Element +- SVG-Formel +- Bildformel mit Alternativtext +- TeX/LaTeX in einem Attribut oder Textbereich, falls das Programm dies behauptet zu unterstützen + +Prüfe für `--math auto`, `--math tex`, `--math mathml` und `--math image`: + +- Keine Formel verschwindet. +- Bei einfacher MathML-zu-TeX-Konvertierung sind Bruch, Hochstellung und Index semantisch erkennbar. +- Bei komplexem oder unbekanntem MathML bleibt eine maschinenlesbare oder visuell verwendbare Repräsentation erhalten. +- Es wird kein offensichtlich falsches TeX erzeugt. +- Gleichungsnummern bleiben sichtbar. +- SVG- und Bildformeln bleiben als Medienreferenz erhalten. +- Warnungen für nicht semantisch extrahierte Formeln erscheinen im Report. + +#### `07-footnotes-and-endnotes.epub` + +Muss enthalten: + +- eine Fußnotenreferenz mit `epub:type="noteref"` oder `role="doc-noteref"` +- Fußnotentext mit mehreren Absätzen +- Fußnote mit Link und Hervorhebung +- Fußnote mit Formel oder Code, wenn möglich +- mehrfach referenzierte Fußnote +- Endnotenabschnitt +- Rücklink zur Textstelle + +Prüfe für: + +```bash +--footnotes end-of-chapter +--footnotes end-of-book +--footnotes inline +--footnotes keep-links +``` + +Prüfkriterien: + +- Fußnotentext verschwindet nicht. +- Fußnoten erscheinen nicht doppelt als Fließtext und als Fußnote. +- Referenzen und Definitionen sind konsistent. +- Inhalte innerhalb von Fußnoten bleiben erhalten. +- Der gewählte Modus entspricht der tatsächlichen Ausgabe. +- Nicht auflösbare Fußnotenverweise erzeugen Warnungen. + +#### `08-glossary-bibliography-index.epub` + +Muss enthalten: + +- Glossar als Definitionsliste oder klar semantisch markierter Abschnitt +- Quellen- oder Bibliografieabschnitt +- URLs, DOI, ISBN und bibliografische Angaben +- Sachregister +- Personenregister +- alphabetische Gruppen +- mindestens einen internen Registerlink + +Prüfe: + +- Diese Bereiche werden nicht als normale, unbenannte Kapitel vermischt, falls das Programm eine Spezialbehandlung behauptet. +- Glossarbegriffe und Definitionen bleiben erhalten. +- Literaturangaben bleiben vollständig. +- URLs bleiben Links. +- Registereinträge und sichtbare Seiten-/Kapitelreferenzen bleiben erhalten. +- Interne Registerlinks werden, soweit möglich, korrekt umgesetzt oder als Warnung dokumentiert. + +#### `09-complex-table.epub` + +Muss enthalten: + +- `colspan` +- `rowspan` +- mehrzeilige Tabellenzellen +- Textformatierung in Tabellenzellen + +Prüfe: + +- Keine Zellinhalte gehen verloren. +- Falls keine GFM-Tabelle sinnvoll ist, bleibt die Tabelle als valides HTML im Markdown erhalten. +- Eine Warnung wird erzeugt, wenn das Werkzeug dies verspricht. + +#### `10-malicious-paths.epub` + +Muss ZIP-Einträge mit unsicheren Pfaden enthalten, beispielsweise: + +```text +../../outside.txt +../escape.xhtml +/absolute/path.txt +``` + +Prüfe: + +- Das Programm schreibt nichts außerhalb des Ausgabeordners. +- Der Fehler wird sicher abgefangen. +- Es kommt nicht zu Path Traversal. +- Die Fehlermeldung ist verständlich. +- Der Rückgabecode entspricht dokumentierten Fehlercodes oder wird als Abweichung dokumentiert. + +#### `11-broken-container.epub` + +Muss mindestens einen absichtlich defekten Container-, OPF- oder Spine-Verweis enthalten. + +Prüfe: + +- Verständliche Fehlermeldung. +- Kein unkontrollierter Python-Traceback im Normalmodus, sofern das Programm benutzerfreundliche Fehlerbehandlung beansprucht. +- Passender Rückgabecode. +- Kein teilweise irreführender Erfolgsreport. + +#### `12-encoding-edge-cases.epub` + +Muss enthalten: + +- deutsche Umlaute +- französische Akzente +- griechische Buchstaben +- mathematische Unicode-Symbole +- typografische Anführungszeichen +- Gedankenstriche +- nicht-ASCII-Dateinamen oder IDs, soweit EPUB-kompatibel + +Prüfe: + +- UTF-8-Ausgabe ist korrekt. +- keine vermeidbaren Ersatzzeichen `�` +- keine ungerechtfertigte Transliteration im Text +- Slug-Verhalten entspricht `--slug-language` + +## Phase 5: CLI- und Ausgabestruktur prüfen + +Prüfe alle tatsächlich vorhandenen Optionen. Falls die ursprüngliche Spezifikation Optionen vorsieht, die Implementierung aber andere Optionen anbietet, bewerte ausschließlich die reale Implementierung und dokumentiere die Abweichung. + +Prüfe mindestens: + +```bash +python3 epub2md.py --help +python3 epub2md.py INPUT.epub OUTPUT_DIR +python3 epub2md.py INPUT.epub OUTPUT_DIR --dry-run +python3 epub2md.py INPUT.epub OUTPUT_DIR --verbose +``` + +Wenn implementiert, prüfe zusätzlich: + +```bash +--format gfm +--format commonmark +--extract-media +--no-extract-media +--copy-original +--chapter-level 1 +--chapter-level 2 +--toc +--no-toc +--frontmatter +--no-frontmatter +--footnotes end-of-chapter +--footnotes end-of-book +--footnotes inline +--footnotes keep-links +--math auto +--math tex +--math mathml +--math image +--images relative +--images flat +--images original-path +--slug-language de +--slug-language en +--slug-language none +--keep-html +--strict +--force +--report DATEI.json +``` + +Für jede tatsächlich implementierte Option prüfe: + +- Wird die Option akzeptiert? +- Ändert sie die Ausgabe wirklich in erwarteter Weise? +- Ist der Effekt dokumentiert? +- Entsteht bei ungültigen Werten eine verständliche Fehlermeldung? +- Gibt es einen sinnvollen Exit-Code? + +Prüfe die Ausgabestruktur: + +- Wird ein README oder Index erzeugt, falls behauptet? +- Werden Kapiteldateien erzeugt? +- Sind sie stabil nummeriert? +- Sind Slugs sinnvoll und konfliktfrei? +- Werden Medien in einem klaren Unterverzeichnis gespeichert? +- Werden Metadaten separat gespeichert, falls behauptet? +- Wird ein maschinenlesbarer Report erzeugt, falls behauptet? +- Werden Diagnoseinformationen erzeugt, falls Warnungen auftreten? +- Existieren keine Referenzen auf nicht existierende lokale Dateien, soweit die Konvertierung dies kontrollieren kann? + +## Phase 6: Inhaltliche Ausgabevalidierung + +Validiere nicht nur Dateiexistenz, sondern auch den Inhalt. + +### Markdown-Validierung + +Prüfe stichprobenartig und automatisiert: + +- Gibt es unbalancierte Code-Fences? +- Gibt es leere oder abgeschnittene Markdown-Dateien? +- Gibt es kaputte relative Bildlinks? +- Gibt es kaputte interne Markdown-Links? +- Gibt es doppelte Kapitelinhalte? +- Gibt es unerwartete HTML-Entity-Reste wie `<` im Code? +- Gibt es Python-Code, dessen Einrückung verloren ging? +- Gibt es offensichtlich entstellte Tabellen? +- Gibt es Fußnotenreferenzen ohne Definition oder Definitionen ohne Referenz? +- Gibt es offensichtliche Datenverluste bei Formeln? +- Enthält die Ausgabe ungewollte Navigationsfragmente wie „Next“, „Previous“, „Table of Contents“, falls diese aus dem EPUB-Reader-Layout stammen? +- Wurden Bilder oder Formeln fälschlich als Textmüll ausgegeben? +- Wurden Medienreferenzen auf existierende Dateien umgesetzt? + +Verwende bei Bedarf kleine Prüfskripte unter `.qa/`, aber passe nicht die Produktivlogik an, nur um Tests zu täuschen. + +### Relative Links + +Schreibe einen Validator, der alle lokalen Markdown-Links und Bildlinks findet und prüft: + +- Das Ziel ist entweder: + - eine externe URL, + - ein existierender relativer Pfad, + - ein gültiger Anker im selben Dokument, + - oder ein gültiges Dokument plus Anker. +- Fehlende Ziele werden im QA-Bericht aufgelistet. +- Der Validator muss Anker berücksichtigen, sofern praktikabel. +- Dokumentiere bei Ankerprüfung Grenzen der Heuristik. + +### YAML-Frontmatter + +Falls Frontmatter ausgegeben wird: + +- Prüfe, ob es syntaktisch valides YAML ist. +- Prüfe, ob Titel, Kapitelnummer und Quelldatei plausibel sind. +- Prüfe, ob Sonderzeichen korrekt kodiert sind. +- Prüfe, ob die Metadaten nicht unzulässig erfunden wurden. + +### JSON-Validierung + +Falls `manifest.json`, `conversion-report.json` oder andere JSON-Dateien ausgegeben werden: + +- Parse sie mit Python. +- Prüfe, ob die referenzierten Kapitel- und Mediendateien existieren. +- Prüfe, ob Zahlenwerte plausibel sind. +- Prüfe, ob gemeldete Warnungen mit beobachteten Problemen übereinstimmen. +- Prüfe, ob ein Programm bei Warnungen nicht fälschlich „verlustfrei“ oder „vollständig erfolgreich“ behauptet. + +## Phase 7: Prüfung mit realen EPUB-Beispielen + +Falls unter `Beispiele/` EPUB-Dateien vorhanden sind: + +1. Liste alle EPUB-Dateien auf. +2. Bestimme anhand von Metadaten, Dateinamen und kurzer Strukturinspektion, welche davon wahrscheinlich: + - Roman + - Bildband + - Programmierbuch + - Mathematikbuch + - Fachbuch + sind. +3. Konvertiere jede EPUB-Datei in ein eigenes Unterverzeichnis unter: + + ```text + .qa/output/examples/ + ``` + +4. Verwende standardmäßig konservative Optionen, zum Beispiel: + + ```bash + python3 epub2md.py \ + "Beispiele/DATEI.epub" \ + ".qa/output/examples/NAME" \ + --format gfm \ + --extract-media \ + --toc \ + --frontmatter \ + --footnotes end-of-chapter \ + --math auto \ + --images relative \ + --slug-language de \ + --report ".qa/output/examples/NAME/report.json" \ + --verbose + ``` + +5. Prüfe für jedes reale Buch stichprobenartig mindestens: + - erste Kapiteldatei + - ein Kapitel aus der Mitte + - ein Kapitel mit besonderem Inhalt, falls vorhanden + - letzte Kapiteldatei + - README/Index + - Medienverzeichnis + - Konvertierungsbericht +6. Lies nur die für die technische Prüfung erforderlichen Auszüge. Gib im QA-Bericht keine längeren urheberrechtlich geschützten Textpassagen wieder. +7. Dokumentiere pro Buch: + - Anzahl erzeugter Kapitel + - Anzahl Medien + - Anzahl Warnungen + - erkannte Formeln + - erkannte Codeblöcke + - erkannte Fußnoten + - erkannte Spezialabschnitte + - konkrete Probleme und Dateipfade + - Bewertung der Brauchbarkeit für: + - Markdown-Lektüre + - Pandoc-Weiterverarbeitung + - RAG-Import + - technische Bücher mit Code + - Mathematik mit Formeln + +## Phase 8: Sicherheits- und Robustheitsprüfung + +Prüfe insbesondere: + +### ZIP-Sicherheit + +- Path Traversal wird sicher verhindert. +- Keine Datei wird außerhalb des Ausgabeordners geschrieben. +- Große oder ungewöhnliche Archive führen nicht zu unkontrollierten Ausnahmen, soweit ohne absichtliche Ressourcenerschöpfung testbar. +- ZIP-Einträge werden nicht blind extrahiert. + +### XML-Sicherheit + +- XML-Verarbeitung erlaubt keine externen Entitäten. +- Externe Ressourcen werden nicht aus dem Netzwerk geladen. +- Kein XXE-Risiko, soweit anhand der verwendeten Parserkonfiguration prüfbar. + +### Dateisystemverhalten + +- Existierender Ausgabeordner wird nicht stillschweigend überschrieben. +- Falls `--force` existiert, überschreibt es nur den angegebenen Ausgabeordner. +- `--dry-run` erzeugt keine fachlichen Ausgabedateien. +- Fehler führen nicht zu verwirrenden, halbvollständigen Ergebnissen ohne Warnung. +- Temporäre Verzeichnisse werden nach Möglichkeit bereinigt. + +### Fehlercodes + +Prüfe Rückgabecodes für: + +- fehlende Eingabedatei +- ungültige Option +- kaputte ZIP-Datei +- kaputter EPUB-Container +- kaputte OPF-/Spine-Struktur +- Schreibfehler im Ziel, soweit sicher simulierbar +- unerwartete Ausnahme, falls reproduzierbar + +Vergleiche mit der Dokumentation. Abweichungen sind als Fehler oder Dokumentationsmangel zu erfassen. + +## Phase 9: Codequalität und Wartbarkeit + +Führe eine gezielte Codeanalyse durch. + +Prüfe: + +- Sind Funktionen und Klassen klar abgegrenzt? +- Gibt es überlange, schwer testbare Funktionen? +- Werden Dataclasses oder strukturierte Modelle verwendet? +- Ist die HTML-zu-Markdown-Konvertierung rekursiv und nachvollziehbar? +- Sind Sonderfälle lokal gekapselt? +- Gibt es unnötige globale Zustände? +- Werden Dateipfade sicher behandelt? +- Werden Textkodierungen konsistent als UTF-8 behandelt? +- Gibt es Bare-`except:` oder verschluckte Fehler? +- Werden Warnungen zentral gesammelt? +- Sind Logs und Fehlermeldungen hilfreich? +- Ist die Konvertierung deterministisch? +- Funktioniert die Ausgabe bei wiederholtem Lauf reproduzierbar? +- Werden nicht unterstützte Inhalte sichtbar dokumentiert, statt still zu verschwinden? +- Gibt es Tests für tatsächlich komplexe Inhalte oder nur triviale Happy Paths? + +Nutze bei Bedarf Werkzeuge wie: + +```bash +python -m compileall . +``` + +und, falls im Projekt verfügbar oder angemessen: + +```bash +ruff check . +``` + +```bash +mypy . +``` + +Führe zusätzliche Linter oder Typprüfungen nur aus, wenn sie ohne unvertretbare Umbauten nutzbar sind. Ein fehlender Linter ist nicht automatisch ein Fehler; fehlende Tests für Kernfunktionalität hingegen sind relevant. + +## Fehlerklassifikation + +Klassifiziere jedes Problem eindeutig: + +```text +Kritisch + Sicherheitsproblem, Datenverlust, Absturz bei normalen Eingaben, + Überschreiben außerhalb des Zielordners, fehlerhafte Spine-Reihenfolge, + verlorene Kapitel, zerstörter Programmcode, verschwindende Formeln oder Fußnoten. + +Hoch + Nicht funktionierende zentrale Option, falsche interne Links, + unbrauchbare Bildpfade, fehlerhafte Kapitelaufteilung, + ungültige Markdown-Ausgabe, nicht verarbeitete EPUB-3-Navigation, + falsche oder fehlende Metadaten trotz Vorhandensein in EPUB. + +Mittel + Begrenzte Spezialfälle, schlechte Warnungen, unvollständige Reports, + nicht optimal erhaltene Tabellen, fehlende Sprachkennung in Code, + Dokumentationsabweichungen. + +Niedrig + Kosmetik, uneinheitliche Dateinamen, unpräzise Hilfetexte, + kleinere README-Mängel ohne Auswirkung auf Konvertierungsqualität. +``` + +Bewerte immer die reale Auswirkung. Eine bekannte und klar dokumentierte Einschränkung ist weniger gravierend als stiller Datenverlust. + +## Reparaturregeln + +Führe Reparaturen nur durch, wenn: + +- ein konkreter Fehler reproduziert wurde, +- der Fehler in den Projektumfang fällt, +- eine kleine, sichere Korrektur möglich ist, +- du zuvor oder gleichzeitig einen Regressionstest ergänzt. + +Bei jeder Reparatur: + +1. Erstelle einen fehlschlagenden Test oder dokumentiere einen reproduzierbaren CLI-Test. +2. Implementiere die kleinste mögliche Korrektur. +3. Führe den spezifischen Regressionstest aus. +4. Führe die vollständige Testsuite aus. +5. Führe mindestens einen realistischen EPUB-Integrationstest aus. +6. Aktualisiere README oder Hilfetext, wenn Verhalten oder Einschränkung relevant ist. +7. Dokumentiere Änderung, Ursache, Test und Ergebnis in `.qa/diff-summary.md`. + +Nimm keine umfangreichen Refactorings nur aus Stilgründen vor. Priorität haben Zuverlässigkeit, Datenintegrität und nachvollziehbare Fehlerbehebung. + +## Abschlussbericht + +Erstelle am Ende eine vollständige Datei: + +```text +.qa/QA_REPORT.md +``` + +Nutze exakt diese Gliederung: + +```markdown +# QA-Bericht: epub2md + +## Kurzfazit + +## Projekt- und Laufzeitumgebung + +## Bestandsaufnahme + +## Tatsächlich getestete CLI-Schnittstelle + +## Vorhandene Tests + +## Ergänzte QA-Tests und Testfixtures + +## Ergebnisse der EPUB-Integrationstests + +## Prüfung der Markdown-Ausgabe + +## Prüfung von Codeblöcken + +## Prüfung von Medien und Bildlinks + +## Prüfung von Mathematik und Formeln + +## Prüfung von Fußnoten und Endnoten + +## Prüfung von Glossar, Quellen und Registern + +## Prüfung interner Links und Anker + +## Sicherheits- und Robustheitsprüfung + +## Gefundene Probleme + +## Behobene Probleme + +## Verbleibende Einschränkungen + +## Testabdeckung und Vertrauensniveau + +## Endbewertung + +## Reproduzierbare Testkommandos +``` + +Die Datei `.qa/QA_REPORT.json` muss dieselben Kernergebnisse maschinenlesbar enthalten, mindestens mit folgender Struktur: + +```json +{ + "project": { + "name": "epub2md", + "entrypoint": "epub2md.py", + "python_version": "", + "tested_at": "" + }, + "summary": { + "status": "pass|pass-with-limitations|fail", + "critical_issues": 0, + "high_issues": 0, + "medium_issues": 0, + "low_issues": 0, + "tests_run": 0, + "tests_passed": 0, + "tests_failed": 0 + }, + "cli": { + "works": false, + "options_tested": [] + }, + "features": { + "epub2": "", + "epub3": "", + "chapters": "", + "code_blocks": "", + "images": "", + "math": "", + "footnotes": "", + "internal_links": "", + "glossary": "", + "bibliography": "", + "indexes": "", + "reports": "", + "security": "" + }, + "findings": [], + "fixes_applied": [], + "remaining_limitations": [], + "reproduction_commands": [] +} +``` + +## Endbewertung + +Gib im Abschlussbericht eine klare Gesamtbewertung in genau einer dieser Kategorien: + +```text +BESTANDEN + Alle Kernfunktionen funktionieren in den geprüften Fällen. + Keine kritischen oder hohen Probleme. + Verbleibende Einschränkungen sind dokumentiert und verursachen keinen stillen Datenverlust. + +BESTANDEN MIT EINSCHRÄNKUNGEN + Grundfunktion funktioniert, aber es gibt mittlere oder niedrige Probleme, + oder klar abgegrenzte Einschränkungen, beispielsweise bei komplexem MathML, + Layouttabellen oder nicht standardkonformen EPUBs. + Diese Einschränkungen müssen transparent dokumentiert sein. + +NICHT BESTANDEN + Mindestens ein kritisches oder hohes Problem bleibt bestehen, + insbesondere bei Datenverlust, Sicherheit, Kapitelreihenfolge, + Codeverlust, Fußnotenverlust, zerstörten Links oder nicht funktionierender CLI. +``` + +Die Endbewertung darf nicht nur aus grünen Unit-Tests abgeleitet werden. Sie muss besonders die realen CLI-Integrationstests, die synthetischen EPUB-Fixtures und – falls vorhanden – die EPUBs aus `Beispiele/` berücksichtigen. + +## Abschlussausgabe im Terminal + +Gib am Ende im Terminal eine kurze, präzise Zusammenfassung aus: + +```text +QA abgeschlossen. + +Gesamtbewertung: BESTANDEN | BESTANDEN MIT EINSCHRÄNKUNGEN | NICHT BESTANDEN +Unit-/Integrationstests: X bestanden, Y fehlgeschlagen +Kritische Probleme: X +Hohe Probleme: X +Behobene Probleme: X +Verbleibende Einschränkungen: X + +Vollständiger Bericht: +.qa/QA_REPORT.md + +Maschinenlesbarer Bericht: +.qa/QA_REPORT.json +``` + +Wichtig: Behaupte nur dann „BESTANDEN“, wenn die tatsächliche Konvertierung mit realen und synthetischen EPUBs überprüft wurde, interne Links und Medienpfade kontrolliert wurden und keine kritischen oder hohen Probleme bestehen. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d69c950 --- /dev/null +++ b/README.md @@ -0,0 +1,292 @@ +# epub2md + +Kommandozeilenwerkzeug, das **DRM-freie EPUB-Dateien** (EPUB 2 und EPUB 3) lokal und offline in eine +strukturierte Sammlung **kapitelweiser Markdown-Dateien** umwandelt – mit extrahierten Medien, +YAML-Metadaten, Inhaltsverzeichnis, maschinenlesbarem Manifest und einem Konvertierungsbericht. + +Gedacht für die Weiterverarbeitung mit Markdown-Renderern, Pandoc, Obsidian/Zettelkasten, Git, +RAG-/Embedding-Pipelines, Volltextsuche und lokale LLMs. + +## Grenzen + +- **Kein DRM:** Verschlüsselte EPUBs (`META-INF/encryption.xml` mit verschlüsselten Inhalten) werden mit + Exit-Code 2 abgelehnt. Es wird kein Kopierschutz umgangen. Reine Font-Obfuscation ist erlaubt. +- **Keine OCR:** Gescannte Bücher (Seiten als Bilder) liefern nur Bildreferenzen, keinen Text. +- **Formeln als Bild/SVG** werden nie in TeX „geraten“. Sie bleiben als Bild/SVG erhalten und werden im + Bericht gemeldet. TeX entsteht nur aus echtem MathML, TeX-Annotationen oder TeX im `alt`-Text. +- **Layout-Heuristiken** (z. B. Überschriften, die als Tabellen gesetzt sind) sind konservativ und + erfassen nicht jedes Verlagslayout. + +## Installation (Ubuntu 24.04) + +```bash +sudo apt install python3 python3-venv +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt # Laufzeit: nur lxml +pip install -r requirements-dev.txt # Tests: pytest, PyYAML +``` + +Laufzeitabhängigkeit ist ausschließlich **lxml** (sicheres XML/HTML-Parsing). Alles andere ist +Standardbibliothek. Pandoc, Calibre, Java oder Node.js werden **nicht** benötigt. Pandoc ist nur für +die optionale Weiterverarbeitung der Ausgabe nützlich. + +## Aufruf + +```bash +python3 epub2md.py INPUT.epub OUTPUT_VERZEICHNIS [Optionen] +# alternativ +python3 -m epub2md INPUT.epub OUTPUT_VERZEICHNIS [Optionen] +``` + +Beispiele: + +```bash +# Programmierbuch mit Standardoptionen +python3 epub2md.py \ + Beispiele/programmierbuch.epub \ + output/programmierbuch \ + --format gfm \ + --math auto \ + --footnotes end-of-chapter \ + --extract-media \ + --frontmatter \ + --toc \ + --verbose + +# 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 +python3 epub2md.py buch.epub output/buch --dry-run + +# Erneut konvertieren und frühere Ausgabe ersetzen +python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json +``` + +## Optionen + +| Option | Standard | Wirkung | +| --- | --- | --- | +| `--format {gfm,commonmark}` | `gfm` | `gfm`: Pipe-Tabellen, `~~durchgestrichen~~`, `[^fn]`-Fußnoten. `commonmark`: Tabellen als HTML, `<del>`, Fußnoten als Anker-Links | +| `--extract-media` / `--no-extract-media` | an | Medien nach `media/` extrahieren. Ohne Extraktion stehen Bildreferenzen als Text `[Bild: alt – Pfad]` da (keine kaputten Links) | +| `--copy-original` / `--no-copy-original` | aus | Original-EPUB nach `source/` kopieren | +| `--chapter-level N` | `1` | Überschriftenebene (1–6), ab der eine neue Kapiteldatei beginnt | +| `--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) | +| `--math {auto,tex,mathml,image}` | `auto` | siehe [Mathematik](#math-modi) | +| `--images {relative,flat,original-path}` | `relative` | `relative`: `media/images/0001-name.ext`; `flat`: `media/name.ext`; `original-path`: `media/<Pfad im EPUB>` | +| `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt | +| `--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) | +| `--verbose`, `-v` | aus | Verarbeitungsschritte und Warnungen auf stderr | +| `--dry-run` | aus | EPUB analysieren, geplante Kapiteldateien anzeigen, **nichts** schreiben (auch kein `--report`) | +| `--report DATEI.json` | – | Bericht zusätzlich an diesen Ort schreiben. `OUTPUT/conversion-report.json` entsteht immer | +| `--version` | – | Programmversion | + +### Rückgabecodes + +| Code | Bedeutung | +| --- | --- | +| 0 | Erfolgreich, ggf. mit nicht-kritischen Warnungen | +| 1 | Ungültige Parameter, Eingabedatei fehlt/nicht lesbar, Ausgabeverzeichnis belegt | +| 2 | ZIP-/Container-Fehler, DRM, unsichere ZIP-Pfade (mit `--strict`) | +| 3 | OPF-/Spine-/XHTML-Fehler (z. B. leere Spine; mit `--strict` auch einzelne defekte Dokumente) | +| 4 | Unerwarteter interner Fehler (Traceback mit `--verbose`) | + +## Ausgabestruktur + +```text +output/mein-buch/ +├── README.md # Titel, Metadaten, Inhaltsverzeichnis, Statistik, Reproduktion +├── metadata.yaml # Buchmetadaten (nur vorhandene Felder, nichts erfunden) +├── manifest.json # Kapitel mit Anker-Listen, Wortzahl, Code/Bild/Formel-Zählern – für RAG +├── conversion-report.json # vollständiger Bericht (Optionen, SHA-256, Warnungen, Formeln, Links …) +├── source/mein-buch.epub # nur mit --copy-original +├── media/ +│ ├── images/0001-cover.jpg # stabile, kollisionsfreie Namen; jede Quelle genau einmal +│ ├── audio/ video/ other/ # Audio, Video, Schriften, CSS … +├── chapters/ +│ ├── 001-cover.md +│ ├── 002-vorwort.md +│ ├── 003-1-einleitung.md +│ ├── … +│ └── 999-anmerkungen.md # nur bei --footnotes end-of-book +└── diagnostics/ + ├── warnings.md # Warnungen/Hinweise gruppiert, für Menschen + └── unsupported-elements.md +``` + +- Dateinamen: fortlaufend dreistellig nummeriert (`001-…`) plus Slug aus dem Kapiteltitel. Gleiche Titel + bekommen eindeutige Namen, weil die Nummer immer Teil des Namens ist. +- Kapitel-Frontmatter (YAML, `---`-Begrenzer): `book_title`, `book_author`, `language`, + `chapter_number`, `chapter_title`, `source_epub`, `source_href` (+ `source_hrefs`, wenn ein Kapitel aus + mehreren XHTML-Dateien besteht), `epub_type`, `conversion_status` (`converted` oder + `converted-with-warnings`). +- Spezialabschnitte (Glossar, Literatur, Sach-/Personenregister, Anhang, Endnoten) erhalten eigene + Dateien und werden in `README.md` unter „Besondere Abschnitte“ verlinkt. +- Nicht-lineare Spine-Dokumente (`linear="no"`, z. B. Bild-Zoomseiten) bleiben in Spine-Reihenfolge + erhalten und sind im Inhaltsverzeichnis als *Zusatzseite* markiert. + +## Wie Kapitel gebildet werden + +1. Die **OPF-Spine** bestimmt die Lesereihenfolge (nicht Dateinamen, nicht das Inhaltsverzeichnis). +2. Überschriften `h1`…`hN` (N = `--chapter-level`) und semantische Abschnitte (`epub:type`/`role`: + chapter, part, appendix, glossary, bibliography, index, preface, …) beginnen neue Kapiteldateien, + auch mitten in einer XHTML-Datei. +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 + NAV/NCX, `<title>`, `epub:type` oder dem Dateinamen. +5. Ein HTML-Inhaltsverzeichnis wird übersprungen, wenn NAV/NCX vorhanden ist (keine doppelte Ausgabe). + Links darauf zeigen auf `README.md`. +6. Überschriftenebenen werden je Datei verschoben: Der Kapiteltitel ist `#`, alle weiteren beginnen bei `##`. + +Verlagsspezifische Heuristiken: Layout-Tabellen mit Klassen wie `Head1`/`Title_document` werden zu +Überschriften. `<pre>` mit `<br/>`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken. +Literaturlisten mit eigenen Labels (`[BryL06]`) werden nicht doppelt nummeriert. + +**Tipp:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), erzeugt +`--chapter-level 2` eine Datei pro Kapitel. Mit dem Standard `1` entsteht eine Datei pro Teil. + +## Math-Modi + +| Modus | MathML | TeX in `alt`/Annotation | SVG-/Bildformel ohne TeX | +| --- | --- | --- | --- | +| `auto` | konservativ → TeX `$…$` / `$$…$$`; bei Unklarheit MathML behalten | TeX `$…$` / `$$…$$` | Bild + Warnung | +| `tex` | wie auto, aber Begrenzer `\(…\)` / `\[…\]` (nie `$`) | TeX `\(…\)` / `\[…\]` | Bild + Warnung | +| `mathml` | MathML als eingebettetes XML (Namespace normalisiert) | Bild (TeX bleibt im Alt-Text) | Bild | +| `image` | `altimg`-Bild falls vorhanden, sonst MathML + Warnung | Bild | Bild | + +- Die MathML→TeX-Umsetzung unterstützt `mi, mn, mo, mtext, mspace, mrow, mstyle, mfrac, msqrt, mroot, + msup, msub, msubsup, mover, munder, munderover, mfenced, mtable/mtr/mtd, semantics, maction`. + Bei allem anderen (z. B. `menclose`, `mmultiscripts`, `mlabeledtr`) bleibt das MathML erhalten. Lieber + MathML als falsches TeX. +- TeX-Annotationen (`<annotation encoding="application/x-tex">`) haben Vorrang. +- Im `alt`-Text doppelt escapte Zeichen (`&amp;` → `&`) werden repariert. +- Gleichungsnummern wie `(3.7)` bleiben als Text sichtbar, Gleichungsanker als `<a id>` erhalten. +- Formelbilder, die durch TeX ersetzt wurden, werden nicht extrahiert. Die Anzahl steht im Bericht + (`formula_images_replaced_by_tex`). +- **Qualität hängt vom EPUB ab:** Echtes MathML/TeX liefert semantische Formeln. Reine SVG-/Bitmap-Formeln + bleiben Bilder. + +## Fußnoten-Modi + +Erkannt werden `epub:type="noteref|footnote|endnote|rearnote"`, `role="doc-noteref|doc-footnote|doc-endnote"`, +Klassen wie `footnote`/`endnote` und Links auf solche Ziele. Rücklinks innerhalb der Note werden entfernt. +Die Note erscheint nicht zusätzlich im Fließtext. + +| Modus | Ergebnis | +| --- | --- | +| `end-of-chapter` | `Text[^fn-001]` und `[^fn-001]: …` am Kapitelende. Mehrere Absätze, Code, Formeln und Links bleiben erhalten. Mehrfachverweise nutzen dieselbe Definition | +| `end-of-book` | `chapters/999-anmerkungen.md` mit Ankern. Verweise `<sup>[1](999-anmerkungen.md#fn-001)</sup>`, Rücklinks `↩` zu jeder Verweisstelle | +| `inline` | `Text (Anmerkung: …)`. Noten mit Blockinhalt (Code, Listen, Tabellen) fallen auf `end-of-chapter` zurück (Hinweis im Bericht) | +| `keep-links` | Originalstruktur: Note bleibt an ihrer Stelle, Verweise sind normale Links auf Anker | + +Mit `--format commonmark` werden Fußnoten als Anker-Links mit Abschnitt „Anmerkungen“ ausgegeben, +weil CommonMark keine `[^…]`-Syntax kennt. + +## Qualitätshinweise + +- **Formelbasierte EPUBs:** Am besten sind EPUBs mit MathML oder TeX im `alt`-Text (z. B. Springer). +- **SVG-Formeln** bleiben SVG (nie gerastert) und werden im Bericht als Formel markiert. +- **Layouttabellen / komplexe Tabellen** (`colspan`, `rowspan`, Blockinhalte in Zellen, verschachtelte + Tabellen) bleiben als bereinigtes HTML im Markdown erhalten, mit Warnung. Einfache Tabellen werden + GFM-Pipe-Tabellen. Einzellige Layouttabellen werden als normaler Text ausgegeben. +- **Syntax-Highlighting:** Farb-Spans, `<strong>`/`<em>` in Listings und Zeilennummern werden entfernt. + Einrückung, Tabs, `<`, `>`, `&`, `$`, `|`, Backslashes und Backticks bleiben exakt erhalten. Die Sprache + kommt aus Klassen/Attributen (`language-python`, `data-lang`, …) oder starken Inhaltssignalen + (`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. +- **Gescannte Texte:** nur Bilder, kein Text (keine OCR). +- **DRM:** wird erkannt und abgelehnt. +- **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne + Registerlinks werden auf Kapiteldatei + Anker umgeschrieben. Seitenzahlen bleiben als Text sichtbar. + +## Weiterverarbeitung mit Pandoc + +```bash +pandoc output/programmierbuch/chapters/*.md \ + --metadata-file=output/programmierbuch/metadata.yaml \ + -o programmierbuch.html + +pandoc output/mathematikbuch/chapters/*.md \ + --metadata-file=output/mathematikbuch/metadata.yaml \ + --pdf-engine=xelatex \ + -o mathematikbuch.pdf +``` + +- Für HTML mit Formeln `--mathml`, `--mathjax` oder `--katex` verwenden. Pandocs Standard-HTML kann + Matrizen nicht in Unicode umsetzen, meldet „Could not convert TeX math“ und belässt das TeX. +- Ausgaben mit `--math tex` (`\(…\)`) brauchen `-f markdown+tex_math_single_backslash`. +- Das YAML-Frontmatter jeder Kapiteldatei wird von Pandoc als Metadaten gelesen. Für ein Gesamtdokument + ggf. `--no-frontmatter` verwenden. + +## Bericht und Diagnose + +`conversion-report.json` enthält u. a. Programmversion, Zeitstempel, Eingabedatei mit SHA-256, EPUB-Version, +Optionen, Metadaten, Kapitel mit Statistik, alle Medien, Codeblöcke (Sprache + Erkennungsart), +Formeln (Herkunft + Darstellung), Fußnoten (Modus, erkannt/referenziert/ausgegeben), Spezialabschnitte, +Warnungen, nicht-fatale Fehler, nicht aufgelöste interne Links, nicht unterstützte Elemente, +übersprungene Dokumente und das Ergebnis eines **Linkchecks über die erzeugte Ausgabe**. Dabei wird +jeder relative Link und jedes Bild auf Zieldatei und Anker geprüft. + +`status` ist `success`, `success-with-warnings` oder `success-with-errors`. Bei Warnungen wird nie +Verlustfreiheit behauptet (`lossless: false`). + +## Sicherheit und Fehlerbehandlung + +- Das EPUB wird **nie** pauschal entpackt. Einträge werden einzeln gelesen. Absolute Pfade und `..` + werden ignoriert und gemeldet (mit `--strict`: Abbruch). Alle Ausgabedateien werden vor dem Schreiben + gegen das Zielverzeichnis geprüft. +- Schutz gegen ZIP-Bomben: Größenlimit je Eintrag und Prüfung des Kompressionsverhältnisses. +- XML wird mit lxml **ohne DTD-Laden, ohne Entity-Auflösung und ohne Netzwerk** geparst (kein XXE). + HTML-Entities (` ` …) werden vorab in Zeichenreferenzen umgesetzt. +- Ungültiges UTF-8 wird mit Ersatzzeichen gelesen und als Warnung gemeldet. +- Keine Netzwerkzugriffe, keine Shell-Aufrufe, keine Telemetrie. +- Die Ausgabe entsteht in einem temporären Verzeichnis neben dem Ziel und wird erst am Ende an ihren Platz + verschoben. Bei Fehlern wird das temporäre Verzeichnis gelöscht, und es bleibt keine halbe Ausgabe zurück. +- **Bestehendes Ausgabeverzeichnis:** Ist es nicht leer, bricht das Programm ab (Exit 1). `--force` ersetzt + es nur, wenn es eine frühere epub2md-Ausgabe ist (`conversion-report.json`/`manifest.json` vorhanden), + nicht das Arbeits- oder Home-Verzeichnis ist und die Eingabedatei nicht darin liegt. + +## Architektur + +```text +epub2md.py Einstiegspunkt +epub2md/ +├── cli.py argparse, Exit-Codes +├── pipeline.py Ablaufsteuerung, Schreiben (atomar), Bericht/Manifest +├── epub_reader.py ZIP-Sicherheit, DRM-Check, container.xml, OPF, NAV, NCX, Guide +├── normalize.py Laden der Spine-Dokumente, Layout-Heuristiken, Seitenumbrüche, HTML-TOC +├── footnotes.py Erkennung/Auslagerung von Fuß- und Endnoten +├── 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) +├── codeblocks.py Codeerkennung, exakte Textextraktion, Spracherkennung, Fences +├── mathml.py konservative MathML→TeX-Konvertierung, TeX-Erkennung +├── media.py Medienregistry mit stabilen Namen +├── linkcheck.py Validierung aller relativen Links/Anker der Ausgabe +├── writer.py README, metadata.yaml, Frontmatter, Diagnose-Markdown +├── diagnostics.py zentrale Sammlung von Warnungen/Fehlern +├── models.py Dataclasses (Kapitel, Medien, Noten, Metadaten …) +└── utils.py Slugs, YAML-Emitter, Pfadhelfer +``` + +Statt BeautifulSoup wird direkt lxml verwendet: Es ist für die XHTML-Bäume ausreichend, schneller und +ermöglicht die sichere Parser-Konfiguration (XXE). Die Konvertierung ist deterministisch. Gleiche +Eingabe und Optionen ergeben identische Kapiteldateien. + +## Tests + +```bash +python3 -m pytest -q +``` + +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. 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. diff --git a/convert_epub2md.sh b/convert_epub2md.sh new file mode 100755 index 0000000..1a65f95 --- /dev/null +++ b/convert_epub2md.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +pandoc buch.epub \ + --from=epub \ + --to=markdown+fenced_code_blocks+backtick_code_blocks+tex_math_dollars+footnotes+pipe_tables \ + --wrap=none \ + --extract-media=medien \ + --output=buch.md + +./split-md-chapters.py buch.md buch-md + diff --git a/epub2md.py b/epub2md.py new file mode 100755 index 0000000..ad333d7 --- /dev/null +++ b/epub2md.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +"""Entry point: python3 epub2md.py INPUT.epub OUTPUT_VERZEICHNIS [Optionen]""" + +import sys + +from epub2md.cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/epub2md/__init__.py b/epub2md/__init__.py new file mode 100644 index 0000000..9aada3e --- /dev/null +++ b/epub2md/__init__.py @@ -0,0 +1,4 @@ +"""epub2md – konvertiert DRM-freie EPUB-Dateien in kapitelweise Markdown-Sammlungen.""" + +__version__ = "0.1.0" +TOOL_NAME = "epub2md" diff --git a/epub2md/__main__.py b/epub2md/__main__.py new file mode 100644 index 0000000..dd8a8c9 --- /dev/null +++ b/epub2md/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .cli import main + +sys.exit(main()) diff --git a/epub2md/chapters.py b/epub2md/chapters.py new file mode 100644 index 0000000..89472ab --- /dev/null +++ b/epub2md/chapters.py @@ -0,0 +1,336 @@ +"""Splitting the spine documents into chapter units.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + +from .diagnostics import Diagnostics +from .models import Chapter, NavEntry +from .normalize import Doc, has_content +from .utils import classes, epub_types, is_element, iter_elements, local, norm_ws, roles, text_content + +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"} +# epub:type / role values that start a new chapter file +SPLIT_TYPES = { + "cover", "titlepage", "halftitlepage", "copyright-page", "dedication", "epigraph", "foreword", + "preface", "prologue", "introduction", "chapter", "part", "conclusion", "epilogue", "afterword", + "appendix", "glossary", "bibliography", "index", "endnotes", "rearnotes", "acknowledgments", + "colophon", "imprint", "errata", "contributors", +} + +KIND_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ + ("index-persons", re.compile(r"^(personenregister|namensregister|personenverzeichnis|namensverzeichnis|index of (names|persons))", re.I)), + ("index", re.compile(r"^(sachregister|stichwortverzeichnis|register|index|schlagwortverzeichnis|sachverzeichnis)\b", re.I)), + ("glossary", re.compile(r"^(glossar|glossary|begriffserkl)", re.I)), + ("bibliography", re.compile(r"^(literatur|bibliogra|quellen|references|weiterführende literatur|quellenverzeichnis|literaturverzeichnis)", re.I)), + ("endnotes", re.compile(r"^(anmerkungen|endnoten|notes|fußnoten)\s*$", re.I)), + ("toc", re.compile(r"^(inhalt|inhaltsverzeichnis|contents|table of contents)\s*$", re.I)), + ("preface", re.compile(r"^(vorwort|preface|geleitwort)", re.I)), + ("foreword", re.compile(r"^(foreword)", re.I)), + ("acknowledgments", re.compile(r"^(danksagung|dank|acknowledg)", re.I)), + ("introduction", re.compile(r"^(einleitung|einführung|introduction)\s*$", re.I)), + ("appendix", re.compile(r"^(anhang|appendix)", re.I)), + ("colophon", re.compile(r"^(impressum|colophon|kolophon)", re.I)), + ("dedication", re.compile(r"^(widmung|dedication)", re.I)), + ("cover", re.compile(r"^(cover|umschlag|titelbild)\b", re.I)), + ("titlepage", re.compile(r"^(titelei|titelseite|title ?page)", re.I)), + ("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)), +] +TITLE_CLASS = re.compile(r"^(?!sub).*(heading|title)$") +NUMBER_PREFIX = re.compile(r"^\s*(?:(?:kapitel|chapter)\s+)?(?:\d+(?:\.\d+)*|[IVXLC]+)\.?\s+", re.I) + + +@dataclass +class Marker: + """Start of a new chapter in the flattened item stream.""" + + element: Any + kind: str # "heading" | "semantic" + level: int = 0 + types: list[str] = field(default_factory=list) + unwrapped: bool = False # semantic container that itself contains chapter headings + + +@dataclass +class IdCarrier: + """Unwrapped container whose ids must still be emitted as anchors.""" + + element: Any + + +@dataclass +class DocStart: + doc: Doc + + +def heading_level(el: Any) -> int: + return HEADINGS.get(local(el.tag), 0) if is_element(el) else 0 + + +def classify_title(title: str) -> str: + t = NUMBER_PREFIX.sub("", norm_ws(title)) + for kind, pattern in KIND_PATTERNS: + if pattern.match(t) or (kind == "part" and pattern.match(norm_ws(title))): + return kind + return "" + + +def semantic_types(el: Any) -> list[str]: + values = epub_types(el) + [r.removeprefix("doc-") for r in roles(el)] + return [v for v in values if v in SPLIT_TYPES] + + +class ChapterBuilder: + def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics) -> None: + self.level = chapter_level + self.diag = diag + self.nav_titles: dict[str, str] = {} + self._index_nav(nav) + + def _index_nav(self, entries: list[NavEntry]) -> None: + for e in entries: + if e.path and e.path not in self.nav_titles and e.label: + self.nav_titles[e.path] = e.label + self._index_nav(e.children) + + # ------------------------------------------------------------------ split detection + + def _split_kind(self, el: Any) -> Marker | None: + lvl = heading_level(el) + if lvl and lvl <= self.level and has_content(el): + return Marker(el, "heading", lvl) + if local(el.tag) in CONTAINERS | {"body"}: + types = semantic_types(el) + if types and local(el.tag) != "body": + return Marker(el, "semantic", 0, types) + return None + + def _contains_split(self, el: Any) -> bool: + for e in iter_elements(el): + if e is not el and self._split_kind(e): + return True + return False + + def flatten(self, el: Any, doc_path: str) -> list[tuple[str, Any]]: + items: list[tuple[str, Any]] = [] + if el.text: + items.append((doc_path, el.text)) + for child in el: + if is_element(child): + marker = self._split_kind(child) + unwrap = local(child.tag) in CONTAINERS and self._contains_split(child) + if marker: + marker.unwrapped = unwrap + items.append((doc_path, marker)) + if unwrap: + items.append((doc_path, IdCarrier(child))) + items.extend(self.flatten(child, doc_path)) + else: + items.append((doc_path, child)) + if child.tail: + items.append((doc_path, child.tail)) + return items + + # ------------------------------------------------------------------ building + + def build(self, docs: list[Doc]) -> list[Chapter]: + chapters: list[Chapter] = [] + pending: list[tuple[str, Any]] = [] # content without own chapter (anchors only) + for doc in docs: + if doc.skipped: + continue + items = self.flatten(doc.body, doc.path) + segments: list[tuple[Marker | None, list[tuple[str, Any]]]] = [(None, [])] + for item in items: + if isinstance(item[1], Marker): + segments.append((item[1], [])) + else: + segments[-1][1].append(item) + head_marker, head_items = segments[0] + body_segments = segments[1:] + if _meaningful(head_items): + first = _first_meaningful(head_items) + if chapters and leading_heading_level(first) > self.level and not semantic_types(doc.body): + chapters[-1].items.extend(pending + head_items) + pending = [] + if doc.path not in chapters[-1].docs: + chapters[-1].docs.append(doc.path) + else: + ch = self._new_chapter(len(chapters) + 1, None, pending + head_items, doc) + pending = [] + chapters.append(ch) + else: + pending.extend(head_items) + for marker, seg_items in body_segments: + assert marker is not None + if marker.kind == "semantic" and (marker.unwrapped or not _meaningful(seg_items)): + # Wrapper such as <div epub:type="chapter"> around the real <h1>: content before the + # heading (context info, epigraph, anchors) belongs to the heading's chapter. + pending.extend(seg_items) + pending.append((doc.path, _TypeCarrier(marker.types))) + continue + ch = self._new_chapter(len(chapters) + 1, marker, pending + seg_items, doc) + pending = [] + chapters.append(ch) + if pending: + if chapters: + chapters[-1].items.extend(pending) + elif _meaningful(pending): + chapters.append(self._new_chapter(1, None, pending, docs[0])) + if not chapters: + self.diag.warn("no-content", "Das EPUB enthält keinen verwertbaren Textinhalt.") + previous_main = "" + titles = [c.title for c in chapters] + for ch in chapters: + self._finalize(ch) + special = {"bibliography", "glossary", "index", "endnotes"} & set(ch.types) + if special and titles.count(ch.title) > 1 and previous_main: + ch.title = f"{ch.title} – {previous_main}" + elif not special: + previous_main = ch.title + return chapters + + def _new_chapter(self, number: int, marker: Marker | None, items: list[tuple[str, Any]], doc: Doc) -> Chapter: + ch = Chapter(number=number, title="", items=items, docs=[]) + for path, _ in items: + if path not in ch.docs: + ch.docs.append(path) + if doc.path not in ch.docs: + ch.docs.append(doc.path) + types: list[str] = [t for t in doc.types if t in SPLIT_TYPES] if ch.docs[0] == doc.path else [] + for _, it in items: + if isinstance(it, _TypeCarrier): + types.extend(it.types) + if marker is not None and marker.kind == "heading": + ch.title_el = marker.element + ch.title_level = marker.level + ch.title = norm_ws(_heading_text(marker.element)) + elif marker is not None: + types.extend(marker.types) + title_el = _find_title_element(marker.element) + if title_el is not None: + ch.title_el = title_el + ch.title_level = heading_level(title_el) or 1 + ch.title = norm_ws(_heading_text(title_el)) + if ch.title_el is None: + # an early heading of the doc (deeper than chapter level) can still serve as title + first = _first_meaningful(items) + if heading_level(first): + ch.title_el = first + ch.title_level = heading_level(first) + ch.title = norm_ws(_heading_text(first)) + if not ch.title: + nav = self.nav_titles.get(doc.path, "") + if marker is not None: # section inside a document: its type says more than the document's nav label + ch.title = _type_label(types) or nav or doc.title + else: + ch.title = nav or doc.title or _type_label(types) + ch.title = ch.title or doc.path.rsplit("/", 1)[-1].rsplit(".", 1)[0] + ch.synthetic_title = True + 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)) + return ch + + def _finalize(self, ch: Chapter) -> None: + kind = classify_title(ch.title) + if kind and kind not in ch.types: + ch.types.append(kind) + if "index" in ch.types and classify_title(ch.title) == "index-persons": + ch.types.remove("index") + if not ch.types: + ch.types.append("chapter") + + +@dataclass +class _TypeCarrier: + types: list[str] + + +def leading_heading_level(item: Any) -> int: + """Heading level of ``item`` or of the first meaningful descendant inside wrapper containers.""" + node = item + for _ in range(8): + if not is_element(node): + return 0 + lvl = heading_level(node) + if lvl: + return lvl + if local(node.tag) not in CONTAINERS: + return 0 + if node.text and node.text.strip(): + return 0 + nxt = None + for child in node: + if is_element(child) and has_content(child): + nxt = child + break + if child.tail and child.tail.strip(): + return 0 + node = nxt + return 0 + + +def _meaningful(items: list[tuple[str, Any]]) -> bool: + return _first_meaningful(items) is not None + + +def _first_meaningful(items: list[tuple[str, Any]]) -> Any: + for _, it in items: + if isinstance(it, (Marker, IdCarrier, DocStart, _TypeCarrier)): + continue + if isinstance(it, str): + if it.strip(): + return it + continue + if has_content(it): + return it + return None + + +def _heading_text(el: Any) -> str: + parts = [el.text or ""] + for child in el: + if is_element(child): + parts.append(" " if local(child.tag) == "br" else _heading_text(child)) + parts.append(child.tail or "") + return "".join(parts) + + +def _find_title_element(el: Any) -> Any: + for e in iter_elements(el): + if e is el: + continue + if heading_level(e): + return e + if any(TITLE_CLASS.search(c) for c in classes(e)) and has_content(e): + # only title-like wrappers without block children + if not any(is_element(c) and local(c.tag) in ("p", "div", "table", "ul", "ol") and has_content(c) for c in e): + return e + if local(e.tag) in ("p", "table", "ul", "ol", "dl", "pre") and has_content(e): + return None + return None + + +_TYPE_LABELS = { + "cover": "Cover", "titlepage": "Titelei", "copyright-page": "Impressum", "colophon": "Impressum", + "dedication": "Widmung", "epigraph": "Motto", "preface": "Vorwort", "foreword": "Geleitwort", + "acknowledgments": "Danksagung", "glossary": "Glossar", "bibliography": "Literatur", + "index": "Register", "endnotes": "Anmerkungen", "appendix": "Anhang", "toc": "Inhalt", +} + + +def _type_label(types: list[str]) -> str: + for t in types: + if t in _TYPE_LABELS: + return _TYPE_LABELS[t] + return "" + + +def text_of(el: Any) -> str: + return norm_ws(text_content(el)) diff --git a/epub2md/cli.py b/epub2md/cli.py new file mode 100644 index 0000000..def2d3e --- /dev/null +++ b/epub2md/cli.py @@ -0,0 +1,129 @@ +"""Command line interface.""" + +from __future__ import annotations + +import argparse +import sys +import traceback +from pathlib import Path +from typing import NoReturn + +from . import __version__ +from .errors import EXIT_INPUT, EXIT_INTERNAL, Epub2MdError +from .pipeline import Options, convert, print_summary + +DESCRIPTION = """\ +Konvertiert eine DRM-freie EPUB-Datei (EPUB 2/3) in eine strukturierte Sammlung +kapitelweiser Markdown-Dateien mit Medien, Metadaten und Konvertierungsbericht. +""" + +EPILOG = """\ +Rückgabecodes: + 0 Erfolgreich (ggf. mit nicht-kritischen Warnungen) + 1 Ungültige Parameter, Eingabedatei nicht lesbar oder Ausgabeverzeichnis belegt + 2 EPUB-/ZIP-/Containerfehler (auch DRM, unsichere ZIP-Pfade mit --strict) + 3 OPF-/Spine-/XHTML-Verarbeitungsfehler + 4 Unerwarteter interner Fehler + +Beispiel: + python3 epub2md.py Beispiele/buch.epub output/buch --math auto --footnotes end-of-chapter +""" + + +class _Parser(argparse.ArgumentParser): + def error(self, message: str) -> NoReturn: # argparse would exit with 2, which we reserve for EPUB errors + self.print_usage(sys.stderr) + print(f"{self.prog}: Fehler: {message}", file=sys.stderr) + sys.exit(EXIT_INPUT) + + +def build_parser() -> argparse.ArgumentParser: + p = _Parser( + prog="epub2md.py", + description=DESCRIPTION, + epilog=EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument("input", metavar="INPUT.epub", help="DRM-freie EPUB-Datei") + p.add_argument("output", metavar="OUTPUT_VERZEICHNIS", help="Zielverzeichnis (darf nicht existieren oder muss leer sein)") + p.add_argument("--format", choices=["gfm", "commonmark"], default="gfm", + help="Markdown-Variante (Standard: gfm). commonmark: Tabellen als HTML, Fußnoten als Anker-Links") + p.add_argument("--extract-media", action=argparse.BooleanOptionalAction, default=True, + help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)") + p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False, + help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)") + p.add_argument("--chapter-level", type=int, default=1, metavar="N", + help="Überschriftenebene 1–6, ab der neue Kapiteldateien entstehen (Standard: 1). " + "Für Bücher mit Teilen (h1) und Kapiteln (h2) ist 2 sinnvoll") + p.add_argument("--toc", action=argparse.BooleanOptionalAction, default=True, + help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)") + p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True, + help="YAML-Frontmatter in jeder Kapiteldatei (Standard: an)") + p.add_argument("--footnotes", choices=["keep-links", "end-of-chapter", "end-of-book", "inline"], + default="end-of-chapter", help="Strategie für Fuß-/Endnoten (Standard: end-of-chapter)") + p.add_argument("--math", choices=["auto", "tex", "mathml", "image"], default="auto", + help="Strategie für Formeln (Standard: auto)") + p.add_argument("--images", choices=["relative", "flat", "original-path"], default="relative", + help="Ablage der Medien: relative = media/images/0001-name.ext, flat = media/name.ext, " + "original-path = media/<Pfad im EPUB> (Standard: relative)") + p.add_argument("--slug-language", choices=["de", "en", "none"], default="de", + help="Dateinamen-Regeln: de (ä→ae), en (ä→a), none (Unicode behalten) (Standard: de)") + p.add_argument("--keep-html", action="store_true", + help="Nicht zuverlässig konvertierbare Elemente als bereinigtes HTML erhalten") + p.add_argument("--strict", action="store_true", + help="Bei kritischen Fehlern (fehlende Spine-Dateien, unsichere Pfade, kaputtes XHTML) abbrechen") + p.add_argument("--force", action="store_true", + help="Bestehendes Ausgabeverzeichnis ersetzen – nur wenn es eine frühere epub2md-Ausgabe ist") + p.add_argument("--verbose", "-v", action="store_true", help="Verarbeitungsschritte und Warnungen ausgeben") + p.add_argument("--dry-run", action="store_true", help="Nur analysieren und geplante Dateien anzeigen, nichts schreiben") + p.add_argument("--report", metavar="DATEI.json", + help="Konvertierungsbericht zusätzlich in diese Datei schreiben " + "(OUTPUT/conversion-report.json wird immer erzeugt)") + p.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + return p + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + try: + args = build_parser().parse_args(argv) + except SystemExit as exc: # --help/--version (0) or invalid arguments (1) + return exc.code if isinstance(exc.code, int) else EXIT_INPUT + opts = Options( + format=args.format, + extract_media=args.extract_media, + copy_original=args.copy_original, + chapter_level=args.chapter_level, + toc=args.toc, + frontmatter=args.frontmatter, + footnotes=args.footnotes, + math=args.math, + images=args.images, + slug_language=args.slug_language, + keep_html=args.keep_html, + strict=args.strict, + verbose=args.verbose, + dry_run=args.dry_run, + force=args.force, + report=args.report, + command=["python3", "epub2md.py"] + argv, + ) + try: + result = convert(Path(args.input), Path(args.output), opts) + except Epub2MdError as exc: + print(f"epub2md: Fehler: {exc}", file=sys.stderr) + if args.verbose: + traceback.print_exc() + return exc.exit_code + except KeyboardInterrupt: + print("epub2md: abgebrochen", file=sys.stderr) + return EXIT_INTERNAL + except Exception as exc: # noqa: BLE001 – last line of defense, mapped to exit code 4 + print(f"epub2md: Interner Fehler: {type(exc).__name__}: {exc}", file=sys.stderr) + if args.verbose: + traceback.print_exc() + else: + print("Mit --verbose wird der vollständige Traceback angezeigt.", file=sys.stderr) + return EXIT_INTERNAL + print_summary(result, opts) + return 0 diff --git a/epub2md/codeblocks.py b/epub2md/codeblocks.py new file mode 100644 index 0000000..65c3b13 --- /dev/null +++ b/epub2md/codeblocks.py @@ -0,0 +1,195 @@ +"""Code block detection, exact text extraction and language recognition.""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from .utils import classes, is_element, local + +LANG_ALIASES = { + "python": "python", "py": "python", "python3": "python", "py3": "python", + "bash": "bash", "shell": "bash", "sh": "sh", "zsh": "zsh", + "c": "c", "h": "c", "cpp": "cpp", "c++": "cpp", "cxx": "cpp", "hpp": "cpp", + "java": "java", "javascript": "javascript", "js": "javascript", "jsx": "javascript", + "typescript": "typescript", "ts": "typescript", "tsx": "typescript", + "json": "json", "yaml": "yaml", "yml": "yaml", "toml": "toml", "xml": "xml", + "html": "html", "xhtml": "html", "htm": "html", "css": "css", "sql": "sql", + "dockerfile": "dockerfile", "docker": "dockerfile", "makefile": "makefile", "make": "makefile", + "ini": "ini", "cfg": "ini", "conf": "ini", "text": "text", "plaintext": "text", "txt": "text", + "plain": "text", "rust": "rust", "rs": "rust", "go": "go", "golang": "go", "ruby": "ruby", "rb": "ruby", + "php": "php", "perl": "perl", "r": "r", "matlab": "matlab", "kotlin": "kotlin", "swift": "swift", + "scala": "scala", "haskell": "haskell", "csharp": "csharp", "cs": "csharp", "c#": "csharp", + "powershell": "powershell", "ps1": "powershell", "lua": "lua", "diff": "diff", "latex": "latex", + "tex": "latex", "markdown": "markdown", "md": "markdown", "graphql": "graphql", "nginx": "nginx", +} +BLOCK_CODE_CLASS = re.compile( + r"(programlisting|sourcecode|source-code|codeblock|code-block|codelisting|code_listing|^listing$|" + r"^code$|^pre$|^src$|^screen$|^verbatim$|^highlight$|^sourcecode$|^literallayout$)" +) +INLINE_CODE_CLASS = re.compile( + r"^(tt|code|mono|monospace|literal|pre|inlinecode|inline-code|computeroutput|userinput|" + r"varname|function|command|filename|classname|methodname|parameter)$|nonproportional|monospace" +) +LINE_NUMBER_CLASS = re.compile(r"^(lineno|linenos|linenumber|line-number|line-numbers|ln|gutter|lnt|linenodiv)$") +PRESENTATION_TAGS = {"button"} +BLOCK_CHILD_TAGS = {"p", "div", "table", "ul", "ol", "dl", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "figure", "section"} + + +def is_block_code_container(el: Any) -> bool: + """div/p whose class marks a code listing and which contains only inline content.""" + if local(el.tag) not in ("div", "p"): + return False + if not any(BLOCK_CODE_CLASS.search(c) for c in classes(el)): + return False + for e in el.iter(): + if e is el or not is_element(e): + continue + if local(e.tag) in BLOCK_CHILD_TAGS or local(e.tag) == "pre": + return False + return True + + +def is_inline_code(el: Any) -> bool: + name = local(el.tag) + if name in ("code", "samp", "tt", "kbd", "var"): + return name != "kbd" and name != "var" + return name == "span" and any(INLINE_CODE_CLASS.search(c) for c in classes(el)) + + +def code_text(el: Any) -> str: + """Exact text of a code element: <br> become newlines, line numbers/buttons are dropped.""" + parts: list[str] = [] + + def walk(node: Any) -> None: + if node.text: + parts.append(node.text) + for child in node: + if is_element(child): + name = local(child.tag) + if name == "br": + parts.append("\n") + elif name in PRESENTATION_TAGS or any(LINE_NUMBER_CLASS.match(c) for c in classes(child)): + pass + elif name in ("p", "div") and parts and not "".join(parts).endswith("\n"): + parts.append("\n") + walk(child) + else: + walk(child) + if child.tail: + parts.append(child.tail) + + walk(el) + text = "".join(parts).replace("\r\n", "\n").replace("\r", "\n").replace("\xa0", " ") + if text.startswith("\n"): + text = text[1:] # HTML ignores the first newline in <pre> + lines = text.split("\n") + while lines and not lines[-1].strip(): + lines.pop() + while lines and not lines[0].strip(): + lines.pop(0) + return "\n".join(lines) + + +def _lang_from_value(value: str) -> str | None: + value = value.strip().lower() + for pattern in (r"(?:^|\s)(?:language|lang)-([\w+#-]+)", r"brush:\s*([\w+#-]+)", r"(?:^|\s)sourcecode\s+([\w+#-]+)"): + m = re.search(pattern, value) + if m and m.group(1) in LANG_ALIASES: + return LANG_ALIASES[m.group(1)] + return None + + +def detect_language(el: Any) -> tuple[str, str]: + """Return (language, how). Empty language when not sure.""" + candidates = [el] + parent = el.getparent() + for _ in range(2): + if parent is None: + break + candidates.append(parent) + parent = parent.getparent() + candidates.extend(e for e in el.iter() if is_element(e) and local(e.tag) == "code") + for cand in candidates: + for attr in ("data-language", "data-lang", "lang", "language", "data-code-language"): + value = (cand.get(attr) or "").strip().lower() + if value in LANG_ALIASES and attr != "lang": + return LANG_ALIASES[value], f"attribute {attr}" + cls = cand.get("class") or "" + lang = _lang_from_value(cls) + if lang: + return lang, "class" + if local(cand.tag) in ("pre", "code"): + for c in classes(cand): + if c in LANG_ALIASES and c not in ("text", "h", "r", "cs", "ts", "md", "rs"): + return LANG_ALIASES[c], "class" + return "", "" + + +def detect_language_from_content(code: str) -> str: + """Very conservative content heuristics – only strong signals.""" + text = code.strip() + if not text: + return "" + first = text.split("\n", 1)[0] + if first.startswith("#!"): + if re.search(r"\b(bash)\b", first): + return "bash" + if re.search(r"\b(sh|dash)\b", first): + return "sh" + if "zsh" in first: + return "zsh" + if "python" in first: + return "python" + if text.startswith("<?xml"): + return "xml" + if re.match(r"(?i)<!doctype html|<html[\s>]", text): + return "html" + lines = text.split("\n") + if re.search(r"^\s*#\s*include\s*[<\"]", text, re.M): + if re.search(r"#include\s*<(iostream|vector|string|map|memory|algorithm)>|std::|\bcout\b|\bnamespace\b", text): + return "cpp" + return "c" + if re.search(r"^\s*(int|void)\s+main\s*\(", text, re.M) and ";" in text: + return "c" + if re.search(r"^\s*public\s+(static\s+)?(class|interface)\s+\w+", text, re.M) and ";" in text: + return "java" + py_signals = 0 + if re.search(r"^\s*def \w+\s*\(.*\)\s*(->\s*[^:]+)?:\s*(#.*)?$", text, re.M): + py_signals += 2 + if re.search(r"^\s*class \w+(\(.*\))?:\s*$", text, re.M): + py_signals += 2 + if re.search(r"^\s*(from [\w.]+ import [\w*., ]+|import [\w.]+( as \w+)?)\s*$", text, re.M): + py_signals += 1 + if re.search(r"^\s*(for|while|if|elif|with|try|except)\b.*:\s*$", text, re.M): + py_signals += 1 + c_like = sum(1 for ln in lines if ln.rstrip().endswith((";", "{"))) + if py_signals >= 2 and c_like == 0: + 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): + return "dockerfile" + if text[0] in "{[" and len(lines) > 1: + try: + json.loads(text) + return "json" + except ValueError: + pass + return "" + + +def fence_for(code: str) -> str: + longest = max((len(m) for m in re.findall(r"`+", code)), default=0) + return "`" * max(3, longest + 1) + + +def inline_code(text: str) -> str: + text = re.sub(r"[ \t\r\n\f]+", " ", text) + if not text.strip(): + return "" + longest = max((len(m) for m in re.findall(r"`+", text)), default=0) + ticks = "`" * (longest + 1) + pad = " " if text.startswith("`") or text.endswith("`") else "" + return f"{ticks}{pad}{text}{pad}{ticks}" diff --git a/epub2md/diagnostics.py b/epub2md/diagnostics.py new file mode 100644 index 0000000..0b97ab6 --- /dev/null +++ b/epub2md/diagnostics.py @@ -0,0 +1,57 @@ +"""Central collection of warnings, errors and unsupported elements.""" + +from __future__ import annotations + +import sys +from collections import Counter + +from .errors import StructureError +from .models import Diagnostic + + +class Diagnostics: + def __init__(self, verbose: bool = False, strict: bool = False) -> None: + self.verbose = verbose + self.strict = strict + self.items: list[Diagnostic] = [] + self.unsupported: Counter[str] = Counter() + self.unsupported_examples: dict[str, str] = {} + self.unresolved_links: list[dict[str, str]] = [] + self._seen: set[tuple[str, str, str]] = set() + + def add(self, level: str, code: str, message: str, source: str = "", chapter: str = "") -> None: + key = (code, message, source) + if key in self._seen: + return + self._seen.add(key) + self.items.append(Diagnostic(level, code, message, source, chapter)) + if self.verbose: + print(f"[{level}] {message}" + (f" ({source})" if source else ""), file=sys.stderr) + + def info(self, code: str, message: str, source: str = "", chapter: str = "") -> None: + self.add("info", code, message, source, chapter) + + def warn(self, code: str, message: str, source: str = "", chapter: str = "") -> None: + self.add("warning", code, message, source, chapter) + + def error(self, code: str, message: str, source: str = "", chapter: str = "", critical: bool = False, + exc: type[Exception] = StructureError) -> None: + """Record a non-fatal error; with --strict, critical errors abort.""" + self.add("error", code, message, source, chapter) + if critical and self.strict: + raise exc(f"--strict: {message}" + (f" ({source})" if source else "")) + + def unsupported_element(self, tag: str, source: str) -> None: + self.unsupported[tag] += 1 + self.unsupported_examples.setdefault(tag, source) + + def unresolved(self, source: str, href: str, chapter: str) -> None: + self.unresolved_links.append({"source": source, "href": href, "chapter": chapter}) + self.warn("unresolved-link", f"Interner Link {href} konnte nicht aufgelöst werden.", source, chapter) + + def step(self, message: str) -> None: + if self.verbose: + print(f"… {message}", file=sys.stderr) + + def count(self, level: str) -> int: + return sum(1 for d in self.items if d.level == level) diff --git a/epub2md/epub_reader.py b/epub2md/epub_reader.py new file mode 100644 index 0000000..e708116 --- /dev/null +++ b/epub2md/epub_reader.py @@ -0,0 +1,488 @@ +"""Safe EPUB container access, XML parsing and OPF/NAV/NCX analysis.""" + +from __future__ import annotations + +import html +import html.entities +import posixpath +import re +import zipfile +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlsplit + +from lxml import etree + +from .diagnostics import Diagnostics +from .errors import ContainerError, StructureError +from .models import BookMetadata, Landmark, ManifestItem, NavEntry, Package, SpineItem +from .utils import EPUB_NS, is_element, local, norm_ws, resolve_href, safe_zip_path, text_content + +OPF_NS = "http://www.idpf.org/2007/opf" +MAX_MEMBER_SIZE = 512 * 1024 * 1024 +MAX_RATIO = 200 # compression ratio above which a large member is treated as ZIP bomb +ALLOWED_FONT_OBFUSCATION = { + "http://www.idpf.org/2008/embedding", + "http://ns.adobe.com/pdf/enc#RC", +} +XHTML_TYPES = {"application/xhtml+xml", "text/html", "application/x-dtbook+xml"} + + +def _secure_xml_parser(recover: bool = True) -> etree.XMLParser: + # No DTD loading, no entity resolution, no network: prevents XXE. + return etree.XMLParser( + recover=recover, + resolve_entities=False, + no_network=True, + load_dtd=False, + dtd_validation=False, + huge_tree=True, + ) + + +_KEEP_ENTITIES = {"amp", "lt", "gt", "quot", "apos"} + + +def _replace_named_entities(text: str) -> str: + def repl(m: re.Match[str]) -> str: + name = m.group(1) + if name in _KEEP_ENTITIES: + return m.group(0) + cp = html.entities.name2codepoint.get(name) + if cp is None: + return m.group(0) + return f"&#{cp};" + + return re.sub(r"&([A-Za-z][A-Za-z0-9]*);", repl, text) + + +def decode_markup(data: bytes, source: str, diag: Diagnostics) -> str: + """Decode XML/HTML bytes, honoring BOM and declared encodings.""" + encoding = None + if data.startswith(b"\xef\xbb\xbf"): + data = data[3:] + encoding = "utf-8" + elif data.startswith((b"\xff\xfe", b"\xfe\xff")): + encoding = "utf-16" + if encoding is None: + head = data[:1024].decode("ascii", "replace") + m = re.search(r"""<\?xml[^>]*encoding=["']([A-Za-z0-9._-]+)["']""", head) or re.search( + r"""<meta[^>]*charset=["']?([A-Za-z0-9._-]+)""", head, re.I + ) + encoding = m.group(1) if m else "utf-8" + try: + return data.decode(encoding) + except (UnicodeDecodeError, LookupError): + text = data.decode("utf-8", errors="replace") + n = text.count("�") + diag.warn( + "decode-error", + f"Ungültige Zeichenkodierung ({encoding}); {n} Zeichen durch U+FFFD ersetzt.", + source, + ) + return text + + +def parse_xml(data: bytes, source: str, diag: Diagnostics, html_fallback: bool = False) -> Any: + """Parse XML securely; optionally fall back to the HTML parser for tag soup.""" + text = decode_markup(data, source, diag) + text = re.sub(r"^\s*<\?xml[^>]*\?>", "", text) + text = _replace_named_entities(text) + root = None + try: + root = etree.fromstring(text, _secure_xml_parser()) + except etree.XMLSyntaxError: + root = None + if html_fallback and (root is None or local(root.tag) != "html" or _find(root, "body") is None): + parser = etree.HTMLParser(recover=True, no_network=True, huge_tree=True) + try: + root = etree.fromstring(text, parser) + except (etree.XMLSyntaxError, ValueError): + root = None + if root is not None: + diag.info("html-fallback", "XHTML nicht wohlgeformt; mit HTML-Parser gelesen.", source) + if root is None: + raise StructureError(f"XML/XHTML-Datei nicht lesbar: {source}") + return root + + +def _find(root: Any, name: str) -> Any: + for el in root.iter(): + if is_element(el) and local(el.tag) == name: + return el + return None + + +def _findall(root: Any, name: str) -> list[Any]: + return [el for el in root.iter() if is_element(el) and local(el.tag) == name] + + +def _children(el: Any, name: str) -> list[Any]: + return [c for c in el if is_element(c) and local(c.tag) == name] + + +def split_href(href: str) -> tuple[str, str]: + parts = urlsplit(href) + return unquote(parts.path), unquote(parts.fragment) + + +class EpubReader: + """Read-only access to an EPUB archive. Never extracts the archive to disk.""" + + def __init__(self, path: Path, diag: Diagnostics) -> None: + self.path = path + self.diag = diag + try: + self.zip = zipfile.ZipFile(path) + except (zipfile.BadZipFile, OSError) as exc: + raise ContainerError(f"Keine gültige ZIP/EPUB-Datei: {path} ({exc})") from exc + self.names: dict[str, zipfile.ZipInfo] = {} + self.unsafe_entries: list[str] = [] + for info in self.zip.infolist(): + if info.is_dir(): + continue + norm = safe_zip_path(info.filename) + if norm is None: + self.unsafe_entries.append(info.filename) + continue + self.names[norm] = info + if self.unsafe_entries: + diag.error( + "unsafe-zip-path", + f"{len(self.unsafe_entries)} ZIP-Eintrag/Einträge mit unsicherem Pfad ignoriert: " + + ", ".join(self.unsafe_entries[:5]), + critical=True, + exc=ContainerError, + ) + self._check_mimetype() + self._check_drm() + + def close(self) -> None: + self.zip.close() + + # ------------------------------------------------------------------ raw access + + def exists(self, path: str) -> bool: + return path in self.names + + def read(self, path: str) -> bytes: + info = self.names.get(path) + if info is None: + raise KeyError(path) + if info.file_size > MAX_MEMBER_SIZE or ( + info.compress_size and info.file_size > 50 * 1024 * 1024 and info.file_size / info.compress_size > MAX_RATIO + ): + raise ContainerError(f"ZIP-Eintrag zu groß oder verdächtig stark komprimiert: {path}") + try: + with self.zip.open(info) as fh: + data = fh.read(MAX_MEMBER_SIZE + 1) + except (zipfile.BadZipFile, OSError, RuntimeError, EOFError) as exc: + raise ContainerError(f"ZIP-Eintrag nicht lesbar: {path} ({exc})") from exc + if len(data) > MAX_MEMBER_SIZE: + raise ContainerError(f"ZIP-Eintrag zu groß: {path}") + return data + + def _check_mimetype(self) -> None: + if "mimetype" not in self.names: + self.diag.warn("mimetype-missing", "Datei 'mimetype' fehlt im EPUB.") + return + value = self.zip.read(self.names["mimetype"]).decode("ascii", "replace").strip() + if value != "application/epub+zip": + self.diag.warn("mimetype-invalid", f"Unerwarteter mimetype: {value!r}") + + def _check_drm(self) -> None: + if "META-INF/rights.xml" in self.names: + self.diag.info("rights-xml", "META-INF/rights.xml vorhanden (mögliche DRM-Rechteinformation).") + if "META-INF/encryption.xml" not in self.names: + return + root = parse_xml(self.read("META-INF/encryption.xml"), "META-INF/encryption.xml", self.diag) + for enc in _findall(root, "encrypteddata"): + method = _find(enc, "encryptionmethod") + algorithm = method.get("Algorithm", "") if method is not None else "" + ref = _find(enc, "cipherreference") + uri = ref.get("URI", "") if ref is not None else "" + if algorithm in ALLOWED_FONT_OBFUSCATION: + continue + raise ContainerError( + f"Das EPUB ist verschlüsselt/DRM-geschützt ({uri or 'unbekannte Ressource'}). " + "epub2md unterstützt nur DRM-freie EPUBs und umgeht keinen Kopierschutz." + ) + self.diag.info("font-obfuscation", "Schriftarten sind per EPUB-Font-Obfuscation verschleiert (kein DRM).") + + # ------------------------------------------------------------------ package + + def load_package(self) -> Package: + if "META-INF/container.xml" not in self.names: + raise ContainerError("META-INF/container.xml fehlt – keine gültige EPUB-Struktur.") + try: + container = parse_xml(self.read("META-INF/container.xml"), "META-INF/container.xml", self.diag) + except StructureError as exc: + raise ContainerError(str(exc)) from exc + opf_path = None + for rootfile in _findall(container, "rootfile"): + full = rootfile.get("full-path", "") + mt = rootfile.get("media-type", "application/oebps-package+xml") + if full and mt == "application/oebps-package+xml": + opf_path = safe_zip_path(unquote(full)) + break + if not opf_path: + raise ContainerError("container.xml verweist auf keine OPF-Paketdatei.") + if opf_path not in self.names: + raise ContainerError(f"OPF-Paketdatei fehlt im Archiv: {opf_path}") + opf = parse_xml(self.read(opf_path), opf_path, self.diag) + if local(opf.tag) != "package": + raise StructureError(f"OPF-Wurzelelement ist nicht <package>: {opf_path}") + version = opf.get("version", "") + manifest = self._parse_manifest(opf, opf_path) + spine, ncx_id = self._parse_spine(opf, opf_path, manifest) + metadata = self._parse_metadata(opf, manifest) + nav_item = next((i for i in manifest.values() if "nav" in i.properties), None) + ncx_item = manifest.get(ncx_id) if ncx_id else None + if ncx_item is None: + ncx_item = next((i for i in manifest.values() if i.media_type == "application/x-dtbncx+xml"), None) + toc: list[NavEntry] = [] + landmarks: list[Landmark] = [] + if nav_item and nav_item.path in self.names: + try: + toc, landmarks = self._parse_nav(nav_item.path) + except StructureError as exc: + self.diag.warn("nav-unreadable", str(exc), nav_item.path) + if not toc and ncx_item and ncx_item.path in self.names: + try: + toc = self._parse_ncx(ncx_item.path) + except StructureError as exc: + self.diag.warn("ncx-unreadable", str(exc), ncx_item.path) + landmarks.extend(self._parse_guide(opf, opf_path)) + if not toc: + self.diag.warn("no-toc", "Weder NAV- noch NCX-Inhaltsverzeichnis gefunden; Titel werden aus Inhalten abgeleitet.") + return Package( + opf_path=opf_path, + version=version, + metadata=metadata, + manifest=manifest, + spine=spine, + toc=toc, + landmarks=landmarks, + nav_path=nav_item.path if nav_item else "", + ncx_path=ncx_item.path if ncx_item else "", + ) + + def _parse_manifest(self, opf: Any, opf_path: str) -> dict[str, ManifestItem]: + manifest_el = _find(opf, "manifest") + if manifest_el is None: + raise StructureError("OPF enthält kein <manifest>.") + items: dict[str, ManifestItem] = {} + for item in _children(manifest_el, "item"): + item_id = item.get("id", "") + href = item.get("href", "") + if not item_id or not href: + self.diag.warn("manifest-item-invalid", f"Manifest-Eintrag ohne id/href ignoriert ({item_id or href}).", opf_path) + continue + path_part, _ = split_href(href) + path = resolve_href(opf_path, path_part) + if path is None: + self.diag.error("unsafe-href", f"Manifest-Pfad verlässt das EPUB: {href}", opf_path, + critical=True, exc=ContainerError) + continue + if path not in self.names: + self.diag.warn("manifest-missing-file", f"Manifest-Datei fehlt im Archiv: {path}", opf_path) + items[item_id] = ManifestItem( + id=item_id, + path=path, + media_type=(item.get("media-type") or "").strip().lower(), + properties=(item.get("properties") or "").split(), + ) + return items + + def _parse_spine(self, opf: Any, opf_path: str, manifest: dict[str, ManifestItem]) -> tuple[list[SpineItem], str]: + spine_el = _find(opf, "spine") + if spine_el is None: + raise StructureError("OPF enthält keine <spine>.") + spine: list[SpineItem] = [] + for ref in _children(spine_el, "itemref"): + idref = ref.get("idref", "") + item = manifest.get(idref) + if item is None: + self.diag.error("spine-idref-missing", f"Spine verweist auf unbekanntes Manifest-Element '{idref}'.", + opf_path, critical=True) + continue + if item.path not in self.names: + self.diag.error("spine-file-missing", f"Spine-Dokument fehlt im Archiv: {item.path}", + opf_path, critical=True) + continue + if item.media_type not in XHTML_TYPES and not item.path.lower().endswith((".xhtml", ".html", ".htm")): + self.diag.warn("spine-non-xhtml", f"Spine-Element mit Medientyp {item.media_type} übersprungen.", item.path) + continue + spine.append( + SpineItem( + idref=idref, + path=item.path, + linear=(ref.get("linear", "yes").lower() != "no"), + properties=(ref.get("properties") or "").split(), + ) + ) + if not spine: + raise StructureError("Die Spine enthält keine lesbaren XHTML-Dokumente.") + return spine, spine_el.get("toc", "") + + def _parse_metadata(self, opf: Any, manifest: dict[str, ManifestItem]) -> BookMetadata: + md = BookMetadata() + meta_el = _find(opf, "metadata") + if meta_el is None: + self.diag.warn("metadata-missing", "OPF enthält keine <metadata>.") + return md + refines: dict[str, dict[str, str]] = {} + for meta in _findall(meta_el, "meta"): + target = meta.get("refines", "") + prop = meta.get("property", "") + if target.startswith("#") and prop: + refines.setdefault(target[1:], {})[prop] = norm_ws(text_content(meta)) + + def dc(name: str) -> list[Any]: + return [el for el in meta_el.iter() if is_element(el) and local(el.tag) == name and el.tag != f"{{{OPF_NS}}}{name}"] + + titles = [(el, norm_ws(text_content(el))) for el in dc("title")] + titles = [(el, t) for el, t in titles if t] + main_titles = [t for el, t in titles if refines.get(el.get("id", ""), {}).get("title-type", "main") == "main"] + subtitles = [t for el, t in titles if refines.get(el.get("id", ""), {}).get("title-type") == "subtitle"] + if main_titles: + md.title = main_titles[0] + elif titles: + md.title = titles[0][1] + if subtitles: + md.subtitle = subtitles[0] + elif len(main_titles) > 1: + md.subtitle = main_titles[1] + + for el in dc("creator"): + name = norm_ws(text_content(el)) + if not name: + continue + role = el.get(f"{{{OPF_NS}}}role") or el.get("role") or refines.get(el.get("id", ""), {}).get("role", "") + if role in ("", "aut"): + md.authors.append(name) + else: + md.contributors.append(f"{name} ({role})") + for el in dc("contributor"): + name = norm_ws(text_content(el)) + if name and "calibre" not in name.lower(): + md.contributors.append(name) + + langs = [norm_ws(text_content(el)) for el in dc("language")] + md.language = next((x for x in langs if x), "") + unique_id = opf.get("unique-identifier", "") + identifiers = [(el.get("id", ""), norm_ws(text_content(el))) for el in dc("identifier")] + identifiers = [(i, v) for i, v in identifiers if v] + md.identifier = next((v for i, v in identifiers if i and i == unique_id), identifiers[0][1] if identifiers else "") + for _, value in identifiers: + digits = re.sub(r"[^0-9Xx]", "", value) + if "isbn" in value.lower() or (len(digits) == 13 and digits.startswith(("978", "979"))): + if len(digits) in (10, 13): + md.isbn = digits + break + md.publisher = next((v for v in (norm_ws(text_content(e)) for e in dc("publisher")) if v), "") + md.published = next((v for v in (norm_ws(text_content(e)) for e in dc("date")) if v), "") + md.rights = next((v for v in (norm_ws(text_content(e)) for e in dc("rights")) if v), "") + md.subjects = [v for v in (norm_ws(text_content(e)) for e in dc("subject")) if v] + descriptions = [text_content(e) for e in dc("description")] + if descriptions and descriptions[0].strip(): + md.description = html_to_plain(descriptions[0]) + + cover = next((i for i in manifest.values() if "cover-image" in i.properties), None) + if cover is None: + for meta in _findall(meta_el, "meta"): + if meta.get("name") == "cover": + cover = manifest.get(meta.get("content", "")) + if cover is None: + cover = next((i for i in manifest.values() if i.path.endswith("/" + meta.get("content", ""))), None) + break + if cover is not None and cover.media_type.startswith("image/"): + md.cover_path = cover.path + return md + + def _parse_ncx(self, path: str) -> list[NavEntry]: + root = parse_xml(self.read(path), path, self.diag) + nav_map = _find(root, "navmap") + if nav_map is None: + return [] + + def walk(el: Any, depth: int) -> list[NavEntry]: + out = [] + for point in _children(el, "navpoint"): + label_el = _find(point, "navlabel") + label = norm_ws(text_content(label_el)) if label_el is not None else "" + content = _find(point, "content") + src = content.get("src", "") if content is not None else "" + target, frag = self._resolve_nav_href(path, src) + entry = NavEntry(label=label, path=target, fragment=frag, depth=depth) + entry.children = walk(point, depth + 1) + out.append(entry) + return out + + return walk(nav_map, 1) + + def _parse_nav(self, path: str) -> tuple[list[NavEntry], list[Landmark]]: + root = parse_xml(self.read(path), path, self.diag, html_fallback=True) + toc: list[NavEntry] = [] + landmarks: list[Landmark] = [] + for nav in _findall(root, "nav"): + types = (nav.get(f"{{{EPUB_NS}}}type") or nav.get("epub:type") or "").split() + if "toc" in types and not toc: + ol = next((c for c in nav.iter() if is_element(c) and local(c.tag) == "ol"), None) + if ol is not None: + toc = self._walk_nav_list(ol, path, 1) + elif "landmarks" in types: + for a in _findall(nav, "a"): + target, frag = self._resolve_nav_href(path, a.get("href", "")) + ltype = (a.get(f"{{{EPUB_NS}}}type") or a.get("epub:type") or "").strip() + landmarks.append(Landmark(type=ltype, label=norm_ws(text_content(a)), path=target, fragment=frag)) + return toc, landmarks + + def _walk_nav_list(self, ol: Any, path: str, depth: int) -> list[NavEntry]: + out = [] + for li in _children(ol, "li"): + link = next((c for c in li if is_element(c) and local(c.tag) in ("a", "span")), None) + label = norm_ws(text_content(link)) if link is not None else "" + href = link.get("href", "") if link is not None and local(link.tag) == "a" else "" + target, frag = self._resolve_nav_href(path, href) if href else ("", "") + entry = NavEntry(label=label, path=target, fragment=frag, depth=depth) + sub = next((c for c in li if is_element(c) and local(c.tag) == "ol"), None) + if sub is not None: + entry.children = self._walk_nav_list(sub, path, depth + 1) + out.append(entry) + return out + + def _parse_guide(self, opf: Any, opf_path: str) -> list[Landmark]: + guide = _find(opf, "guide") + if guide is None: + return [] + out = [] + for ref in _children(guide, "reference"): + target, frag = self._resolve_nav_href(opf_path, ref.get("href", "")) + out.append(Landmark(type=ref.get("type", ""), label=ref.get("title", ""), path=target, fragment=frag)) + return out + + def _resolve_nav_href(self, base: str, href: str) -> tuple[str, str]: + if not href: + return "", "" + path_part, frag = split_href(href) + if not path_part: + return base, frag + target = resolve_href(base, path_part) + return (target or ""), frag + + +def html_to_plain(text: str) -> str: + """Convert an HTML-ish description into plain text paragraphs.""" + if "<" not in text: + return norm_ws(html.unescape(text)) + text = re.sub(r"(?i)<br\s*/?>", "\n", text) + text = re.sub(r"(?i)</p\s*>|</div\s*>", "\n\n", text) + text = re.sub(r"<[^>]+>", "", text) + text = html.unescape(text) + paragraphs = [norm_ws(p) for p in re.split(r"\n\s*\n", text)] + return "\n\n".join(p for p in paragraphs if p) + + +def doc_dir(path: str) -> str: + return posixpath.dirname(path) diff --git a/epub2md/errors.py b/epub2md/errors.py new file mode 100644 index 0000000..8088a43 --- /dev/null +++ b/epub2md/errors.py @@ -0,0 +1,31 @@ +"""Exceptions with associated process exit codes.""" + +EXIT_OK = 0 +EXIT_INPUT = 1 +EXIT_CONTAINER = 2 +EXIT_STRUCTURE = 3 +EXIT_INTERNAL = 4 + + +class Epub2MdError(Exception): + """Base class; ``exit_code`` is returned by the CLI.""" + + exit_code = EXIT_INTERNAL + + +class InputError(Epub2MdError): + """Invalid parameters, unreadable input or unusable output directory.""" + + exit_code = EXIT_INPUT + + +class ContainerError(Epub2MdError): + """Broken ZIP, missing container.xml, unsafe paths, DRM.""" + + exit_code = EXIT_CONTAINER + + +class StructureError(Epub2MdError): + """Broken OPF, spine or XHTML.""" + + exit_code = EXIT_STRUCTURE diff --git a/epub2md/footnotes.py b/epub2md/footnotes.py new file mode 100644 index 0000000..ace2db6 --- /dev/null +++ b/epub2md/footnotes.py @@ -0,0 +1,184 @@ +"""Detection and extraction of footnotes/endnotes and their references.""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import unquote, urlsplit + +from .diagnostics import Diagnostics +from .models import Note +from .normalize import Doc, has_content, remove_keep_tail +from .utils import classes, epub_types, is_element, iter_elements, local, resolve_href, roles + +NOTE_TYPES = {"footnote", "endnote", "rearnote", "note"} +NOTE_ROLES = {"doc-footnote", "doc-endnote"} +NOTE_CLASSES = {"footnote", "endnote", "fn", "fussnote", "fußnote"} +CONTAINER_TYPES = {"footnotes", "endnotes", "rearnotes"} +CONTAINER_ROLES = {"doc-endnotes"} +CONTAINER_CLASSES = {"footnotes", "endnotes", "footnotesection", "endnotesection", "fussnoten"} +NOTEREF_ATTR = "data-e2m-noteref" + + +def _is_note(el: Any) -> bool: + return bool( + NOTE_TYPES.intersection(epub_types(el)) + or NOTE_ROLES.intersection(roles(el)) + or NOTE_CLASSES.intersection(classes(el)) + ) + + +def _is_container(el: Any) -> bool: + return bool( + CONTAINER_TYPES.intersection(epub_types(el)) + or CONTAINER_ROLES.intersection(roles(el)) + or CONTAINER_CLASSES.intersection(classes(el)) + ) + + +def _note_kind(el: Any) -> str: + for e in iter_elements(el): + t = set(epub_types(e)) | {r.removeprefix("doc-") for r in roles(e)} + if t & {"endnote", "rearnote"}: + return "endnote" + if "footnote" in t: + return "footnote" + return "footnote" + + +def _href_target(doc_path: str, href: str) -> tuple[str, str] | None: + parts = urlsplit(href) + if parts.scheme or parts.netloc: + return None + path = unquote(parts.path) + frag = unquote(parts.fragment) + if not frag: + return None + target = resolve_href(doc_path, path) if path else doc_path + return (target, frag) if target else None + + +class NoteIndex: + def __init__(self) -> None: + self.notes: dict[str, Note] = {} + self.by_target: dict[tuple[str, str], Note] = {} + self.extracted = False + + def note_for_ref(self, el: Any) -> Note | None: + key = el.get(NOTEREF_ATTR) + return self.notes.get(key) if key else None + + +def collect_notes(docs: list[Doc], mode: str, diag: Diagnostics) -> NoteIndex: + index = NoteIndex() + # 1. note elements (outermost candidates only) + for doc in docs: + for el in iter_elements(doc.body): + if not _is_note(el): + continue + anc = el.getparent() + nested = False + while anc is not None: + if _is_note(anc): + nested = True + break + anc = anc.getparent() + if nested: + continue + ids = {e.get("id") for e in iter_elements(el) if e.get("id")} + if not ids: + continue + key = f"{doc.path}#{sorted(ids)[0]}" + note = Note(key=key, element=el, doc=doc.path, ids=ids, kind=_note_kind(el)) + index.notes[key] = note + for i in ids: + index.by_target[(doc.path, i)] = note + + # 2. references + for doc in docs: + for a in iter_elements(doc.body): + if local(a.tag) != "a" or not a.get("href"): + continue + target = _href_target(doc.path, a.get("href")) + is_noteref = "noteref" in epub_types(a) or "doc-noteref" in roles(a) + found = index.by_target.get(target) if target else None + if found is None: + if is_noteref: + diag.warn("noteref-unresolved", f"Fußnotenverweis {a.get('href')} verweist auf kein erkanntes Ziel.", doc.path) + continue + if _inside(a, found.element): + continue # backlink inside the note itself + a.set(NOTEREF_ATTR, found.key) + found.ref_count += 1 + found.ref_anchor_ids.update(_ref_ids(a)) + + # 3. extraction + if mode == "keep-links": + return index + for note in index.notes.values(): + if note.ref_count == 0: + semantic = NOTE_TYPES.intersection(epub_types(note.element)) or NOTE_ROLES.intersection(roles(note.element)) + if semantic: + diag.warn("note-unreferenced", "Anmerkung ohne Verweis im Text – an Ort und Stelle belassen.", + f"{note.doc}#{sorted(note.ids)[0]}") + continue + _strip_backlinks(note) + remove_keep_tail(note.element) + index.extracted = True + for doc in docs: + for el in list(iter_elements(doc.body)): + if el is not doc.body and _is_container(el) and el.getparent() is not None and not _has_body_content(el): + remove_keep_tail(el) + return index + + +def _inside(el: Any, ancestor: Any) -> bool: + node = el + while node is not None: + if node is ancestor: + return True + node = node.getparent() + return False + + +def _ref_ids(a: Any) -> set[str]: + ids = {e.get("id") for e in iter_elements(a) if e.get("id")} + parent = a.getparent() + for _ in range(2): + if parent is None or local(parent.tag) not in ("sup", "span", "small"): + break + if parent.get("id"): + ids.add(parent.get("id")) + parent = parent.getparent() + return ids + + +def _strip_backlinks(note: Note) -> None: + for a in list(iter_elements(note.element)): + if local(a.tag) != "a" or not a.get("href"): + continue + target = _href_target(note.doc, a.get("href")) + if target and target[1] in note.ref_anchor_ids: + parent = a.getparent() + remove_keep_tail(a) + # drop now-empty label wrappers such as <span class="FootnoteNumber"> + while parent is not None and parent is not note.element and not has_content(parent) and not parent.get("id"): + grand = parent.getparent() + remove_keep_tail(parent) + parent = grand + + +def _has_body_content(container: Any) -> bool: + """Content of a notes container apart from its heading.""" + for child in container: + if not is_element(child): + continue + name = local(child.tag) + if name in ("h1", "h2", "h3", "h4", "h5", "h6", "hr"): + continue + if {"heading", "headings", "title"} & set(classes(child)): + continue + if has_content(child): + return True + if container.text and container.text.strip(): + return True + return any(c.tail and c.tail.strip() for c in container if is_element(c)) diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py new file mode 100644 index 0000000..7725ac9 --- /dev/null +++ b/epub2md/html_converter.py @@ -0,0 +1,1183 @@ +"""Recursive XHTML -> Markdown renderer. + +Rendering works on "pieces": ("t", text) is escaped prose whose whitespace may be +collapsed, ("r", raw) is emitted verbatim, ("br", "") is a hard line break and +("blk", block) is a complete block (e.g. display math) that splits the paragraph. +""" + +from __future__ import annotations + +import html +import re +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any +from urllib.parse import unquote + +from lxml import etree + +from .chapters import IdCarrier, Marker, heading_level +from .codeblocks import ( + code_text, + detect_language, + detect_language_from_content, + fence_for, + inline_code, + is_block_code_container, + is_inline_code, +) +from .diagnostics import Diagnostics +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 .references import LinkResolver +from .utils import ( + MATHML_NS, + SVG_NS, + XLINK_NS, + classes, + epub_types, + is_element, + iter_elements, + local, + norm_ws, + rel_link, + resolve_href, + text_content, + url_path, +) + +Piece = tuple[str, str] + +BLOCK_TAGS = { + "p", "div", "section", "article", "header", "footer", "aside", "main", "nav", "blockquote", "ul", "ol", + "dl", "dt", "dd", "li", "table", "pre", "figure", "figcaption", "h1", "h2", "h3", "h4", "h5", "h6", "hr", + "address", "center", "details", "summary", "body", "hgroup", "caption", "audio", "video", "form", + "fieldset", "legend", "object", "iframe", "embed", "html", "menu", "dir", "listing", "xmp", "plaintext", + "canvas", "noscript", +} +TRANSPARENT_INLINE = {"span", "small", "big", "font", "label", "time", "bdi", "bdo", "data", "abbr", "acronym", + "nobr", "wbr", "ruby", "rb", "rt", "rp", "output"} +FLOW_BLOCKS = {"p", "div", "section", "article", "header", "footer", "aside", "main", "nav", "address", + "center", "details", "summary", "body", "hgroup", "caption", "form", "fieldset", "legend", + "html", "dt", "dd", "li", "figcaption", "menu", "dir", "noscript", "canvas"} +KNOWN_INLINE = TRANSPARENT_INLINE | { + "a", "em", "i", "strong", "b", "u", "ins", "del", "s", "strike", "sup", "sub", "q", "cite", "kbd", "mark", + "code", "samp", "tt", "var", "dfn", "img", "svg", "math", "br", "image", +} +SIMPLE_CELL_BLOCKERS = {"pre", "ul", "ol", "dl", "table", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "figure"} +FORMULA_CLASS = re.compile(r"equation|formula|math|katex|mathjax|displaymath|eqn") +INLINE_FORMULA_CLASS = re.compile(r"inlineequation|inline-?math|inlineformula|math-inline|inline") +LABEL_CLASS = re.compile(r"number$|item-number|label|marker") +HTML_KEEP_TAGS = { + "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", "p", "br", "em", + "strong", "i", "b", "u", "s", "del", "ins", "sup", "sub", "code", "pre", "kbd", "samp", "var", "a", + "img", "ul", "ol", "li", "dl", "dt", "dd", "div", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", + "figure", "figcaption", "abbr", "mark", "q", "cite", "small", "hr", "span", +} +HTML_KEEP_ATTRS = {"colspan", "rowspan", "alt", "title", "start", "scope", "headers", "lang", "dir", "align", + "valign", "reversed", "type", "abbr"} +WS = re.compile(r"[ \t\r\n\f]+") + + +@dataclass +class RenderOptions: + format: str = "gfm" + footnotes: str = "end-of-chapter" + math: str = "auto" + extract_media: bool = True + keep_html: bool = False + language: str = "de" + + +def md_escape(text: str, table: bool = False) -> str: + text = text.replace("\\", "\\\\") + text = re.sub(r"([`*_\[\]$])", r"\\\1", text) + text = re.sub(r"<(?=[A-Za-z/!?])", r"\\<", text) + text = re.sub(r"&(?=#?\w+;)", r"\\&", text) + text = text.replace("~~", "\\~\\~") + if table: + text = text.replace("|", "\\|") + return text + + +LINE_START = re.compile(r"^(\s*)([#>+=-]|\d{1,9}[.)])(\s|$)") + + +def escape_line_starts(text: str) -> str: + out = [] + for line in text.split("\n"): + m = LINE_START.match(line) + if m: + token = m.group(2) + pos = m.start(2) + (len(token) - 1 if token[0].isdigit() else 0) + line = line[:pos] + "\\" + line[pos:] + out.append(line) + return "\n".join(out) + + +def join_inline(pieces: list[Piece], hard_break: str = "\\\n") -> str: + out = "" + for kind, s in pieces: + if kind == "t": + s = WS.sub(" ", s) + if not out or out.endswith((" ", "\n")): + s = s.lstrip(" ") + out += s + elif kind == "br": + out = out.rstrip(" ") + if out and not out.endswith(hard_break): + out += hard_break + elif kind == "blk": + out += " " + WS.sub(" ", s) + " " + else: + out += s + out = out.strip(" ") + while out.endswith(hard_break): + out = out[: -len(hard_break)].rstrip(" ") + while out.startswith(hard_break): + out = out[len(hard_break) :].lstrip(" ") + return out + + +def indent_block(text: str, first: str, rest: str) -> str: + lines = text.split("\n") + out = [first + lines[0]] + for line in lines[1:]: + out.append(rest + line if line.strip() else "") + return "\n".join(out) + + +def _ancestors(el: Any, depth: int = 5) -> list[Any]: + out: list[Any] = [] + node = el.getparent() + while node is not None and len(out) < depth: + out.append(node) + node = node.getparent() + return out + + +def serialize_mathml(math: Any) -> str: + def build(src: Any, parent: Any | None) -> Any: + name = src.tag.rsplit("}", 1)[-1] + if parent is None: + el = etree.Element(f"{{{MATHML_NS}}}{name}", nsmap={None: MATHML_NS}) + else: + el = etree.SubElement(parent, f"{{{MATHML_NS}}}{name}") + for k, v in src.attrib.items(): + key = k.rsplit("}", 1)[-1] + if key.startswith("on") or key in ("style", "class"): + continue + el.set(key, v) + el.text = src.text + for child in src: + if is_element(child): + sub = build(child, el) + sub.tail = child.tail + return el + + tree = build(math, None) + text = etree.tostring(tree, encoding="unicode", with_tail=False) + return WS.sub(" ", text).replace("> <", "><").strip() + + +class Renderer: + def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry, + notes: NoteIndex, diag: Diagnostics, zip_names: set[str]) -> None: + self.opt = options + self.zip_names = zip_names + self.in_table_html = False + self.resolver = resolver + self.media = media + self.notes = notes + self.diag = diag + self.note_counter = 0 + self.book_notes: list[Note] = [] + self.note_refs: dict[str, list[tuple[str, str]]] = {} # label -> [(chapter file, ref anchor)] + self.consumed_formula_images: set[str] = set() + self.formulas: list[dict[str, str]] = [] + self.code_blocks: list[dict[str, Any]] = [] + self.endnotes_file = "" + # per chapter state + self.ch: Chapter | None = None + self.file = "" + self.doc = "" + self.min_level = 1 + self.em = 0 + self.strong = 0 + self.in_heading = False + self.in_table = False + self.in_note = False + self.figure_caption = "" + self.chapter_notes: list[Note] = [] + self.md_ref_labels: set[str] = set() + + # ================================================================== chapter + + def render_chapter(self, ch: Chapter) -> str: + self.ch = ch + self.file = ch.filename + self.doc = ch.docs[0] if ch.docs else "" + self.chapter_notes = [] + self.md_ref_labels = set() + has_headings = any( + is_element(it) and any(heading_level(e) for e in iter_elements(it)) for _, it in ch.items + ) + blocks: list[str] = [] + if ch.title_el is None and has_headings: + blocks.append("# " + md_escape(ch.title)) + self.min_level = 2 if (ch.title_el is not None or has_headings) else 1 + blocks.extend(self.render_items(ch.items)) + blocks.extend(self._chapter_note_definitions()) + text = "\n\n".join(b for b in blocks if b.strip()) + return re.sub(r"\n{3,}", "\n\n", text).strip() + "\n" + + def render_items(self, items: Sequence[tuple[str | None, Any]]) -> list[str]: + blocks: list[str] = [] + buf: list[Piece] = [] + + def flush() -> None: + text = join_inline(buf) + buf.clear() + if text.strip(): + blocks.append(escape_line_starts(text)) + + for doc, item in items: + if doc: + self.doc = doc + if isinstance(item, str): + 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): + continue + elif self.is_block(item): + flush() + blocks.extend(self.block(item)) + else: + for piece in self.inline(item): + if piece[0] == "blk": + flush() + blocks.append(piece[1]) + else: + buf.append(piece) + flush() + return blocks + + def children(self, el: Any) -> list[tuple[str | None, Any]]: + items: list[tuple[str | None, Any]] = [] + if el.text: + items.append((None, el.text)) + for child in el: + if is_element(child): + items.append((None, child)) + if child.tail: + items.append((None, child.tail)) + return items + + def flow(self, el: Any) -> list[str]: + return self.render_items(self.children(el)) + + # ================================================================== helpers + + def _anchor(self, name: str) -> str: + return f'<a id="{name}"></a>' + + def anchors_of(self, el: Any, subtree: bool = False) -> str: + nodes = iter_elements(el) if subtree else [el] + names = [] + for node in nodes: + name = self.resolver.anchor_for(self.doc, node) + if name and name not in names: + names.append(name) + return "".join(self._anchor(n) for n in names) + + def is_block(self, el: Any) -> bool: + name = local(el.tag) + if name == "math": + return el.get("display") == "block" + if name in BLOCK_TAGS: + return True + if is_inline_code(el): + return False + if name in KNOWN_INLINE or name not in BLOCK_TAGS: + return any(local(e.tag) in BLOCK_TAGS - {"caption"} for e in iter_elements(el) if e is not el) + return False + + def _source(self, el: Any) -> str: + el_id = el.get("id") + return f"{self.doc}#{el_id}" if el_id else self.doc + + # ================================================================== blocks + + def block(self, el: Any) -> list[str]: + name = local(el.tag) + 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) + if heading_level(el): + return self.heading(el) + if name == "pre" or is_block_code_container(el): + return self.code_block(el) + if name == "blockquote": + return self.blockquote(el) + if name in ("ul", "ol", "menu", "dir"): + return self.list_block(el) + if name == "dl": + return self.definition_list(el) + if name == "table": + return self.table(el) + if name == "figure": + return self.figure(el) + if name == "hr": + return ["* * *"] + if name == "math": + return [self.math(el, display=True)[0][1]] + if name in ("audio", "video", "object", "iframe", "embed"): + return self.media_block(el) + if name in ("listing", "xmp", "plaintext"): + return self.code_block(el) + if name in FLOW_BLOCKS: + return self._flow_with_anchor(el) + # unknown element or inline element containing blocks + if name not in KNOWN_INLINE: + self.diag.unsupported_element(name, self._source(el)) + if self.opt.keep_html: + return [self.clean_html(el, keep_root=True)] + return self._flow_with_anchor(el) + + def _flow_with_anchor(self, el: Any) -> list[str]: + anchor = self.anchors_of(el) + if self.ch is not None and {"index", "index-persons", "glossary"} & set(self.ch.types): + special = self._index_block(el, anchor) + if special is not None: + return special + blocks = self.flow(el) + if anchor: + if blocks and not blocks[0].startswith(("#", "`", "|", "<table", "> ", "- ", "$$", "\\[", "* * *")) \ + and not re.match(r"\d+\. ", blocks[0]): + blocks[0] = anchor + blocks[0] + else: + blocks.insert(0, anchor) + return blocks + + def _index_block(self, el: Any, anchor: str) -> list[str] | None: + """Register/glossary layout: letter groups become headings, sub-entries keep their depth.""" + cls = classes(el) + if any(c in ("heading", "indexheading", "letter", "groupletter", "index-letter") for c in cls): + text = norm_ws(text_content(el)) + if 0 < len(text) <= 3: + return ([anchor] if anchor else []) + [f"## {md_escape(text)}"] + depth = 0 + for c in cls: + if re.search(r"(tertiary|index-?3|level-?3|ie3|subsubentry)", c): + depth = 2 + elif re.search(r"(secondary|index-?2|level-?2|ie2|subentry|sub-entry)", c): + depth = max(depth, 1) + if not depth: + return None + blocks = self.flow(el) + if blocks: + blocks[0] = "– " * depth + anchor + blocks[0] + return blocks + + def heading(self, el: Any, forced_level: int = 0) -> list[str]: + ch = self.ch + if forced_level: + level = forced_level + elif ch is not None and el is ch.title_el: + level = 1 + else: + 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) + self.in_heading = True + try: + text = join_inline(self.inline_children(el), hard_break=" ") + finally: + self.in_heading = False + text = re.sub(r"<a id=\"[^\"]*\"></a>", "", text).strip() + out = [] + if anchors: + out.append(anchors) + if not text: + if any(local(e.tag) in ("img", "svg") for e in iter_elements(el)): + out.extend(self.flow(el)) + return out + text = re.sub(r"\s*#+\s*$", lambda m: m.group(0).replace("#", "\\#"), text) + out.append("#" * level + " " + text) + return out + + def code_block(self, el: Any) -> list[str]: + code = code_text(el) + anchors = self.anchors_of(el, subtree=True) + out = [anchors] if anchors else [] + if not code.strip(): + return out + lang, how = detect_language(el) + if not lang: + lang = detect_language_from_content(code) + how = "content" if lang else "" + if self.ch: + self.ch.stats.code_blocks += 1 + if not lang: + self.ch.stats.code_blocks_unlabeled += 1 + self.code_blocks.append({"source": self._source(el), "chapter": self.file, "language": lang, + "detected_by": how, "lines": code.count("\n") + 1}) + fence = fence_for(code) + out.append(f"{fence}{lang}\n{code}\n{fence}") + return out + + def blockquote(self, el: Any) -> list[str]: + anchor = self.anchors_of(el) + inner = "\n\n".join(self.flow(el)) + if not inner.strip(): + return [anchor] if anchor else [] + quoted = "\n".join("> " + line if line.strip() else ">" for line in inner.split("\n")) + return ([anchor] if anchor else []) + [quoted] + + def list_block(self, el: Any) -> list[str]: + anchor = self.anchors_of(el) + ordered = local(el.tag) == "ol" + try: + number = int(el.get("start", "1")) + except ValueError: + number = 1 + items = [c for c in el if is_element(c) and local(c.tag) == "li"] + labeled = bool(items) and all(self._list_label(li) is not None for li in items) + style = (el.get("style") or "").replace(" ", "").lower() + if "list-style-type:none" in style or "list-style:none" in style: + labeled = labeled or True + rendered: list[str] = [] + loose = False + for li in items: + label_el = self._list_label(li) if labeled else None + if label_el is not None: + label = norm_ws(text_content(label_el)) + rest = [(None, x) for x in self._items_after(li, label_el)] + blocks = self.render_items(rest) + if blocks: + blocks[0] = md_escape(label) + " " + blocks[0] + else: + blocks = [md_escape(label)] + else: + blocks = self.flow(li) + li_anchor = self.anchors_of(li) + if li_anchor: + blocks = [li_anchor + (blocks[0] if blocks else "")] + blocks[1:] + if len(blocks) > 1: + loose = True + body = "\n\n".join(blocks) if blocks else "" + if ordered and not labeled: + marker = f"{number}." + number += 1 + else: + marker = "-" + rendered.append(indent_block(body, marker + " ", " " * (len(marker) + 1))) + if not rendered: + return [anchor] if anchor else [] + joined = ("\n\n" if loose else "\n").join(rendered) + return ([anchor] if anchor else []) + [joined] + + def _list_label(self, li: Any) -> Any: + first = next((c for c in li if is_element(c)), None) + if first is None or (li.text and li.text.strip()): + return None + if any(LABEL_CLASS.search(c) for c in classes(first)): + return first + return None + + def _items_after(self, li: Any, label_el: Any) -> list[Any]: + out: list[Any] = [] + seen = False + for child in li: + if child is label_el: + seen = True + if child.tail: + out.append(child.tail) + continue + if seen: + if is_element(child): + out.append(child) + if child.tail: + out.append(child.tail) + return out + + def definition_list(self, el: Any) -> list[str]: + anchor = self.anchors_of(el) + out: list[str] = [anchor] if anchor else [] + terms: list[str] = [] + for child in el: + if not is_element(child): + continue + name = local(child.tag) + if name == "dt": + t = join_inline(self.inline_children(child), hard_break=" ") + a = self.anchors_of(child) + terms.append(a + t) + elif name == "dd": + blocks = self.flow(child) + a = self.anchors_of(child) + term = " / ".join(terms) if terms else "" + terms = [] + if term and len(blocks) == 1 and "\n" not in blocks[0] and not blocks[0].startswith(("```", "|", "<")): + out.append(f"**{term}:** {a}{blocks[0]}") + else: + if term: + out.append(f"**{term}**") + if a: + out.append(a) + out.extend(blocks) + elif name == "div": # HTML5 allows <div> grouping inside <dl> + out.extend(self.definition_list(child)) + for t in terms: + out.append(f"**{t}**") + return out + + def figure(self, el: Any) -> list[str]: + anchor = self.anchors_of(el) + caption_el = next((c for c in el if is_element(c) and local(c.tag) == "figcaption"), None) + caption = "" + if caption_el is not None: + caption = join_inline(self.inline_children(caption_el), hard_break=" ") + old = self.figure_caption + self.figure_caption = norm_ws(text_content(caption_el)) if caption_el is not None else "" + items = [(None, x) for x in self._items_without(el, caption_el)] + try: + blocks = self.render_items(items) + finally: + self.figure_caption = old + out = ([anchor] if anchor else []) + blocks + if caption: + cap_anchor = self.anchors_of(caption_el, subtree=False) if caption_el is not None else "" + out.append(f"{cap_anchor}*{caption}*" if not caption.startswith("*") else cap_anchor + caption) + return out + + def _items_without(self, el: Any, skip: Any) -> list[Any]: + out: list[Any] = [] + if el.text: + out.append(el.text) + for child in el: + if child is not skip and is_element(child): + out.append(child) + if child.tail: + out.append(child.tail) + return out + + def media_block(self, el: Any) -> list[str]: + name = local(el.tag) + srcs = [el.get("src") or el.get("data") or ""] + srcs += [s.get("src", "") for s in el if is_element(s) and local(s.tag) == "source"] + out = [] + for src in [s for s in srcs if s]: + if self.resolver.is_external(src): + out.append(f"[{name.capitalize()}: {md_escape(src)}]({self._url(src)})") + continue + path = resolve_href(self.doc, unquote(src.split("#")[0])) + if path and self._exists(path): + if self.opt.extract_media: + mf = self.media.register(path, role=name) + label = {"audio": "Audio", "video": "Video"}.get(name, "Medium") + out.append(f"[{label}: {md_escape(path.rsplit('/', 1)[-1])}]({url_path(rel_link(self.file, mf.output))})") + else: + out.append(f"*[{name}: {md_escape(path)}]*") + self.diag.info("media-link", f"{name}-Element als Link erhalten (in Markdown nicht direkt abspielbar).", self._source(el)) + else: + self.diag.warn("media-missing", f"Medienquelle {src} nicht im EPUB gefunden.", self._source(el)) + if name in ("object", "iframe", "embed"): + self.diag.unsupported_element(name, self._source(el)) + if not out: + out = self.flow(el) + return out + + # ================================================================== tables + + def table(self, el: Any) -> list[str]: + anchor = self.anchors_of(el) + rows = self._rows(el) + if self.ch: + self.ch.stats.tables += 1 + cells_total = sum(len(r) for r in rows) + if cells_total <= 1 and not self._has_nested_table(el): + # single-cell layout table: keep its content as normal flow + cell = rows[0][0] if rows and rows[0] else None + return ([anchor] if anchor else []) + (self.flow(cell) if cell is not None else []) + reason = self._complex_reason(el, rows) + caption_el = next((c for c in el if is_element(c) and local(c.tag) == "caption"), None) + if reason: + if self.ch: + self.ch.stats.tables_html += 1 + self.diag.warn("table-html", f"Tabelle enthält {reason}; als HTML erhalten.", self._source(el), self.file) + return ([anchor] if anchor else []) + [self.clean_html(el)] + out = [anchor] if anchor else [] + if caption_el is not None: + cap = join_inline(self.inline_children(caption_el), hard_break=" ") + if cap: + out.append(f"*{cap}*") + width = max(len(r) for r in rows) + rendered_rows = [] + self.in_table = True + try: + for r in rows: + cells = [] + for cell in r: + blocks = self.flow(cell) + text = "<br>".join(b.replace("\\\n", "<br>").replace("\n", " ") for b in blocks) + a = self.anchors_of(cell) + cells.append((a + text).strip()) + cells += [""] * (width - len(cells)) + rendered_rows.append(cells) + finally: + self.in_table = False + header_is_th = all(local(c.tag) == "th" for c in rows[0]) or self._in_thead(rows[0][0]) + if header_is_th: + header, body = rendered_rows[0], rendered_rows[1:] + else: + header, body = [""] * width, rendered_rows + lines = ["| " + " | ".join(header) + " |", "|" + "|".join([" --- "] * width) + "|"] + lines += ["| " + " | ".join(r) + " |" for r in body] + out.append("\n".join(lines)) + return out + + def _has_nested_table(self, el: Any) -> bool: + return any(local(e.tag) == "table" for e in iter_elements(el) if e is not el) + + def _rows(self, table: Any) -> list[list[Any]]: + rows = [] + for e in iter_elements(table): + if local(e.tag) == "tr" and self._owner_table(e) is table: + cells = [c for c in e if is_element(c) and local(c.tag) in ("td", "th")] + if cells: + rows.append(cells) + return rows + + def _owner_table(self, el: Any) -> Any: + node = el.getparent() + while node is not None and local(node.tag) != "table": + node = node.getparent() + return node + + def _in_thead(self, cell: Any) -> bool: + tr = cell.getparent() + return tr is not None and tr.getparent() is not None and local(tr.getparent().tag) == "thead" + + def _complex_reason(self, el: Any, rows: list[list[Any]]) -> str: + if self.opt.format == "commonmark": + return "keine Tabellen-Syntax in CommonMark" + for r in rows: + for c in r: + for attr in ("colspan", "rowspan"): + try: + if int(c.get(attr, "1")) > 1: + return attr + except ValueError: + pass + if self._has_nested_table(el): + return "verschachtelte Tabellen" + for r in rows: + for c in r: + for e in iter_elements(c): + if e is not c and (local(e.tag) in SIMPLE_CELL_BLOCKERS or is_block_code_container(e)): + return f"Blockelemente ({local(e.tag)}) in Zellen" + if local(e.tag) == "math" and e.get("display") == "block": + return "abgesetzte Formeln in Zellen" + return "" + + # ================================================================== inline + + def inline_children(self, el: Any) -> list[Piece]: + pieces: list[Piece] = [] + if 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", md_escape(child.tail, self.in_table))) + return pieces + + def inline(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 [] + if name == "br": + return pre + ([("t", " ")] if self.in_heading else [("br", "")]) + if name == "wbr": + return pre + if name == "a": + return self.link(el) + if name == "img": + return pre + self.image(el) + if name == "image": + href = el.get(f"{{{XLINK_NS}}}href") or el.get("href") or "" + return pre + self.image(el, src=href) + if name == "svg": + return pre + self.svg(el) + if name == "math": + return pre + self.math(el, display=el.get("display") == "block") + if is_inline_code(el): + text = code_text(el).replace("\n", " ") if local(el.tag) != "span" else text_content(el) + code = inline_code(text) + return pre + ([("r", code)] if code else []) + if name in ("em", "i", "cite", "dfn", "var"): + if self.em: + return pre + self.inline_children(el) + self.em += 1 + try: + return pre + self._wrap(self.inline_children(el), "*") + finally: + self.em -= 1 + if name in ("strong", "b"): + if self.strong: + return pre + self.inline_children(el) + self.strong += 1 + try: + return pre + self._wrap(self.inline_children(el), "**") + finally: + self.strong -= 1 + if name in ("del", "s", "strike"): + marker = ("~~", "~~") if self.opt.format == "gfm" else ("<del>", "</del>") + 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)] + if len(elems) == 1 and self.notes.note_for_ref(elems[0]) is not None and self.notes.extracted \ + and not (el.text or "").strip() and not (elems[0].tail or "").strip(): + return pre + self.inline(elems[0]) + if name == "u": + tag = "u" + elif name == "kbd": + return pre + [("r", f"<kbd>{html.escape(norm_ws(text_content(el)), quote=False)}</kbd>")] + else: + tag = name + return pre + self._wrap(self.inline_children(el), f"<{tag}>", f"</{tag}>") + if name == "q": + o, c = ("„", "“") if self.opt.language.startswith("de") else ("“", "”") + return pre + [("t", o)] + self.inline_children(el) + [("t", c)] + if name in BLOCK_TAGS: # block element nested in inline context (e.g. <div> inside <sup>) + return pre + [("t", " ")] + self.inline_children(el) + [("t", " ")] + if name not in KNOWN_INLINE: + self.diag.unsupported_element(name, self._source(el)) + if self.opt.keep_html: + return pre + [("r", self.clean_html(el, keep_root=True))] + return pre + self.inline_children(el) + + def _wrap(self, pieces: list[Piece], open_: str, close: str | None = None) -> list[Piece]: + close = open_ if close is None else close + hard = " " if self.in_heading else "\\\n" + s = join_inline(pieces, hard_break=hard) if pieces else "" + raw = "".join(p[1] for p in pieces if p[0] == "t") + lead = " " if raw[:1].isspace() or s[:1].isspace() else "" + trail = " " if raw[-1:].isspace() or s[-1:].isspace() else "" + core = s.strip() + while core.endswith(hard) and hard.strip(): + core = core[: -len(hard)].rstrip() + if not core: + return [("t", lead or trail)] if (lead or trail) else [] + return [("t", lead), ("r", f"{open_}{core}{close}"), ("t", trail)] + + # ------------------------------------------------------------------ links + + def _url(self, url: str) -> str: + url = url.replace(" ", "%20") + if "(" in url or ")" in url or "<" in url or ">" in url: + return "<" + url.replace("<", "%3C").replace(">", "%3E") + ">" + return url + + def link(self, el: Any) -> list[Piece]: + anchor = self.anchors_of(el) + pre: list[Piece] = [("r", anchor)] if anchor and not self.in_heading else [] + note = self.notes.note_for_ref(el) if self.notes.extracted else None + if note is not None: + return pre + self.note_ref(note, el) + href = (el.get("href") or "").strip() + inner = self.inline_children(el) + if not href: + return pre + inner + text = join_inline(inner, hard_break=" ") + if href.startswith("javascript:"): + return pre + inner + if self.resolver.is_external(href): + if not text: + return pre + [("r", f"<{href}>")] if re.match(r"https?://\S+$", href) else pre + return pre + [("r", f"[{text}]({self._url(href)})")] + target = self.resolver.split(self.doc, href) + if target and target[0] != self.doc and self._is_media_target(target[0]): + if not self.opt.extract_media: + return pre + inner + mf = self.media.register(target[0], role="linked") + return pre + [("r", f"[{text or md_escape(target[0].rsplit('/', 1)[-1])}]({url_path(rel_link(self.file, mf.output))})")] + url, status = self.resolver.resolve(self.file, self.doc, href) + if url is None: + if status in ("missing", "unsafe"): + self.diag.unresolved(self.doc, href, self.file) + elif status == "note": + self.diag.info("link-to-note", f"Verweis {href} zeigt auf eine verschobene Anmerkung; als Text belassen.", self.doc, self.file) + return pre + inner + if status == "anchor-missing": + self.diag.warn("anchor-missing", f"Anker in {href} nicht gefunden; Link zeigt auf den Kapitelanfang.", self.doc, self.file) + if not text: + return pre + return pre + [("r", f"[{text}]({url})")] + + def _is_media_target(self, path: str) -> bool: + item = self.media.package.item_by_path(path) + return item is not None and not (item.media_type in ("application/xhtml+xml", "text/html") or path.endswith((".xhtml", ".html", ".htm"))) + + # ------------------------------------------------------------------ notes + + def _assign_label(self, note: Note) -> None: + if not note.label: + self.note_counter += 1 + note.label = f"fn-{self.note_counter:03d}" + self.book_notes.append(note) + + def note_ref(self, note: Note, el: Any) -> list[Piece]: + self._assign_label(note) + mode = self.opt.footnotes + num = int(note.label.split("-")[1]) + if self.ch: + self.ch.stats.footnotes += 1 + if mode == "inline" and not self.in_note: + text = self._note_inline_text(note) + if text is not None: + label = "Anmerkung" if self.opt.language.startswith("de") else "Note" + return [("r", f" ({label}: {text})")] + self.diag.info("note-inline-fallback", "Anmerkung mit Blockinhalt kann nicht inline stehen; als Fußnote ausgegeben.", note.key) + if mode == "end-of-book": + refs = self.note_refs.setdefault(note.label, []) + ref_id = f"fnref-{num:03d}" + (f"-{len(refs) + 1}" if refs else "") + refs.append((self.file, ref_id)) + target = url_path(rel_link(self.file, self.endnotes_file)) + "#" + note.label + return [("r", f'<a id="{ref_id}"></a><sup>[{num}]({target})</sup>')] + if note not in self.chapter_notes: + self.chapter_notes.append(note) + if self.opt.format == "gfm" and not self.in_table_html: + self.md_ref_labels.add(note.label) + return [("r", f"[^{note.label}]")] + refs = self.note_refs.setdefault(f"{self.file}:{note.label}", []) + ref_id = f"fnref-{num:03d}" + (f"-{len(refs) + 1}" if refs else "") + refs.append((self.file, ref_id)) + return [("r", f'<a id="{ref_id}"></a><sup><a href="#{note.label}">{num}</a></sup>')] + + def _note_inline_text(self, note: Note) -> str | None: + blocks = self.render_note(note) + for b in blocks: + if b.startswith(("```", "|", "<table", "- ", "> ", "#", "$$", "\\[")) or re.match(r"\d+\. ", b): + return None + return " ".join(b.replace("\\\n", " ") for b in blocks).strip() + + def render_note(self, note: Note) -> list[str]: + saved_doc, saved_note = self.doc, self.in_note + self.doc = note.doc + self.in_note = True + try: + el = note.element + if self.is_block(el): + return self.flow(el) + return self.render_items(self.children(el)) + finally: + self.doc, self.in_note = saved_doc, saved_note + + def _chapter_note_definitions(self) -> list[str]: + if self.opt.footnotes in ("keep-links", "end-of-book"): + return [] + out: list[str] = [] + anchor_style: list[str] = [] + done: set[str] = set() + i = 0 + while i < len(self.chapter_notes): # notes may reference further notes + note = self.chapter_notes[i] + i += 1 + if note.label in done: + continue + done.add(note.label) + blocks = self.render_note(note) or ["(leer)"] + body = "\n\n".join(blocks) + if note.label in self.md_ref_labels: + out.append(indent_block(body, f"[^{note.label}]: ", " ")) + else: + num = int(note.label.split("-")[1]) + back = " ".join( + f"[↩](#{ref})" for _, ref in self.note_refs.get(f"{self.file}:{note.label}", []) + ) + anchor_style.append(indent_block(body, f'<a id="{note.label}"></a>**{num}:** ', "") + (f" {back}" if back else "")) + if anchor_style: + title = "Anmerkungen" if self.opt.language.startswith("de") else "Notes" + out = [f"**{title}**"] + anchor_style + out + return out + + def render_endnotes_file(self, title: str) -> str: + """Content of the end-of-book notes file (after all chapters were rendered).""" + dummy = Chapter(number=0, title=title, items=[], docs=[]) + dummy.filename = self.endnotes_file + self.ch = dummy + self.file = self.endnotes_file + self.min_level = 2 + out = [f"# {md_escape(title)}"] + i = 0 + while i < len(self.book_notes): + note = self.book_notes[i] + i += 1 + num = int(note.label.split("-")[1]) + blocks = self.render_note(note) or ["(leer)"] + back = " ".join(f"[↩]({url_path(rel_link(self.file, f))}#{ref})" for f, ref in self.note_refs.get(note.label, [])) + body = "\n\n".join(blocks) + out.append(f'<a id="{note.label}"></a>**{num}.** ' + body + (f" {back}" if back else "")) + return "\n\n".join(out).strip() + "\n" + + # ------------------------------------------------------------------ images + + def image(self, el: Any, src: str | None = None) -> list[Piece]: + src = src if src is not None else (el.get("src") or "") + alt = el.get("alt") or "" + title = el.get("title") or "" + if not src: + return [("t", md_escape(alt))] if alt else [] + if src.startswith("data:"): + self.diag.info("data-uri-image", "Eingebettetes data:-Bild nicht extrahiert; Alternativtext erhalten.", self._source(el)) + return [("t", md_escape(f"[Bild: {alt}]"))] + if self.resolver.is_external(src): + self.diag.warn("external-image", f"Externes Bild {src} nicht geladen (offline); Link erhalten.", self._source(el)) + return [("r", f"![{self._alt(alt)}]({self._url(src)})")] + path = resolve_href(self.doc, unquote(src.split("#")[0].split("?")[0])) + if path is None: + self.diag.warn("unsafe-image-path", f"Unsicherer Bildpfad ignoriert: {src}", self._source(el)) + return [("t", md_escape(alt))] + if not self._exists(path): + self.diag.warn("image-missing", f"Bild {src} nicht im EPUB gefunden.", self._source(el), self.file) + return [("t", md_escape(f"[Bild fehlt: {alt or src}]"))] + if self._is_formula_image(el, alt, path): + return self.formula_image(el, alt, path) + if not alt and self.figure_caption: + alt = self.figure_caption + return self._image_ref(path, alt, title, role="") + + def _exists(self, path: str) -> bool: + return path in self.zip_names + + def _image_ref(self, path: str, alt: str, title: str, role: str) -> list[Piece]: + if self.ch: + if path.lower().endswith(".svg"): + self.ch.stats.svgs += 1 + else: + self.ch.stats.images += 1 + if not self.opt.extract_media: + return [("r", f"*\\[Bild: {self._alt(alt)} – {md_escape(path)}\\]*")] + mf = self.media.register(path, role=role, hint=alt[:40] if self.media.strategy == "relative" and role != "formula" else "") + if self.in_table_html: + return [("r", f'<img src="{html.escape(url_path(rel_link(self.file, mf.output)))}" alt="{html.escape(alt)}"/>')] + 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 _alt(self, alt: str) -> str: + return WS.sub(" ", alt).replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]").strip() + + def _is_formula_image(self, el: Any, alt: str, path: str) -> bool: + if looks_like_tex(alt): + return True + for anc in _ancestors(el, 4): + if any(FORMULA_CLASS.search(c) for c in classes(anc)) or {"equation"} & set(epub_types(anc)): + return True + return False + + def _formula_display(self, el: Any, alt: str) -> bool: + for anc in _ancestors(el, 5): + cls = classes(anc) + if any(INLINE_FORMULA_CLASS.search(c) for c in cls): + return False + if any(FORMULA_CLASS.search(c) for c in cls) and local(anc.tag) in ("div", "p", "figure", "td"): + return True + return is_display_tex(alt) and not (el.getparent() is not None and norm_ws(text_content(el.getparent()))) + + def formula_image(self, el: Any, alt: str, path: str) -> list[Piece]: + display = self._formula_display(el, alt) + tex = strip_tex_delimiters(alt) if looks_like_tex(alt) else "" + source = self._source(el) + if tex and self.opt.math in ("auto", "tex"): + self.consumed_formula_images.add(path) + self.formulas.append({"source": source, "chapter": self.file, "origin": "image-alt-tex", + "representation": "tex", "display": str(display).lower()}) + return self._tex_pieces(tex, display) + if self.ch: + self.ch.stats.math_as_image += 1 + if display: + self.ch.stats.math_block += 1 + else: + self.ch.stats.math_inline += 1 + self.formulas.append({"source": source, "chapter": self.file, "origin": "image", + "representation": "image", "display": str(display).lower()}) + if not tex: + self.diag.warn("formula-image", f"Formel liegt nur als Bild vor ({path.rsplit('/', 1)[-1]}); als Bild referenziert, keine semantische Textextraktion.", source, self.file) + pieces = self._image_ref(path, alt, el.get("title") or "", role="formula") + if display: + return [("blk", join_inline(pieces))] + return pieces + + def _tex_pieces(self, tex: str, display: bool) -> list[Piece]: + tex = tex.strip() + if self.ch: + if display: + self.ch.stats.math_block += 1 + else: + self.ch.stats.math_inline += 1 + if self.in_table and display: + display = False + if display: + body = "\n".join(line for line in tex.split("\n") if line.strip()) + if self.opt.math == "tex": + return [("blk", f"\\[\n{body}\n\\]")] + return [("blk", f"$$\n{body}\n$$")] + flat = WS.sub(" ", tex) + if self.opt.math == "tex": + return [("r", f"\\({flat}\\)")] + return [("r", f"${flat}$")] + + # ------------------------------------------------------------------ svg / math + + def svg(self, el: Any) -> list[Piece]: + images = [e for e in iter_elements(el) if local(e.tag) == "image"] + drawing = [e for e in iter_elements(el) if local(e.tag) in ("path", "text", "g", "rect", "circle", "line", "polygon", "polyline", "ellipse", "use")] + if len(images) == 1 and not drawing: + href = images[0].get(f"{{{XLINK_NS}}}href") or images[0].get("href") or "" + return self.image(images[0], src=href) + data = etree.tostring(el, encoding="utf-8", with_tail=False) + if b"xmlns=" not in data[:300]: + data = data.replace(b"<svg", f'<svg xmlns="{SVG_NS}"'.encode(), 1) + is_formula = any(FORMULA_CLASS.search(c) for a in [el] + _ancestors(el, 3) for c in classes(a)) + title = next((norm_ws(text_content(e)) for e in iter_elements(el) if local(e.tag) in ("title", "desc")), "") + alt = el.get("aria-label") or title or ("Formel" if is_formula else "SVG-Grafik") + if self.ch: + self.ch.stats.svgs += 1 + if not self.opt.extract_media: + return [("t", md_escape(f"[SVG: {alt}]"))] + name = f"{self.doc.rsplit('/', 1)[-1].rsplit('.', 1)[0]}-svg" + mf = self.media.register_generated(name, ".svg", data, "image/svg+xml", role="formula" if is_formula else "inline-svg") + if is_formula: + self.formulas.append({"source": self._source(el), "chapter": self.file, "origin": "svg", + "representation": "svg", "display": "false"}) + self.diag.warn("formula-svg", "Formel liegt nur als SVG vor; als Bild referenziert.", self._source(el), self.file) + return [("r", f"![{self._alt(alt)}]({url_path(rel_link(self.file, mf.output))})")] + + def math(self, el: Any, display: bool) -> list[Piece]: + source = self._source(el) + mode = self.opt.math + if mode in ("auto", "tex"): + try: + tex = mathml_to_tex(el) + self.formulas.append({"source": source, "chapter": self.file, "origin": "mathml", + "representation": "tex", "display": str(display).lower()}) + return self._tex_pieces(tex, display) + except Unsupported as exc: + self.diag.warn("mathml-kept", f"MathML ({exc}) nicht sicher nach TeX konvertierbar; MathML erhalten.", source, self.file) + if mode == "image": + altimg = el.get("altimg") + if altimg: + path = resolve_href(self.doc, altimg) + if path and self._exists(path): + alt = el.get("alttext") or "Formel" + if self.ch: + self.ch.stats.math_as_image += 1 + self.formulas.append({"source": source, "chapter": self.file, "origin": "mathml-altimg", + "representation": "image", "display": str(display).lower()}) + pieces = self._image_ref(path, alt, "", role="formula") + return [("blk", join_inline(pieces))] if display else pieces + self.diag.warn("mathml-no-image", "Formel ohne Bildfassung; als MathML erhalten.", source, self.file) + raw = serialize_mathml(el) + if self.ch: + self.ch.stats.math_as_mathml += 1 + if display: + self.ch.stats.math_block += 1 + else: + self.ch.stats.math_inline += 1 + self.formulas.append({"source": source, "chapter": self.file, "origin": "mathml", + "representation": "mathml", "display": str(display).lower()}) + if display: + return [("blk", raw)] + return [("r", raw)] + + # ------------------------------------------------------------------ HTML passthrough + + def clean_html(self, el: Any, keep_root: bool = False) -> str: + """Sanitized HTML; ``keep_root`` keeps an unknown root tag (``--keep-html``).""" + saved = self.in_table_html + self.in_table_html = True + try: + out = self._html(el, in_pre=False, keep=keep_root) + finally: + self.in_table_html = saved + out = re.sub(r"\n\s*\n", "\n", out) + return out.strip() + + def _html(self, el: Any, in_pre: bool, keep: bool = False) -> str: + name = local(el.tag) + if name == "math": + if self.opt.math in ("auto", "tex"): + try: + tex = mathml_to_tex(el) + return html.escape(f"\\({tex}\\)" if self.opt.math == "tex" else f"${tex}$", quote=False) + except Unsupported: + pass + return serialize_mathml(el) + if name in ("img", "svg", "image"): + if name == "img" and self._is_formula_image(el, el.get("alt") or "", ""): + alt = el.get("alt") or "" + if looks_like_tex(alt) and self.opt.math in ("auto", "tex"): + path = resolve_href(self.doc, el.get("src") or "") + if path: + self.consumed_formula_images.add(path) + tex = strip_tex_delimiters(alt) + return html.escape(f"\\({tex}\\)" if self.opt.math == "tex" else f"${tex}$", quote=False) + pieces = self.inline(el) + return join_inline(pieces) + if name == "a" and self.notes.extracted and self.notes.note_for_ref(el) is not None: + return join_inline(self.link(el)) + text_fn = (lambda t: html.escape(t, quote=False).replace("\n", " ")) if in_pre else (lambda t: html.escape(WS.sub(" ", t), quote=False)) + inner = [] + if el.text: + inner.append(text_fn(el.text)) + for child in el: + if is_element(child): + inner.append(self._html(child, in_pre or name == "pre")) + if child.tail: + inner.append(text_fn(child.tail)) + content = "".join(inner) + if keep and re.fullmatch(r"[a-z][a-z0-9-]*", name): + safe = [f'{k.rsplit("}", 1)[-1]}="{html.escape(v)}"' for k, v in el.attrib.items() + if re.fullmatch(r"[A-Za-z_:][-A-Za-z0-9_:.]*", k.rsplit("}", 1)[-1]) + and not k.rsplit("}", 1)[-1].lower().startswith("on") and k.rsplit("}", 1)[-1] != "style" + and not v.strip().lower().startswith("javascript:")] + return f"<{name}{(' ' + ' '.join(safe)) if safe else ''}>{content}</{name}>" + if name not in HTML_KEEP_TAGS or name == "span": + anchor = self.anchors_of(el) + return anchor + content + attrs = [] + for k, v in el.attrib.items(): + key = k.rsplit("}", 1)[-1] + if key in HTML_KEEP_ATTRS: + attrs.append(f'{key}="{html.escape(v)}"') + el_anchor = self.resolver.anchor_for(self.doc, el) + if el_anchor: + attrs.append(f'id="{el_anchor}"') + if name == "a": + href = el.get("href") or "" + url = None + if href and self.resolver.is_external(href): + url = href + elif href: + url, status = self.resolver.resolve(self.file, self.doc, href) + if url is None and status in ("missing", "unsafe"): + self.diag.unresolved(self.doc, href, self.file) + if url: + attrs.append(f'href="{html.escape(url)}"') + elif not el_anchor: + return content + attr_str = (" " + " ".join(attrs)) if attrs else "" + if name in ("br", "hr", "col"): + return f"<{name}{attr_str}/>" + sep = "\n" if name in ("table", "thead", "tbody", "tfoot", "tr") else "" + return f"<{name}{attr_str}>{sep}{content}</{name}>{sep}" + diff --git a/epub2md/linkcheck.py b/epub2md/linkcheck.py new file mode 100644 index 0000000..78241ff --- /dev/null +++ b/epub2md/linkcheck.py @@ -0,0 +1,69 @@ +"""Validation of relative links and anchors in the generated Markdown files.""" + +from __future__ import annotations + +import posixpath +import re +import unicodedata +from urllib.parse import unquote + +FENCE = re.compile(r"^(`{3,}|~{3,}).*?$.*?^\1\s*$", re.M | re.S) +# inline code spans never cross a blank line and never start at an escaped backtick +INLINE_CODE = re.compile(r"(?<![\\`])(`+)(?!`)((?:[^`\n]|\n(?![ \t]*\n)|`(?!\1))+?)(?<!`)\1(?!`)") +MD_LINK = re.compile(r"!?\[(?:[^\]\\]|\\.)*\]\((<[^>]*>|[^)\s]+)(?:\s+\"[^\"]*\")?\)") +HTML_ATTR = re.compile(r"""\b(?:href|src)="([^"]*)\"""") +ANCHOR = re.compile(r"""\bid="([^"]+)\"""") +HEADING = re.compile(r"^#{1,6}\s+(.*?)\s*#*\s*$", re.M) + + +def strip_code(text: str) -> str: + text = FENCE.sub("", text) + return INLINE_CODE.sub("", text) + + +def gfm_slug(heading: str) -> str: + text = re.sub(r"<[^>]+>", "", heading) + text = re.sub(r"\\(.)", r"\1", text) + text = unicodedata.normalize("NFC", text).lower() + text = re.sub(r"[^\w\- ]", "", text) + return text.replace(" ", "-") + + +def anchors_in(text: str) -> set[str]: + body = strip_code(text) + names = set(ANCHOR.findall(body)) + names.update(gfm_slug(h) for h in HEADING.findall(body)) + return names + + +def extract_links(text: str) -> list[str]: + body = strip_code(text) + links = [m.group(1) for m in MD_LINK.finditer(body)] + links += HTML_ATTR.findall(body) + return [link[1:-1] if link.startswith("<") and link.endswith(">") else link for link in links] + + +def check_links(files: dict[str, str], existing: set[str]) -> list[dict[str, str]]: + """``files``: output-relative path -> Markdown text; ``existing``: other output files.""" + anchors = {path: anchors_in(text) for path, text in files.items()} + broken: list[dict[str, str]] = [] + for path, text in files.items(): + for link in extract_links(text): + if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", link): + continue # external URL / mailto: + target, _, frag = link.partition("#") + target = unquote(target) + frag = unquote(frag) + if target: + resolved = posixpath.normpath(posixpath.join(posixpath.dirname(path), target)) + if resolved.startswith("../"): + broken.append({"file": path, "link": link, "reason": "verlässt das Ausgabeverzeichnis"}) + continue + else: + resolved = path + if resolved not in files and resolved not in existing: + broken.append({"file": path, "link": link, "reason": "Ziel existiert nicht"}) + continue + if frag and resolved in anchors and frag not in anchors[resolved]: + broken.append({"file": path, "link": link, "reason": "Anker existiert nicht"}) + return broken diff --git a/epub2md/mathml.py b/epub2md/mathml.py new file mode 100644 index 0000000..4687944 --- /dev/null +++ b/epub2md/mathml.py @@ -0,0 +1,373 @@ +"""Conservative MathML -> TeX conversion and TeX detection helpers. + +The converter only handles constructs it can translate unambiguously. Anything +else raises :class:`Unsupported`; callers must then keep the original MathML +instead of emitting possibly wrong TeX. +""" + +from __future__ import annotations + +import html +import re +from typing import Any + +from .utils import is_element, local, norm_ws, text_content + + +class Unsupported(Exception): + pass + + +GREEK = { + "α": r"\alpha", "β": r"\beta", "γ": r"\gamma", "δ": r"\delta", "ε": r"\varepsilon", "ϵ": r"\epsilon", + "ζ": r"\zeta", "η": r"\eta", "θ": r"\theta", "ϑ": r"\vartheta", "ι": r"\iota", "κ": r"\kappa", + "λ": r"\lambda", "μ": r"\mu", "ν": r"\nu", "ξ": r"\xi", "π": r"\pi", "ϖ": r"\varpi", "ρ": r"\rho", + "ϱ": r"\varrho", "σ": r"\sigma", "ς": r"\varsigma", "τ": r"\tau", "υ": r"\upsilon", "φ": r"\varphi", + "ϕ": r"\phi", "χ": r"\chi", "ψ": r"\psi", "ω": r"\omega", "Γ": r"\Gamma", "Δ": r"\Delta", + "Θ": r"\Theta", "Λ": r"\Lambda", "Ξ": r"\Xi", "Π": r"\Pi", "Σ": r"\Sigma", "Υ": r"\Upsilon", + "Φ": r"\Phi", "Ψ": r"\Psi", "Ω": r"\Omega", +} +OPERATORS = { + "∑": r"\sum", "∏": r"\prod", "∫": r"\int", "∬": r"\iint", "∮": r"\oint", "⋃": r"\bigcup", "⋂": r"\bigcap", + "≤": r"\leq", "≥": r"\geq", "≠": r"\neq", "≈": r"\approx", "≡": r"\equiv", "∼": r"\sim", "≅": r"\cong", + "∝": r"\propto", "×": r"\times", "⋅": r"\cdot", "·": r"\cdot", "∗": r"\ast", "÷": r"\div", "±": r"\pm", + "∓": r"\mp", "∘": r"\circ", "⊗": r"\otimes", "⊕": r"\oplus", "→": r"\to", "←": r"\leftarrow", + "↔": r"\leftrightarrow", "⇒": r"\Rightarrow", "⇐": r"\Leftarrow", "⇔": r"\Leftrightarrow", + "↦": r"\mapsto", "∈": r"\in", "∉": r"\notin", "∋": r"\ni", "⊂": r"\subset", "⊆": r"\subseteq", + "⊃": r"\supset", "⊇": r"\supseteq", "∪": r"\cup", "∩": r"\cap", "∖": r"\setminus", "∅": r"\emptyset", + "∀": r"\forall", "∃": r"\exists", "¬": r"\neg", "∧": r"\wedge", "∨": r"\vee", "∞": r"\infty", + "∂": r"\partial", "∇": r"\nabla", "√": r"\surd", "…": r"\ldots", "⋯": r"\cdots", "⋮": r"\vdots", + "⋱": r"\ddots", "′": "'", "″": "''", "−": "-", "∣": r"\mid", "∥": r"\parallel", "⊥": r"\perp", + "⟨": r"\langle", "⟩": r"\rangle", "〈": r"\langle", "〉": r"\rangle", "⌊": r"\lfloor", "⌋": r"\rfloor", + "⌈": r"\lceil", "⌉": r"\rceil", "ℓ": r"\ell", "ℏ": r"\hbar", "ℵ": r"\aleph", "°": r"^\circ", + "{": r"\{", "}": r"\}", "|": "|", "‖": r"\|", "\u2061": "", "\u2062": "", "\u2063": ",", "\u2064": "+", +} +FUNCTIONS = {"sin", "cos", "tan", "cot", "sec", "csc", "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", + "log", "ln", "lg", "exp", "lim", "liminf", "limsup", "max", "min", "sup", "inf", "det", "dim", + "ker", "deg", "gcd", "arg", "Pr", "hom"} +LARGE_OPS = {r"\sum", r"\prod", r"\int", r"\iint", r"\oint", r"\bigcup", r"\bigcap", r"\lim", r"\max", r"\min", + r"\sup", r"\inf", r"\limsup", r"\liminf"} +ACCENTS = {"^": r"\hat", "ˆ": r"\hat", "¯": r"\bar", "‾": r"\overline", "_": r"\overline", "→": r"\vec", + "⃗": r"\vec", "~": r"\tilde", "˜": r"\tilde", "˙": r"\dot", "¨": r"\ddot", "⏞": r"\overbrace", + "︷": r"\overbrace"} +UNDER_ACCENTS = {"_": r"\underline", "‾": r"\underline", "¯": r"\underline", "⏟": r"\underbrace", "︸": r"\underbrace"} +VARIANTS = {"bold": r"\mathbf", "double-struck": r"\mathbb", "script": r"\mathcal", "fraktur": r"\mathfrak", + "normal": r"\mathrm", "sans-serif": r"\mathsf", "monospace": r"\mathtt", "bold-italic": r"\boldsymbol"} +TEX_SPECIAL = {"#": r"\#", "$": r"\$", "%": r"\%", "&": r"\&", "_": r"\_", "~": r"\sim", "^": r"\hat{}", "\\": r"\backslash"} +OPEN_FENCES = {"(": "(", "[": "[", "{": r"\{", "|": "|", "‖": r"\|", "⟨": r"\langle", "⌊": r"\lfloor", "⌈": r"\lceil"} +CLOSE_FENCES = {")": ")", "]": "]", "}": r"\}", "|": "|", "‖": r"\|", "⟩": r"\rangle", "⌋": r"\rfloor", "⌉": r"\rceil"} + + +def _kids(el: Any) -> list[Any]: + return [c for c in el if is_element(c)] + + +def _txt(el: Any) -> str: + return norm_ws(text_content(el)) + + +def _escape_symbol(ch: str) -> str: + if ch in GREEK: + return GREEK[ch] + if ch in OPERATORS: + return OPERATORS[ch] + if ch in TEX_SPECIAL: + return TEX_SPECIAL[ch] + return ch + + +def _symbols(text: str) -> str: + out = [] + for ch in text: + sym = _escape_symbol(ch) + if sym.startswith("\\") and sym[-1].isalpha(): + sym += " " + out.append(sym) + return "".join(out).strip() + + +def _group(tex: str) -> str: + tex = tex.strip() + if len(tex) == 1 or re.fullmatch(r"\\[A-Za-z]+", tex) or re.fullmatch(r"\d", tex): + return tex + return "{" + tex + "}" + + +def _text_escape(text: str) -> str: + return "".join({"\\": r"\textbackslash{}", "{": r"\{", "}": r"\}", "$": r"\$", "&": r"\&", + "#": r"\#", "%": r"\%", "_": r"\_", "^": r"\^{}", "~": r"\~{}"}.get(c, c) for c in text) + + +def tex_annotation(math: Any) -> str | None: + for el in math.iter(): + if is_element(el) and local(el.tag) == "annotation": + enc = (el.get("encoding") or "").lower() + if enc in ("application/x-tex", "tex", "latex", "application/x-latex", "text/x-tex"): + value = (el.text or "").strip() + if value: + return value + alttext = math.get("alttext") + if alttext and looks_like_tex(alttext): + return strip_tex_delimiters(alttext) + return None + + +def mathml_to_tex(math: Any) -> str: + """Convert a <math> element. Raises Unsupported for constructs we do not handle.""" + annotated = tex_annotation(math) + if annotated: + return annotated + tex = _row(_kids(math)) + tex = re.sub(r"[ ]{2,}", " ", tex).strip() + if not tex: + raise Unsupported("empty") + return tex + + +def _row(children: list[Any]) -> str: + # \left ... \right around tall content + if len(children) >= 3 and local(children[0].tag) == "mo" and local(children[-1].tag) == "mo": + o, c = _txt(children[0]), _txt(children[-1]) + if o in OPEN_FENCES and c in CLOSE_FENCES and any(_is_tall(k) for k in children[1:-1]): + inner = " ".join(_node(k) for k in children[1:-1]) + return rf"\left{OPEN_FENCES[o]} {inner} \right{CLOSE_FENCES[c]}" + return " ".join(_node(k) for k in children) + + +def _is_tall(el: Any) -> bool: + return any(is_element(e) and local(e.tag) in ("mfrac", "mtable", "munderover", "msqrt", "mroot") for e in el.iter()) + + +def _node(el: Any) -> str: + name = local(el.tag) + handler = _HANDLERS.get(name) + if handler is None: + raise Unsupported(name) + return handler(el) + + +def _mi(el: Any) -> str: + text = _txt(el) + if not text: + return "" + variant = el.get("mathvariant", "") + if len(text) > 1: + if text in FUNCTIONS: + return "\\" + text + " " + base = _text_escape(text) + cmd = VARIANTS.get(variant, "\\mathrm") if variant not in ("", "normal", "italic") else "\\mathrm" + return f"{cmd}{{{base}}}" + sym = _symbols(text) + if variant and variant != "italic": + if variant not in VARIANTS: + raise Unsupported(f"mathvariant={variant}") + return f"{VARIANTS[variant]}{{{sym}}}" + return sym + + +def _mn(el: Any) -> str: + return _symbols(_txt(el)) + + +def _mo(el: Any) -> str: + text = _txt(el) + if text in FUNCTIONS: + return "\\" + text + " " + return _symbols(text) + + +def _mtext(el: Any) -> str: + text = text_content(el) + if not text.strip(): + return r"\ " if text else "" + return rf"\text{{{_text_escape(norm_ws(text))}}}" + + +def _mspace(el: Any) -> str: + return r"\," + + +def _mrow(el: Any) -> str: + return _row(_kids(el)) + + +def _first_child(el: Any) -> str: + kids = _kids(el) + return _node(kids[0]) if kids else "" + + +def _mphantom(el: Any) -> str: + return rf"\phantom{{{_row(_kids(el))}}}" + + +def _mfrac(el: Any) -> str: + kids = _kids(el) + if len(kids) != 2: + raise Unsupported("mfrac arity") + lt = (el.get("linethickness") or "").strip() + if lt in ("0", "0px", "0em", "0pt"): + return rf"\genfrac{{}}{{}}{{0pt}}{{}}{{{_node(kids[0])}}}{{{_node(kids[1])}}}" + if el.get("bevelled") == "true": + return f"{_group(_node(kids[0]))}/{_group(_node(kids[1]))}" + return rf"\frac{{{_node(kids[0])}}}{{{_node(kids[1])}}}" + + +def _msqrt(el: Any) -> str: + return rf"\sqrt{{{_row(_kids(el))}}}" + + +def _mroot(el: Any) -> str: + kids = _kids(el) + if len(kids) != 2: + raise Unsupported("mroot arity") + return rf"\sqrt[{_node(kids[1])}]{{{_node(kids[0])}}}" + + +def _base(el: Any) -> str: + tex = _node(el) + if local(el.tag) in ("mi", "mn", "mo") and len(_txt(el)) == 1: + return tex.strip() + if re.fullmatch(r"\\[A-Za-z]+\s*", tex): + return tex.strip() + return "{" + tex + "}" + + +def _msup(el: Any) -> str: + kids = _kids(el) + if len(kids) != 2: + raise Unsupported("msup arity") + return f"{_base(kids[0])}^{{{_node(kids[1])}}}" + + +def _msub(el: Any) -> str: + kids = _kids(el) + if len(kids) != 2: + raise Unsupported("msub arity") + return f"{_base(kids[0])}_{{{_node(kids[1])}}}" + + +def _msubsup(el: Any) -> str: + kids = _kids(el) + if len(kids) != 3: + raise Unsupported("msubsup arity") + return f"{_base(kids[0])}_{{{_node(kids[1])}}}^{{{_node(kids[2])}}}" + + +def _mover(el: Any) -> str: + kids = _kids(el) + if len(kids) != 2: + raise Unsupported("mover arity") + base, over = kids + over_txt = _txt(over) + if local(over.tag) == "mo" and over_txt in ACCENTS: + return rf"{ACCENTS[over_txt]}{{{_node(base)}}}" + base_tex = _node(base).strip() + if base_tex in LARGE_OPS: + return f"{base_tex}^{{{_node(over)}}}" + return rf"\overset{{{_node(over)}}}{{{base_tex}}}" + + +def _munder(el: Any) -> str: + kids = _kids(el) + if len(kids) != 2: + raise Unsupported("munder arity") + base, under = kids + under_txt = _txt(under) + if local(under.tag) == "mo" and under_txt in UNDER_ACCENTS: + return rf"{UNDER_ACCENTS[under_txt]}{{{_node(base)}}}" + base_tex = _node(base).strip() + if base_tex in LARGE_OPS: + return f"{base_tex}_{{{_node(under)}}}" + return rf"\underset{{{_node(under)}}}{{{base_tex}}}" + + +def _munderover(el: Any) -> str: + kids = _kids(el) + if len(kids) != 3: + raise Unsupported("munderover arity") + base_tex = _node(kids[0]).strip() + if base_tex in LARGE_OPS: + return f"{base_tex}_{{{_node(kids[1])}}}^{{{_node(kids[2])}}}" + return rf"\overset{{{_node(kids[2])}}}{{\underset{{{_node(kids[1])}}}{{{base_tex}}}}}" + + +def _mfenced(el: Any) -> str: + o = el.get("open", "(") + c = el.get("close", ")") + seps = (el.get("separators", ",") or "").replace(" ", "") + kids = _kids(el) + parts = [] + for i, k in enumerate(kids): + parts.append(_node(k)) + if i < len(kids) - 1 and seps: + parts.append(_symbols(seps[min(i, len(seps) - 1)])) + lo = OPEN_FENCES.get(o, "." if o == "" else None) + rc = CLOSE_FENCES.get(c, "." if c == "" else None) + if lo is None or rc is None: + raise Unsupported(f"mfenced {o}{c}") + return rf"\left{lo} {' '.join(parts)} \right{rc}" + + +def _mtable(el: Any) -> str: + rows = [] + for tr in _kids(el): + if local(tr.tag) == "mlabeledtr": + raise Unsupported("mlabeledtr") + if local(tr.tag) != "mtr": + raise Unsupported(local(tr.tag)) + cells = [] + for td in _kids(tr): + if local(td.tag) != "mtd": + raise Unsupported(local(td.tag)) + cells.append(_row(_kids(td))) + rows.append(" & ".join(cells)) + return r"\begin{matrix} " + r" \\ ".join(rows) + r" \end{matrix}" + + +def _semantics(el: Any) -> str: + kids = [k for k in _kids(el) if local(k.tag) not in ("annotation", "annotation-xml")] + if not kids: + raise Unsupported("semantics") + return _node(kids[0]) + + +def _mstyle(el: Any) -> str: + return _row(_kids(el)) + + +_HANDLERS = { + "mi": _mi, "mn": _mn, "mo": _mo, "mtext": _mtext, "ms": _mtext, "mspace": _mspace, + "mrow": _mrow, "mstyle": _mstyle, "mpadded": _mrow, "merror": _mrow, "mphantom": _mphantom, + "mfrac": _mfrac, "msqrt": _msqrt, "mroot": _mroot, "msup": _msup, "msub": _msub, + "msubsup": _msubsup, "mover": _mover, "munder": _munder, "munderover": _munderover, + "mfenced": _mfenced, "mtable": _mtable, "semantics": _semantics, "maction": _first_child, + "none": lambda el: "{}", +} + + +# --------------------------------------------------------------------------- TeX in text/attributes + +TEX_HINT = re.compile(r"\\[A-Za-z]+|[\^_]\{|\\[\[\]()]|^\$.*\$$", re.S) + + +def looks_like_tex(text: str) -> bool: + t = (text or "").strip() + if not t: + return False + if (t.startswith("$") and t.endswith("$") and len(t) > 2) or t.startswith(("\\(", "\\[")): + return True + return bool(re.search(r"\\(frac|sqrt|sum|int|alpha|beta|mathbb|mathrm|begin|left|right|cdot|in|leq|geq)\b", t)) + + +def strip_tex_delimiters(text: str) -> str: + t = html.unescape(html.unescape((text or "").strip())) # some EPUBs double-escape '&' in alt texts + for o, c in (("$$", "$$"), ("\\[", "\\]"), ("\\(", "\\)"), ("$", "$")): + if t.startswith(o) and t.endswith(c) and len(t) >= len(o) + len(c): + t = t[len(o) : len(t) - len(c)] + break + return t.strip() + + +def is_display_tex(text: str) -> bool: + t = (text or "").strip() + return t.startswith(("$$", "\\[")) or "\\displaystyle" in t[:40] diff --git a/epub2md/media.py b/epub2md/media.py new file mode 100644 index 0000000..c999998 --- /dev/null +++ b/epub2md/media.py @@ -0,0 +1,98 @@ +"""Media registry: stable, collision-free output names; each source extracted once.""" + +from __future__ import annotations + +import mimetypes +import posixpath + +from .models import MediaFile, Package +from .utils import safe_zip_path, slugify + +KIND_DIRS = {"images": "images", "audio": "audio", "video": "video", "other": "other"} + + +def media_kind(media_type: str, path: str) -> str: + mt = media_type or (mimetypes.guess_type(path)[0] or "") + if mt.startswith("image/"): + return "images" + if mt.startswith("audio/"): + return "audio" + if mt.startswith("video/"): + return "video" + return "other" + + +class MediaRegistry: + def __init__(self, package: Package, strategy: str, slug_language: str) -> None: + self.package = package + self.strategy = strategy # relative | flat | original-path + self.slug_language = slug_language + self.files: dict[str, MediaFile] = {} + self._used: set[str] = set() + self._counter = 0 + self.opf_dir = posixpath.dirname(package.opf_path) + + def _media_type(self, path: str) -> str: + item = self.package.item_by_path(path) + if item and item.media_type: + return item.media_type + return mimetypes.guess_type(path)[0] or "application/octet-stream" + + def _unique(self, candidate: str) -> str: + if candidate not in self._used: + self._used.add(candidate) + return candidate + stem, ext = posixpath.splitext(candidate) + n = 2 + while f"{stem}-{n}{ext}" in self._used: + n += 1 + result = f"{stem}-{n}{ext}" + self._used.add(result) + return result + + def _output_name(self, source: str, kind: str, hint: str = "") -> str: + base = posixpath.basename(source) + stem, ext = posixpath.splitext(base) + ext = ext.lower() + if self.strategy == "original-path": + rel = source[len(self.opf_dir) + 1 :] if self.opf_dir and source.startswith(self.opf_dir + "/") else source + rel = safe_zip_path(rel) or base + return self._unique(f"media/{rel}") + if self.strategy == "flat": + return self._unique(f"media/{base}") + self._counter += 1 + slug = slugify(hint or stem, self.slug_language, max_len=40, fallback="media") + return self._unique(f"media/{KIND_DIRS[kind]}/{self._counter:04d}-{slug}{ext}") + + def register(self, source: str, role: str = "", hint: str = "") -> MediaFile: + existing = self.files.get(source) + if existing: + existing.references += 1 + if role and not existing.role: + existing.role = role + return existing + media_type = self._media_type(source) + kind = media_kind(media_type, source) + mf = MediaFile( + source=source, + output=self._output_name(source, kind, hint), + media_type=media_type, + kind=kind, + references=1, + role=role, + ) + self.files[source] = mf + return mf + + def register_generated(self, name: str, ext: str, data: bytes, media_type: str, role: str) -> MediaFile: + source = f"generated:{name}{ext}" + kind = media_kind(media_type, name + ext) + if self.strategy == "relative": + self._counter += 1 + out = self._unique(f"media/{KIND_DIRS[kind]}/{self._counter:04d}-{slugify(name, self.slug_language, 40, 'media')}{ext}") + else: + out = self._unique(f"media/generated/{slugify(name, self.slug_language, 40, 'media')}{ext}") + mf = MediaFile(source=source, output=out, media_type=media_type, kind=kind, size=len(data), + references=1, role=role, data=data) + self.files[source] = mf + return mf diff --git a/epub2md/models.py b/epub2md/models.py new file mode 100644 index 0000000..fdfb83d --- /dev/null +++ b/epub2md/models.py @@ -0,0 +1,169 @@ +"""Data models shared between the conversion stages.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ManifestItem: + id: str + path: str # normalized path inside the ZIP archive + media_type: str + properties: list[str] = field(default_factory=list) + + +@dataclass +class SpineItem: + idref: str + path: str + linear: bool = True + properties: list[str] = field(default_factory=list) + + +@dataclass +class NavEntry: + label: str + path: str # ZIP path of the target document ("" if unresolvable) + fragment: str + depth: int + children: list[NavEntry] = field(default_factory=list) + + +@dataclass +class Landmark: + type: str + label: str + path: str + fragment: str + + +@dataclass +class BookMetadata: + title: str = "" + subtitle: str = "" + authors: list[str] = field(default_factory=list) + contributors: list[str] = field(default_factory=list) + language: str = "" + identifier: str = "" + isbn: str = "" + publisher: str = "" + published: str = "" + description: str = "" + rights: str = "" + subjects: list[str] = field(default_factory=list) + cover_path: str = "" # ZIP path of the cover image + + +@dataclass +class Package: + opf_path: str + version: str + metadata: BookMetadata + manifest: dict[str, ManifestItem] + spine: list[SpineItem] + toc: list[NavEntry] + landmarks: list[Landmark] + nav_path: str = "" + ncx_path: str = "" + + def item_by_path(self, path: str) -> ManifestItem | None: + for item in self.manifest.values(): + if item.path == path: + return item + return None + + +@dataclass +class Diagnostic: + level: str # "info" | "warning" | "error" + code: str + message: str + source: str = "" + chapter: str = "" + + def as_dict(self) -> dict[str, Any]: + return { + "level": self.level, + "code": self.code, + "message": self.message, + "source": self.source, + "chapter": self.chapter, + } + + +@dataclass +class MediaFile: + source: str # ZIP path, or "generated:<name>" for synthesized files + output: str # path relative to the output directory (posix) + media_type: str + kind: str # images | audio | video | other + size: int = 0 + references: int = 0 + role: str = "" # e.g. "cover", "formula", "inline-svg" + data: bytes | None = None # only for generated files + + def as_dict(self) -> dict[str, Any]: + return { + "source": self.source, + "file": self.output, + "media_type": self.media_type, + "kind": self.kind, + "size": self.size, + "references": self.references, + "role": self.role, + } + + +@dataclass +class ChapterStats: + word_count: int = 0 + code_blocks: int = 0 + code_blocks_unlabeled: int = 0 + images: int = 0 + svgs: int = 0 + math_inline: int = 0 + math_block: int = 0 + math_as_image: int = 0 + math_as_mathml: int = 0 + footnotes: int = 0 + tables: int = 0 + tables_html: int = 0 + + def as_dict(self) -> dict[str, int]: + return dict(self.__dict__) + + +@dataclass +class Chapter: + number: int + title: str + items: list[Any] # list[tuple[str, item]] – (doc_path, str | Element | marker) + docs: list[str] + types: list[str] = field(default_factory=list) + title_el: Any = None + title_level: int = 0 + synthetic_title: bool = False + filename: str = "" # relative to output dir, e.g. chapters/001-x.md + anchors: list[str] = field(default_factory=list) + stats: ChapterStats = field(default_factory=ChapterStats) + markdown: str = "" + warnings: int = 0 + + @property + def shift(self) -> int: + return max(0, self.title_level - 1) + + +@dataclass +class Note: + key: str # internal key (doc#id of the note element) + element: Any + doc: str + ids: set[str] + kind: str # footnote | endnote | note + ref_count: int = 0 + label: str = "" # assigned on first rendered reference, e.g. fn-001 + first_chapter: int = -1 + ref_anchor_ids: set[str] = field(default_factory=set) diff --git a/epub2md/normalize.py b/epub2md/normalize.py new file mode 100644 index 0000000..886c9b0 --- /dev/null +++ b/epub2md/normalize.py @@ -0,0 +1,186 @@ +"""Document loading and conservative normalization of publisher-specific markup.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + +from lxml import etree + +from .diagnostics import Diagnostics +from .utils import classes, epub_types, is_element, iter_elements, local, norm_ws, roles, text_content + +HEAD_CLASS_RE = re.compile(r"^(?:head|heading|h|ueberschrift|uberschrift)[_-]?(\d)$") +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"} + + +@dataclass +class Doc: + path: str + root: Any + body: Any + title: str + linear: bool = True + types: list[str] = field(default_factory=list) + skipped: str = "" # reason, e.g. "html-toc" + toc_ids: set[str] = field(default_factory=set) + pagebreaks: int = 0 + + +def has_content(el: Any) -> bool: + """True if ``el`` contains visible text or media (anchors alone do not count).""" + if not is_element(el): + return bool(str(el).strip()) + if text_content(el).strip(): + return True + return any(local(e.tag) in MEDIA_TAGS for e in iter_elements(el)) + + +def remove_keep_tail(el: Any) -> None: + """Remove ``el`` from its parent but keep its tail text in the flow.""" + parent = el.getparent() + if parent is None: + return + tail = el.tail + if tail: + prev = el.getprevious() + if prev is not None: + prev.tail = (prev.tail or "") + tail + else: + parent.text = (parent.text or "") + tail + parent.remove(el) + + +def _make(tag_like: Any, name: str) -> Any: + ns = tag_like.tag[1:].split("}")[0] if tag_like.tag.startswith("{") else None + return etree.Element(f"{{{ns}}}{name}" if ns else name) + + +def make_doc(path: str, root: Any, linear: bool) -> Doc: + body = next((e for e in root.iter() if is_element(e) and local(e.tag) == "body"), None) + if body is None: + body = root + head_title = next((e for e in root.iter() if is_element(e) and local(e.tag) == "title"), None) + title = norm_ws(text_content(head_title)) if head_title is not None else "" + types = epub_types(body) + [r.removeprefix("doc-") for r in roles(body)] + return Doc(path=path, root=root, body=body, title=title, linear=linear, types=types) + + +def book_uses_title_tables(docs: list[Doc]) -> bool: + for doc in docs: + for el in iter_elements(doc.body): + if local(el.tag) in ("table", "td") and any(TITLE_CLASS_RE.match(c) for c in classes(el)): + return True + return False + + +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): + continue # already removed or replaced together with an ancestor + name = local(el.tag) + if name in ("script", "style", "noscript", "template", "link", "meta"): + remove_keep_tail(el) + continue + types = epub_types(el) + if "pagebreak" in types or "doc-pagebreak" in roles(el): + _replace_with_anchor(el) + doc.pagebreaks += 1 + continue + 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])): + if not any(is_element(c) and local(c.tag) in ("p", "div", "table", "ul", "ol", "pre") for c in el): + level = int(m.group(1)) + (1 if title_tables else 0) + ns_prefix = el.tag.rsplit("}", 1)[0] + "}" if el.tag.startswith("{") else "" + el.tag = f"{ns_prefix}h{max(1, min(6, level))}" + if el is not body and ("toc" in types or "doc-toc" in roles(el)) and name not in ("a", "body"): + 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) + + +def _attached(el: Any, body: Any) -> bool: + node = el + while node is not None: + if node is body: + return True + node = node.getparent() + return False + + +def _replace_with_anchor(el: Any) -> None: + ids = [e.get("id") for e in iter_elements(el) if e.get("id")] + if not ids: + remove_keep_tail(el) + return + anchor = _make(el, "a") + anchor.set("id", ids[0]) + anchor.tail = el.tail + el.getparent().replace(el, anchor) + + +def _maybe_heading_table(table: Any, title_tables: bool) -> None: + """Layout tables used as headings (e.g. <table class="Head1">) become <hN>.""" + rows = [e for e in iter_elements(table) if local(e.tag) == "tr"] + if len(rows) != 1: + return + if any(local(e.tag) in ("table", "img", "svg", "math", "pre", "ul", "ol") for e in iter_elements(table) if e is not table): + return + cls = classes(table) + cells = [e for e in rows[0] if is_element(e) and local(e.tag) in ("td", "th")] + cell_classes = [c for cell in cells for c in classes(cell)] + level = None + if any(TITLE_CLASS_RE.match(c) for c in cls + cell_classes): + level = 1 + else: + for c in cls: + m = HEAD_CLASS_RE.match(c) + if m: + level = int(m.group(1)) + (1 if title_tables else 0) + break + if level is None: + return + level = max(1, min(6, level)) + texts = [] + ids = [e.get("id") for e in iter_elements(table) if e.get("id")] + for cell in cells: + parts = [] + for node in cell.iter(): + if is_element(node) and local(node.tag) == "br": + parts.append(" ") + if is_element(node) and node.text: + parts.append(node.text) + if node is not cell and node.tail: + parts.append(node.tail) + t = norm_ws("".join(parts)) + if t: + texts.append(t) + heading = _make(table, f"h{level}") + for anchor_id in ids: + a = _make(table, "a") + a.set("id", anchor_id) + heading.append(a) + if len(heading): + heading[-1].tail = " ".join(texts) + else: + heading.text = " ".join(texts) + heading.tail = table.tail + table.getparent().replace(table, heading) + + +def is_html_toc_doc(doc: Doc, nav_path: str, landmark_toc_docs: set[str]) -> bool: + if doc.path == nav_path or doc.path in landmark_toc_docs or "toc" in doc.types: + return True + heading = next((e for e in iter_elements(doc.body) if local(e.tag) in ("h1", "h2", "h3")), None) + title = norm_ws(text_content(heading)) if heading is not None else doc.title + if not TOC_TITLE_RE.match(title or "") and not TOC_TITLE_RE.match(doc.title or ""): + return False + total = len(norm_ws(text_content(doc.body))) - (len(norm_ws(text_content(heading))) if heading is not None else 0) + links = [a for a in iter_elements(doc.body) if local(a.tag) == "a" and a.get("href") and "://" not in a.get("href")] + link_text = sum(len(norm_ws(text_content(a))) for a in links) + return len(links) >= 3 and total > 0 and link_text / total >= 0.6 diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py new file mode 100644 index 0000000..6b3e02b --- /dev/null +++ b/epub2md/pipeline.py @@ -0,0 +1,448 @@ +"""Orchestration of the complete EPUB -> Markdown conversion.""" + +from __future__ import annotations + +import os +import posixpath +import shutil +import sys +import tempfile +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from . import TOOL_NAME, __version__ +from .chapters import ChapterBuilder +from .diagnostics import Diagnostics +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, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail +from .references import LinkResolver +from .utils import is_inside, iter_elements, sha256_file, slugify +from . import writer + +MARKER_FILES = ("conversion-report.json", "manifest.json") + + +@dataclass +class Options: + format: str = "gfm" + extract_media: bool = True + copy_original: bool = False + chapter_level: int = 1 + toc: bool = True + frontmatter: bool = True + footnotes: str = "end-of-chapter" + math: str = "auto" + images: str = "relative" + slug_language: str = "de" + keep_html: bool = False + strict: bool = False + verbose: bool = False + dry_run: bool = False + force: bool = False + report: str | None = None + command: list[str] = field(default_factory=list) + + def public(self) -> dict[str, Any]: + data = asdict(self) + data.pop("command") + return data + + +@dataclass +class Result: + report: dict[str, Any] + chapters: list[Chapter] + files: dict[str, str] + output_dir: Path + + +def check_paths(input_path: Path, output_dir: Path, opts: Options) -> None: + if not input_path.is_file(): + raise InputError(f"Eingabedatei nicht gefunden oder keine Datei: {input_path}") + if not os.access(input_path, os.R_OK): + raise InputError(f"Eingabedatei nicht lesbar: {input_path}") + if not 1 <= opts.chapter_level <= 6: + raise InputError("--chapter-level muss zwischen 1 und 6 liegen.") + if opts.dry_run: + return + if output_dir.exists() and not output_dir.is_dir(): + raise InputError(f"Ausgabepfad existiert und ist kein Verzeichnis: {output_dir}") + if output_dir.exists() and any(output_dir.iterdir()): + if not opts.force: + raise InputError( + f"Ausgabeverzeichnis existiert bereits und ist nicht leer: {output_dir}\n" + "Mit --force wird es ersetzt (nur wenn es eine frühere epub2md-Ausgabe ist)." + ) + resolved = output_dir.resolve() + protected = {Path("/").resolve(), Path.home().resolve(), Path.cwd().resolve()} + if resolved in protected or is_inside(Path.cwd(), resolved): + raise InputError(f"--force verweigert: {output_dir} ist ein geschütztes Verzeichnis.") + if is_inside(input_path, output_dir): + raise InputError("--force verweigert: die Eingabedatei liegt im Ausgabeverzeichnis.") + if not any((output_dir / m).is_file() for m in MARKER_FILES): + raise InputError( + f"--force verweigert: {output_dir} enthält keine frühere epub2md-Ausgabe " + "(conversion-report.json/manifest.json fehlen). Bitte manuell prüfen." + ) + + +def convert(input_path: Path, output_dir: Path, opts: Options) -> Result: + diag = Diagnostics(verbose=opts.verbose, strict=opts.strict) + check_paths(input_path, output_dir, opts) + generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat() + sha = sha256_file(input_path) + diag.step(f"Öffne {input_path}") + reader = EpubReader(input_path, diag) + try: + return _convert(reader, input_path, output_dir, opts, diag, generated_at, sha) + finally: + reader.close() + + +def _load_docs(reader: EpubReader, package: Any, diag: Diagnostics) -> list[Doc]: + docs: list[Doc] = [] + for item in package.spine: + try: + root = parse_xml(reader.read(item.path), item.path, diag, html_fallback=True) + except StructureError as exc: + diag.error("xhtml-unreadable", str(exc), item.path, critical=True) + continue + except KeyError: + diag.error("spine-file-missing", f"Spine-Dokument fehlt: {item.path}", item.path, critical=True) + continue + docs.append(make_doc(item.path, root, item.linear)) + if not docs: + raise StructureError("Keines der Spine-Dokumente konnte gelesen werden.") + return docs + + +def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Options, diag: Diagnostics, + generated_at: str, sha: str) -> Result: + package = reader.load_package() + md = package.metadata + diag.step(f"EPUB {package.version}: {md.title!r}, {len(package.spine)} Spine-Dokumente") + docs = _load_docs(reader, package, diag) + + # --- normalization and HTML-TOC handling + title_tables = book_uses_title_tables(docs) + for doc in docs: + normalize_doc(doc, diag, title_tables) + landmark_toc_docs = {lm.path for lm in package.landmarks if lm.type == "toc" and lm.path and not lm.fragment} + for lm in package.landmarks: + if lm.type == "toc" and lm.fragment: + toc_doc = next((d for d in docs if d.path == lm.path), None) + el = next((e for e in iter_elements(toc_doc.body) if e.get("id") == lm.fragment), None) if toc_doc else None + if toc_doc is not None and el is not None and el is not toc_doc.body and _mostly_links(el): + toc_doc.toc_ids.update(e.get("id") for e in iter_elements(el) if e.get("id")) + remove_keep_tail(el) + if package.toc: + for doc in docs: + if is_html_toc_doc(doc, package.nav_path, landmark_toc_docs): + doc.skipped = "html-toc" + diag.info("html-toc-skipped", "HTML-Inhaltsverzeichnis übersprungen (ersetzt durch README.md/NAV).", doc.path) + if all(d.skipped for d in docs): + for d in docs: + d.skipped = "" + + # --- notes, chapters, links + notes = collect_notes(docs, opts.footnotes, diag) + diag.step(f"{len(notes.notes)} Anmerkungen erkannt") + chapters = ChapterBuilder(opts.chapter_level, package.toc, diag).build(docs) + _assign_filenames(chapters, opts.slug_language) + readme_path = "README.md" if opts.toc else None + resolver = LinkResolver(docs, chapters, notes, readme_path) + nav_targets: list[tuple[str, str]] = [] + + def walk(entries: list[Any]) -> None: + for e in entries: + if e.path and e.fragment: + nav_targets.append((e.path, e.fragment)) + walk(e.children) + + walk(package.toc) + resolver.add_targets(nav_targets) + diag.step(f"{len(chapters)} Kapitel gebildet") + + # --- rendering + media = MediaRegistry(package, opts.images, opts.slug_language) + cover_out = "" + if md.cover_path and opts.extract_media and reader.exists(md.cover_path): + cover_out = media.register(md.cover_path, role="cover", hint="cover").output + language = (md.language or "de").lower() + renderer = Renderer( + RenderOptions(opts.format, opts.footnotes, opts.math, opts.extract_media, opts.keep_html, language), + resolver, media, notes, diag, set(reader.names), + ) + width = max(3, len(str(len(chapters) + 1))) + notes_title = "Anmerkungen" if language.startswith("de") else "Notes" + endnotes_number = 999 if len(chapters) < 999 else len(chapters) + 1 + renderer.endnotes_file = f"chapters/{endnotes_number:0{width}d}-{slugify(notes_title, opts.slug_language)}.md" + for ch in chapters: + diag.step(f"Rendere {ch.filename}") + ch.markdown = renderer.render_chapter(ch) + ch.stats.word_count = _word_count(ch.markdown) + endnotes: Chapter | None = None + endnotes_md = "" + if opts.footnotes == "end-of-book" and renderer.book_notes: + endnotes_md = renderer.render_endnotes_file(notes_title) + endnotes = Chapter(number=len(chapters) + 1, title=notes_title, items=[], docs=[], types=["endnotes"]) + endnotes.filename = renderer.endnotes_file + endnotes.stats.footnotes = len(renderer.book_notes) + endnotes.stats.word_count = _word_count(endnotes_md) + + # --- remaining media (fonts, css, audio, unreferenced images) + skipped_formula_images = 0 + if opts.extract_media: + for item in package.manifest.values(): + if item.path in media.files or not reader.exists(item.path): + continue + if item.media_type in ("application/xhtml+xml", "text/html", "application/x-dtbncx+xml", + "application/oebps-package+xml") or item.path == package.opf_path: + continue + if item.path in renderer.consumed_formula_images: + skipped_formula_images += 1 + continue + mf = media.register(item.path, role="unreferenced") + mf.references = 0 + for mf in media.files.values(): + if mf.data is None and reader.exists(mf.source): + mf.size = reader.names[mf.source].file_size + + # --- assemble output files in memory + unresolved_notes = [n for n in notes.notes.values() if n.ref_count and not n.label and notes.extracted] + for n in unresolved_notes: + diag.warn("note-not-rendered", "Anmerkung wurde referenziert, aber nicht ausgegeben.", n.key) + opf_dir = posixpath.dirname(package.opf_path) + files: dict[str, str] = {} + for ch in chapters: + warn_count = sum(1 for d in diag.items if d.chapter == ch.filename and d.level != "info") + ch.warnings = warn_count + front = writer.chapter_frontmatter(ch, md, input_path.name, opf_dir, warn_count) if opts.frontmatter else "" + files[ch.filename] = front + ch.markdown + if endnotes is not None: + front = writer.chapter_frontmatter(endnotes, md, input_path.name, opf_dir, 0) if opts.frontmatter else "" + files[endnotes.filename] = front + endnotes_md + all_chapters = chapters + ([endnotes] if endnotes else []) + + stats = _book_stats(all_chapters, media, renderer, diag) + if opts.toc: + files["README.md"] = writer.readme(md, chapters, endnotes, package.toc, resolver, stats, cover_out, + package.version, input_path.name, opts.command) + existing = {mf.output for mf in media.files.values()} if opts.extract_media else set() + if opts.copy_original: + existing.add(f"source/{input_path.name}") + existing.update({"metadata.yaml", "manifest.json", "conversion-report.json", + "diagnostics/warnings.md", "diagnostics/unsupported-elements.md"}) + broken = check_links(files, existing) + for b in broken: + diag.warn("broken-output-link", f"Link {b['link']} in der Ausgabe ist ungültig ({b['reason']}).", b["file"], b["file"]) + stats["warnings"] = diag.count("warning") + + report = _report(input_path, sha, generated_at, package, opts, all_chapters, media, renderer, notes, diag, + docs, broken, skipped_formula_images) + files["metadata.yaml"] = writer.metadata_yaml(md, input_path.name, sha, package.version, cover_out, + opts.public(), generated_at) + files["manifest.json"] = writer.to_json(_manifest(md, all_chapters, media, diag)) + files["conversion-report.json"] = writer.to_json(report) + files["diagnostics/warnings.md"] = writer.warnings_md(diag) + files["diagnostics/unsupported-elements.md"] = writer.unsupported_md(diag) + + if opts.dry_run: + return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir) + + _write_output(reader, input_path, output_dir, opts, files, media) + if opts.report: + report_path = Path(opts.report) + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(writer.to_json(report), encoding="utf-8") + return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir) + + +def _mostly_links(el: Any) -> bool: + from .utils import local, norm_ws, text_content + + total = len(norm_ws(text_content(el))) + links = sum(len(norm_ws(text_content(a))) for a in iter_elements(el) if local(a.tag) == "a") + return total > 0 and links / total >= 0.5 + + +def _assign_filenames(chapters: list[Chapter], slug_language: str) -> None: + width = max(3, len(str(len(chapters) + 1))) + used: set[str] = set() + for ch in chapters: + base = slugify(ch.title, slug_language, fallback=(ch.types[0] if ch.types else "kapitel")) + name = f"{ch.number:0{width}d}-{base}" + candidate, n = name, 2 + while candidate in used: + candidate = f"{name}-{n}" + n += 1 + used.add(candidate) + ch.filename = f"chapters/{candidate}.md" + + +def _word_count(markdown: str) -> int: + import re + + text = re.sub(r"^(`{3,}).*?^\1\s*$", " ", markdown, flags=re.M | re.S) + text = re.sub(r"<[^>]+>|\]\([^)]*\)", " ", text) + return len(re.findall(r"\w+", text)) + + +def _book_stats(chapters: list[Chapter], media: MediaRegistry, renderer: Renderer, diag: Diagnostics) -> dict[str, int]: + def total(attr: str) -> int: + return sum(getattr(c.stats, attr) for c in chapters) + + return { + "chapters": len(chapters), + "images": total("images"), + "svgs": total("svgs"), + "media_files": len(media.files), + "code_blocks": total("code_blocks"), + "code_blocks_unlabeled": total("code_blocks_unlabeled"), + "formulas": len(renderer.formulas), + "formulas_as_image": total("math_as_image"), + "formulas_as_mathml": total("math_as_mathml"), + "footnotes": len(renderer.book_notes), + "tables": total("tables"), + "tables_html": total("tables_html"), + "warnings": diag.count("warning"), + } + + +def _manifest(md: Any, chapters: list[Chapter], media: MediaRegistry, diag: Diagnostics) -> dict[str, Any]: + return { + "book": {"title": md.title, "subtitle": md.subtitle, "authors": md.authors, "language": md.language, + "identifier": md.identifier, "isbn": md.isbn, "publisher": md.publisher}, + "chapters": [ + { + "number": c.number, + "title": c.title, + "file": c.filename, + "source_href": c.docs[0] if c.docs else None, + "source_hrefs": c.docs, + "types": c.types, + "anchors": c.anchors, + "word_count": c.stats.word_count, + "code_blocks": c.stats.code_blocks, + "images": c.stats.images + c.stats.svgs, + "math_blocks": c.stats.math_block, + "math_inline": c.stats.math_inline, + "footnotes": c.stats.footnotes, + "tables": c.stats.tables, + "warnings": c.warnings, + } + for c in chapters + ], + "media": [m.as_dict() for m in media.files.values()], + "warnings": [d.as_dict() for d in diag.items if d.level != "info"], + } + + +def _report(input_path: Path, sha: str, generated_at: str, package: Any, opts: Options, chapters: list[Chapter], + media: MediaRegistry, renderer: Renderer, notes: Any, diag: Diagnostics, docs: list[Doc], + broken: list[dict[str, str]], skipped_formula_images: int) -> dict[str, Any]: + from collections import Counter + + reps = Counter(f["representation"] for f in renderer.formulas) + origins = Counter(f["origin"] for f in renderer.formulas) + langs = Counter(c["language"] or "(keine)" for c in renderer.code_blocks) + special = [{"type": t, "title": c.title, "file": c.filename} for c in chapters for t in c.types + if t in ("glossary", "bibliography", "index", "index-persons", "endnotes", "appendix")] + errors = [d.as_dict() for d in diag.items if d.level == "error"] + warnings = [d.as_dict() for d in diag.items if d.level == "warning"] + status = "success" if not errors and not warnings else ("success-with-errors" if errors else "success-with-warnings") + return { + "tool": TOOL_NAME, + "version": __version__, + "generated_at": generated_at, + "status": status, + "lossless": False if (warnings or errors) else None, + "input": {"file": str(input_path), "name": input_path.name, "sha256": sha, + "size": input_path.stat().st_size}, + "epub": {"version": package.version, "opf": package.opf_path, "nav": package.nav_path, + "ncx": package.ncx_path, "spine_items": len(package.spine), + "manifest_items": len(package.manifest)}, + "options": opts.public(), + "metadata": asdict(package.metadata), + "chapters": [ + {"number": c.number, "title": c.title, "file": c.filename, "source_hrefs": c.docs, "types": c.types, + "stats": c.stats.as_dict(), "warnings": c.warnings} + for c in chapters + ], + "skipped_documents": [{"href": d.path, "reason": d.skipped} for d in docs if d.skipped], + "media": {"count": len(media.files), "files": [m.as_dict() for m in media.files.values()], + "formula_images_replaced_by_tex": skipped_formula_images}, + "code_blocks": {"count": len(renderer.code_blocks), "languages": dict(langs), + "items": renderer.code_blocks}, + "formulas": {"count": len(renderer.formulas), "representations": dict(reps), "origins": dict(origins), + "items": renderer.formulas}, + "footnotes": {"mode": opts.footnotes, "detected": len(notes.notes), + "referenced": sum(1 for n in notes.notes.values() if n.ref_count), + "rendered": len(renderer.book_notes)}, + "special_sections": special, + "warnings": warnings, + "errors": errors, + "infos": [d.as_dict() for d in diag.items if d.level == "info"], + "unresolved_links": diag.unresolved_links, + "unsupported_elements": dict(diag.unsupported), + "output_link_check": {"broken": broken, "broken_count": len(broken)}, + } + + +def _write_output(reader: EpubReader, input_path: Path, output_dir: Path, opts: Options, + files: dict[str, str], media: MediaRegistry) -> None: + parent = output_dir.resolve().parent + parent.mkdir(parents=True, exist_ok=True) + tmp = Path(tempfile.mkdtemp(prefix=".epub2md-", dir=parent)) + try: + for rel, content in files.items(): + _write_file(tmp, rel, content.encode("utf-8")) + if opts.extract_media: + for mf in media.files.values(): + data = mf.data if mf.data is not None else reader.read(mf.source) + _write_file(tmp, mf.output, data) + if opts.copy_original: + dest = tmp / "source" / input_path.name + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(input_path, dest) + if output_dir.exists(): + if any(output_dir.iterdir()): + shutil.rmtree(output_dir) # safety checks were done in check_paths() + else: + output_dir.rmdir() + os.replace(tmp, output_dir) + except BaseException: + shutil.rmtree(tmp, ignore_errors=True) + raise + + +def _write_file(root: Path, rel: str, data: bytes) -> None: + target = (root / rel).resolve() + if not is_inside(target, root): + raise ContainerError(f"Ausgabepfad außerhalb des Zielverzeichnisses verweigert: {rel}") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + + +def print_summary(result: Result, opts: Options) -> None: + r = result.report + n_warn = len(r["warnings"]) + n_err = len(r["errors"]) + action = "Analyse (--dry-run, nichts geschrieben)" if opts.dry_run else f"Ausgabe: {result.output_dir}" + print(f"{TOOL_NAME}: {len(r['chapters'])} Kapitel, {r['media']['count']} Mediendateien, " + f"{r['code_blocks']['count']} Codeblöcke, {r['formulas']['count']} Formeln, " + f"{r['footnotes']['rendered']} Anmerkungen, {n_warn} Warnungen, {n_err} Fehler") + print(action) + if opts.dry_run: + for c in r["chapters"]: + print(f" {c['file']} ← {', '.join(c['source_hrefs'][:2])}{' …' if len(c['source_hrefs']) > 2 else ''}") + if opts.report: + print("Hinweis: --report wird bei --dry-run nicht geschrieben.", file=sys.stderr) diff --git a/epub2md/references.py b/epub2md/references.py new file mode 100644 index 0000000..9a7dc51 --- /dev/null +++ b/epub2md/references.py @@ -0,0 +1,140 @@ +"""Mapping of EPUB link targets (document + id) to output files and anchors.""" + +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import unquote, urlsplit + +from .chapters import IdCarrier +from .footnotes import NoteIndex +from .models import Chapter +from .normalize import Doc +from .utils import is_element, iter_elements, local, rel_link, resolve_href, url_path + +EXTERNAL_SCHEMES = {"http", "https", "ftp", "mailto", "tel", "urn", "doi", "news", "irc", "data"} + + +def anchor_name(raw: str) -> str: + name = re.sub(r"[^A-Za-z0-9_.:-]", "-", raw).strip("-") + if not name or not name[0].isalpha(): + name = "a-" + name + return name + + +class LinkResolver: + def __init__(self, docs: list[Doc], chapters: list[Chapter], notes: NoteIndex, readme: str | None) -> None: + self.targets: set[tuple[str, str]] = set() + self.id_location: dict[tuple[str, str], tuple[int, str]] = {} # -> (chapter index, anchor) + self.doc_start: dict[str, int] = {} + self.doc_ids: dict[str, set[str]] = {} + self.skipped_docs: set[str] = {d.path for d in docs if d.skipped} + self.toc_ids: set[tuple[str, str]] = {(d.path, i) for d in docs for i in d.toc_ids} + self.notes = notes + self.readme = readme + self.chapters = chapters + self._collect_targets(docs) + self._map_chapters() + + # ------------------------------------------------------------------ setup + + def _collect_targets(self, docs: list[Doc]) -> None: + # extracted notes are no longer part of the document trees but their links count as well + trees = [(doc.path, doc.root) for doc in docs] + [(n.doc, n.element) for n in self.notes.notes.values()] + for doc in docs: + self.doc_ids[doc.path] = {e.get("id") for e in iter_elements(doc.root) if e.get("id")} + for doc_path, root in trees: + for a in iter_elements(root): + if local(a.tag) in ("a", "area") and a.get("href"): + target = self.split(doc_path, a.get("href")) + if target and target[1]: + self.targets.add(target) + + def add_targets(self, targets: list[tuple[str, str]]) -> None: + for t in targets: + if t[1]: + self.targets.add(t) + self._map_chapters() + + def _map_chapters(self) -> None: + self.id_location.clear() + for index, ch in enumerate(self.chapters): + used: set[str] = set() + ch.anchors = [] + for doc_path, item in ch.items: + if doc_path not in self.doc_start: + self.doc_start[doc_path] = index + el = item.element if isinstance(item, IdCarrier) else item + if not is_element(el): + continue + nodes = [el] if isinstance(item, IdCarrier) else iter_elements(el) + for node in nodes: + raw = node.get("id") or (node.get("name") if local(node.tag) == "a" else None) + if not raw: + continue + key = (doc_path, raw) + if key in self.id_location: + continue + name = anchor_name(raw) + if name in used: + stem = anchor_name(doc_path.rsplit("/", 1)[-1].rsplit(".", 1)[0]) + name = f"{stem}--{name}" + n = 2 + while name in used: + name = f"{stem}--{anchor_name(raw)}-{n}" + n += 1 + used.add(name) + self.id_location[key] = (index, name) + if key in self.targets: + ch.anchors.append(name) + + # ------------------------------------------------------------------ queries + + @staticmethod + def split(doc_path: str, href: str) -> tuple[str, str] | None: + parts = urlsplit(href) + if parts.scheme or parts.netloc: + return None + path = unquote(parts.path) + target = resolve_href(doc_path, path) if path else doc_path + if target is None: + return None + return target, unquote(parts.fragment) + + def is_external(self, href: str) -> bool: + parts = urlsplit(href) + return bool(parts.scheme and parts.scheme.lower() in EXTERNAL_SCHEMES) or bool(parts.netloc) + + def anchor_for(self, doc_path: str, el: Any) -> str | None: + """Anchor name for an element id that is targeted by some link.""" + raw = el.get("id") or (el.get("name") if local(el.tag) == "a" else None) + if not raw or (doc_path, raw) not in self.targets: + return None + loc = self.id_location.get((doc_path, raw)) + return loc[1] if loc else None + + def resolve(self, from_file: str, doc_path: str, href: str) -> tuple[str | None, str]: + """Return (link target relative to ``from_file`` or None, status).""" + target = self.split(doc_path, href) + if target is None: + return None, "unsafe" + path, frag = target + if path in self.skipped_docs or (path, frag) in self.toc_ids: + if self.readme: + return url_path(rel_link(from_file, self.readme)), "toc" + return None, "toc-skipped" + if frag: + loc = self.id_location.get((path, frag)) + if loc is not None: + index, name = loc + file = self.chapters[index].filename + rel = rel_link(from_file, file) + return (url_path(rel) if rel else "") + "#" + name, "ok" + if (path, frag) in self.notes.by_target and self.notes.extracted: + return None, "note" + if path in self.doc_start: + file = self.chapters[self.doc_start[path]].filename + rel = rel_link(from_file, file) + status = "ok" if not frag else "anchor-missing" + return (url_path(rel) or "#"), status + return None, "missing" diff --git a/epub2md/utils.py b/epub2md/utils.py new file mode 100644 index 0000000..5d88b3e --- /dev/null +++ b/epub2md/utils.py @@ -0,0 +1,196 @@ +"""Small helpers: slugs, YAML emitting, path safety, element helpers.""" + +from __future__ import annotations + +import hashlib +import json +import posixpath +import re +import unicodedata +from pathlib import Path +from typing import Any +from urllib.parse import quote + +EPUB_NS = "http://www.idpf.org/2007/ops" +XHTML_NS = "http://www.w3.org/1999/xhtml" +MATHML_NS = "http://www.w3.org/1998/Math/MathML" +SVG_NS = "http://www.w3.org/2000/svg" +XLINK_NS = "http://www.w3.org/1999/xlink" + +_DE_MAP = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"}) + + +def slugify(text: str, language: str = "de", max_len: int = 60, fallback: str = "kapitel") -> str: + """Readable, filesystem-safe slug. + + de: German transliteration (ä -> ae, ß -> ss), then ASCII folding. + en: plain ASCII folding (ä -> a). + none: keep Unicode letters, only normalize separators. + """ + text = unicodedata.normalize("NFKC", text or "") + if language == "de": + text = text.translate(_DE_MAP) + elif language == "en": + text = text.replace("ß", "ss") + if language in ("de", "en"): + text = unicodedata.normalize("NFKD", text) + text = "".join(c for c in text if not unicodedata.combining(c)) + text = text.encode("ascii", "ignore").decode("ascii") + text = text.lower() + text = re.sub(r"[^\w\s-]", " ", text, flags=re.UNICODE) + text = re.sub(r"[\s_-]+", "-", text).strip("-") + if len(text) > max_len: + cut = text[:max_len] + text = cut.rsplit("-", 1)[0] if "-" in cut[max_len // 2 :] else cut + text = text.strip("-") + return text or fallback + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as fh: + for block in iter(lambda: fh.read(1 << 20), b""): + h.update(block) + return h.hexdigest() + + +# --------------------------------------------------------------------------- YAML + + +def _yaml_scalar(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + # JSON string syntax is valid YAML double-quoted scalar syntax. + return json.dumps(str(value), ensure_ascii=False) + + +def to_yaml(data: Any, indent: int = 0) -> str: + """Minimal YAML emitter for dicts/lists/scalars (no external dependency).""" + pad = " " * indent + lines: list[str] = [] + if isinstance(data, dict): + if not data: + return pad + "{}\n" + for key, value in data.items(): + k = key if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", str(key)) else json.dumps(str(key)) + if isinstance(value, dict) and value: + lines.append(f"{pad}{k}:") + lines.append(to_yaml(value, indent + 1).rstrip("\n")) + elif isinstance(value, list) and value: + lines.append(f"{pad}{k}:") + lines.append(to_yaml(value, indent + 1).rstrip("\n")) + elif isinstance(value, (dict, list)): + lines.append(f"{pad}{k}: {'{}' if isinstance(value, dict) else '[]'}") + else: + lines.append(f"{pad}{k}: {_yaml_scalar(value)}") + elif isinstance(data, list): + for value in data: + if isinstance(value, (dict, list)) and value: + sub = to_yaml(value, indent + 1).rstrip("\n").split("\n") + first = sub[0][len(" " * (indent + 1)) :] + lines.append(f"{pad}- {first}") + lines.extend(sub[1:]) + else: + lines.append(f"{pad}- {_yaml_scalar(value)}") + else: + lines.append(pad + _yaml_scalar(data)) + return "\n".join(lines) + "\n" + + +# --------------------------------------------------------------------------- paths + + +def safe_zip_path(name: str) -> str | None: + """Normalize a ZIP member name; return None if it is unsafe (absolute, '..', drive).""" + if not name or "\x00" in name: + return None + name = name.replace("\\", "/") + if name.startswith("/") or re.match(r"^[A-Za-z]:", name): + return None + norm = posixpath.normpath(name) + if norm == ".." or norm.startswith("../") or norm.startswith("/"): + return None + return norm + + +def resolve_href(base_doc: str, href_path: str) -> str | None: + """Resolve a (URL-decoded) relative path against the directory of ``base_doc``.""" + if href_path.startswith("/"): + joined = href_path.lstrip("/") + else: + joined = posixpath.join(posixpath.dirname(base_doc), href_path) + return safe_zip_path(joined) + + +def is_inside(child: Path, parent: Path) -> bool: + try: + child.resolve().relative_to(parent.resolve()) + return True + except ValueError: + return False + + +def url_path(path: str) -> str: + """Percent-encode a relative posix path for use as Markdown link target.""" + return quote(path, safe="/-._~") + + +def rel_link(from_file: str, to_file: str) -> str: + """Relative link between two output-relative posix paths.""" + if from_file == to_file: + return "" + return posixpath.relpath(to_file, posixpath.dirname(from_file) or ".") + + +# --------------------------------------------------------------------------- elements + + +def local(tag: Any) -> str: + if not isinstance(tag, str): + return "" + return tag.rsplit("}", 1)[-1].lower() + + +def is_element(node: Any) -> bool: + return isinstance(getattr(node, "tag", None), str) + + +def epub_types(el: Any) -> list[str]: + value = el.get(f"{{{EPUB_NS}}}type") or el.get("epub:type") or "" + return value.split() + + +def roles(el: Any) -> list[str]: + return (el.get("role") or "").split() + + +def classes(el: Any) -> list[str]: + return (el.get("class") or "").lower().split() + + +def text_content(el: Any) -> str: + """All text inside ``el`` (without its tail).""" + parts: list[str] = [] + if el.text: + parts.append(el.text) + for child in el: + if is_element(child): + parts.append(text_content(child)) + if child.tail: + parts.append(child.tail) + return "".join(parts) + + +def norm_ws(text: str) -> str: + return re.sub(r"[ \t\r\n\f]+", " ", text or "").strip() + + +def iter_elements(el: Any): + """Depth-first iteration over element descendants including ``el``.""" + for node in el.iter(): + if is_element(node): + yield node diff --git a/epub2md/writer.py b/epub2md/writer.py new file mode 100644 index 0000000..83f4cc5 --- /dev/null +++ b/epub2md/writer.py @@ -0,0 +1,173 @@ +"""Builders for README, metadata.yaml, manifest.json, report and diagnostics files.""" + +from __future__ import annotations + +import json +import posixpath +import shlex +from collections import Counter +from typing import Any + +from . import TOOL_NAME, __version__ +from .diagnostics import Diagnostics +from .html_converter import md_escape +from .models import BookMetadata, Chapter, NavEntry +from .references import LinkResolver +from .utils import rel_link, to_yaml, url_path + +SPECIAL_LABELS = { + "glossary": "Glossar", + "bibliography": "Literatur / Quellen", + "index": "Sachregister", + "index-persons": "Personenregister", + "endnotes": "Anmerkungen", + "appendix": "Anhang", +} + + +def chapter_frontmatter(ch: Chapter, md: BookMetadata, source_epub: str, opf_dir: str, warnings: int) -> str: + def rel(p: str) -> str: + return p[len(opf_dir) + 1 :] if opf_dir and p.startswith(opf_dir + "/") else p + + data: dict[str, Any] = { + "book_title": md.title, + "book_author": md.authors, + "language": md.language or None, + "chapter_number": ch.number, + "chapter_title": ch.title, + "source_epub": source_epub, + "source_href": rel(ch.docs[0]) if ch.docs else None, + } + if len(ch.docs) > 1: + data["source_hrefs"] = [rel(d) for d in ch.docs] + data["epub_type"] = ch.types + data["conversion_status"] = "converted-with-warnings" if warnings else "converted" + return "---\n" + to_yaml(data) + "---\n\n" + + +def metadata_yaml(md: BookMetadata, source_name: str, sha256: str, epub_version: str, cover: str, + options: dict[str, Any], generated_at: str) -> str: + data: dict[str, Any] = {} + + def put(key: str, value: Any) -> None: + if value not in ("", None, []): + data[key] = value + + put("title", md.title) + put("subtitle", md.subtitle) + put("authors", md.authors) + put("contributors", md.contributors) + put("language", md.language) + put("publisher", md.publisher) + put("published", md.published) + put("identifier", md.identifier) + put("isbn", md.isbn) + data["source_file"] = source_name + data["source_sha256"] = sha256 + put("epub_version", epub_version) + put("description", md.description) + put("rights", md.rights) + put("subjects", md.subjects) + put("cover_image", cover) + data["conversion"] = {"tool": TOOL_NAME, "version": __version__, "generated_at": generated_at, "options": options} + return to_yaml(data) + + +def _nav_lines(entries: list[NavEntry], resolver: LinkResolver, depth: int = 0) -> list[str]: + lines = [] + for e in entries: + label = md_escape(e.label or "(ohne Titel)") + url = None + if e.path: + url, _ = resolver.resolve("README.md", e.path, ("#" + e.fragment) if e.fragment else "") + lines.append(" " * depth + (f"- [{label}]({url})" if url else f"- {label}")) + lines.extend(_nav_lines(e.children, resolver, depth + 1)) + return lines + + +def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, nav: list[NavEntry], + resolver: LinkResolver, stats: dict[str, int], cover: str, epub_version: str, source_name: str, + command: list[str]) -> str: + out = [f"# {md_escape(md.title or source_name)}"] + if md.subtitle: + out.append(f"*{md_escape(md.subtitle)}*") + if md.authors: + out.append("**" + md_escape(", ".join(md.authors)) + "**") + if cover: + out.append(f"![Cover]({url_path(cover)})") + rows = [("Sprache", md.language), ("Verlag", md.publisher), ("Veröffentlicht", md.published), + ("Identifier", md.identifier), ("ISBN", md.isbn), ("EPUB-Version", epub_version), + ("Quelldatei", source_name)] + table = ["| Feld | Wert |", "| --- | --- |"] + [ + f"| {k} | {md_escape(v, table=True)} |" for k, v in rows if v + ] + out.append("\n".join(table)) + out.append("## Inhaltsverzeichnis") + lines = [f"{c.number}. [{md_escape(c.title)}]({url_path(c.filename)})" + + (" *(Zusatzseite, nicht in linearer Lesereihenfolge)*" if "auxiliary" in c.types else "") + for c in chapters] + if endnotes is not None: + lines.append(f"{len(chapters) + 1}. [{md_escape(endnotes.title)}]({url_path(endnotes.filename)})") + out.append("\n".join(lines) if lines else "*(keine Kapitel)*") + specials = [(c, t) for c in chapters for t in c.types if t in SPECIAL_LABELS] + if specials or endnotes is not None: + out.append("## Besondere Abschnitte") + sl = [f"- {SPECIAL_LABELS[t]}: [{md_escape(c.title)}]({url_path(c.filename)})" for c, t in specials] + if endnotes is not None: + sl.append(f"- Anmerkungen (gesammelt): [{md_escape(endnotes.title)}]({url_path(endnotes.filename)})") + out.append("\n".join(sl)) + if nav: + out.append("## Original-Inhaltsverzeichnis (NAV/NCX)") + out.append("\n".join(_nav_lines(nav, resolver))) + out.append("## Statistik") + labels = [("chapters", "Kapitel"), ("images", "Bilder"), ("svgs", "SVG-Grafiken"), + ("media_files", "Extrahierte Mediendateien"), ("code_blocks", "Codeblöcke"), + ("formulas", "Erkannte Formeln"), ("formulas_as_image", "Formeln als Bild/SVG belassen"), + ("formulas_as_mathml", "Formeln als MathML belassen"), ("footnotes", "Fußnoten/Anmerkungen"), + ("warnings", "Warnungen")] + out.append("\n".join(["| Kennzahl | Anzahl |", "| --- | ---: |"] + [f"| {lbl} | {stats.get(k, 0)} |" for k, lbl in labels])) + out.append("Details: [conversion-report.json](conversion-report.json), " + "[manifest.json](manifest.json), [Warnungen](diagnostics/warnings.md)") + out.append("## Reproduktion") + out.append("```bash\n" + " ".join(shlex.quote(c) for c in command) + "\n```") + return "\n\n".join(out) + "\n" + + +def warnings_md(diag: Diagnostics) -> str: + out = ["# Warnungen und Hinweise", ""] + levels = Counter(d.level for d in diag.items) + out.append(f"Fehler: {levels.get('error', 0)} · Warnungen: {levels.get('warning', 0)} · Hinweise: {levels.get('info', 0)}") + if not diag.items: + out += ["", "Keine Auffälligkeiten."] + return "\n".join(out) + "\n" + by_code: dict[str, list[Any]] = {} + for d in diag.items: + by_code.setdefault(f"{d.level}: {d.code}", []).append(d) + order = {"error": 0, "warning": 1, "info": 2} + for key in sorted(by_code, key=lambda k: (order.get(k.split(":")[0], 3), k)): + items = by_code[key] + out += ["", f"## {key} ({len(items)})", ""] + for d in items: + where = " · ".join(x for x in (d.source, d.chapter) if x) + out.append(f"- {md_escape(d.message)}" + (f" (`{where}`)" if where else "")) + return "\n".join(out) + "\n" + + +def unsupported_md(diag: Diagnostics) -> str: + out = ["# Nicht direkt unterstützte Elemente", ""] + if not diag.unsupported: + out.append("Keine.") + else: + out.append("Inhalte dieser Elemente wurden als Text übernommen (mit `--keep-html` als HTML).") + out += ["", "| Element | Anzahl | Beispiel |", "| --- | ---: | --- |"] + for tag, n in diag.unsupported.most_common(): + out.append(f"| `{tag}` | {n} | `{diag.unsupported_examples.get(tag, '')}` |") + return "\n".join(out) + "\n" + + +def to_json(data: Any) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) + "\n" + + +def rel(from_file: str, to: str) -> str: + return rel_link(from_file, to) or posixpath.basename(to) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..9ee0463 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -p no:asyncio diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..2da57a2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0 +PyYAML>=6.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a366824 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +lxml>=5.0 diff --git a/split-md-chapters.py b/split-md-chapters.py new file mode 100755 index 0000000..5e36b3a --- /dev/null +++ b/split-md-chapters.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +import re +import sys +from pathlib import Path + +if len(sys.argv) != 3: + raise SystemExit(f"Aufruf: {sys.argv[0]} EINGABE.md AUSGABE_ORDNER") + +src = Path(sys.argv[1]) +outdir = Path(sys.argv[2]) +outdir.mkdir(parents=True, exist_ok=True) + +text = src.read_text(encoding="utf-8") +parts = re.split(r"(?m)^(?=#\s+)", text) + +def slugify(title: str) -> str: + title = title.lower() + title = re.sub(r"[^\w\s-]", "", title, flags=re.UNICODE) + title = re.sub(r"[\s_-]+", "-", title).strip("-") + return title[:80] or "kapitel" + +index = [] +chapter_no = 0 + +for part in parts: + if not part.strip(): + continue + + m = re.match(r"^#\s+(.+?)\s*$", part, re.M) + if m: + chapter_no += 1 + title = m.group(1).strip() + filename = f"{chapter_no:03d}-{slugify(title)}.md" + else: + filename = "000-vorspann.md" + title = "Vorspann" + + (outdir / filename).write_text(part.rstrip() + "\n", encoding="utf-8") + index.append(f"- [{title}]({filename})") + +(outdir / "README.md").write_text( + "# Inhaltsverzeichnis\n\n" + "\n".join(index) + "\n", + encoding="utf-8", +) + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a08e5b7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,163 @@ +"""Builders for small synthetic EPUB files used throughout the tests.""" + +from __future__ import annotations + +import json +import sys +import zipfile +from dataclasses import dataclass, field +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from epub2md.cli import main # noqa: E402 + +# 1x1 transparent PNG +PNG = bytes.fromhex( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" + "0000000d4944415478da63f8ffff3f0005fe02fea7d6a5b60000000049454e44ae426082" +) +SVG = b'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>' + +XHTML = """<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="de"> +<head><title>{title} +{body} +""" + + +@dataclass +class Doc: + name: str # file name inside OEBPS/ + body: str + title: str = "" + linear: bool = True + + +@dataclass +class Book: + docs: list[Doc] + version: str = "3.0" + title: str = "Testbuch" + authors: list[str] = field(default_factory=lambda: ["Erika Musterfrau"]) + language: str = "de" + identifier: str = "urn:isbn:9783161484100" + media: dict[str, bytes] = field(default_factory=dict) # OEBPS-relative path -> bytes + nav: list[tuple[str, str]] | None = None # (label, href) – default: one entry per doc + ncx: bool = False + extra_entries: dict[str, bytes] = field(default_factory=dict) # raw ZIP entries (any name) + container: str | None = None + opf_override: str | None = None + spine_extra: str = "" + cover: str | None = None + + +MEDIA_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".gif": "image/gif", + ".mp3": "audio/mpeg", ".css": "text/css", ".otf": "font/otf"} + + +def build_epub(path: Path, book: Book) -> Path: + manifest = [] + spine = [] + for i, d in enumerate(book.docs): + manifest.append(f'') + spine.append(f'") + for i, (name, _) in enumerate(book.media.items()): + ext = "." + name.rsplit(".", 1)[-1].lower() + props = ' properties="cover-image"' if book.cover == name else "" + manifest.append(f'') + nav_entries = book.nav if book.nav is not None else [(d.title or d.name, d.name) for d in book.docs] + epub3 = book.version.startswith("3") + if epub3: + manifest.append('') + if book.ncx: + manifest.append('') + creators = "".join(f"{a}" for a in book.authors) + opf = book.opf_override or f""" + + + {book.title}{creators} + {book.language} + {book.identifier} + Testverlag + 2024-01-01 + + {''.join(manifest)} + {''.join(spine)}{book.spine_extra} +""" + nav_xhtml = XHTML.format( + title="Inhalt", + body='", + ) + ncx = ( + '' + + "".join( + f'{lbl}' + for i, (lbl, h) in enumerate(nav_entries) + ) + + "" + ) + container = book.container or ( + '' + '' + ) + with zipfile.ZipFile(path, "w") as z: + z.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED) + z.writestr("META-INF/container.xml", container) + z.writestr("OEBPS/content.opf", opf) + if epub3: + z.writestr("OEBPS/nav.xhtml", nav_xhtml) + if book.ncx: + z.writestr("OEBPS/toc.ncx", ncx) + for d in book.docs: + z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, body=d.body)) + for name, data in book.media.items(): + z.writestr(f"OEBPS/{name}", data) + for name, data in book.extra_entries.items(): + z.writestr(zipfile.ZipInfo(name), data) + return path + + +@dataclass +class Run: + rc: int + out: Path + stdout: str + stderr: str + + def chapter(self, prefix: str) -> str: + files = sorted((self.out / "chapters").glob(f"{prefix}*.md")) + assert files, f"no chapter {prefix} in {sorted(p.name for p in (self.out / 'chapters').glob('*.md'))}" + return files[0].read_text(encoding="utf-8") + + def chapters(self) -> list[Path]: + return sorted((self.out / "chapters").glob("*.md")) + + def all_md(self) -> str: + return "\n".join(p.read_text(encoding="utf-8") for p in self.chapters()) + + @property + def report(self) -> dict: + return json.loads((self.out / "conversion-report.json").read_text(encoding="utf-8")) + + +@pytest.fixture +def convert(tmp_path, capsys): + counter = {"n": 0} + + def run(book_or_path, *args: str, out: Path | None = None) -> Run: + counter["n"] += 1 + if isinstance(book_or_path, Book): + epub = build_epub(tmp_path / f"book{counter['n']}.epub", book_or_path) + else: + epub = Path(book_or_path) + target = out or tmp_path / f"out{counter['n']}" + rc = main([str(epub), str(target), *args]) + captured = capsys.readouterr() + return Run(rc, target, captured.out, captured.err) + + return run diff --git a/tests/test_chapters.py b/tests/test_chapters.py new file mode 100644 index 0000000..c836683 --- /dev/null +++ b/tests/test_chapters.py @@ -0,0 +1,116 @@ +import yaml + +from conftest import Book, Doc + + +def frontmatter(text: str) -> dict: + assert text.startswith("---\n") + return yaml.safe_load(text.split("---\n")[1]) + + +def test_files_are_numbered_and_slugged(convert): + docs = [Doc("c1.xhtml", "

Übersicht & Größen

a

"), Doc("c2.xhtml", "

Übersicht & Größen

b

")] + run = convert(Book(docs=docs)) + assert [p.name for p in run.chapters()] == ["001-uebersicht-groessen.md", "002-uebersicht-groessen.md"] + run_en = convert(Book(docs=docs), "--slug-language", "en") + assert run_en.chapters()[0].name == "001-ubersicht-grossen.md" + run_none = convert(Book(docs=docs), "--slug-language", "none") + assert run_none.chapters()[0].name == "001-übersicht-größen.md" + + +def test_h1_split_inside_one_file(convert): + body = "

Eins

a

Eins.Eins

b

Zwei

c

" + run = convert(Book(docs=[Doc("all.xhtml", body)])) + assert [p.name for p in run.chapters()] == ["001-eins.md", "002-zwei.md"] + assert "## Eins.Eins" in run.chapter("001") + + +def test_h2_split_with_chapter_level_2(convert): + body = "

Teil I

Intro

Kapitel 1

a

Kapitel 2

b

" + run = convert(Book(docs=[Doc("all.xhtml", body)]), "--chapter-level", "2") + names = [p.name for p in run.chapters()] + assert names == ["001-teil-i.md", "002-kapitel-1.md", "003-kapitel-2.md"] + assert run.chapter("002").count("\n# ") == 1 # exactly one H1 per file + + +def test_section_files_without_h1_continue_previous_chapter(convert): + docs = [ + Doc("01.xhtml", "

Kapitel 1

a

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

1.1 Abschnitt

b

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

Kapitel 2

c

"), + ] + run = convert(Book(docs=docs)) + assert len(run.chapters()) == 2 + first = run.chapter("001") + assert "## 1.1 Abschnitt" in first + assert frontmatter(first)["source_hrefs"] == ["01.xhtml", "01b.xhtml"] + + +def test_headingless_documents_become_own_chapters_with_nav_title(convert): + docs = [Doc("titel.xhtml", "

Widmung für alle

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

Start

x

")] + book = Book(docs=docs, nav=[("Widmung", "titel.xhtml"), ("Start", "c.xhtml")]) + run = convert(book) + assert [p.name for p in run.chapters()] == ["001-widmung.md", "002-start.md"] + fm = frontmatter(run.chapter("001")) + assert fm["chapter_title"] == "Widmung" + assert "dedication" in fm["epub_type"] + + +def test_semantic_sections_are_split_and_classified(convert): + body = ( + '

Kapitel

Text

' + '

Glossar

API
Schnittstelle.
' + '

Literatur

' + '

Sachregister

A

' + '

Personenregister

Knuth

' + ) + run = convert(Book(docs=[Doc("b.xhtml", body)])) + names = [p.name for p in run.chapters()] + assert names == ["001-kapitel.md", "002-glossar.md", "003-literatur.md", "004-sachregister.md", "005-personenregister.md"] + assert frontmatter(run.chapter("002"))["epub_type"] == ["glossary"] + assert "**API:** Schnittstelle." in run.chapter("002") + lit = run.chapter("003") + assert "[doi:10.1000/xyz](https://doi.org/10.1000/xyz)" in lit and "ISBN 978-0-201-89683-1" in lit + assert "index-persons" in frontmatter(run.chapter("005"))["epub_type"] + readme = (run.out / "README.md").read_text(encoding="utf-8") + assert "Glossar: [Glossar](chapters/002-glossar.md)" in readme + assert "Personenregister: [Personenregister](chapters/005-personenregister.md)" in readme + types = {s["type"] for s in run.report["special_sections"]} + assert {"glossary", "bibliography", "index", "index-persons"} <= types + + +def test_wrapper_preamble_belongs_to_heading_chapter(convert): + body = '
Kontextinfo

Kapitel

Text

' + run = convert(Book(docs=[Doc("c.xhtml", body)])) + assert len(run.chapters()) == 1 + text = run.chapter("001") + assert "Kontextinfo" in text and text.count("# Kapitel") == 1 + + +def test_html_toc_document_is_not_duplicated(convert): + toc = '

Inhaltsverzeichnis

Kapitel A

Kapitel B

B.1

' + docs = [Doc("toc.xhtml", toc), Doc("a.xhtml", "

A

a

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

B

b

')] + run = convert(Book(docs=docs, nav=[("A", "a.xhtml"), ("B", "b.xhtml")])) + assert [p.name for p in run.chapters()] == ["001-a.md", "002-b.md"] + assert run.report["skipped_documents"] == [{"href": "OEBPS/toc.xhtml", "reason": "html-toc"}] + + +def test_short_title_page_is_kept(convert): + docs = [Doc("t.xhtml", "

Für M.

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

K

x

")] + run = convert(Book(docs=docs, nav=[])) + assert len(run.chapters()) == 2 + assert "Für M." in run.chapter("001") + + +def test_layout_table_headings_are_recognized(convert): + body = ( + '
3Grundlagen
' + "

Text

" + '
3.1Einführung

mehr

' + ) + run = convert(Book(docs=[Doc("c.xhtml", body)])) + text = run.chapter("001") + assert "# 3 Grundlagen" in text + assert "## 3.1 Einführung" in text + assert " list[tuple[str, str]]: + return re.findall(r"^(`{3,})([\w+-]*)\n(.*?)\n\1$", text, re.M | re.S) + + +def test_python_indentation_and_special_chars(convert): + body = f'

Code

{PY}
' + run = convert(Book(docs=[Doc("c.xhtml", body)])) + blocks = fenced_blocks(run.chapter("001")) + assert len(blocks) == 1 + fence, lang, code = blocks[0] + assert lang == "python" + assert code == PY.replace("<", "<").replace(">", ">") + assert "<" not in code + + +def test_shell_code_with_pipes_backslashes_and_dollar(convert): + shell = "$ cat datei.txt | grep -v '^#' \\\n | sort && echo \"$HOME\" > out.txt" + body = f"

Shell

{shell}
" + run = convert(Book(docs=[Doc("c.xhtml", body)])) + (_, lang, code), = fenced_blocks(run.chapter("001")) + assert code == "$ cat datei.txt | grep -v '^#' \\\n | sort && echo \"$HOME\" > out.txt" + assert lang == "" # no reliable language signal -> no label + + +def test_backticks_in_code_get_longer_fence(convert): + body = "

T

echo ```nested``` and `x`
" + run = convert(Book(docs=[Doc("c.xhtml", body)])) + (fence, _, code), = fenced_blocks(run.chapter("001")) + assert fence == "````" + assert code == "echo ```nested``` and `x`" + + +def test_br_lines_and_highlight_spans_in_pre(convert): + body = ('

C

#include <stdio.h>

' + 'int main(void) {
printf("Hallo\\n");
}
') + run = convert(Book(docs=[Doc("c.xhtml", body)])) + (_, lang, code), = fenced_blocks(run.chapter("001")) + assert lang == "c" + assert code == '#include \n\nint main(void) {\n printf("Hallo\\n");\n} ' + + +def test_listing_div_with_span_pre(convert): + body = ('

K

def f(x):\n' + ' return 2 * x\n\ndef g(x):\n return x
') + run = convert(Book(docs=[Doc("c.xhtml", body)])) + (_, lang, code), = fenced_blocks(run.chapter("001")) + assert lang == "python" + assert code == "def f(x):\n return 2 * x\n\ndef g(x):\n return x" + + +def test_line_numbers_are_removed(convert): + body = '

L

1a = 1
2b = 2
' + run = convert(Book(docs=[Doc("c.xhtml", body)])) + (_, _, code), = fenced_blocks(run.chapter("001")) + assert code == "a = 1\nb = 2" + + +def test_inline_code_variants(convert): + body = ('

I

Nutze a*b_c, x | y und `tick` ' + 'sowie Strg.

') + run = convert(Book(docs=[Doc("c.xhtml", body)])) + text = run.chapter("001") + assert "`a*b_c`" in text + assert "`x | y`" in text + assert "`` `tick` ``" in text + assert "Strg" in text + + +def test_prose_is_not_forced_into_code(convert): + body = "

P

Ein normaler Absatz mit Leerzeichen.

" + run = convert(Book(docs=[Doc("c.xhtml", body)])) + assert "```" not in run.chapter("001") + assert "Ein normaler Absatz mit Leerzeichen." in run.chapter("001") + + +def test_unlabeled_code_is_reported(convert): + run = convert(Book(docs=[Doc("c.xhtml", "

U

foo bar
")])) + assert run.report["code_blocks"]["languages"] == {"(keine)": 1} diff --git a/tests/test_epub_reader.py b/tests/test_epub_reader.py new file mode 100644 index 0000000..26e8ed3 --- /dev/null +++ b/tests/test_epub_reader.py @@ -0,0 +1,139 @@ +import zipfile + +import yaml + +from conftest import PNG, Book, Doc, build_epub + + +def test_minimal_epub2_with_ncx(convert): + book = Book(version="2.0", ncx=True, docs=[Doc("ch1.xhtml", "

Erstes Kapitel

Hallo Welt.

", "Eins")]) + run = convert(book) + assert run.rc == 0 + text = run.chapter("001") + assert "# Erstes Kapitel" in text + assert "Hallo Welt." in text + assert run.report["epub"]["version"] == "2.0" + assert run.report["epub"]["ncx"] == "OEBPS/toc.ncx" + + +def test_minimal_epub3_with_nav_and_metadata(convert): + book = Book( + authors=["Anna A.", "Bert B."], + docs=[Doc("a.xhtml", "

A

x

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

B

y

")], + ) + run = convert(book) + assert run.rc == 0 + meta = yaml.safe_load((run.out / "metadata.yaml").read_text(encoding="utf-8")) + assert meta["title"] == "Testbuch" + assert meta["authors"] == ["Anna A.", "Bert B."] + assert meta["language"] == "de" + assert meta["identifier"] == "urn:isbn:9783161484100" + assert meta["isbn"] == "9783161484100" + assert meta["epub_version"] == "3.0" + assert len(meta["source_sha256"]) == 64 + assert "subtitle" not in meta # nothing invented + assert run.report["epub"]["nav"] == "OEBPS/nav.xhtml" + + +def test_spine_order_is_respected_not_file_names(convert): + docs = [Doc("z.xhtml", "

Zuerst

1

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

Danach

2

")] + run = convert(Book(docs=docs)) + names = [p.name for p in run.chapters()] + assert names == ["001-zuerst.md", "002-danach.md"] + + +def test_cover_image_is_extracted_first(convert): + book = Book(docs=[Doc("a.xhtml", "

A

x

")], media={"img/cover.png": PNG}, cover="img/cover.png") + run = convert(book) + meta = yaml.safe_load((run.out / "metadata.yaml").read_text(encoding="utf-8")) + assert meta["cover_image"] == "media/images/0001-cover.png" + assert (run.out / meta["cover_image"]).read_bytes() == PNG + + +def test_missing_container_returns_exit_code_2(tmp_path, convert): + path = tmp_path / "broken.epub" + with zipfile.ZipFile(path, "w") as z: + z.writestr("mimetype", "application/epub+zip") + z.writestr("OEBPS/x.xhtml", "") + run = convert(path) + assert run.rc == 2 + assert "container.xml" in run.stderr + assert not run.out.exists() + + +def test_not_a_zip_returns_exit_code_2(tmp_path, convert): + path = tmp_path / "kaputt.epub" + path.write_bytes(b"this is not a zip") + run = convert(path) + assert run.rc == 2 + + +def test_broken_opf_spine_reference(convert): + book = Book(docs=[Doc("a.xhtml", "

A

x

")], spine_extra='') + run = convert(book) + assert run.rc == 0 + assert any(e["code"] == "spine-idref-missing" for e in run.report["errors"]) + strict = convert(book, "--strict") + assert strict.rc == 3 + assert not strict.out.exists() + + +def test_drm_encrypted_epub_is_rejected(tmp_path, convert): + book = Book(docs=[Doc("a.xhtml", "

A

x

")], + extra_entries={"META-INF/encryption.xml": b""" + """}) + run = convert(book) + assert run.rc == 2 + assert "DRM" in run.stderr + + +def test_unsafe_zip_paths_are_never_written(tmp_path, convert): + book = Book( + docs=[Doc("a.xhtml", '

A

x raus

')], + extra_entries={"../../outside.txt": b"evil", "/absolute/path.txt": b"evil", "OEBPS/../../escape.xhtml": b"evil"}, + ) + run = convert(book) + assert run.rc == 0 + assert any(e["code"] == "unsafe-zip-path" for e in run.report["errors"]) + assert not (tmp_path / "outside.txt").exists() + assert not (tmp_path.parent / "outside.txt").exists() + for f in run.out.rglob("*"): + assert run.out.resolve() in f.resolve().parents + assert convert(book, "--strict").rc == 2 + + +def test_xml_external_entities_are_not_resolved(tmp_path, convert): + secret = tmp_path / "secret.txt" + secret.write_text("GEHEIM") + body = "

A

&xxe;

" + book = Book(docs=[Doc("a.xhtml", body)]) + path = build_epub(tmp_path / "xxe.epub", book) + # inject a DOCTYPE with an external entity into the chapter + with zipfile.ZipFile(path) as z: + entries = {n: z.read(n) for n in z.namelist()} + entries["OEBPS/a.xhtml"] = ( + f']>' + 'a

A

&xxe;

' + ).encode() + with zipfile.ZipFile(path, "w") as z: + for n, d in entries.items(): + z.writestr(n, d) + run = convert(path) + assert run.rc == 0 + assert "GEHEIM" not in run.all_md() + + +def test_invalid_utf8_is_reported(tmp_path, convert): + book = Book(docs=[Doc("a.xhtml", "

A

ok

")]) + path = build_epub(tmp_path / "enc.epub", book) + with zipfile.ZipFile(path) as z: + entries = {n: z.read(n) for n in z.namelist()} + entries["OEBPS/a.xhtml"] = entries["OEBPS/a.xhtml"].replace(b"ok", b"kaputt \xff\xfe ende") + with zipfile.ZipFile(path, "w") as z: + for n, d in entries.items(): + z.writestr(n, d) + run = convert(path) + assert run.rc == 0 + assert any(w["code"] == "decode-error" for w in run.report["warnings"]) + assert "ende" in run.all_md() diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py new file mode 100644 index 0000000..cf8cb94 --- /dev/null +++ b/tests/test_footnotes.py @@ -0,0 +1,93 @@ +import re + +from conftest import Book, Doc + +CH1 = ( + "

Kapitel

" + '

Erste Aussage1 und nochmal1.

' + '

Zweite2.

' + '' +) +NOTES = ( + "

Anmerkungen

" + '' +) + + +def book() -> Book: + return Book(docs=[Doc("ch.xhtml", CH1), Doc("notes.xhtml", NOTES)]) + + +def test_end_of_chapter(convert): + run = convert(book()) + text = run.chapter("001") + body, _, defs = text.partition("[^fn-001]:") + assert "Erste Aussage[^fn-001] und nochmal[^fn-001]." in body + assert "Zweite[^fn-002]." in body + assert "Erster Absatz" not in body # not duplicated as running text + assert "Erster Absatz mit *Betonung* und [Link](https://example.org).\n\n Zweiter Absatz mit `code()`." in defs + assert "[^fn-002]: Endnote mit Formel $e^{x}$." in text + assert text.count("[^fn-001]:") == 1 + assert run.report["footnotes"]["rendered"] == 2 + + +def test_end_of_book(convert): + run = convert(book(), "--footnotes", "end-of-book") + text = run.chapter("001") + assert '[1](999-anmerkungen.md#fn-001)' in text + assert '' in text + notes = run.chapter("999") + assert '**1.** Erster Absatz' in notes + assert "[↩](001-kapitel.md#fnref-001)" in notes and "[↩](001-kapitel.md#fnref-001-2)" in notes + assert run.report["output_link_check"]["broken_count"] == 0 + + +def test_inline(convert): + text = convert(book(), "--footnotes", "inline").chapter("001") + assert "Zweite (Anmerkung: Endnote mit Formel $e^{x}$.)." in text + assert "Erste Aussage (Anmerkung: Erster Absatz mit *Betonung* und [Link](https://example.org). Zweiter Absatz" in text + assert "[^fn" not in text + + +def test_inline_falls_back_for_block_content(convert): + body = ('

K

Text1.

' + '') + text = convert(Book(docs=[Doc("c.xhtml", body)]), "--footnotes", "inline").chapter("001") + assert "Text[^fn-001]." in text + assert "[^fn-001]: ```" in text + + +def test_keep_links(convert): + run = convert(book(), "--footnotes", "keep-links") + text = run.chapter("001") + assert "[1](#fn1)" in text + assert '' in text + assert "Erster Absatz" in text + assert run.report["output_link_check"]["broken_count"] == 0 + + +def test_class_based_footnotes_with_backlinks(convert): + body = ('

K

Text1.


' + '

1 Die Fußnote.

') + text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001") + assert "Text[^fn-001]." in text + assert re.search(r"^\[\^fn-001\]: Die Fußnote\.$", text, re.M) + + +def test_unresolvable_noteref_is_warned(convert): + body = '

K

Text9

' + run = convert(Book(docs=[Doc("c.xhtml", body)])) + codes = [w["code"] for w in run.report["warnings"]] + assert "noteref-unresolved" in codes + + +def test_commonmark_footnotes_use_anchor_links(convert): + run = convert(book(), "--format", "commonmark") + text = run.chapter("001") + assert "[^fn" not in text + assert '1' in text + assert '**1:** Erster Absatz' in text + assert "[↩](#fnref-001)" in text + assert run.report["output_link_check"]["broken_count"] == 0 diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..650d822 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,126 @@ +import json +import re +from pathlib import Path + +import pytest +import yaml + +from conftest import ROOT, Book, Doc, build_epub + +BEISPIELE = sorted((ROOT / "Beispiele").glob("*.epub")) + + +def simple_book() -> Book: + return Book(docs=[Doc("a.xhtml", "

A

Text

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

B

Mehr

")]) + + +def test_output_structure_and_reports(convert): + run = convert(simple_book(), "--copy-original") + assert run.rc == 0 + for rel in ("README.md", "metadata.yaml", "manifest.json", "conversion-report.json", + "diagnostics/warnings.md", "diagnostics/unsupported-elements.md"): + assert (run.out / rel).is_file(), rel + assert len(list((run.out / "source").glob("*.epub"))) == 1 + manifest = json.loads((run.out / "manifest.json").read_text(encoding="utf-8")) + assert [c["file"] for c in manifest["chapters"]] == ["chapters/001-a.md", "chapters/002-b.md"] + for c in manifest["chapters"]: + assert (run.out / c["file"]).is_file() + assert c["word_count"] > 0 + assert run.report["status"] == "success" + assert run.report["input"]["sha256"] + fm = yaml.safe_load(run.chapter("001").split("---\n")[1]) + assert fm["chapter_number"] == 1 and fm["source_href"] == "a.xhtml" and fm["book_title"] == "Testbuch" + + +def test_no_frontmatter_no_toc(convert): + run = convert(simple_book(), "--no-frontmatter", "--no-toc") + assert not run.chapter("001").startswith("---") + assert not (run.out / "README.md").exists() + + +def test_dry_run_writes_nothing(convert, tmp_path): + report = tmp_path / "r.json" + run = convert(simple_book(), "--dry-run", "--report", str(report)) + assert run.rc == 0 + assert not run.out.exists() + assert not report.exists() + assert "chapters/001-a.md" in run.stdout + + +def test_existing_output_requires_force(convert, tmp_path): + out = tmp_path / "ziel" + out.mkdir() + (out / "wichtig.txt").write_text("nicht löschen") + run = convert(simple_book(), out=out) + assert run.rc == 1 + assert (out / "wichtig.txt").exists() + # --force refuses directories that are not epub2md output + assert convert(simple_book(), "--force", out=out).rc == 1 + assert (out / "wichtig.txt").exists() + + +def test_force_replaces_previous_output(convert, tmp_path): + out = tmp_path / "ziel" + assert convert(simple_book(), out=out).rc == 0 + (out / "alt.txt").write_text("alt") + run = convert(simple_book(), "--force", out=out) + assert run.rc == 0 + assert not (out / "alt.txt").exists() + assert (out / "README.md").exists() + assert not list(tmp_path.glob(".epub2md-*")) # temp dir cleaned up + + +def test_report_option_and_verbose(convert, tmp_path): + report = tmp_path / "rep" / "bericht.json" + run = convert(simple_book(), "--report", str(report), "--verbose") + assert json.loads(report.read_text(encoding="utf-8"))["tool"] == "epub2md" + assert "Rendere" in run.stderr + + +def test_invalid_arguments_exit_1(convert, tmp_path): + assert convert(simple_book(), "--math", "falsch").rc == 1 + assert convert(simple_book(), "--chapter-level", "9").rc == 1 + assert convert(tmp_path / "gibt-es-nicht.epub").rc == 1 + + +def test_deterministic_output(convert, tmp_path): + epub = build_epub(tmp_path / "same.epub", simple_book()) + a = convert(epub, out=tmp_path / "x1") + b = convert(epub, out=tmp_path / "x2") + for p in a.chapters(): + assert p.read_text() == (b.out / "chapters" / p.name).read_text() + + +def test_encoding_edge_cases(convert): + text_in = "Ärger über Öl – „Zitat“ — café naïve, Ωμέγα αβγ, ∀x∈ℝ: x² ≥ 0 … ‚einfach‘" + book = Book(docs=[Doc("Kapitel-é.xhtml", f'

Grüße

{text_in}

')]) + run = convert(book) + text = run.chapter("001") + assert text_in in text + assert "�" not in text + assert run.chapters()[0].name == "001-gruesse.md" + + +@pytest.mark.skipif(not BEISPIELE, reason="keine Beispiel-EPUBs vorhanden") +@pytest.mark.parametrize("epub", BEISPIELE, ids=[p.stem[:20] for p in BEISPIELE]) +def test_example_books_structural_invariants(convert, epub: Path): + """Checks structure only – no copyrighted text is asserted.""" + run = convert(epub) + assert run.rc == 0, run.stderr + report = run.report + chapters = run.chapters() + assert len(chapters) >= 3 + assert report["output_link_check"]["broken_count"] == 0, report["output_link_check"]["broken"][:5] + assert not report["errors"] + for path in chapters: + text = path.read_text(encoding="utf-8") + body = text.split("---\n", 2)[2] if text.startswith("---\n") else text + yaml.safe_load(text.split("---\n")[1]) + assert body.strip(), f"leere Kapiteldatei {path.name}" + fences = re.findall(r"^(`{3,})", body, re.M) + assert len(fences) % 2 == 0, f"unbalancierte Code-Fences in {path.name}" + for block in re.findall(r"^```[\w+-]*\n(.*?)\n```$", body, re.M | re.S): + assert "<" not in block and "&" not in block + for m in report["media"]["files"]: + assert (run.out / m["file"]).is_file() + assert sum(c["stats"]["word_count"] for c in report["chapters"]) > 1000 diff --git a/tests/test_links.py b/tests/test_links.py new file mode 100644 index 0000000..6b22d8e --- /dev/null +++ b/tests/test_links.py @@ -0,0 +1,68 @@ +from conftest import PNG, SVG, Book, Doc +from epub2md.linkcheck import check_links + + +def link_book() -> Book: + a = ('

A

Siehe Kapitel B, Ziel in B, ' + 'unten und kaputt.

' + '

Zweites H1

Unten.

') + b = '

B

Text

Ziel

zurück

' + return Book(docs=[Doc("a.xhtml", a), Doc("b.xhtml", b)]) + + +def test_internal_links_are_rewritten(convert): + run = convert(link_book()) + names = [p.name for p in run.chapters()] + assert names == ["001-a.md", "002-zweites-h1.md", "003-b.md"] + a = run.chapter("001") + assert "[Kapitel B](003-b.md)" in a + assert "[Ziel in B](003-b.md#ziel)" in a + assert "[unten](002-zweites-h1.md#unten)" in a # anchor ended up in the split-off chapter + assert "kaputt" in a and "gibtsnicht" not in a # unresolved: text kept, no broken link + assert 'Ziel' in run.chapter("003") + assert "[zurück](001-a.md#start)" in run.chapter("003") + assert run.report["unresolved_links"][0]["href"] == "gibtsnicht.xhtml#x" + assert any(w["code"] == "unresolved-link" for w in run.report["warnings"]) + assert run.report["output_link_check"]["broken_count"] == 0 + readme = (run.out / "README.md").read_text(encoding="utf-8") + assert "[A](chapters/001-a.md)" in readme + + +def test_images_are_extracted_once_with_relative_links(convert): + body = ('

Bilder

Foto' + '
Abbildung 1: Ein Foto.
' + '

Nochmal

' + '
Nur Unterschrift
') + book = Book(docs=[Doc("c.xhtml", body)], media={"img/foto eins.png": PNG, "img/grafik.svg": SVG}) + run = convert(book) + text = run.chapter("001") + assert '![Foto](../media/images/0001-foto.png "Titel")' in text + assert "*Abbildung 1: Ein Foto.*" in text + assert "![Nochmal](../media/images/0001-foto.png)" in text + assert "![Nur Unterschrift](../media/images/0002-grafik.svg)" in text + images = sorted(p.name for p in (run.out / "media" / "images").iterdir()) + assert images == ["0001-foto.png", "0002-grafik.svg"] + assert (run.out / "media/images/0002-grafik.svg").read_bytes() == SVG + assert run.report["output_link_check"]["broken_count"] == 0 + + +def test_image_strategies(convert): + book = Book(docs=[Doc("c.xhtml", '

B

a

')], media={"img/a.png": PNG}) + flat = convert(book, "--images", "flat") + assert "![a](../media/a.png)" in flat.chapter("001") + orig = convert(book, "--images", "original-path") + assert "![a](../media/img/a.png)" in orig.chapter("001") + assert (orig.out / "media/img/a.png").exists() + none = convert(book, "--no-extract-media") + assert not (none.out / "media").exists() + assert "Bild: a" in none.chapter("001") + + +def test_link_checker_detects_problems(): + files = { + "chapters/001-a.md": '# A\n\nText [ok](002-b.md#y) [kaputt](002-b.md#nein) [fehlt](003.md) ' + "![bild](../media/images/a.png) [ext](https://example.org)\n\n```\n[nicht](geprüft.md)\n```\n", + "chapters/002-b.md": '# B\n\nText [rück](001-a.md#x) [heading](#b)\n', + } + broken = check_links(files, {"media/images/a.png"}) + assert {b["link"] for b in broken} == {"002-b.md#nein", "003.md"} diff --git a/tests/test_math.py b/tests/test_math.py new file mode 100644 index 0000000..3e6be79 --- /dev/null +++ b/tests/test_math.py @@ -0,0 +1,99 @@ +import pytest +from lxml import etree + +from conftest import PNG, SVG, Book, Doc +from epub2md.mathml import Unsupported, mathml_to_tex + +M = 'xmlns="http://www.w3.org/1998/Math/MathML"' + + +def tex(xml: str) -> str: + return mathml_to_tex(etree.fromstring(f"{xml}")) + + +@pytest.mark.parametrize( + ("xml", "expected"), + [ + ("ab", r"\frac{a}{b}"), + ("x2", "x^{2}"), + ("ai", "a_{i}"), + ("xi2", "x_{i}^{2}"), + ("x", r"\sqrt{x}"), + ("x3", r"\sqrt[3]{x}"), + ("∑i=1nai", + r"\sum_{i = 1}^{n} a_{i}"), + ("α≤β", r"\alpha \leq \beta"), + ("x^", r"\hat{x}"), + ("R", r"\mathbb{R}"), + ("für alle", r"\text{für alle}"), + ("(10" + "01)", + r"\left( \begin{matrix} 1 & 0 \\ 0 & 1 \end{matrix} \right)"), + ], +) +def test_mathml_to_tex(xml, expected): + assert tex(xml) == expected + + +def test_tex_annotation_is_preferred(): + xml = ('x\\mathbf{x}') + assert tex(xml) == r"\mathbf{x}" + + +@pytest.mark.parametrize("xml", ["x", "x", + "1"]) +def test_unsupported_mathml_raises(xml): + with pytest.raises(Unsupported): + tex(xml) + + +BODY = ( + "

Formeln

" + f'

Inline ab im Satz.

' + f'
x2 (3.7)
' + f'

Unbekannt: z

' + '

SVG: Formel 1

' + '

Bild ohne TeX: Summe

' + '

Bild mit TeX: $$a &amp; b \\leq c$$

' +) + + +def math_book() -> Book: + return Book(docs=[Doc("m.xhtml", BODY)], media={"f.svg": SVG, "eq.png": PNG, "eq2.png": PNG}) + + +def test_math_auto(convert): + run = convert(math_book()) + text = run.chapter("001") + assert r"Inline $\frac{a}{b}$ im Satz." in text + assert "$$\nx^{2}\n$$" in text + assert "(3.7)" in text # equation number visible + assert "ab' in text + assert "frac{" not in text + + +def test_math_image_mode(convert): + run = convert(math_book(), "--math", "image") + text = run.chapter("001") + assert text.count("![") == 3 # svg + both formula images + assert "L
  • Eins
    1. Unterpunkt A
    2. Unterpunkt B
      • tief
  • " + "
  • Zwei
  1. vier
  2. fünf
") + text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001") + assert "- Eins\n\n 1. Unterpunkt A\n\n 2. Unterpunkt B\n\n - tief\n\n- Zwei" in text + assert "4. vier\n5. fünf" in text + + +def test_simple_table_as_gfm(convert): + body = ("

T

" + "
Tabelle 1: Werte
NameWert
a1 | 2
bx
") + text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001") + assert "*Tabelle 1: Werte*" in text + assert "| Name | Wert |\n| --- | --- |\n| *a* | 1 \\| 2 |\n| b | `x` |" in text + + +def test_complex_table_kept_as_html_with_warning(convert): + body = ("

T

Kopf
A

Zeile 1

" + "

Zeile 2

B
") + run = convert(Book(docs=[Doc("c.xhtml", body)])) + text = run.chapter("001") + assert 'Kopf' in text + assert 'A' in text + assert "Zeile 1" in text and "Zeile 2" in text and "B" in text + assert "\n\n" not in text.split("")[1].split("
")[0] # HTML block must not be interrupted + assert any(w["code"] == "table-html" for w in run.report["warnings"]) + + +def test_commonmark_uses_html_tables(convert): + body = "

T

a
b

alt

" + text = convert(Book(docs=[Doc("c.xhtml", body)]), "--format", "commonmark").chapter("001") + assert "" in text and "| a |" not in text + assert "alt" in text + + +def test_definition_list_blockquote_and_inline_semantics(convert): + body = ("

D

Term
Erklärung.

Zitat

weiter

" + "

H2O, x2, Zitat, weg, unter
neue Zeile


") + text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001") + assert "**Term:** Erklärung." in text + assert "> Zitat\n>\n> weiter" in text + assert "H2O, x2, „Zitat“, ~~weg~~, unter\\\nneue Zeile" in text + assert "* * *" in text + + +def test_markdown_special_characters_are_escaped(convert): + body = "

E

1. kein Listenpunkt

# keine Überschrift

a*b*c [x](y) <tag> $5

" + text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001") + assert "1\\. kein Listenpunkt" in text + assert "\\# keine Überschrift" in text + assert "a\\*b\\*c \\[x\\](y) \\ \\$5" in text + + +def test_unknown_elements_are_reported_and_keep_html(convert): + body = ("

U

Vor Inhalt bleibt" + " nach.

") + run = convert(Book(docs=[Doc("c.xhtml", body)])) + assert "Vor Inhalt bleibt nach." in run.chapter("001") + assert run.report["unsupported_elements"] == {"foo-widget": 1} + kept = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-html") + assert 'Inhalt bleibt' in kept.chapter("001") + assert "evil" not in kept.chapter("001")