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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-05 19:33:42 +02:00
commit 02449734a2
38 changed files with 7726 additions and 0 deletions

35
.gitignore vendored Normal file
View file

@ -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/

928
Prompt.md Normal file
View file

@ -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 `<title>`, `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.

978
Prompt_Kontrolle.md Normal file
View file

@ -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 `<60>`
- 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 `&lt;` 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.

292
README.md Normal file
View file

@ -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;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 (`&nbsp;` …) 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.

11
convert_epub2md.sh Executable file
View file

@ -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

9
epub2md.py Executable file
View file

@ -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())

4
epub2md/__init__.py Normal file
View file

@ -0,0 +1,4 @@
"""epub2md – konvertiert DRM-freie EPUB-Dateien in kapitelweise Markdown-Sammlungen."""
__version__ = "0.1.0"
TOOL_NAME = "epub2md"

5
epub2md/__main__.py Normal file
View file

@ -0,0 +1,5 @@
import sys
from .cli import main
sys.exit(main())

336
epub2md/chapters.py Normal file
View file

@ -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))

129
epub2md/cli.py Normal file
View file

@ -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

195
epub2md/codeblocks.py Normal file
View file

@ -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}"

57
epub2md/diagnostics.py Normal file
View file

@ -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)

488
epub2md/epub_reader.py Normal file
View file

@ -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("<EFBFBD>")
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)

31
epub2md/errors.py Normal file
View file

@ -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

184
epub2md/footnotes.py Normal file
View file

@ -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))

1183
epub2md/html_converter.py Normal file

File diff suppressed because it is too large Load diff

69
epub2md/linkcheck.py Normal file
View file

@ -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

373
epub2md/mathml.py Normal file
View file

@ -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]

98
epub2md/media.py Normal file
View file

@ -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

169
epub2md/models.py Normal file
View file

@ -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)

Some files were not shown because too many files have changed in this diff Show more