Compare commits

..
41 changed files with 138 additions and 3836 deletions

2
.gitignore vendored
View file

@ -12,7 +12,7 @@ htmlcov/
build/
dist/
# QA-Läufe (Prompt_Kontrolle.md): erzeugte Daten von tools/qa/
# QA-Läufe (Prompt_Kontrolle.md)
.qa/
.qa-output/
.qa-fixtures/

View file

@ -115,14 +115,12 @@ python3 epub2md_merge.py output/meinbuch
```
Ergebnis: `output/meinbuch/meinbuch.md`. Die Datei heißt wie das Original-E-Book, nur mit `.md`.
Bilder und Querverweise funktionieren darin weiter. Am Anfang stehen die Buchdaten (Titel, Autor, Verlag,
Datum als `13.01.2025`), die Pandoc für die Titelseite verwendet.
Bilder und Querverweise funktionieren darin weiter.
Nützliche Varianten:
```bash
# mit Inhaltsverzeichnis (Liste der Kapitel) hinter Cover, Titelseite und Impressum –
# für PDFs mit Pandoc nicht nötig, dort baut book.lua ein Verzeichnis mit Seitenzahlen (Kapitel 7)
# mit Inhaltsverzeichnis am Anfang
python3 epub2md_merge.py output/meinbuch --toc
# an einen anderen Ort schreiben (Bildpfade werden automatisch angepasst)
@ -141,21 +139,14 @@ python3 epub2md_merge.py output/meinbuch --force
### Roman (Teile und Kapitel)
Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Das Programm erkennt sehr große
Teile (über 30 000 Wörter) selbst und legt dann eine kurze Datei für den Teil und eine Datei pro
**Kapitel** an; kurze Abschnitte wie Vorwort oder Anhang bleiben eine Datei. Du musst dafür nichts
einstellen. Soll wirklich jeder Abschnitt mit Unterüberschrift eine eigene Datei werden, gilt:
Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Standardmäßig entsteht eine Datei
pro **Teil**. Das können sehr große Dateien werden. Das Programm warnt dann mit `chapter-large` und
empfiehlt die passende Einstellung. Eine Datei pro **Kapitel** bekommst du so:
```bash
python3 epub2md.py roman.epub output/roman --chapter-level 2
```
### Roman (Szenenwechsel, Gedichte)
Szenenwechsel, die im Buch nur als Leerzeile zwischen zwei Absätzen erscheinen, werden als `* * *`
übernommen; sonst würden zwei Szenen nahtlos ineinanderlaufen. Gedichte und Liedtexte bleiben Zeile für
Zeile erhalten (als eingerückter Zitatblock, Strophen durch Leerzeile getrennt).
### Programmierbuch
```bash
@ -163,9 +154,8 @@ python3 epub2md.py python-buch.epub output/python-buch
```
Programmcode bleibt zeichengenau erhalten, inklusive Einrückung. Die Programmiersprache wird nur
eingetragen, wenn sie eindeutig erkennbar ist. Ist ein Buch erkennbar in **einer** Sprache geschrieben,
erhalten auch kurze Codeschnipsel dieses Label (etwa `char ch = 65;` in einem C-Buch). Programmausgaben
bleiben ohne Label. Willst du wirklich alle übrigen Blöcke kennzeichnen, gib die Sprache vor:
eingetragen, wenn sie eindeutig erkennbar ist. Ist das ganze Buch in **einer** Sprache geschrieben,
kannst du sie für alle übrigen Codeblöcke vorgeben:
```bash
python3 epub2md.py python-buch.epub output/python-buch --code-language python
@ -187,9 +177,7 @@ python3 epub2md.py mathe.epub output/mathe
```
Formeln, die im E-Book als echte Formeln (MathML oder TeX) vorliegen, werden zu TeX: `$…$` im Text,
`$$…$$` als eigene Zeile. Viele Verlage legen das TeX einer Formel in den Alternativtext eines Formelbildes;
epub2md übernimmt es und passt Schreibweisen an, die Pandoc nicht versteht (z. B. `\"u` → `ü`), ohne den
Inhalt zu ändern. Formeln, die nur als Bild vorliegen, bleiben Bilder und erscheinen in
`$$…$$` als eigene Zeile. Formeln, die nur als Bild vorliegen, bleiben Bilder und erscheinen in
`diagnostics/warnings.md`.
| Gewünschte Formeldarstellung | Option |
@ -215,55 +203,6 @@ python3 epub2md.py bildband.epub output/bildband --chapter-level 3
- Bilder, die im E-Book zwar enthalten, aber nirgends eingebunden sind, werden trotzdem gespeichert.
Im Bericht sind sie als `unreferenced` gekennzeichnet.
### Infokästen (Tipps, Hinweise, Warnungen)
Kästen aus dem Buch, etwa „Tipp“, „Achtung“, „Exkurs“ oder „Die MATLAB-Minute“, werden als hervorgehobene
Blöcke übernommen:
```markdown
> [!TIP]
> **Erwartungen an dieses Buch**
>
> Text des Kastens …
```
GitHub und Obsidian zeigen das als farbigen Kasten mit Symbol; andere Programme als eingerückten Zitatblock.
Für Pandoc (HTML, EPUB, PDF) gibt es einen Filter, der echte Kästen zeichnet, siehe Kapitel 7 („Weiterverarbeiten“).
Andere Darstellungen wählst du mit `--boxes`:
- `quote`: Zitatblock ohne `[!TIP]`
- `html`: HTML-`<div>`
- `plain`: wie normaler Text
### Farben, Schriftarten und Schriftgrößen
Markdown kennt keine Farben, Schriftarten oder Schriftgrößen. Standardmäßig übernimmt das Programm daher
nur, was eine Bedeutung trägt:
- **fett** und *kursiv*, auch wenn das Buch sie nur über sein Stylesheet setzt
- unterstrichen, hoch- und tiefgestellt
- farbig hinterlegter Text als `<mark>`-Markierung
Möchtest du das Aussehen möglichst erhalten, etwa für eine HTML-, EPUB- oder PDF-Ausgabe mit Pandoc oder zum
Lesen in Obsidian, wandle mit `--styles html` um. Dann bleiben Farbe, Hintergrund, Schriftgröße,
Schriftart, Kapitälchen und zentrierte oder rechtsbündige Absätze als HTML erhalten. Das gilt nur dort, wo
sie vom normalen Text abweichen. Fremdsprachige Passagen werden als solche gekennzeichnet. GitHub zeigt
diese Angaben nicht an.
### Seitenzahlen der Druckausgabe (zum Zitieren)
Viele E-Books enthalten unsichtbare Marken, wo im gedruckten Buch eine neue Seite beginnt. Standardmäßig
lässt das Programm sie weg. Mit `--page-markers visible` erscheinen sie im Text als `[S. 123]`, mit
`--page-markers comment` als unsichtbarer Kommentar `<!-- S. 123 -->`. Den findest du über die Suche, er
stört aber beim Lesen nicht.
```bash
python3 epub2md.py roman.epub roman-md --page-markers visible
```
In Codebeispielen stehen keine Seitenmarken, damit der Code unverändert bleibt. Enthält das E-Book keine
Seitenmarken, bleibt die Option ohne Wirkung; im Bericht steht dann `"page_markers": 0`.
### Buch mit vielen Fußnoten
Standardmäßig stehen die Fußnoten am Ende des jeweiligen Kapitels. Alternativen:
@ -315,57 +254,28 @@ cd output/meinbuch
```
`meinbuch.md` steht im Folgenden für den Namen der Gesamtdatei. Er entspricht dem Namen des
Original-E-Books. `$P` ist der Ordner mit den Pandoc-Hilfsdateien von epub2md; lege ihn einmal fest:
```bash
P=~/Python_Programs/epub2md/pandoc # Pfad zu deinem epub2md-Ordner anpassen
```
Original-E-Books.
| Ziel | Befehl |
| --- | --- |
| PDF (empfohlen) | `pandoc meinbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua --css $P/print.css --pdf-engine=weasyprint -o meinbuch.pdf` |
| Webseite (HTML) | `pandoc meinbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html` |
| Webseite mit Formeln | wie HTML, zusätzlich `--mathml` |
| E-Book (EPUB) | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.epub` |
| E-Book mit Formeln | wie EPUB, zusätzlich `--mathml` |
| Word | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.docx` |
| LibreOffice | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.odt` |
| PDF mit LaTeX (Bücher mit Formeln) | `pandoc meinbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html && pandoc meinbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex -o meinbuch.pdf` |
| Kindle (AZW3) | erst EPUB wie oben, dann `ebook-convert meinbuch.epub meinbuch.azw3` |
Die drei Hilfsdateien:
- **`book.lua`** ordnet den Buchanfang wie im gedruckten Buch: Cover, Titelseite, Impressum, dann das
Inhaltsverzeichnis, jeweils auf einer neuen Seite. Ohne den Filter setzt Pandoc mit `--toc` das
Inhaltsverzeichnis (und seinen eigenen Titelkopf) vor das Cover. Mit `book.lua` deshalb **kein** `--toc`
angeben. Hat das Buch keine eigene Titelseite, setzt der Filter Pandocs Titelkopf hinter das Cover. Das
gilt für HTML und für beide PDF-Wege (beim LaTeX-Weg den Filter in beiden Schritten angeben); EPUB, Word
und LibreOffice bauen ihr Verzeichnis mit `--toc` selbst. Kurze Seiten zwischen Titelseite und Impressum
(Auflagenvermerk, Verlagsbrief, „Über dieses Buch“) bleiben im Vorspann; das Inhaltsverzeichnis folgt vor
dem ersten Teil, Vorwort oder nummerierten Kapitel.
- **`callouts.lua`** zeichnet die Infokästen (`> [!TIP]`). Pandoc in der Ubuntu-Version kennt diese
Schreibweise nicht und druckt „[!TIP]“ sonst einfach mit. In HTML, EPUB und im PDF über weasyprint werden
daraus gerahmte, farbige Kästen mit Titel („Tipp: …“), im PDF über LaTeX und in Word/LibreOffice ein
eingerückter Block mit fettem Titel.
- **`print.css`** liefert das Druck-Layout für weasyprint: A4, Seitenzahlen (nicht auf dem Cover), jedes
Kapitel auf einer neuen Seite, Inhaltsverzeichnis mit Seitenzahlen, Silbentrennung und umbrechende lange
Codezeilen. Ohne sie
übernimmt weasyprint das Bildschirmlayout, und lange Codezeilen werden am Seitenrand abgeschnitten.
Du musst die Befehle nicht abtippen: Die `README.md` im Ausgabeordner enthält sie fertig, mit allen Pfaden.
| Webseite (HTML) | `pandoc meinbuch.md -s --toc -o meinbuch.html` |
| Webseite mit Formeln | `pandoc meinbuch.md -s --toc --mathml -o meinbuch.html` |
| E-Book (EPUB) | `pandoc meinbuch.md --toc -o meinbuch.epub` |
| Word | `pandoc meinbuch.md -o meinbuch.docx` |
| LibreOffice | `pandoc meinbuch.md -o meinbuch.odt` |
| PDF mit LaTeX | `pandoc meinbuch.md -s -o meinbuch.html && pandoc meinbuch.html --toc --pdf-engine=xelatex -o meinbuch.pdf` |
| PDF ohne LaTeX | `pandoc meinbuch.md --toc --pdf-engine=weasyprint -o meinbuch.pdf` |
| Kindle (AZW3) | `pandoc meinbuch.md --toc -o meinbuch.epub && ebook-convert meinbuch.epub meinbuch.azw3` |
**Zum PDF:**
- **Mit weasyprint** (empfohlen für Bücher ohne Formeln) genügt ein Schritt. Tabellen, Farben,
Infokästen und die Gestaltung aus `--styles html` bleiben erhalten.
- **Mit LaTeX** immer den Zwischenschritt über HTML nehmen, wie in der Tabelle. Beim direkten Weg von
Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Für
Bücher mit Formeln ist das der richtige Weg, weil weasyprint keine Formeln setzen kann. Farben und
Kastenrahmen gehen dabei verloren; Kästen erscheinen als eingerückter Block mit fettem Titel. `book.lua`
stellt dabei A4 ein und wählt die Schrift DejaVu, die auch Zeichen wie ∈, λ oder ≠ im Fließtext enthält.
Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Bei
Büchern mit Formeln ist der LaTeX-Weg die beste Wahl.
- **Mit weasyprint** genügt ein Schritt; Tabellen bleiben erhalten. Für Bücher mit vielen Formeln ist der
LaTeX-Weg sicherer.
Bei Büchern mit Formeln für HTML und EPUB zusätzlich `--mathml` angeben, sonst bleiben Matrizen und
ähnliche Formeln als TeX-Text stehen.
Bei Büchern mit Formeln für HTML zusätzlich `--mathml` angeben, sonst bleiben Matrizen als TeX-Text stehen.
#### Direkt aus den Kapiteldateien
@ -395,8 +305,8 @@ gilt hier derselbe Umweg über HTML.
- Die einzelnen Kapiteldateien eignen sich als Abschnitte. Die Metadaten im Datenkopf (Buch, Kapitel,
Herkunft) helfen beim Zuordnen von Fundstellen.
- `manifest.json` listet alle Kapitel mit Titel, Datei, Wortanzahl und Ankern.
- Sehr große Kapitel teilt das Programm selbst an ihren Unterkapiteln. Hast du `--chapter-level` fest
eingestellt und erscheint die Warnung `chapter-large`, dann mit dem empfohlenen Wert neu umwandeln.
- Achte auf die Warnung `chapter-large`: Sehr große Kapiteldateien lassen sich schlecht zerlegen. Dann
mit höherem `--chapter-level` neu umwandeln.
---
@ -521,14 +431,9 @@ python3 epub2md_merge.py --help
| `--dry-run` | nur anzeigen, nichts schreiben |
| `--verbose` | ausführliche Ausgabe |
| `--force` | frühere Ausgabe ersetzen |
| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard `auto`: Ebene 1, sehr große Kapitel werden an ihren Unterkapiteln geteilt) |
| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) |
| `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache |
| `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen |
| `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten |
| `--boxes callout/quote/html/plain` | Darstellung von Infokästen |
| `--styles semantic/html` | Farben, Schriften, Größen, Ausrichtung und Sprache als HTML übernehmen (`html`) |
| `--page-markers none/visible/comment` | Seitenzahlen der Druckausgabe als `[S. 123]` oder `<!-- S. 123 -->` |
| `--no-css-emphasis` | Hervorhebungen nur aus HTML-Tags übernehmen, nicht aus dem Stylesheet des Buches (farbige Markierungen `<mark>` bleiben, sie gehören zu `--styles`) |
| `--math auto/tex/mathml/image` | Formeldarstellung |
| `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung |
| `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme |

View file

@ -1,124 +0,0 @@
# Änderungsprotokoll
Alle nennenswerten Änderungen an epub2md. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
### Neu
- `tools/qa/`: QA-Skripte versioniert (CLI-Matrix mit 89 Fällen, Generator für Test-EPUBs, unabhängiger
Markdown-Validator, Wortvergleich gegen Datenverlust, Prüfung des Pandoc-Wegs für HTML, EPUB und PDF).
Beschreibung in `tools/qa/README.md`; erzeugte Daten landen in `.qa/`.
### Verbessert
- Sprachlabels für Codeblöcke (R1): Gedruckte Zeilennummern (`00 #include …`) verdecken die Sprache
nicht mehr. Python wird zusätzlich per `ast.parse` erkannt (Kontrollstrukturen, `def`/`class`, Import
mit Code) sowie an `>>>`-Sitzungen. Ein Vordurchlauf bestimmt die Sprache des Buches aus den sicher
erkannten Blöcken. Passt sie, reichen für kurze Schnipsel schwächere Signale (`detected_by:
"book-context"`); Programmausgaben bleiben ohne Label. Ohne Label waren vorher im C-Buch 518 von 574
Blöcken, jetzt 132 (fast nur Ausgaben). Im Python-Buch waren es 492 von 747, jetzt 326 (Ausgaben).
## [0.3.0] – 2026-10-06
### Neu
- `pandoc/book.lua`: Inhaltsverzeichnis wie im gedruckten Buch nach Cover, Titelseite und Impressum statt
davor (Pandocs `--toc` setzt es immer an den Anfang). `epub2md_merge.py` markiert dafür das Ende des
Vorspanns; ohne eigene Titelseite kommt Pandocs Titelkopf hinter das Cover. Jedes Kapitel, auch Cover,
Titelseite und Impressum, beginnt mit `print.css` auf einer neuen Seite; das Inhaltsverzeichnis hat
Seitenzahlen. Mit `epub2md_merge.py --toc` steht dessen Verzeichnis an derselben Stelle. Gilt auch für den
PDF-Weg über LaTeX (`\tableofcontents` hinter dem Vorspann); bei Büchern mit Formeln empfiehlt die
Buch-README diesen Weg. Dort setzt der Filter außerdem A4, ein seitengroßes Cover und DejaVu-Schriften,
sodass keine Zeichen mehr fehlen (Mathebuch: vorher über 10.000 fehlende Glyphen wie ∈, λ, ≠).
- Kapitelerkennung: Ein erstes Kapitel nur aus einem Bild ist das Cover (auch „[Titelinfo]“, „Deckblatt“),
ein Kapitel „Titel“ die Titelseite. Der Vorspann darf kurze Zwischenseiten enthalten.
- `pandoc/print.css`: Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite,
Silbentrennung außer in Code, umbrechende Codezeilen). Die Buch-README enthält den passenden Befehl.
### Geändert
- `--chapter-level auto` ist der neue Standard: Ebene 1 wie bisher, aber Kapitel mit über 30 000 Wörtern
und mindestens zwei Unterkapiteln (Teile eines Romans) werden an ihren Unterkapiteln geteilt; alle
anderen Kapitel bleiben ganz (Info `chapter-split`). Der Roman im Test ergibt 78 statt 9 Dateien, die
übrigen Beispielbücher bleiben unverändert. Eine feste Zahl verhält sich wie bisher (R2).
- `epub2md_merge.py` kürzt ISO-Zeitstempel im Buch-Kopf (`2025-01-13T08:00:00+00:00` → `13.01.2025`, bei
nicht-deutschen Büchern `2025-01-13`), damit die Pandoc-Titelseite ein lesbares Datum zeigt.
- Seitenmarken (`--page-markers`) aus einem Kastentitel stehen jetzt vor dem Kasten statt im fetten Titel,
wie bei Überschriften.
### Behoben
- `epub2md_merge.py`: Kapitel-Startanker heißen jetzt `kap-003-name` statt `003-name`. Pandocs EPUB-Ausgabe
löste Links auf IDs mit führender Ziffer nicht auf (C-Buch 32, Mathebuch 20 tote Links im EPUB).
`pandoc/book.lua` erkennt beide Formen (QA3-05).
- Buch-README: Der EPUB-Befehl enthält bei Büchern mit Formeln `--mathml`. Ohne blieben im Mathebuch
1072 Formeln als TeX-Text stehen (QA3-04).
- TeX aus `alt`-Texten wird für Pandoc portabel gemacht (`\"u` → `ü`, `\O` → `\emptyset`, `@{…}` und
`\arraycolsep` in `array`, leeres `\displaystyle` vor Zellenende). Im Mathebuch kann Pandoc damit 4985 statt
4977 Formeln setzen; vorher blieben 9 als TeX-Text stehen (QA3-06).
- `pandoc/print.css` und `pandoc/book.lua`: keine Seitenzahl auf dem Cover.
- CSS-Auswertung: Es zählen nur noch Stylesheets, die ein Dokument per `<link>` (oder `@import`) einbindet.
Bisher wirkten alle CSS-Dateien des Manifests, auch mitgelieferte Layouts anderer Buchreihen. Im C-Buch
machte das aus fetten Buchstaben Inline-Code (`` `st`an`d`ard ``) und setzte falsche `<mark>`; eine
fremde `display:none`-Regel hätte Text gelöscht (QA3-01, Regressionstests in `tests/test_styles.py`).
- `pandoc/callouts.lua`: Die Kästen blieben ohne Rahmen, wenn man Pandoc zusätzlich `-H`/`--include-in-header`
übergab, und im EPUB hatten nur die Titelseite, nicht die Kapitel, das Kasten-CSS. Das CSS steht jetzt in
`pandoc/callouts.css`; der Filter setzt es bei HTML an den Anfang des Dokuments und fügt es bei EPUB den
Stylesheets hinzu (zusammen mit Pandocs Standard-CSS bzw. zu eigenen `css:`-Angaben).
### Dokumentation
- `--no-css-emphasis` lässt `<mark>`-Markierungen bestehen; diese steuert `--styles`.
## [0.2.0] – 2026-10-06
Schwerpunkt: Typografie und Gestaltung des Buches im Markdown abbilden.
### Neu
- **Hervorhebungen aus dem CSS des Buches** (`epub2md/css.py`): fett, kursiv, unterstrichen, durchgestrichen,
hoch-/tiefgestellt und Festbreitenschrift werden auch übernommen, wenn der Verlag sie nur per Stylesheet
setzt. Per CSS ausgeblendete Elemente (`display:none`) entfallen. Abschaltbar mit `--no-css-emphasis`.
- **Infokästen** (Tipp, Hinweis, Warnung, Exkurs …) als GitHub-/Obsidian-Callouts `> [!TIP]`, wahlweise als
Zitatblock, HTML-`div` oder normaler Text: `--boxes {callout,quote,html,plain}`.
- **Pandoc-Filter** `pandoc/callouts.lua`: zeichnet die Kästen in HTML/EPUB als gerahmte Kästen, in
LaTeX/PDF, Word und ODT als Zitatblock mit Titel. Die Buch-README nennt ihn in den Pandoc-Befehlen.
- **Farben, Schriftarten, Schriftgrößen, Kapitälchen, Ausrichtung und Fremdsprache** als HTML-Stil:
`--styles html`. Standard `semantic` übernimmt nur Hintergrundmarkierungen (`<mark>`).
- **Szenenwechsel** (Leerabsätze, Ornamente) als `* * *`, **Gedichte** als Zitatblock mit Zeilenumbrüchen.
- **Seitenzahlen der Druckausgabe** als `[S. 123]` oder `<!-- S. 123 -->`: `--page-markers {none,visible,comment}`.
- **Unsichtbare Zeichen** (Nullbreiten-Leerzeichen, weiche Trennstriche) werden im Fließtext entfernt, im
Code nicht. Abschaltbar mit `--keep-invisible-chars`.
- Verzeichnisse (Quellen, Literatur, Register, Glossar) unterhalb von `--chapter-level` werden eigene Dateien.
- Bericht: neue Abschnitte `text_cleanup`, `css` und `layout`.
### Geändert
- Kastentitel ohne dekorative Symbole wie `[»]`. Der Kastentyp aus der Verlagsklasse hat Vorrang vor einem
Typwort im Titel.
- Zwischen Feld-Spans wird kein Leerzeichen mehr eingefügt, wenn einer davon per CSS ausgeblendet ist.
### Dokumentation
- Bedienungsanleitung: Umwandlung in andere Formate (HTML, PDF, EPUB, Word, Kindle), Infokästen,
Farben/Schriften, Seitenzahlen.
## [0.1.0] – 2026-10-06
Erste Fassung.
- `epub2md.py`: DRM-freie EPUB 2/3 → kapitelweises Markdown mit Medien, `metadata.yaml`, `manifest.json`,
`conversion-report.json` und Diagnose-Dateien; Code zeichengetreu, MathML → TeX, Fußnoten-Modi,
sichere ZIP-/XML-Verarbeitung.
- `epub2md_merge.py`: Gesamtdatei `<Original>.md` aus einem Ausgabeordner.
- Optionen `--code-language`, `--keep-wrap-markers`; Warnung `chapter-large`; Titel überschriftsloser Seiten
aus dem Inhalt.
- Alle Befunde der QA-Prüfungen (R1–R7, QA2-01 bis QA2-19) behoben oder abgemildert.
- README und Bedienungsanleitung.
[Unveröffentlicht]: https://kitux.de/forgejo/dschlueter/epub2md/compare/v0.3.0...main
[0.3.0]: https://kitux.de/forgejo/dschlueter/epub2md/compare/v0.2.0...v0.3.0
[0.2.0]: https://kitux.de/forgejo/dschlueter/epub2md/compare/v0.1.0...v0.2.0
[0.1.0]: https://kitux.de/forgejo/dschlueter/epub2md/src/tag/v0.1.0

169
README.md
View file

@ -7,7 +7,7 @@ YAML-Metadaten, Inhaltsverzeichnis, maschinenlesbarem Manifest und einem Konvert
Gedacht für die Weiterverarbeitung mit Markdown-Renderern, Pandoc, Obsidian/Zettelkasten, Git,
RAG-/Embedding-Pipelines, Volltextsuche und lokale LLMs.
Eine Schritt-für-Schritt-Anleitung für Anwender steht in der [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md). Änderungen je Version: [CHANGELOG.md](CHANGELOG.md).
Eine Schritt-für-Schritt-Anleitung für Anwender steht in der [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md).
Das Projekt enthält zwei Programme:
@ -71,7 +71,7 @@ python3 epub2md.py \
--toc \
--verbose
# Jedes Kapitel unterhalb von h2 als eigene Datei (sonst teilt auto nur sehr große Kapitel)
# Roman mit Teilen (h1) und Kapiteln (h2): ein File pro Kapitel
python3 epub2md.py roman.epub output/roman --chapter-level 2
# Nur analysieren, nichts schreiben
@ -88,7 +88,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
| `--format {gfm,commonmark}` | `gfm` | `gfm`: Pipe-Tabellen, `~~durchgestrichen~~`, `[^fn]`-Fußnoten. `commonmark`: Tabellen als HTML, `<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 {auto,1-6}` | `auto` | Überschriftenebene, ab der eine neue Kapiteldatei beginnt. `auto`: Ebene 1; nur Kapitel mit über 30 000 Wörtern und mindestens zwei Unterkapiteln (z. B. Teile eines Romans) werden an ihren Unterkapiteln geteilt (Info `chapter-split`). Eine Zahl gilt ausnahmslos für das ganze Buch |
| `--chapter-level N` | `1` | Überschriftenebene (1–6), ab der eine neue Kapiteldatei beginnt |
| `--toc` / `--no-toc` | an | `README.md` mit Inhaltsverzeichnis, Statistik und Reproduktionsbefehl |
| `--frontmatter` / `--no-frontmatter` | an | YAML-Frontmatter in jeder Kapiteldatei |
| `--footnotes {keep-links,end-of-chapter,end-of-book,inline}` | `end-of-chapter` | siehe [Fußnoten](#fußnoten-modi) |
@ -97,11 +97,6 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
| `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt |
| `--code-language SPRACHE` | – | Sprachlabel für Codeblöcke ohne erkannte Sprache (z. B. `python`, `c`; Aliase wie `py` erlaubt). Erkannte Labels haben Vorrang; im Bericht `detected_by: "option"` |
| `--keep-wrap-markers` | aus | Druck-Umbruchzeichen `↪`/`➥` in Codeblöcken stehen lassen. Standard: Fortsetzungszeilen werden wieder zur ursprünglichen Codezeile zusammengefügt (Anzahl im Bericht: `wrap_lines_joined`) |
| `--boxes {callout,quote,html,plain}` | `callout` | Infokästen (Tipp, Hinweis, Warnung, Exkurs, Merke …): `callout` = `> [!TIP]` mit fettem Titel (GitHub-Alerts, Obsidian-Callouts; anderswo ein Zitatblock), `quote` = Zitatblock mit fettem Titel, `html` = `<div class="box box-tip">`, `plain` = normaler Text wie bisher |
| `--styles {semantic,html}` | `semantic` | `semantic`: nur bedeutungstragende Gestaltung (Hintergrundmarkierung → `<mark>`). `html`: zusätzlich Farbe, Hintergrund, Schriftgröße, Schriftart, Kapitälchen, Ausrichtung als `<span style>`/`<div style>` und fremdsprachige Passagen als `<span lang>`, jeweils nur, wo sie vom normalen Text des Buches abweichen. Obsidian, VS Code und Pandoc (HTML/PDF/EPUB) zeigen das an, GitHub nicht |
| `--page-markers {none,visible,comment}` | `none` | Seitenzahlen der Druckausgabe an der Stelle des Seitenwechsels: `visible` = `\[S. 123\]` im Text (zum Zitieren), `comment` = `<!-- S. 123 -->` (unsichtbar, aber durchsuchbar), `none` = weglassen |
| `--css-emphasis` / `--no-css-emphasis` | an | Fett, kursiv, unterstrichen, durchgestrichen, hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS-Klasse setzt (z. B. `span.italic`). Einzelwörter in Monospace-Schrift werden zu Inline-Code; per CSS ausgeblendete Elemente (`display:none`) entfallen. Statistik im Bericht unter `css`. Hintergrundmarkierungen (`<mark>`) und Stile steuert unabhängig davon `--styles` |
| `--keep-invisible-chars` | aus | Nullbreiten-Zeichen und weiche Trennstriche im Fließtext behalten. Standard: entfernen, weil sie Suche und RAG stören (Anzahl im Bericht: `text_cleanup`). Code bleibt immer unverändert |
| `--keep-html` | aus | Unbekannte/nicht sicher konvertierbare Elemente als bereinigtes HTML erhalten (sonst: Text übernommen + Eintrag in `diagnostics/unsupported-elements.md`) |
| `--strict` | aus | Abbruch bei kritischen Fehlern (fehlende/unlesbare Spine-Dokumente, unsichere ZIP-Pfade, ungültige Spine-Verweise) |
| `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) |
@ -158,9 +153,9 @@ output/mein-buch/
## Wie Kapitel gebildet werden
1. Die **OPF-Spine** bestimmt die Lesereihenfolge (nicht Dateinamen, nicht das Inhaltsverzeichnis).
2. Überschriften `h1`…`hN` (N = `--chapter-level`, bei `auto` 1 bzw. 2 in sehr großen Kapiteln) und
semantische Abschnitte (`epub:type`/`role`: chapter, part, appendix, glossary, bibliography, index,
preface, …) beginnen neue Kapiteldateien, auch mitten in einer XHTML-Datei.
2. Überschriften `h1`…`hN` (N = `--chapter-level`) und semantische Abschnitte (`epub:type`/`role`:
chapter, part, appendix, glossary, bibliography, index, preface, …) beginnen neue Kapiteldateien,
auch mitten in einer XHTML-Datei.
3. Eine XHTML-Datei, die nur mit einer tieferen Überschrift beginnt (z. B. eine Datei pro Abschnitt
`1.2`), wird an das laufende Kapitel angehängt.
4. Dateien ohne Überschrift (Cover, Titelei, Widmung) werden eigene Kapitel. Ihr Titel kommt aus
@ -170,10 +165,6 @@ output/mein-buch/
- ©/ISBN/„urheberrechtlich“ → „Impressum“
- sonst die ersten acht Wörter
- erst als letzte Notlösung der Dateiname
Unabhängig vom Titel gilt ein erstes Kapitel, das nur aus einem Bild besteht, als Cover (z. B.
„[Titelinfo]“); die Titel „Deckblatt“/„Umschlag“ ergeben ein Cover, „Titel“/„Titelei“ eine Titelseite.
Diese Typen stehen in `manifest.json` und bestimmen in der Gesamtdatei die Stelle des Inhaltsverzeichnisses.
5. Ein HTML-Inhaltsverzeichnis wird übersprungen, wenn NAV/NCX vorhanden ist (keine doppelte Ausgabe).
Links darauf zeigen auf `README.md`.
6. Verzeichnisse erhalten immer eine eigene Datei, auch unterhalb von `--chapter-level`, z. B. ein
@ -188,13 +179,10 @@ Verlagsspezifische Heuristiken: Layout-Tabellen mit Klassen wie `Head1`/`Title_d
Überschriften. `<pre>` mit `<br/>`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken.
Literaturlisten mit eigenen Labels (`[BryL06]`) werden nicht doppelt nummeriert.
**Teile und Kapitel:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), teilt der
Standard `auto` jeden Teil mit über 30 000 Wörtern und mindestens zwei Kapiteln in eine kurze Datei für den
Teil (Überschrift, Einleitung) und eine Datei pro Kapitel; kleinere Kapitel (Vorwort, Anhang) bleiben ganz.
Verzeichnisse (Register, Literatur, Glossar) werden dabei nie zerlegt. `--chapter-level 2` teilt dagegen
jedes Kapitel des Buches an `h2`. Ist bei einer festen Zahl eine Kapiteldatei größer als 30 000 Wörter und
hat sie mindestens zwei `##`-Unterabschnitte, meldet epub2md eine Warnung `chapter-large` mit der
passenden `--chapter-level`-Empfehlung.
**Tipp:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), erzeugt
`--chapter-level 2` eine Datei pro Kapitel. Mit dem Standard `1` entsteht eine Datei pro Teil.
Ist eine Kapiteldatei größer als 30 000 Wörter und hat sie mindestens zwei `##`-Unterabschnitte, meldet
epub2md eine Warnung `chapter-large` mit der passenden `--chapter-level`-Empfehlung.
## Math-Modi
@ -246,44 +234,9 @@ weil CommonMark keine `[^…]`-Syntax kennt.
- **Syntax-Highlighting:** Farb-Spans, `<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`, Shebang, `>>>`, Python-Code, der sich parsen lässt und `def`/`class`/Kontrollstrukturen
enthält, …). Gedruckte Zeilennummern als Text (`00 #include …`) stören die Erkennung nicht und bleiben
im Block stehen. Sind sich die sicher erkannten Blöcke einig (mindestens 5, davon 90 % eine Sprache),
genügen für die übrigen schwächere Signale: in einem Python-Buch Zuweisungen und Aufrufe, in einem
Buch mit C-artiger Sprache Anweisungen und Deklarationen mit `;` oder `{` (im Bericht
`detected_by: "book-context"`). Programmausgaben (Literale, Zahlen, Text) bleiben ohne Label.
`--code-language python` (o. Ä.) kennzeichnet alle übrigen Blöcke, auch Programmausgaben und
Shell-Befehle im Buch.
- **CSS-Formatierung:** Viele Verlage setzen Hervorhebungen nur über Stylesheets. epub2md wertet die
CSS-Dateien des Buches aus: Klassen-, Element- und ID-Selektoren, Nachfahren- und Kind-Kombinatoren,
Spezifität und `style`-Attribute. `@media`-Blöcke und Selektoren mit Pseudoklassen werden ignoriert.
Berücksichtigt werden nur Stylesheets, die ein Dokument per `<link>` (oder `@import`) einbindet;
mitgelieferte Layouts anderer Buchreihen bleiben wie im E-Book-Reader wirkungslos (Info `css-unlinked`).
Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst (`*x**y*` → `*xy*`).
- **Farben und Schriften:** Markdown kennt keine Farben, Schriftarten oder -größen. Mit `--styles html`
bleiben sie als HTML-Stil erhalten. Übernommen wird nur, was vom Grundstil abweicht (häufigste Werte aller
Absätze; Schriftgröße erst ab ±15 %). Abgesetzte Formeln und Bildabsätze bekommen keine Ausrichtung. Werte
mit `url(...)`, `expression` usw. werden verworfen.
- **Seitenzahlen (`--page-markers`):** Erkannt werden `epub:type="pagebreak"`/`role="doc-pagebreak"`
(Nummer aus `aria-label`, `title` oder Text) und Seitenanker mit IDs wie `page_47`, `page37`, `seite-12`
oder – nur bei leeren `<a>`/`<span>` – `p17`. In Codeblöcken und Inline-Code stehen keine Marker; ein
Seitenwechsel in einer Überschrift steht als eigener Absatz davor. Die Anzahl steht im Bericht unter
`layout.page_markers`.
- **Infokästen:**
- **Erkennung:** `aside`/`epub:type` (sidebar, tip, warning …), Klassennamen (box, kasten, tipp, hinweis,
warnung, achtung, wichtig, merke, exkurs …) oder ein CSS-Rahmen bzw. -Hintergrund um kurzen Fließtext.
Folgen von Tipp-Absätzen ohne Rahmen werden zu einem Kasten zusammengefasst.
- **Titel und Symbole:** Kastenüberschriften werden zum fetten Titel und tauchen damit nicht mehr in der
Gliederung auf. Dekorative Symbolbilder entfallen.
- **Typ:** Er ergibt sich aus Klasse, Titel oder erstem Wort: TIP, IMPORTANT, WARNING, CAUTION, sonst NOTE.
- **Ausnahmen:** Umrahmte Codelistings und Bildrahmen gelten nicht als Kasten.
- **Szenenwechsel und Verse:**
- Leere Absätze zwischen zwei Textabsätzen werden zu `* * *`; mehrere hintereinander ergeben einen
Wechsel. Ebenso werden Ornament-Absätze wie `***` oder `⁂` zu `* * *`.
- Leere `div`s und Absätze, die nur Anker enthalten, gelten als Layout und nicht als Wechsel.
- Gedichte (`poem`, `verse`, `gedicht` … bzw. `z3998:poem`) werden zum Zitatblock mit einer Zeile pro Vers
und einer Leerzeile zwischen Strophen.
- Anzahl im Bericht unter `layout`.
(`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. Bei einsprachigen
Büchern kann `--code-language python` (o. Ä.) alle übrigen Blöcke kennzeichnen. Achtung: Das betrifft
auch Programmausgaben und Shell-Befehle im Buch.
- **Gescannte Texte:** nur Bilder, kein Text (keine OCR).
- **DRM:** wird erkannt und abgelehnt.
- **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne
@ -310,15 +263,14 @@ verwendet und das Verzeichnis gibt den Namen vor.
| --- | --- |
| `-o`, `--output DATEI.md` | anderer Zielort; Medienpfade werden relativ zum Ziel umgeschrieben |
| `--frontmatter` / `--no-frontmatter` | ein YAML-Kopf mit Buchmetadaten (`title`, `author`, `lang`, …) für Pandoc (Standard: an) |
| `--toc` | Inhaltsverzeichnis mit Sprunglinks auf die Kapitel einfügen, hinter Cover, Titelseite und Impressum (für Pandoc-PDFs besser `pandoc/book.lua`, das Seitenzahlen und Unterabschnitte liefert) |
| `--toc` | Inhaltsverzeichnis mit Sprunglinks auf die Kapitel einfügen |
| `--force` | bestehende Zieldatei überschreiben (sonst Abbruch mit Exit 1) |
| `--version` | Programmversion |
Beim Zusammenfügen passiert Folgendes:
- Die Frontmatter der einzelnen Kapitel wird entfernt.
- Jedes Kapitel erhält einen Startanker (`<a id="kap-003-kapitelname"></a>`). Er beginnt mit einem Buchstaben,
weil Pandocs EPUB-Ausgabe Links auf IDs mit führender Ziffer nicht auflöst.
- Jedes Kapitel erhält einen Startanker (`<a id="003-kapitelname"></a>`).
- Links zwischen Kapiteln (`003-x.md#anker`) werden zu Sprunglinks in der Gesamtdatei.
- Anker, die in mehreren Kapiteln vorkommen (z. B. `Sec1` in jedem Kapitel eines Springer-Buchs), werden
ab dem zweiten Vorkommen in `c012-Sec1` umbenannt, samt aller Links darauf.
@ -327,15 +279,8 @@ Beim Zusammenfügen passiert Folgendes:
- Codeblöcke und Inline-Code werden nie verändert.
- Kapitelüberschriften bleiben `#`, jedes Kapitel beginnt also mit einer H1. Pandoc behandelt sie als
Kapitel.
- Hinter dem Vorspann (Cover, Titelseite, Impressum und kurze Seiten dazwischen, bis vor den ersten Teil,
das Vorwort oder das erste nummerierte Kapitel) steht ein unsichtbarer Platzhalter
`<div id="epub2md-toc"></div>`; hat das Buch keine eigene Titelseite, steht hinter dem Cover zusätzlich
`<div id="epub2md-titlepage"></div>`. `pandoc/book.lua` setzt dort Inhaltsverzeichnis und Titelkopf ein;
GitHub, Obsidian und andere Programme zeigen die leeren `div`s nicht an.
- Der Buch-Kopf enthält nur Felder, die im EPUB vorhanden sind (`title`, `subtitle`, `author`, `lang`,
`publisher`, `date`, `identifier`, `rights`, `description`). Ein ISO-Zeitstempel als Datum wird gekürzt,
damit Pandoc auf der Titelseite nicht `2025-01-13T08:00:00+00:00` druckt: bei deutschen Büchern zu
`13.01.2025`, sonst zu `2025-01-13`. `metadata.yaml` behält den Originalwert.
`publisher`, `date`, `identifier`, `rights`, `description`).
Am Ende werden alle Links der Gesamtdatei geprüft. Ein Link auf einen nicht vorhandenen Anker zeigt auf
den Kapitelanfang und erzeugt eine Warnung auf stderr. Die Zusammenfassung nennt Kapitelanzahl,
@ -351,54 +296,21 @@ konsistente Anker:
```bash
cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei
P=~/Python_Programs/epub2md/pandoc # Pfad zum epub2md-Ordner anpassen
pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua \
--css $P/print.css --pdf-engine=weasyprint -o programmierbuch.pdf # PDF (empfohlen)
pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o programmierbuch.html
pandoc programmierbuch.md --toc --lua-filter $P/callouts.lua -o programmierbuch.epub
pandoc programmierbuch.md --toc -s -o programmierbuch.html
pandoc programmierbuch.md --toc -o programmierbuch.epub
cd output/mathematikbuch # Formeln: PDF über HTML und LaTeX (weasyprint setzt kein MathML)
pandoc mathematikbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o mathematikbuch.html
pandoc mathematikbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex \
-o mathematikbuch.pdf
pandoc mathematikbuch.md --toc --mathml --lua-filter $P/callouts.lua -o mathematikbuch.epub # EPUB: MathML
cd output/mathematikbuch
pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html
# PDF über den Zwischenschritt HTML (siehe unten)
pandoc mathematikbuch.html --toc --pdf-engine=xelatex -o mathematikbuch.pdf
```
Hilfsdateien in `pandoc/`:
- **`book.lua`** (HTML und beide PDF-Wege; statt `--toc`): Pandocs `--toc` setzt Inhaltsverzeichnis und
Titelkopf immer an den Anfang, vor Cover, Titelseite und Impressum. `epub2md_merge.py` markiert deshalb das
Ende des Vorspanns (`<div id="epub2md-toc"></div>`, Kapiteltypen aus `manifest.json`) und, falls das Buch
keine eigene Titelseite hat, die Stelle hinter dem Cover (`<div id="epub2md-titlepage"></div>`). Der
Filter baut dort das Inhaltsverzeichnis (`--toc-depth` gilt), lässt Pandocs Titelkopf bei vorhandener
Titelseite weg, zeigt das Cover ohne Bildunterschrift und macht aus den Kapitelankern
`div.chapter-start` für Seitenumbrüche. Beim LaTeX-Weg (Filter in beiden Schritten) wird daraus
`\tableofcontents` an derselben Stelle, `\clearpage` vor jedem Kapitel und kein `\maketitle` vor dem Cover.
Außerdem A4 mit 2 cm Rand, das Cover in Seitengröße und – falls installiert und nicht selbst gewählt –
DejaVu-Schriften (`mainfont`, `monofont`, `mathfont`): Latin Modern fehlen Zeichen wie ∈ λ ≠ ≥ □ und
schmale Leerzeichen (im Mathebuch über 10.000 Stellen). ℓ, 〈 〉, ∖, ⋯ und „↩︎“ werden als Mathe-Symbole gesetzt.
Bei anderen Formaten entfernt er nur die Platzhalter. Wer selbst `--toc` angibt, bekommt Pandocs
Verzeichnis wie gewohnt.
- Der Vorspann reicht bis vor das erste Kapitel des Hauptteils (Teil, Vorwort, Einleitung, Prolog,
nummerierter Titel oder mehr als 1000 Wörter), sofern er Cover, Titelseite oder Impressum enthält. Ein
erstes Kapitel nur aus einem Bild gilt als Cover, ein Kapitel „Titel“ als Titelseite.
- **`print.css`** (weasyprint): A4, Seitenzahlen (nicht auf dem Cover), jedes Kapitel (auch Cover,
Titelseite, Impressum) und das Inhaltsverzeichnis auf neuer Seite, Inhaltsverzeichnis mit Punktlinie und
Seitenzahl, Silbentrennung (nicht in Code), umbrechende Codezeilen. Mit `--css` lässt Pandoc sein eigenes
Stylesheet weg, deshalb bringt `print.css` auch Grundschrift, Zeilenabstand und Titelkopf mit. Getestet
mit dem Python-Buch (362 Seiten).
**Infokästen:** Pandoc kennt die Schreibweise `> [!TIP]` erst ab neueren Versionen (Erweiterung `alerts`);
3.1.3 (Ubuntu 24.04) zeigt „[!TIP]“ als Text. Der Filter `pandoc/callouts.lua` behebt das: In HTML und
EPUB entstehen gerahmte, farbige Kästen mit Titel („Tipp: …“, bei `lang: en` „Tip: …“), in LaTeX/PDF, Word
und ODT ein Zitatblock mit fettem Titel. Beim PDF-Weg über HTML den Filter in beiden Schritten angeben.
Die `README.md` eines konvertierten Buches mit Kästen enthält die Befehle bereits mit dem Filter.
**PDF immer über HTML erzeugen.** Beim direkten Weg Markdown → LaTeX verwirft Pandoc allen rohen HTML-Code.
Dabei gehen HTML-Tabellen samt Inhalt verloren, und `<a id>`-Sprungziele fehlen, sodass interne Links im PDF
ins Leere zeigen. Liest Pandoc dagegen die HTML-Fassung, macht es daraus echte Tabellen und Sprungziele.
Getestet mit dem C-Buch (322 Seiten, 34 Tabellen; alle 831 internen Links haben ein Ziel, kein Zeichen
fehlt). Die `README.md` jedes konvertierten Buches enthält den passenden Befehl.
Getestet mit dem C-Buch (405 Seiten, 34 Tabellen, 728 von 755 internen Links funktionieren; die übrigen
stehen in Codeblöcken innerhalb von Tabellen). Die `README.md` jedes konvertierten Buches enthält den
passenden Befehl.
Alternativ direkt aus den Kapiteldateien:
@ -413,10 +325,8 @@ pandoc output/programmierbuch/chapters/*.md \
- Ausgaben mit `--math tex` (`\(…\)`) brauchen `-f markdown+tex_math_single_backslash`.
- Das YAML-Frontmatter jeder Kapiteldatei wird von Pandoc als Metadaten gelesen. Wer die Kapiteldateien
direkt zusammen verarbeitet, sollte mit `--no-frontmatter` konvertieren oder die Gesamtdatei verwenden.
- TeX aus dem E-Book wird für Pandocs Formelparser angepasst, ohne den Inhalt zu ändern: `\"u` → `ü`,
`\O` (leere Menge) → `\emptyset`, keine `@{…}`-Spaltentrenner und kein `\arraycolsep` in `array`, kein
leeres `\displaystyle` vor einem Zellenende. Seltene Handkonstruktionen (z. B. `\mathinner{…\raise…}`)
bleiben als TeX stehen, mit der Warnung „Could not convert TeX math“ (Mathebuch: 1 von 4986 Formeln).
- Einzelne TeX-Befehle aus EPUBs (z. B. `\O` oder `@`-Konstrukte) kennt Pandocs Formelparser nicht. Er
belässt sie als TeX, mit der Warnung „Could not convert TeX math“.
## Bericht und Diagnose
@ -460,12 +370,7 @@ Verlustfreiheit behauptet (`lossless: false`).
- Audio/Video sind implementiert, aber nur mit synthetischen Daten geprüft. Bildbände sind mit einem
realen Buch geprüft (434 Bilder, 246 Figuren, ausführliche Alternativtexte).
- Rein per CSS erzeugte Inhalte (`::before`/`content:`, z. B. automatische Schrittnummern) werden nicht
übernommen, weil Pseudo-Elemente nicht ausgewertet werden.
- Die eingebundenen Stylesheets gelten für das ganze Buch, nicht je Kapiteldokument. Bindet nur ein
Kapitel ein bestimmtes Stylesheet ein, wirkt es auch auf die anderen. In Fußnoten greifen CSS-Regeln
mit Vorfahren-Selektoren nicht, weil die Noten vor dem Rendern aus dem Text gelöst werden.
- Von Hand gebaute TeX-Konstrukte (z. B. `\mathinner{…\raise…}`) kann Pandoc nicht in MathML umsetzen;
sie bleiben als TeX-Text stehen (Mathebuch: 1 von 4986 Formeln).
übernommen, weil CSS nicht ausgewertet wird.
## Architektur
@ -481,7 +386,6 @@ epub2md/
├── chapters.py Kapitelbildung (Spine + Überschriften + epub:type), Klassifikation
├── references.py Ziel-IDs → Kapiteldatei + Anker, Link-Auflösung
├── html_converter.py rekursiver XHTML→Markdown-Renderer (Blöcke, Inline, Tabellen, Listen, Bilder)
├── css.py Mini-CSS-Auswertung (Selektoren, Spezifität) für Hervorhebungen und Stile
├── codeblocks.py Codeerkennung, exakte Textextraktion, Spracherkennung, Fences
├── mathml.py konservative MathML→TeX-Konvertierung, TeX-Erkennung
├── media.py Medienregistry mit stabilen Namen
@ -491,12 +395,6 @@ epub2md/
├── diagnostics.py zentrale Sammlung von Warnungen/Fehlern
├── models.py Dataclasses (Kapitel, Medien, Noten, Metadaten …)
└── utils.py Slugs, YAML-Emitter, Pfadhelfer
pandoc/
├── book.lua Pandoc-Filter: Inhaltsverzeichnis nach Cover/Titelseite/Impressum, Kapitel-Seitenumbrüche
├── callouts.lua Pandoc-Filter: '> [!TIP]'-Kästen → gerahmte Kästen (HTML/EPUB) bzw. Zitatblock mit Titel
├── callouts.css Gestaltung der Kästen (vom Filter eingebunden; muss neben callouts.lua liegen)
└── print.css Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite, Codeumbruch)
tools/qa/ QA-Skripte: CLI-Matrix, Validator, Wortvergleich, Pandoc-Prüfung (siehe tools/qa/README.md)
```
Statt BeautifulSoup wird direkt lxml verwendet: Es ist für die XHTML-Bäume ausreichend, schneller und
@ -514,14 +412,7 @@ ruff check epub2md tests && mypy epub2md --ignore-missing-imports # optional,
Die Tests erzeugen synthetische Minimal-EPUBs (EPUB 2/3, NAV/NCX, Code, Tabellen, MathML, SVG,
Fußnoten, Glossar/Literatur/Register, unsichere ZIP-Pfade, DRM, XXE, defekte Container, Kodierung) und
prüfen die tatsächliche CLI-Ausgabe. `tests/test_merge.py` prüft die Gesamtdatei: Reihenfolge,
Anker-Umbenennung, Pfade, Fußnoten, unveränderter Code, Überschreibschutz, Datumsformat und die Stelle des
Inhaltsverzeichnisses hinter dem Vorspann. `tests/test_styles.py` prüft die Typografie: CSS-Hervorhebungen,
unsichtbare Zeichen, Szenenwechsel, Gedichte, Infokästen, `--styles`, `--page-markers` und die Pandoc-Befehle
in der Buch-README. Liegen EPUBs unter `Beispiele/`, werden sie zusätzlich auf
Anker-Umbenennung, Pfade, Fußnoten, unveränderter Code und Überschreibschutz. Liegen EPUBs unter `Beispiele/`, werden sie zusätzlich auf
Struktur-Invarianten geprüft: keine kaputten Links, balancierte Code-Fences, gültiges YAML, alle Medien
vorhanden. Dabei werden keine Buchtexte verglichen. `Beispiele/` ist per `.gitignore` vom Repository
ausgeschlossen.`tests/test_pandoc.py` prüft den Pandoc-Weg (nur mit installiertem Pandoc).
Für gründlichere Prüfungen nach `Prompt_Kontrolle.md` gibt es `tools/qa/`: eine CLI-Matrix über alle Optionen
und Fehlercodes, einen unabhängigen Markdown-Validator, einen Wortvergleich gegen Datenverlust und die
Prüfung des Pandoc-Wegs (HTML, EPUB, PDF) für alle Beispielbücher. Details in `tools/qa/README.md`.
ausgeschlossen.

View file

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

View file

@ -9,17 +9,7 @@ from typing import Any
from .diagnostics import Diagnostics
from .models import Chapter, NavEntry
from .normalize import Doc, has_content
from .utils import (
classes,
epub_types,
is_element,
iter_elements,
local,
norm_ws,
remove_invisible,
roles,
text_content,
)
from .utils import classes, epub_types, is_element, iter_elements, local, norm_ws, roles, text_content
HEADINGS = {"h1": 1, "h2": 2, "h3": 3, "h4": 4, "h5": 5, "h6": 6}
CONTAINERS = {"body", "div", "section", "article", "aside", "main", "header", "footer", "nav", "span", "hgroup"}
@ -45,8 +35,8 @@ KIND_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("appendix", re.compile(r"^(anhang|appendix)", re.I)),
("colophon", re.compile(r"^(impressum|colophon|kolophon)", re.I)),
("dedication", re.compile(r"^(widmung|dedication)", re.I)),
("cover", re.compile(r"^(cover|umschlag|titelbild|deckblatt)\b", re.I)),
("titlepage", re.compile(r"^(titelei|titelseite|title ?page|titel\s*$|title\s*$)", re.I)),
("cover", re.compile(r"^(cover|umschlag|titelbild)\b", re.I)),
("titlepage", re.compile(r"^(titelei|titelseite|title ?page)", re.I)),
("part", re.compile(r"^(teil|part)\s+([IVXLC]+|\d+|[a-zäöü]+)\b", re.I)),
("epilogue", re.compile(r"^(epilog|nachwort|epilogue|afterword)", re.I)),
("prologue", re.compile(r"^(prolog|prologue)", re.I)),
@ -105,11 +95,9 @@ def semantic_types(el: Any) -> list[str]:
class ChapterBuilder:
def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics,
book_title: str = "", authors: list[str] | None = None, deep_starts: set[Any] | None = None) -> None:
book_title: str = "", authors: list[str] | None = None) -> None:
self.level = chapter_level
self.diag = diag
self.deep_starts = deep_starts or set() # chapter headings whose sections are split one level deeper
self._deep = False # current chapter is one of them
self._special_level = 0 # level of a split-out special heading whose section is still open
self._in_special = False # current chapter itself is a bibliography/index/glossary
self.book_title = book_title
@ -131,12 +119,6 @@ class ChapterBuilder:
if lvl and has_content(el):
special = _special_heading(el)
if lvl <= self.level:
if update:
self._special_level = 0
self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS
self._deep = el in self.deep_starts
return Marker(el, "heading", lvl)
if self._deep and lvl == self.level + 1:
if update:
self._special_level = 0
self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS
@ -204,7 +186,7 @@ class ChapterBuilder:
body_segments = segments[1:]
if _meaningful(head_items):
first = _first_meaningful(head_items)
if chapters and leading_heading_level(first) > self.level + self._deep and not semantic_types(doc.body):
if chapters and leading_heading_level(first) > self.level and not semantic_types(doc.body):
chapters[-1].items.extend(pending + head_items)
pending = []
if doc.path not in chapters[-1].docs:
@ -288,7 +270,6 @@ class ChapterBuilder:
if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]):
types.append("auxiliary")
ch.types = list(dict.fromkeys(types))
ch.title = remove_invisible(ch.title)[0] # titles feed file names, frontmatter and README
return ch
def _title_from_content(self, items: list[tuple[str, Any]], number: int) -> tuple[str, str]:
@ -315,21 +296,12 @@ class ChapterBuilder:
kind = classify_title(ch.title)
if kind and kind not in ch.types:
ch.types.append(kind)
if ch.number == 1 and "cover" not in ch.types and _image_only(ch.items):
ch.types.append("cover") # first chapter with nothing but a picture, whatever its label ('[Titelinfo]')
if "index" in ch.types and classify_title(ch.title) == "index-persons":
ch.types.remove("index")
if not ch.types:
ch.types.append("chapter")
def _image_only(items: list[tuple[str, Any]]) -> bool:
"""Content is one or more pictures without any text."""
text = "".join(it if isinstance(it, str) else text_content(it) for _, it in items if isinstance(it, str) or is_element(it))
return not text.strip() and any(is_element(it) and any(local(e.tag) in ("img", "svg", "image") for e in iter_elements(it))
for _, it in items)
@dataclass
class _TypeCarrier:
types: list[str]

View file

@ -46,14 +46,6 @@ def code_language(value: str) -> str:
return lang
def _chapter_level(value: str) -> int | str:
if value == "auto":
return value
if value.isdigit() and 1 <= int(value) <= 6:
return int(value)
raise argparse.ArgumentTypeError("auto oder eine Zahl zwischen 1 und 6 erwartet")
def build_parser() -> argparse.ArgumentParser:
p = _Parser(
prog="epub2md.py",
@ -69,10 +61,9 @@ def build_parser() -> argparse.ArgumentParser:
help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)")
p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False,
help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)")
p.add_argument("--chapter-level", type=_chapter_level, default="auto", metavar="{auto,1-6}",
help="Überschriftenebene, ab der neue Kapiteldateien entstehen. auto (Standard): Ebene 1, "
"nur sehr große Kapitel (über 30000 Wörter, z. B. Teile eines Romans) werden an ihren "
"Unterkapiteln geteilt. Eine Zahl 1–6 gilt ausnahmslos für das ganze Buch")
p.add_argument("--chapter-level", type=int, default=1, metavar="N",
help="Überschriftenebene 1–6, ab der neue Kapiteldateien entstehen (Standard: 1). "
"Für Bücher mit Teilen (h1) und Kapiteln (h2) ist 2 sinnvoll")
p.add_argument("--toc", action=argparse.BooleanOptionalAction, default=True,
help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)")
p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True,
@ -94,23 +85,6 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--keep-wrap-markers", action="store_true",
help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte "
"Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt")
p.add_argument("--boxes", choices=["callout", "quote", "html", "plain"], default="callout",
help="Infokästen (Tipp, Hinweis, Warnung, Exkurs …): callout = '> [!TIP]' (GitHub/Obsidian), "
"quote = Zitatblock mit fettem Titel, html = <div class=\"box …\">, plain = normaler Text "
"(Standard: callout)")
p.add_argument("--styles", choices=["semantic", "html"], default="semantic",
help="semantic: nur Bedeutung übernehmen (Hintergrundmarkierung → <mark>); html: zusätzlich Farbe, "
"Hintergrund, Schriftgröße/-art, Kapitälchen, Ausrichtung und Fremdsprache als <span style>/"
"<span lang> (wo sie vom normalen Text abweichen; GitHub zeigt das nicht an) (Standard: semantic)")
p.add_argument("--page-markers", choices=["none", "visible", "comment"], default="none",
help="Seitenzahlen der Druckausgabe: visible = '[S. 123]' im Text, comment = '<!-- S. 123 -->' "
"(unsichtbar, durchsuchbar), none = weglassen (Standard: none)")
p.add_argument("--css-emphasis", action=argparse.BooleanOptionalAction, default=True,
help="Fett/kursiv/unterstrichen/hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS "
"setzt; per CSS ausgeblendete Elemente weglassen (Standard: an)")
p.add_argument("--keep-invisible-chars", action="store_true",
help="Nullbreiten-Zeichen und weiche Trennstriche (U+00AD) im Fließtext behalten; Standard: entfernen "
"(stören Suche und Weiterverarbeitung). Code bleibt immer unverändert")
p.add_argument("--strict", action="store_true",
help="Bei kritischen Fehlern abbrechen: fehlende/unlesbare Spine-Dokumente, nicht dekodierbares "
"XHTML, unsichere ZIP-Pfade, übersprungene (verdächtige) Medien")
@ -145,11 +119,6 @@ def main(argv: list[str] | None = None) -> int:
keep_html=args.keep_html,
code_language=args.code_language or "",
keep_wrap_markers=args.keep_wrap_markers,
keep_invisible_chars=args.keep_invisible_chars,
css_emphasis=args.css_emphasis,
boxes=args.boxes,
styles=args.styles,
page_markers=args.page_markers,
strict=args.strict,
verbose=args.verbose,
dry_run=args.dry_run,

View file

@ -2,10 +2,8 @@
from __future__ import annotations
import ast
import json
import re
import warnings
from typing import Any
from .utils import classes, is_element, local
@ -129,114 +127,9 @@ def detect_language(el: Any) -> tuple[str, str]:
return "", ""
LINE_NUMBER_PREFIX = re.compile(r"^(\d{1,4})( +|$)")
def strip_line_numbers(code: str) -> str:
"""Remove printed line numbers ("00 #include …") – for language detection only, the block keeps them.
Applies when at least 80 % of the non-blank lines (at least two) start with consecutive numbers.
"""
lines = code.split("\n")
filled = [ln for ln in lines if ln.strip()]
matches = [LINE_NUMBER_PREFIX.match(ln) for ln in filled]
numbers = [int(m.group(1)) for m in matches if m]
if len(numbers) < 2 or len(numbers) < 0.8 * len(filled):
return code
if any(b != a + 1 for a, b in zip(numbers, numbers[1:])):
return code
width = min((len(m.group(2)) for m in matches if m and m.group(2)), default=0)
out = []
for ln in lines:
m = LINE_NUMBER_PREFIX.match(ln) if ln.strip() else None
out.append(ln[len(m.group(1)) + min(width, len(m.group(2))):] if m else ln)
return "\n".join(out)
def _python_module(text: str) -> ast.Module | None:
"""The parsed module, or None when the text is not valid Python (never executed, warnings suppressed)."""
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return ast.parse(text)
except (SyntaxError, ValueError, RecursionError, MemoryError):
return None
PY_COMPOUND = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.If, ast.For, ast.AsyncFor, ast.While,
ast.With, ast.AsyncWith, ast.Try)
PY_CODE_EXPR = (ast.Call, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.Lambda, ast.Await)
def _python_strength(text: str) -> int:
"""2 = surely Python (block statement, or import plus code), 1 = Python code (assignment, call,
comprehension …), 0 = not Python or only literals/names – which is what program output looks like."""
if any(ln.rstrip().endswith((";", "{")) for ln in text.split("\n")):
return 0
module = _python_module(text)
if module is None or not module.body:
return 0
imports = sum(isinstance(n, (ast.Import, ast.ImportFrom)) for n in module.body)
code = 0
for node in module.body:
if isinstance(node, PY_COMPOUND):
return 2
if isinstance(node, ast.Expr):
value = node.value
if isinstance(value, ast.Constant) and isinstance(value.value, str):
continue # docstring
if not isinstance(value, PY_CODE_EXPR):
return 0 # a bare name, number or literal line is program output ("Jane", "42", "[1, 4]")
code += 1
elif isinstance(node, ast.AnnAssign) and node.value is None:
pass # "name: value" is YAML or a Makefile rule as often as Python
elif not isinstance(node, (ast.Import, ast.ImportFrom, ast.Pass)):
code += 1
if imports and code:
return 2
return 1 if code else 0
CURLY_STATEMENT = re.compile(r"[=(]|^\s*[A-Za-z_]\w*[\s*]+[A-Za-z_*]") # assignment/call or "type name"
def _curly_signal(text: str) -> bool:
"""C-family syntax: a statement or declaration ending with ';' or '{', or a function body on one line."""
lines = [re.sub(r"(^|\s)//.*$", "", ln).rstrip() for ln in text.split("\n")]
if any(ln.endswith((";", "{")) and CURLY_STATEMENT.search(ln) for ln in lines):
return True
return bool(re.search(r"\w\s*\([^)]*\)\s*\{.*\}", text))
CURLY_LANGS = {"c", "cpp", "java", "csharp", "javascript", "typescript", "go", "rust", "php", "kotlin", "swift", "scala"}
def detect_language_from_book(code: str, book_language: str) -> str:
"""Weak content signals, accepted only when they fit the book's dominant language."""
if not book_language:
return ""
text = strip_line_numbers(code).strip()
if not text:
return ""
if book_language == "python" and _python_strength(text) >= 1:
return "python"
if book_language in CURLY_LANGS and _curly_signal(text):
return book_language
return ""
def dominant_language(languages: list[str]) -> str:
"""The book's language when the reliably detected blocks agree (at least 5 blocks, 90 %)."""
detected = [lang for lang in languages if lang and lang != "text"]
if len(detected) < 5:
return ""
lang = max(set(detected), key=detected.count)
return lang if detected.count(lang) >= 0.9 * len(detected) else ""
def detect_language_from_content(code: str) -> str:
"""Very conservative content heuristics – only strong signals."""
text = strip_line_numbers(code).strip()
text = code.strip()
if not text:
return ""
first = text.split("\n", 1)[0]
@ -274,8 +167,6 @@ def detect_language_from_content(code: str) -> str:
c_like = sum(1 for ln in lines if ln.rstrip().endswith((";", "{")))
if py_signals >= 2 and c_like == 0:
return "python"
if re.search(r"^>>> \S", text, re.M) or _python_strength(text) == 2:
return "python"
if re.match(r"\s*(SELECT|INSERT\s+INTO|CREATE\s+(TABLE|INDEX|VIEW)|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b", text):
return "sql"
if re.match(r"(?m)^FROM\s+\S+", text) and re.search(r"(?m)^(RUN|CMD|COPY|ENTRYPOINT)\s", text):

View file

@ -1,286 +0,0 @@
"""Minimal CSS evaluation for formatting that publishers express only through stylesheets.
Supports type/class/id selectors, descendant (` `) and child (`>`) combinators, specificity and source
order, and `style` attributes. Selectors with pseudo-classes, attribute selectors or sibling combinators
are ignored (conservative: an unknown rule never adds formatting). @-blocks (@media, @font-face, @page …)
are skipped.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from .utils import classes, local
COMPOUND = re.compile(r"^(\*|[a-zA-Z][\w-]*)?((?:[.#][\w-]+)*)$")
MONOSPACE = re.compile(r"monospace|courier|consolas|menlo|monaco|lucida console|source code|dejavu sans mono|"
r"liberation mono|fira mono|fira code|inconsolata|andale mono|\bmono\b", re.I)
@dataclass
class _Compound:
tag: str | None
classes: frozenset[str]
ids: frozenset[str]
def matches(self, el: Any) -> bool:
if self.tag and self.tag != "*" and local(el.tag) != self.tag:
return False
if self.ids and (el.get("id") or "").lower() not in self.ids:
return False
return not self.classes or self.classes <= set(classes(el))
@dataclass
class _Rule:
parts: list[tuple[str, _Compound]] # (combinator to the previous part, compound); first combinator is ""
specificity: tuple[int, int, int]
order: int
decls: dict[str, str]
def matches(self, el: Any) -> bool:
if not self.parts[-1][1].matches(el):
return False
node = el
for i in range(len(self.parts) - 1, 0, -1):
comb = self.parts[i][0]
compound = self.parts[i - 1][1]
node = node.getparent()
if comb == ">":
if node is None or not compound.matches(node):
return False
else: # descendant
while node is not None and not compound.matches(node):
node = node.getparent()
if node is None:
return False
return True
def _parse_declarations(text: str) -> dict[str, str]:
decls: dict[str, str] = {}
for part in text.split(";"):
if ":" not in part:
continue
prop, value = part.split(":", 1)
prop = prop.strip().lower()
value = re.sub(r"\s*!important\s*$", "", value.strip(), flags=re.I).strip()
if prop and value:
decls[prop] = value
return decls
def _parse_selector(sel: str) -> list[tuple[str, _Compound]] | None:
sel = sel.strip().lower()
if not sel or re.search(r"[:\[\]+~]", sel):
return None
tokens = re.split(r"\s*(>)\s*|\s+", sel)
parts: list[tuple[str, _Compound]] = []
comb = ""
for tok in tokens:
if tok is None or tok == "":
continue
if tok == ">":
comb = ">"
continue
m = COMPOUND.match(tok)
if not m:
return None
rest = m.group(2) or ""
parts.append((comb if parts else "", _Compound(
tag=m.group(1),
classes=frozenset(re.findall(r"\.([\w-]+)", rest)),
ids=frozenset(re.findall(r"#([\w-]+)", rest)),
)))
comb = " "
return parts or None
def _strip_at_blocks(css: str) -> str:
"""Remove @-rules including nested blocks (@media, @font-face, @supports, @page …)."""
out: list[str] = []
i = 0
while i < len(css):
if css[i] == "@":
brace = css.find("{", i)
semi = css.find(";", i)
if semi != -1 and (brace == -1 or semi < brace): # @import/@charset …;
i = semi + 1
continue
if brace == -1:
break
depth, j = 0, brace
while j < len(css):
if css[j] == "{":
depth += 1
elif css[j] == "}":
depth -= 1
if depth == 0:
break
j += 1
i = j + 1
continue
out.append(css[i])
i += 1
return "".join(out)
class StyleSheet:
def __init__(self, css_texts: list[str]) -> None:
self.rules: list[_Rule] = []
self._index: dict[str, list[_Rule]] = {}
self._cache: dict[Any, dict[str, str]] = {}
order = 0
for css in css_texts:
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
css = _strip_at_blocks(css)
for selectors, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
decls = _parse_declarations(body)
if not decls:
continue
for sel in selectors.split(","):
parts = _parse_selector(sel)
if not parts:
continue
spec = (sum(len(c.ids) for _, c in parts), sum(len(c.classes) for _, c in parts),
sum(1 for _, c in parts if c.tag and c.tag != "*"))
rule = _Rule(parts, spec, order, decls)
order += 1
self.rules.append(rule)
last = parts[-1][1]
keys = [f".{c}" for c in last.classes] or ([last.tag] if last.tag and last.tag != "*" else ["*"])
for key in keys[:1]: # one index entry suffices: every key must match anyway
self._index.setdefault(key, []).append(rule)
def computed(self, el: Any) -> dict[str, str]:
"""Declared properties for ``el`` (no inheritance), including its ``style`` attribute."""
cached = self._cache.get(el)
if cached is not None:
return cached
candidates: list[_Rule] = []
for key in [f".{c}" for c in classes(el)] + [local(el.tag), "*"]:
candidates.extend(self._index.get(key, ()))
result: dict[str, str] = {}
for rule in sorted({id(r): r for r in candidates}.values(), key=lambda r: (r.specificity, r.order)):
if rule.matches(el):
result.update(rule.decls)
if el.get("style"):
result.update(_parse_declarations(el.get("style")))
self._cache[el] = result
return result
def inline_style(el: Any) -> dict[str, str]:
"""Only the ``style`` attribute (used when no stylesheet is available)."""
return _parse_declarations(el.get("style") or "")
def emphasis(style: dict[str, str]) -> dict[str, bool]:
"""Semantic formatting expressed by a computed style."""
weight = style.get("font-weight", "").lower()
deco = (style.get("text-decoration-line", "") + " " + style.get("text-decoration", "")).lower()
valign = style.get("vertical-align", "").lower()
return {
"bold": weight in ("bold", "bolder") or (weight.isdigit() and int(weight) >= 600),
"italic": style.get("font-style", "").lower() in ("italic", "oblique"),
"underline": "underline" in deco,
"strike": "line-through" in deco,
"sup": valign == "super",
"sub": valign == "sub",
"mono": bool(MONOSPACE.search(style.get("font-family", ""))),
"hidden": style.get("display", "").lower() == "none",
}
# ---------------------------------------------------------------------------- visual styles (--styles html)
STYLE_PROPS = ("color", "background-color", "font-size", "font-family", "font-variant", "text-align")
UNSAFE_VALUE = re.compile(r"url\(|expression|javascript|[;<>{}\\]", re.I)
TRIVIAL_BG = re.compile(r"^(transparent|none|inherit|initial|unset|white|#fff|#ffffff|rgba?\(\s*255\s*,\s*255\s*,\s*255.*)$")
COLOR_TOKEN = re.compile(r"(#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|\b[a-z]+\b)", re.I)
KEYWORD_SIZES = {"xx-small": 0.6, "x-small": 0.75, "small": 0.89, "medium": 1.0, "large": 1.2, "x-large": 1.5,
"xx-large": 2.0, "smaller": 0.83, "larger": 1.2}
def _font_size_em(value: str) -> float | None:
v = value.strip().lower()
if v in KEYWORD_SIZES:
return KEYWORD_SIZES[v]
m = re.fullmatch(r"([\d.]+)\s*(em|rem|%|px|pt)?", v)
if not m:
return None
n = float(m.group(1))
return {"em": n, "rem": n, "%": n / 100, "px": n / 16, "pt": n / 12, None: None}.get(m.group(2), None)
def _background_color(style: dict[str, str]) -> str:
bg = style.get("background-color", "")
if not bg and style.get("background"):
for tok in COLOR_TOKEN.findall(style["background"]):
if tok.lower() not in ("none", "repeat", "no", "fixed", "scroll", "top", "left", "center", "right",
"bottom", "url"):
bg = tok
break
bg = bg.strip()
return "" if not bg or TRIVIAL_BG.match(bg.lower()) else bg
def _family(value: str) -> str:
parts = [p.strip().strip("'\"") for p in value.split(",") if p.strip()]
return ", ".join(f"'{p}'" if " " in p else p for p in parts)
def _is_black(value: str) -> bool:
return value.strip().lower() in ("black", "#000", "#000000", "rgb(0,0,0)", "rgb(0, 0, 0)", "windowtext")
def has_highlight(style: dict[str, str]) -> bool:
return bool(_background_color(style))
def base_style(stylesheet: StyleSheet, paragraphs: list[Any]) -> dict[str, str]:
"""Most frequent value per visual property over the book's paragraphs (the 'normal' text style)."""
counters: dict[str, dict[str, int]] = {p: {} for p in STYLE_PROPS}
for el in paragraphs:
style = stylesheet.computed(el)
for prop in STYLE_PROPS:
value = _background_color(style) if prop == "background-color" else style.get(prop, "")
counters[prop][value] = counters[prop].get(value, 0) + 1
return {p: max(c, key=lambda v: c[v]) for p, c in counters.items() if c}
def style_props(style: dict[str, str], base: dict[str, str], block: bool = False) -> list[tuple[str, str]]:
"""Visual properties of ``style`` that differ from the book's base text style (sanitized)."""
out: list[tuple[str, str]] = []
for prop in STYLE_PROPS:
if prop == "text-align" and not block:
continue
value = _background_color(style) if prop == "background-color" else style.get(prop, "").strip()
if not value or UNSAFE_VALUE.search(value) or value.lower() in ("inherit", "initial", "unset", "currentcolor"):
continue
reference = base.get(prop, "")
if prop == "color" and not reference:
reference = "black" # no colour declared = default text colour
if prop == "color" and _is_black(value) and _is_black(reference):
continue
if prop == "font-size":
size, ref = _font_size_em(value), _font_size_em(reference) if reference else 1.0
if size is None or ref is None or abs(size / (ref or 1.0) - 1) < 0.15:
continue
elif prop == "font-family":
if _family(value).lower() == _family(reference).lower():
continue
value = _family(value)
elif prop == "font-variant":
if "small-caps" not in value.lower() or "small-caps" in reference.lower():
continue
value = "small-caps"
elif prop == "text-align":
if value.lower() not in ("center", "right", "end") or value.lower() == reference.lower():
continue
elif value.lower() == reference.lower():
continue
out.append((prop, value))
return out

View file

@ -9,7 +9,6 @@ from __future__ import annotations
import html
import re
from collections import Counter
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Any
@ -18,11 +17,9 @@ from urllib.parse import unquote
from lxml import etree
from .chapters import IdCarrier, Marker, heading_level
from .css import StyleSheet, emphasis, has_highlight, inline_style, style_props
from .codeblocks import (
code_text,
detect_language,
detect_language_from_book,
detect_language_from_content,
fence_for,
inline_code,
@ -36,7 +33,6 @@ from .footnotes import NoteIndex
from .mathml import Unsupported, is_display_tex, looks_like_tex, mathml_to_tex, strip_tex_delimiters
from .media import MediaRegistry
from .models import Chapter, Note
from .normalize import PAGE_ATTR
from .references import LinkResolver
from .utils import (
MATHML_NS,
@ -49,9 +45,7 @@ from .utils import (
local,
norm_ws,
rel_link,
remove_invisible,
resolve_href,
roles,
text_content,
url_path,
)
@ -99,11 +93,6 @@ class RenderOptions:
language: str = "de"
code_language: str = "" # --code-language: label for blocks without a detected language
keep_wrap_markers: bool = False # --keep-wrap-markers: do not join ↪-continued code lines
keep_invisible_chars: bool = False # --keep-invisible-chars: keep zero-width chars and soft hyphens
boxes: str = "callout" # --boxes {callout,quote,html,plain}
styles: str = "semantic" # --styles {semantic,html}
page_markers: str = "none" # --page-markers {none,visible,comment}: print page numbers
css_emphasis: bool = True # --no-css-emphasis: ignore bold/italic/… that only the stylesheet expresses
def md_escape(text: str, table: bool = False) -> str:
@ -135,23 +124,9 @@ def escape_line_starts(text: str) -> str:
return "\n".join(out)
EMPH_SEP = "\x1f" # separates open marker, content and close marker inside an ("e", …) piece
def join_inline(pieces: list[Piece], hard_break: str = "\\\n") -> str:
out = ""
last_e: tuple[int, str, str, str, int] | None = None # (start, open, core, close, end) of the last emphasis
for kind, s in pieces:
if kind == "e":
open_, core, close = s.split(EMPH_SEP)
if last_e and last_e[4] == len(out) and last_e[1] == open_ and last_e[3] == close:
# directly adjacent identical emphasis (<em>x</em><em>y</em>): merge, "*x**y*" would break
out = out[: last_e[0]]
core = last_e[2] + core
start = len(out)
out += open_ + core + close
last_e = (start, open_, core, close, len(out))
continue
if kind == "t":
s = WS.sub(" ", s)
if not out or out.endswith((" ", "\n")):
@ -201,49 +176,6 @@ def _collapse_blank_lines(text: str) -> str:
return "\n".join(out)
CONSUMED = "data-e2m-consumed"
BOX_TAGS = {"div", "section", "aside", "p"}
BOX_CLASS = re.compile(r"(?:^|[-_])(box[a-z]?|kasten|infobox|infokasten|tipps?|tip|hint|hinweis|notiz|warn|warning|"
r"warnung|achtung|caution|vorsicht|important|wichtig|merke|merksatz|exkurs|sidebar|callout|"
r"alert)(?:[-_\d]|$)")
CALLOUT = {"note": "NOTE", "tip": "TIP", "important": "IMPORTANT", "warning": "WARNING", "caution": "CAUTION"}
TYPE_WORDS = {"tipp", "tip", "tipps", "hinweis", "note", "notiz", "info", "achtung", "warnung", "warning",
"wichtig", "important", "vorsicht", "caution", "gefahr", "merke"}
def _box_type(hint: str) -> str:
"""Callout type from class names, epub:type, a title or the first words of the box."""
h = hint.lower()
if re.search(r"vorsicht|caution|gefahr|danger", h):
return "caution"
if re.search(r"warn|achtung|attention", h):
return "warning"
if re.search(r"wichtig|important|merke", h):
return "important"
if re.search(r"tipp|\btip|hint", h):
return "tip"
if re.search(r"hinweis|\bnote|notiz|anmerkung|\binfo", h):
return "note"
return ""
ORNAMENT = re.compile(r"(?:[*∗⁂❖✻✱✳◆◇•·~][ \t]*){1,9}")
POEM_CLASS = re.compile(r"(?:^|[-_])(poem|poetry|verse|vers|lyrics|stanza|gedicht|lied|song)(?:$|[-_\d])")
ITALIC_TAGS = {"em", "i", "cite", "dfn", "var"}
BOLD_TAGS = {"strong", "b"}
CSS_SKIP_TAGS = {"br", "wbr", "img", "svg", "image", "math", "code", "samp", "tt", "kbd", "pre"}
def _style_attr(props: list[tuple[str, str]]) -> str:
return html.escape("; ".join(f"{k}: {v}" for k, v in props), quote=False).replace('"', "'")
def _wrap_text(text: str, marker: str) -> str:
"""Wrap a finished paragraph in emphasis markers (whitespace stays outside)."""
core = text.strip()
return f"{marker}{core}{marker}" if core else text
_BLOCK_SYNTAX = ("```", "~~~", "|", "<table", "- ", "> ", "#", "$$", "\\[", "* * *")
@ -297,20 +229,8 @@ def serialize_mathml(math: Any) -> str:
class Renderer:
def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry,
notes: NoteIndex, diag: Diagnostics, zip_names: set[str],
entry_problem: Callable[[str], str | None] | None = None,
stylesheet: StyleSheet | None = None, base_style: dict[str, str] | None = None) -> None:
self.base_style = base_style or {}
entry_problem: Callable[[str], str | None] | None = None) -> None:
self.entry_problem = entry_problem or (lambda path: None)
self.css = stylesheet
self.scene_breaks = 0
self.page_markers = 0
self.marker_sink: list[str] | None = None # collects page markers instead of emitting them inline
self.poems = 0
self.boxes = 0
self.in_box = False
self.css_applied: Counter[str] = Counter()
self.hidden_elements = 0
self.invisible_removed = 0
self.opt = options
self.zip_names = zip_names
self.in_table_html = False
@ -324,7 +244,6 @@ class Renderer:
self.consumed_formula_images: set[str] = set()
self.formulas: list[dict[str, str]] = []
self.code_blocks: list[dict[str, Any]] = []
self.book_code_language = "" # dominant language of the reliably detected blocks (pre-scan)
self.endnotes_file = ""
# per chapter state
self.ch: Chapter | None = None
@ -374,13 +293,13 @@ class Renderer:
if doc:
self.doc = doc
if isinstance(item, str):
buf.append(("t", self._text(item)))
buf.append(("t", md_escape(item, self.in_table)))
elif isinstance(item, IdCarrier):
anchor = self.resolver.anchor_for(self.doc, item.element)
if anchor:
buf.append(("r", self._anchor(anchor)))
elif isinstance(item, Marker) or not is_element(item) or item.get(CONSUMED):
continue # markers, comments, siblings already rendered as part of a box
elif isinstance(item, Marker) or not is_element(item):
continue
elif self.is_block(item):
flush()
blocks.extend(self.block(item))
@ -413,19 +332,6 @@ class Renderer:
def _anchor(self, name: str) -> str:
return f'<a id="{name}"></a>'
def page_marker(self, el: Any) -> str:
"""Visible '[S. 12]' or '<!-- S. 12 -->' for a print page-break element (--page-markers)."""
page = el.get(PAGE_ATTR)
if not page or self.opt.page_markers == "none":
return ""
self.page_markers += 1
page = html.escape(page, quote=False).replace("--", "")
marker = f"<!-- S. {page} -->" if self.opt.page_markers == "comment" else f"\\[S. {md_escape(page)}\\]"
if self.marker_sink is not None:
self.marker_sink.append(marker)
return ""
return marker
def anchors_of(self, el: Any, subtree: bool = False) -> str:
nodes = iter_elements(el) if subtree else [el]
names = []
@ -455,19 +361,6 @@ class Renderer:
def block(self, el: Any) -> list[str]:
name = local(el.tag)
if self._hidden(el):
return []
if name in ("p", "div") and not self.in_table:
if self._is_ornament(el) or self._is_scene_gap(el):
self.scene_breaks += 1
anchor = self.anchors_of(el, subtree=True)
return ([anchor] if anchor else []) + ["* * *"]
if name in ("div", "section", "blockquote") and self._is_poem(el):
return self.poem(el)
if self.opt.boxes != "plain" and not self.in_box and not self.in_table and name in BOX_TAGS:
kind = self._box_kind(el)
if kind is not None:
return self.box(el, kind)
ch = self.ch
if ch is not None and el is ch.title_el and not heading_level(el):
return self.heading(el, forced_level=1)
@ -502,238 +395,15 @@ class Renderer:
return [self.clean_html(el, keep_root=True)]
return self._flow_with_anchor(el)
# ------------------------------------------------------------------ scene breaks and verse (P4)
@staticmethod
def _is_empty(el: Any) -> bool:
return not text_content(el).replace("\xa0", " ").strip() and \
not any(local(e.tag) in ("img", "svg", "math", "image", "hr") for e in iter_elements(el))
def _is_blank_p(self, el: Any) -> bool:
"""<p> with nothing but (non-breaking) spaces or <br>. Empty <div>s are layout helpers (Springer's
div.ClearBoth) and paragraphs holding only anchors are index targets – neither is a scene break."""
return local(el.tag) == "p" and self._is_empty(el) and \
all(local(c.tag) == "br" for c in el if is_element(c))
def _is_prose(self, el: Any) -> bool:
return local(el.tag) in ("p", "div") and not self._is_empty(el) and not self._is_ornament(el) \
and not self._is_poem(el)
@staticmethod
def _element_siblings(el: Any, forward: bool) -> Any:
node = el.getnext() if forward else el.getprevious()
while node is not None:
if is_element(node):
yield node
node = node.getnext() if forward else node.getprevious()
def _is_ornament(self, el: Any) -> bool:
"""Paragraph that only contains a typographic separator such as *** or ⁂."""
return bool(ORNAMENT.fullmatch(text_content(el).replace("\xa0", " ").strip()))
def _is_scene_gap(self, el: Any) -> bool:
"""Empty paragraph between two prose paragraphs (first of a run of empties) → scene break."""
if not self._is_blank_p(el):
return False
prev = next(self._element_siblings(el, forward=False), None)
if prev is None or not self._is_prose(prev):
return False
for nxt in self._element_siblings(el, forward=True):
if self._is_blank_p(nxt):
continue # further empty paragraphs of the same gap
return self._is_prose(nxt)
return False
def _is_poem(self, el: Any) -> bool:
types = set(epub_types(el))
return bool(types & {"z3998:poem", "z3998:verse", "z3998:song", "z3998:lyrics"}) or \
any(POEM_CLASS.search(c) for c in classes(el))
def poem(self, el: Any) -> list[str]:
"""Verse: one quoted line per verse line, stanzas separated by an empty quote line."""
anchor = self.anchors_of(el)
stanzas: list[list[str]] = [[]]
def new_stanza() -> None:
if stanzas[-1]:
stanzas.append([])
def walk(container: Any) -> None:
for child in container:
if not is_element(child) or self._hidden(child):
continue
if local(child.tag) in ("div", "section") and any(
is_element(c) and local(c.tag) in ("p", "div", "span") for c in child):
new_stanza() # stanza container
walk(child)
new_stanza()
elif self._is_empty(child):
new_stanza()
else:
line = join_inline([("r", self.anchors_of(child))] + self.inline_children(child))
stanzas[-1].append(escape_line_starts(line).replace("\n", "\n> "))
walk(el)
stanzas = [s for s in stanzas if s]
if not stanzas:
return self._flow_with_anchor(el)
self.poems += 1
text = "\n>\n".join("\\\n".join(f"> {line}" for line in stanza) for stanza in stanzas)
return ([anchor] if anchor else []) + [text]
# ------------------------------------------------------------------ info boxes (P2)
def _box_kind(self, el: Any) -> str | None:
"""'' for a box without type hint, a type keyword, or None if ``el`` is no box."""
name = local(el.tag)
types = set(epub_types(el)) | {r.removeprefix("doc-") for r in roles(el)}
hint = " ".join(sorted(types)) + " " + " ".join(classes(el))
semantic = types & {"sidebar", "tip", "notice", "warning", "caution", "important", "help", "example"}
by_class = any(BOX_CLASS.search(c) for c in classes(el))
if name == "p":
return _box_type(hint) if by_class and not self._is_empty(el) else None
if not (semantic or by_class or self._css_framed(el)):
return None
if not text_content(el).strip() or any(local(e.tag) == "pre" for e in iter_elements(el)) \
and not any(local(e.tag) == "p" and text_content(e).strip() for e in iter_elements(el)):
return None # bordered code listings or image frames are no info boxes
if self.ch is not None and any(e is self.ch.title_el for e in iter_elements(el)):
return None # never swallow the chapter heading
return _box_type(hint)
def _css_framed(self, el: Any) -> bool:
"""Block with a visible border or background in the book's CSS, holding short prose."""
if self.css is None or local(el.tag) not in ("div", "section", "aside"):
return False
style = self.css.computed(el)
border = any(k.startswith("border") and not re.search(r"\bnone\b|^0(px)?\b|hidden", v)
for k, v in style.items() if k in ("border", "border-top", "border-left", "border-style",
"border-width", "border-bottom", "border-right"))
bg = style.get("background-color") or style.get("background") or ""
background = bool(bg) and not re.search(r"transparent|none|inherit|initial|^#?fff(fff)?$|white", bg.lower())
if not (border or background):
return False
text = norm_ws(text_content(el))
return 0 < len(text) <= 3000 and any(local(e.tag) == "p" for e in iter_elements(el)) \
and not any(heading_level(e) in (1, 2) for e in iter_elements(el))
def box(self, el: Any, kind: str) -> list[str]:
anchor = self.anchors_of(el)
members = [el]
if local(el.tag) == "p": # paragraph-style boxes: group following siblings of the same kind
for sib in self._element_siblings(el, forward=True):
if local(sib.tag) == "p" and any(BOX_CLASS.search(c) for c in classes(sib)) \
and _box_type(" ".join(classes(sib))) == kind:
members.append(sib)
else:
break
title_el = self._box_title(members)
title = ""
markers: list[str] = [] # page breaks in the title go before the box, like for headings
if title_el is not None:
pieces: list[Piece] = [("t", self._text(title_el.text))] if title_el.text else []
self.marker_sink = markers
try:
for child in title_el:
if is_element(child) and not self._title_symbol(child):
pieces.extend(self.inline(child))
if child.tail:
pieces.append(("t", self._text(child.tail)))
finally:
self.marker_sink = None
title = join_inline(pieces, hard_break=" ").strip()
kind = kind or _box_type(norm_ws(text_content(title_el)))
saved = self.in_box
self.in_box = True
try:
blocks: list[str] = []
for member in members:
if member is title_el:
continue
if local(member.tag) == "p":
blocks.extend(self.flow(member))
else:
blocks.extend(self.render_items(
[(None, x) for x in self._items_without(member, title_el) if not self._box_icon(x)]))
finally:
self.in_box = saved
for sib in members[1:]:
sib.set(CONSUMED, "1")
if not kind and blocks:
kind = _box_type(norm_ws(re.sub(r"[*_\\]", "", blocks[0]))[:30])
kind = kind or "note"
if title and norm_ws(re.sub(r"[*_\\:]", "", title)).lower() in TYPE_WORDS:
title = "" # 'Tipp' as title of a TIP box adds nothing
self.boxes += 1
out = ["".join(markers)] if markers else []
if anchor:
out.append(anchor)
head = [f"**{title}**"] if title else []
if self.opt.boxes == "html":
out.append(f'<div class="box box-{kind}">\n\n' + "\n\n".join(head + blocks) + "\n\n</div>")
return out
body = "\n\n".join(head + blocks)
if self.opt.boxes == "callout":
body = f"[!{CALLOUT[kind]}]\n" + body
out.append("\n".join("> " + line if line.strip() else ">" for line in body.split("\n")))
return out
def _box_title(self, members: list[Any]) -> Any:
first = members[0]
if local(first.tag) == "p":
text = norm_ws(text_content(first))
return first if len(members) > 1 and 0 < len(text) <= 40 and not text.endswith((".", "!", "?")) else None
for child in first:
if not is_element(child) or self._box_icon(child):
continue
if heading_level(child) or any(re.search(r"heading|title|head$|ueberschrift", c) for c in classes(child)):
return child
return None
return None
@staticmethod
def _title_symbol(node: Any) -> bool:
"""Decorative symbol in a box title, e.g. <span class="box_icon">[»]</span>."""
text = norm_ws(text_content(node))
return any(re.search(r"icon|symbol", c) for c in classes(node)) and len(text) <= 5 \
and not re.search(r"\w", text)
@staticmethod
def _box_icon(node: Any) -> bool:
"""Decorative icon container inside a box (e.g. <div class="Box_img"><img alt="Image"></div>)."""
if not is_element(node) or text_content(node).strip():
return False
imgs = [e for e in iter_elements(node) if local(e.tag) == "img"]
return bool(imgs) and (any(re.search(r"icon|box_?img|symbol", c) for c in classes(node))
or all((i.get("alt") or "").strip().lower() in ("", "image", "icon", "bild") for i in imgs))
def _flow_with_anchor(self, el: Any) -> list[str]:
anchor = self.anchors_of(el)
if self.ch is not None and {"index", "index-persons", "glossary"} & set(self.ch.types):
special = self._index_block(el, anchor)
if special is not None:
return special
italic = bold = False
if self.opt.css_emphasis and local(el.tag) in ("p", "div") and not self.in_heading \
and not any(local(d.tag) in BLOCK_TAGS for d in iter_elements(el) if d is not el) \
and text_content(el).strip():
e = self._emphasis(el)
italic, bold = e["italic"] and not self.em, e["bold"] and not self.strong
self.em += italic
self.strong += bold
try:
blocks = self.flow(el)
finally:
self.em -= italic
self.strong -= bold
if (italic or bold) and len(blocks) == 1 and not _is_block_syntax(blocks[0]) and not blocks[0].startswith("<"):
for flag, marker, key in ((italic, "*", "italic"), (bold, "**", "bold")):
if flag:
blocks[0] = _wrap_text(blocks[0], marker)
self.css_applied[key] += 1
blocks = self._block_styles(el, blocks)
blocks = self.flow(el)
if anchor:
if blocks and not blocks[0].startswith(("#", "`", "|", "<table", "<div", "> ", "- ", "$$", "\\[", "* * *")) \
if blocks and not blocks[0].startswith(("#", "`", "|", "<table", "> ", "- ", "$$", "\\[", "* * *")) \
and not re.match(r"\d+\. ", blocks[0]):
blocks[0] = anchor + blocks[0]
else:
@ -770,14 +440,13 @@ class Renderer:
level = heading_level(el) - (ch.shift if ch else 0)
level = max(self.min_level, min(6, level))
anchors = self.anchors_of(el, subtree=True)
markers = "".join(self.page_marker(e) for e in iter_elements(el))
self.in_heading = True
try:
text = join_inline(self.inline_children(el), hard_break=" ")
finally:
self.in_heading = False
text = re.sub(r"<a id=\"[^\"]*\"></a>", "", text).strip()
out = [markers] if markers else []
out = []
if anchors:
out.append(anchors)
if not text:
@ -801,9 +470,6 @@ class Renderer:
if not lang:
lang = detect_language_from_content(code)
how = "content" if lang else ""
if not lang:
lang = detect_language_from_book(code, self.book_code_language)
how = "book-context" if lang else ""
if not lang and self.opt.code_language:
lang, how = self.opt.code_language, "option"
if self.ch:
@ -1089,125 +755,24 @@ class Renderer:
def inline_children(self, el: Any) -> list[Piece]:
pieces: list[Piece] = []
if el.text:
pieces.append(("t", self._text(el.text)))
pieces.append(("t", md_escape(el.text, self.in_table)))
for child in el:
if is_element(child):
pieces.extend(self.inline(child))
if child.tail:
pieces.append(("t", self._text(child.tail)))
pieces.append(("t", md_escape(child.tail, self.in_table)))
return pieces
def _emphasis(self, el: Any) -> dict[str, bool]:
style = self.css.computed(el) if self.css is not None else inline_style(el)
return emphasis(style)
def _hidden(self, el: Any) -> bool:
"""display:none in the book's CSS: readers do not show the element, so neither do we."""
if not self.opt.css_emphasis or not is_element(el) or not self._emphasis(el)["hidden"]:
return False
self.hidden_elements += 1
return True
def inline(self, el: Any) -> list[Piece]:
"""Inline rendering plus formatting that only the stylesheet expresses (P1, P3, P7)."""
name = local(el.tag)
if name in CSS_SKIP_TAGS:
return self._inline_core(el)
if self._hidden(el):
return []
if self.in_heading or is_inline_code(el) or not text_content(el).strip():
return self._inline_core(el)
pieces = self._inline_emphasis(el) if self.opt.css_emphasis else self._inline_core(el)
return self._inline_styles(el, pieces)
def _inline_styles(self, el: Any, pieces: list[Piece]) -> list[Piece]:
"""--styles semantic: background highlight → <mark>; --styles html: deviating visual properties and
foreign-language spans as <span style>/<span lang>."""
if self.css is None or (pieces and pieces[0][0] == "r" and pieces[0][1].startswith("`")):
return pieces # no stylesheet, or rendered as inline code
style = self.css.computed(el)
if self.opt.styles == "html":
props = style_props(style, self.base_style)
if props:
pieces = self._wrap(pieces, f'<span style="{_style_attr(props)}">', "</span>")
self.css_applied["style"] += 1
lang = self._foreign_lang(el)
if lang:
pieces = self._wrap(pieces, f'<span lang="{html.escape(lang)}">', "</span>")
self.css_applied["lang"] += 1
elif has_highlight(style):
pieces = self._wrap(pieces, "<mark>", "</mark>")
self.css_applied["mark"] += 1
return pieces
def _foreign_lang(self, el: Any) -> str:
lang = (el.get("lang") or el.get("{http://www.w3.org/XML/1998/namespace}lang") or "").strip()
if lang and re.fullmatch(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*", lang) \
and lang.split("-")[0].lower() != self.opt.language.split("-")[0].lower():
return lang
return ""
def _block_styles(self, el: Any, blocks: list[str]) -> list[str]:
"""--styles html: paragraph alignment, colour, size, font and language as a wrapping <div>."""
if self.opt.styles != "html" or self.css is None or self.in_table or not blocks or local(el.tag) not in ("p", "div") \
or any(local(d.tag) in BLOCK_TAGS for d in iter_elements(el) if d is not el):
return blocks
if all(b.startswith(("$$", "\\[", "![", "<img", "<math", "<a id")) for b in blocks):
return blocks # display formulas and image paragraphs are set apart anyway
props = style_props(self.css.computed(el), self.base_style, block=True)
lang = self._foreign_lang(el)
if not props and not lang:
return blocks
attrs = (f' style="{_style_attr(props)}"' if props else "") + (f' lang="{html.escape(lang)}"' if lang else "")
self.css_applied["style" if props else "lang"] += 1
return [f"<div{attrs}>"] + blocks + ["</div>"]
def _inline_emphasis(self, el: Any) -> list[Piece]:
name = local(el.tag)
e = self._emphasis(el)
if e["mono"] and name != "a" and re.fullmatch(r"\S+", norm_ws(text_content(el))) \
and not any(local(d.tag) in ("img", "svg", "math", "a") for d in iter_elements(el) if d is not el):
self.css_applied["code"] += 1
anchor = self.anchors_of(el)
return ([("r", anchor)] if anchor else []) + [("r", inline_code(norm_ws(text_content(el))))]
has_link = name == "a" or any(local(d.tag) == "a" for d in iter_elements(el))
italic = e["italic"] and name not in ITALIC_TAGS and not self.em
bold = e["bold"] and name not in BOLD_TAGS and not self.strong
strike_marker = ("~~", "~~") if self.opt.format == "gfm" else ("<del>", "</del>")
wrappers = [
(e["sup"] and name != "sup", "<sup>", "</sup>", "sup"),
(e["sub"] and name != "sub", "<sub>", "</sub>", "sub"),
(e["underline"] and name not in ("u", "ins") and not has_link, "<u>", "</u>", "underline"),
(e["strike"] and name not in ("del", "s", "strike"), strike_marker[0], strike_marker[1], "strike"),
(italic, "*", "*", "italic"),
(bold, "**", "**", "bold"),
]
self.em += italic
self.strong += bold
try:
pieces = self._inline_core(el)
finally:
self.em -= italic
self.strong -= bold
for flag, open_, close, key in wrappers:
if flag:
pieces = self._wrap(pieces, open_, close)
self.css_applied[key] += 1
return pieces
def _inline_core(self, el: Any) -> list[Piece]:
name = local(el.tag)
anchor = self.anchors_of(el) if name not in ("a",) else ""
pre: list[Piece] = [("r", anchor)] if anchor and not self.in_heading else []
marker = self.page_marker(el) if not self.in_heading else ""
if marker:
pre.insert(0, ("r", marker))
if name == "br":
return pre + ([("t", " ")] if self.in_heading else [("br", "")])
if name == "wbr":
return pre
if name == "a":
return pre[:1] + self.link(el) if marker else self.link(el)
return self.link(el)
if name == "img":
return pre + self.image(el)
if name == "image":
@ -1238,8 +803,8 @@ class Renderer:
finally:
self.strong -= 1
if name in ("del", "s", "strike"):
del_open, del_close = ("~~", "~~") if self.opt.format == "gfm" else ("<del>", "</del>")
return pre + self._wrap(self.inline_children(el), del_open, del_close)
marker = ("~~", "~~") if self.opt.format == "gfm" else ("<del>", "</del>")
return pre + self._wrap(self.inline_children(el), marker[0], marker[1])
if name in ("u", "ins", "mark", "kbd", "sup", "sub"):
if name in ("sup", "sub"):
elems = [c for c in el if is_element(c)]
@ -1276,7 +841,7 @@ class Renderer:
core = core[: -len(hard)].rstrip()
if not core:
return [("t", lead or trail)] if (lead or trail) else []
return [("t", lead), ("e", f"{open_}{EMPH_SEP}{core}{EMPH_SEP}{close}"), ("t", trail)]
return [("t", lead), ("r", f"{open_}{core}{close}"), ("t", trail)]
# ------------------------------------------------------------------ links
@ -1481,16 +1046,7 @@ class Renderer:
title_part = f' "{title.replace(chr(34), chr(39))}"' if title else ""
return [("r", f"![{self._alt(alt)}]({url_path(rel_link(self.file, mf.output))}{title_part})")]
def _text(self, text: str) -> str:
"""Escape prose text; zero-width characters and soft hyphens are dropped (P6)."""
if not self.opt.keep_invisible_chars:
text, n = remove_invisible(text)
self.invisible_removed += n
return md_escape(text, self.in_table)
def _alt(self, alt: str) -> str:
if not self.opt.keep_invisible_chars:
alt = remove_invisible(alt)[0]
return WS.sub(" ", alt).replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]").strip()
def _is_formula_image(self, el: Any, alt: str, path: str) -> bool:
@ -1633,12 +1189,6 @@ class Renderer:
return out.strip()
def _html(self, el: Any, in_pre: bool, keep: bool = False) -> str:
marker = self.page_marker(el) if not in_pre else ""
if marker.startswith("\\["):
marker = marker.replace("\\", "") # inside HTML no Markdown escapes
return marker + self._html_core(el, in_pre, keep)
def _html_core(self, el: Any, in_pre: bool, keep: bool = False) -> str:
name = local(el.tag)
if name == "math":
# Markdown math ($…$, \(…\)) is not rendered inside an HTML block; MathML is.
@ -1669,31 +1219,11 @@ class Renderer:
if keep and re.fullmatch(r"[a-z][a-z0-9-]*", name):
safe = [f'{k.rsplit("}", 1)[-1]}="{html.escape(v)}"' for k, v in el.attrib.items()
if re.fullmatch(r"[A-Za-z_:][-A-Za-z0-9_:.]*", k.rsplit("}", 1)[-1])
and not k.rsplit("}", 1)[-1].lower().startswith(("on", "data-e2m-")) and k.rsplit("}", 1)[-1] != "style"
and not k.rsplit("}", 1)[-1].lower().startswith("on") and k.rsplit("}", 1)[-1] != "style"
and not v.strip().lower().startswith("javascript:")]
return f"<{name}{(' ' + ' '.join(safe)) if safe else ''}>{content}</{name}>"
if name not in HTML_KEEP_TAGS or name == "span":
anchor = self.anchors_of(el)
if self.opt.css_emphasis and content.strip():
e = self._emphasis(el)
if e["hidden"]:
self.hidden_elements += 1
return ""
for flag, tag, key in ((e["italic"], "em", "italic"), (e["bold"], "strong", "bold"),
(e["underline"] and "<a " not in content, "u", "underline"),
(e["strike"], "del", "strike"), (e["sup"], "sup", "sup"), (e["sub"], "sub", "sub")):
if flag:
content = f"<{tag}>{content}</{tag}>"
self.css_applied[key] += 1
if self.css is not None and content.strip():
style = self.css.computed(el)
props = style_props(style, self.base_style) if self.opt.styles == "html" else []
if props:
content = f'<span style="{_style_attr(props)}">{content}</span>'
self.css_applied["style"] += 1
elif self.opt.styles != "html" and has_highlight(style):
content = f"<mark>{content}</mark>"
self.css_applied["mark"] += 1
return anchor + content
attrs = []
for k, v in el.attrib.items():

View file

@ -365,54 +365,7 @@ def strip_tex_delimiters(text: str) -> str:
if t.startswith(o) and t.endswith(c) and len(t) >= len(o) + len(c):
t = t[len(o) : len(t) - len(c)]
break
return portable_tex(t.strip())
UMLAUTS = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"}
def portable_tex(tex: str) -> str:
"""Publisher TeX → TeX that Pandoc's formula parser (texmath, MathJax, KaTeX) understands, still valid LaTeX:
text accents \\"u → ü, \\O (empty set in Springer books) → \\emptyset, no @{…} column separators in
array specifications, no \\arraycolsep assignments, no empty \\displaystyle before a cell end. Only changes
spacing, never content."""
tex = re.sub(r'\\"(?:\{([aouAOU])\}|([aouAOU]))', lambda m: UMLAUTS[m.group(1) or m.group(2)], tex)
tex = re.sub(r"\\(?:text|mbox|mathrm)\s*\{\s*\\O\s*\}", r"\\emptyset", tex)
tex = re.sub(r"\\O(?![A-Za-z])", r"\\emptyset", tex)
tex = re.sub(r"\\arraycolsep\s*-?\d*\.?\d+\s*(?:pt|em|ex|mm|cm)", "", tex)
tex = re.sub(r"\\displaystyle\s*(?=\\\\|&|\\end\b)", "", tex) # no-op before a cell end; texmath fails
out: list[str] = []
pos = 0
for m in re.finditer(r"\\begin\{array\}\{", tex):
if m.start() < pos:
continue
end = _closing_brace(tex, m.end() - 1)
if end < 0:
break
spec = tex[m.end():end]
i = spec.find("@{")
while i >= 0:
close = _closing_brace(spec, i + 1)
if close < 0:
break
spec = spec[:i] + spec[close + 1:]
i = spec.find("@{")
out.append(tex[pos:m.end()] + spec)
pos = end
return "".join(out) + tex[pos:]
def _closing_brace(text: str, open_pos: int) -> int:
"""Index of the '}' matching the '{' at ``open_pos`` (-1 if unbalanced)."""
depth = 0
for i in range(open_pos, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return i
return -1
return t.strip()
def is_display_tex(text: str) -> bool:

View file

@ -46,8 +46,6 @@ class ChapterFile:
body: str = ""
start_anchor: str = ""
anchor_map: dict[str, str] = field(default_factory=dict)
types: list[str] = field(default_factory=list)
words: int = 0
@dataclass
@ -76,13 +74,12 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]:
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
if manifest_path.is_file():
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
entries = [(c["number"], c["title"], c["file"], c.get("types") or [], c.get("word_count", 0))
for c in manifest.get("chapters", [])]
entries = [(c["number"], c["title"], c["file"]) for c in manifest.get("chapters", [])]
else:
files = sorted((out_dir / "chapters").glob("*.md"))
entries = [(i + 1, p.stem, f"chapters/{p.name}", [], 0) for i, p in enumerate(files)]
chapters = [ChapterFile(n, t, rel, types=list(ty), words=w) for n, t, rel, ty, w in entries if (out_dir / rel).is_file()]
missing = [rel for _, _, rel, _, _ in entries if not (out_dir / rel).is_file()]
entries = [(i + 1, p.stem, f"chapters/{p.name}") for i, p in enumerate(files)]
chapters = [ChapterFile(n, t, rel) for n, t, rel in entries if (out_dir / rel).is_file()]
missing = [rel for _, _, rel in entries if not (out_dir / rel).is_file()]
if missing:
raise MergeError(f"Kapiteldateien fehlen: {', '.join(missing[:5])}")
if not chapters:
@ -92,49 +89,6 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]:
return report, chapters
# Front matter that precedes the table of contents in a printed book (cover, title page, imprint …).
FRONT_TYPES = {"cover", "titlepage", "halftitlepage", "colophon", "copyright-page", "imprint", "dedication",
"epigraph", "frontispiece", "seriespage", "imprimatur"}
TOC_PLACEHOLDER = '<div id="epub2md-toc"></div>' # pandoc/book.lua puts the table of contents here
TITLE_PLACEHOLDER = '<div id="epub2md-titlepage"></div>' # … and Pandoc's title block here (no own title page)
# Where the main matter starts: a part, preface, introduction …, a numbered title or a longer text.
MAIN_TYPES = {"part", "preface", "foreword", "introduction", "prologue", "preamble", "appendix", "bodymatter", "toc"}
NUMBERED_TITLE = re.compile(r"^\s*(\d+|[IVXLC]+\.\s|teil\b|kapitel\b|part\b|chapter\b|buch\b|book\b)", re.I)
FRONT_MAX_WORDS = 1000
FRONT_MAX_CHAPTERS = 12
def front_matter_count(chapters: list[ChapterFile]) -> int:
"""Number of leading chapters before the table of contents: cover, title page, imprint and the short
pages between them (series page, edition note, publisher's letter …) up to the start of the main matter."""
k = 0
while k < min(len(chapters), FRONT_MAX_CHAPTERS):
ch = chapters[k]
if MAIN_TYPES & set(ch.types) or NUMBERED_TITLE.match(ch.title) or ch.words > FRONT_MAX_WORDS:
break
k += 1
if k < len(chapters) and any(FRONT_TYPES & set(c.types) for c in chapters[:k]):
return k
n = 0 # no clear start of the main matter: only the unbroken run of typed front matter
while n < len(chapters) and FRONT_TYPES & set(chapters[n].types):
n += 1
return n
ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:[T ][\d:.]+(?:Z|[+-]\d{2}:?\d{2})?)?$")
def display_date(value: str, language: str) -> str:
"""'2025-01-13T08:00:00+00:00' → '13.01.2025' (German) or '2025-01-13'; other formats unchanged."""
m = ISO_DATE.match(value.strip())
if not m:
return value
year, month, day = m.groups()
return f"{day}.{month}.{year}" if language.lower().startswith("de") else f"{year}-{month}-{day}"
def default_target(out_dir: Path, report: dict) -> Path:
name = (report.get("input") or {}).get("name") or (out_dir.name + ".epub")
stem = name[:-5] if name.lower().endswith(".epub") else name
@ -188,7 +142,7 @@ def assign_anchors(chapters: list[ChapterFile]) -> int:
for m in TOKEN.finditer(seg):
if m.group("attr") and m.group("aname") == "id" and m.group("avalue") not in ids:
ids.append(m.group("avalue"))
start = "kap-" + stems[ch.rel] # ids must start with a letter (Pandoc's EPUB writer breaks '#039-…')
start = stems[ch.rel]
while start in used or start in ids:
start = "kapitel-" + start
ch.start_anchor = start
@ -271,31 +225,18 @@ def merge(out_dir: Path, target: Path | None = None, *, frontmatter: bool = True
("publisher", "publisher"), ("date", "published"), ("identifier", "identifier"),
("rights", "rights"), ("description", "description")):
if md.get(src):
fm[key] = display_date(md[src], md.get("language", "")) if key == "date" else md[src]
fm[key] = md[src]
if fm:
parts.append("---\n" + to_yaml(fm) + "---")
front = front_matter_count(chapters)
has_title_page = any({"titlepage", "halftitlepage"} & set(ch.types) for ch in chapters[:front])
cover_first = bool(chapters) and "cover" in chapters[0].types
if toc:
lines = ["## Inhalt" if (report.get("metadata") or {}).get("language", "de").startswith("de") else "## Contents", ""]
lines += [f"{ch.number}. [{_escape_link_text(ch.title)}](#{ch.start_anchor})" for ch in chapters]
toc_block = "\n".join(lines)
else:
toc_block = TOC_PLACEHOLDER
if not has_title_page and not cover_first:
parts.append(TITLE_PLACEHOLDER)
if front == 0:
parts.append(toc_block)
for i, ch in enumerate(chapters):
parts.append("\n".join(lines))
for ch in chapters:
segments = [(is_code, seg if is_code else rewrite_segment(seg, ch)) for is_code, seg in _non_code_segments(ch.body)]
body, n = _drop_duplicate_definitions(_join(segments), seen_defs)
dropped += n
parts.append(f'<a id="{ch.start_anchor}"></a>\n\n{body.strip()}')
if i == 0 and cover_first and not has_title_page:
parts.append(TITLE_PLACEHOLDER)
if i + 1 == front:
parts.append(toc_block)
text = "\n\n".join(parts).rstrip() + "\n"
existing = {p.resolve().as_posix() for p in out_dir.rglob("*") if p.is_file()}

View file

@ -3,7 +3,6 @@
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
@ -16,9 +15,6 @@ HEAD_CLASS_RE = re.compile(r"^(?:head|heading|h|ueberschrift|uberschrift)[_-]?(\
TITLE_CLASS_RE = re.compile(r"^(?:title[_-]?document|chapter[_-]?title|document[_-]?number|chaptertitle)$")
TOC_TITLE_RE = re.compile(r"^\s*(inhalt|inhaltsverzeichnis|inhaltsübersicht|contents|table of contents|toc)\s*$", re.I)
MEDIA_TAGS = {"img", "svg", "math", "audio", "video", "object", "embed", "iframe", "image"}
PAGE_ATTR = "data-e2m-page" # print page number of a page-break marker (rendered with --page-markers)
PAGE_ID_RE = re.compile(r"^(?:page|pg|seite)[_-]?(\d+|[ivxlcdm]+)$", re.I) # id="page_47", <span id="page37">
PAGE_ID_SHORT_RE = re.compile(r"^p[_-]?(\d+)$", re.I) # id="p17": only on empty <a>/<span>
@dataclass
@ -81,8 +77,7 @@ def book_uses_title_tables(docs: list[Doc]) -> bool:
return False
def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool,
hidden: Callable[[Any], bool] | None = None) -> None:
def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None:
body = doc.body
for el in list(iter_elements(body)):
if not _attached(el, body):
@ -93,11 +88,9 @@ def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool,
continue
types = epub_types(el)
if "pagebreak" in types or "doc-pagebreak" in roles(el):
_replace_with_anchor(el, _pagebreak_label(el))
_replace_with_anchor(el)
doc.pagebreaks += 1
continue
if name in ("a", "span") and not el.get("href") and (page := _page_from_id(el)):
el.set(PAGE_ATTR, page)
if name == "table":
_maybe_heading_table(el, title_tables)
elif name in ("p", "div") and len(classes(el)) == 1 and (m := HEAD_CLASS_RE.match(classes(el)[0])):
@ -109,19 +102,19 @@ def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool,
doc.toc_ids.update(e.get("id") for e in iter_elements(el) if e.get("id"))
remove_keep_tail(el)
diag.info("html-toc-removed", "HTML-Inhaltsverzeichnis entfernt (ersetzt durch README.md).", doc.path)
separated = _separate_field_spans(body, hidden or (lambda el: False))
separated = _separate_field_spans(body)
if separated:
diag.info("inline-separator-added",
f"{separated} Leerzeichen zwischen aneinanderstoßenden Feld-Spans eingefügt.", doc.path)
def _separate_field_spans(body: Any, hidden: Callable[[Any], bool]) -> int:
def _separate_field_spans(body: Any) -> int:
"""Insert a space between adjacent class-styled <span> "fields" that touch without whitespace.
Publishers sometimes lay out metadata as sibling spans (author | title | series) separated only by
CSS. Narrow rule to keep drop caps ("D|as") and split words ("Haupt|bahnhof") intact: both spans
carry different classes, no text between them, both have >= 2 characters and the right one starts
with an uppercase letter or digit. Spans hidden by the book's CSS (display:none) get no separator.
with an uppercase letter or digit.
"""
count = 0
for parent in iter_elements(body):
@ -134,7 +127,7 @@ def _separate_field_spans(body: Any, hidden: Callable[[Any], bool]) -> int:
lt, rt = text_content(left), text_content(right)
if len(lt.strip()) < 2 or len(rt.strip()) < 2 or lt[-1].isspace() or rt[0].isspace():
continue
if (rt[0].isupper() or rt[0].isdigit()) and not hidden(left) and not hidden(right):
if rt[0].isupper() or rt[0].isdigit():
left.tail = " "
count += 1
return count
@ -149,35 +142,13 @@ def _attached(el: Any, body: Any) -> bool:
return False
def _pagebreak_label(el: Any) -> str:
"""Page number of an epub:type="pagebreak" element (aria-label, title, text or id)."""
for value in (el.get("aria-label"), el.get("title"), text_content(el)):
value = norm_ws(value or "")
if value and len(value) <= 12:
return re.sub(r"^(?:seite|page|s\.|p\.)\s*", "", value, flags=re.I)
m = PAGE_ID_RE.match(el.get("id") or "") or PAGE_ID_SHORT_RE.match(el.get("id") or "")
return m.group(1) if m else ""
def _page_from_id(el: Any) -> str:
el_id = el.get("id") or ""
if m := PAGE_ID_RE.match(el_id):
return m.group(1)
if (m := PAGE_ID_SHORT_RE.match(el_id)) and not norm_ws(text_content(el)) and len(el) == 0:
return m.group(1)
return ""
def _replace_with_anchor(el: Any, page: str = "") -> None:
def _replace_with_anchor(el: Any) -> None:
ids = [e.get("id") for e in iter_elements(el) if e.get("id")]
if not ids and not page:
if not ids:
remove_keep_tail(el)
return
anchor = _make(el, "a")
if ids:
anchor.set("id", ids[0])
if page:
anchor.set(PAGE_ATTR, page)
anchor.set("id", ids[0])
anchor.tail = el.tail
el.getparent().replace(el, anchor)
@ -206,7 +177,6 @@ def _maybe_heading_table(table: Any, title_tables: bool) -> None:
level = max(1, min(6, level))
texts = []
ids = [e.get("id") for e in iter_elements(table) if e.get("id")]
pages = {e.get("id"): page for e in iter_elements(table) if local(e.tag) in ("a", "span") and (page := _page_from_id(e))}
for cell in cells:
parts = []
for node in cell.iter():
@ -223,8 +193,6 @@ def _maybe_heading_table(table: Any, title_tables: bool) -> None:
for anchor_id in ids:
a = _make(table, "a")
a.set("id", anchor_id)
if anchor_id in pages:
a.set(PAGE_ATTR, pages[anchor_id])
heading.append(a)
if len(heading):
heading[-1].tail = " ".join(texts)

View file

@ -8,30 +8,24 @@ import re
import shutil
import sys
import tempfile
from collections.abc import Callable
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import unquote
from . import TOOL_NAME, __version__
from .chapters import SPECIAL_KINDS, ChapterBuilder, heading_level
from .codeblocks import (code_text, detect_language, detect_language_from_content, dominant_language,
is_block_code_container, join_wrap_markers)
from .chapters import ChapterBuilder
from .diagnostics import Diagnostics
from .css import StyleSheet, base_style, emphasis
from .epub_reader import EpubReader, decode_markup, parse_xml
from .epub_reader import EpubReader, parse_xml
from .errors import ContainerError, InputError, StructureError
from .footnotes import collect_notes
from .html_converter import RenderOptions, Renderer
from .linkcheck import check_links
from .media import MediaRegistry
from .models import Chapter
from .normalize import (Doc, book_uses_title_tables, has_content, is_html_toc_doc, make_doc, normalize_doc,
remove_keep_tail)
from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail
from .references import LinkResolver
from .utils import is_element, is_inside, iter_elements, local, resolve_href, sha256_file, slugify, text_content
from .utils import is_inside, iter_elements, sha256_file, slugify
from . import writer
MARKER_FILES = ("conversion-report.json", "manifest.json")
@ -43,7 +37,7 @@ class Options:
format: str = "gfm"
extract_media: bool = True
copy_original: bool = False
chapter_level: int | str = "auto" # 1–6, or "auto": level 1 plus oversized chapters split one level deeper
chapter_level: int = 1
toc: bool = True
frontmatter: bool = True
footnotes: str = "end-of-chapter"
@ -53,11 +47,6 @@ class Options:
keep_html: bool = False
code_language: str = ""
keep_wrap_markers: bool = False
keep_invisible_chars: bool = False
css_emphasis: bool = True
boxes: str = "callout"
styles: str = "semantic"
page_markers: str = "none"
strict: bool = False
verbose: bool = False
dry_run: bool = False
@ -84,8 +73,8 @@ def check_paths(input_path: Path, output_dir: Path, opts: Options) -> None:
raise InputError(f"Eingabedatei nicht gefunden oder keine Datei: {input_path}")
if not os.access(input_path, os.R_OK):
raise InputError(f"Eingabedatei nicht lesbar: {input_path}")
if opts.chapter_level != "auto" and not (isinstance(opts.chapter_level, int) and 1 <= opts.chapter_level <= 6):
raise InputError("--chapter-level muss auto oder eine Zahl zwischen 1 und 6 sein.")
if not 1 <= opts.chapter_level <= 6:
raise InputError("--chapter-level muss zwischen 1 und 6 liegen.")
if opts.dry_run:
return
if output_dir.exists() and not output_dir.is_dir():
@ -144,11 +133,9 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
package = reader.load_package()
diag.step(f"EPUB {package.version}: {package.metadata.title!r}, {len(package.spine)} Spine-Dokumente")
docs = _load_docs(reader, package, diag)
stylesheet = _load_stylesheet(reader, package, docs, diag) # before normalization drops <style>
hidden = (lambda el: emphasis(stylesheet.computed(el))["hidden"]) if opts.css_emphasis else None
_prepare_docs(docs, package, diag, hidden)
_prepare_docs(docs, package, diag)
notes, chapters, resolver = _build_structure(docs, package, opts, diag)
rendered = _render(reader, package, chapters, resolver, notes, opts, diag, stylesheet)
rendered = _render(reader, package, chapters, resolver, notes, opts, diag)
files, all_chapters, report = _assemble(input_path, output_dir, opts, diag, generated_at, sha, package, docs,
notes, chapters, resolver, rendered)
if opts.dry_run:
@ -190,12 +177,11 @@ class _Rendered:
skipped_formula_images: int
def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics,
hidden: Callable[[Any], bool] | None = None) -> None:
def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics) -> None:
"""Normalize publisher markup and drop HTML tables of contents that NAV/NCX already cover."""
title_tables = book_uses_title_tables(docs)
for doc in docs:
normalize_doc(doc, diag, title_tables, hidden)
normalize_doc(doc, diag, title_tables)
landmark_toc_docs = {lm.path for lm in package.landmarks if lm.type == "toc" and lm.path and not lm.fragment}
for lm in package.landmarks:
if lm.type == "toc" and lm.fragment:
@ -217,9 +203,10 @@ def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics,
def _build_structure(docs: list[Doc], package: Any, opts: Options,
diag: Diagnostics) -> tuple[Any, list[Chapter], LinkResolver]:
"""Notes, chapter units with file names, and the link resolver (incl. NAV/NCX targets)."""
md = package.metadata
notes = collect_notes(docs, opts.footnotes, diag)
diag.step(f"{len(notes.notes)} Anmerkungen erkannt")
chapters = _build_chapters(docs, package, opts, diag)
chapters = ChapterBuilder(opts.chapter_level, package.toc, diag, md.title, md.authors).build(docs)
_assign_filenames(chapters, opts.slug_language)
resolver = LinkResolver(docs, chapters, notes, "README.md" if opts.toc else None)
nav_targets: list[tuple[str, str]] = []
@ -236,43 +223,8 @@ def _build_structure(docs: list[Doc], package: Any, opts: Options,
return notes, chapters, resolver
CSS_IMPORT = re.compile(r"""@import\s+(?:url\(\s*)?["']?([^"')\s;]+)""", re.I)
def _load_stylesheet(reader: EpubReader, package: Any, docs: list[Doc], diag: Diagnostics) -> StyleSheet:
"""The book's CSS: manifest stylesheets that a spine document links (directly or via @import), in manifest
order, plus <style> elements of the documents. Unlinked stylesheets (publishers ship the layouts of other
series) are ignored – a reading system would not apply them either."""
pending: list[str] = []
for doc in docs:
for e in iter_elements(doc.root):
if local(e.tag) == "link" and "stylesheet" in (e.get("rel") or "").lower().split():
pending.append(resolve_href(doc.path, unquote((e.get("href") or "").split("#")[0].split("?")[0])) or "")
texts: dict[str, str] = {}
while pending:
path = pending.pop()
if not path or path in texts:
continue
try:
texts[path] = decode_markup(reader.read(path), path, diag)
except (KeyError, ContainerError):
diag.warn("css-unreadable", f"Stylesheet {path} nicht lesbar; seine Formatierung entfällt.", path)
texts[path] = ""
continue
pending.extend(resolve_href(path, unquote(m)) or "" for m in CSS_IMPORT.findall(texts[path]))
css_items = [i.path for i in package.manifest.values() if i.media_type == "text/css" or i.path.lower().endswith(".css")]
ordered = [texts[p] for p in css_items if p in texts] + [t for p, t in texts.items() if p not in css_items]
unlinked = len([p for p in css_items if p not in texts])
if unlinked:
diag.info("css-unlinked", f"{unlinked} Stylesheet(s) im Manifest werden von keinem Dokument eingebunden "
"und daher nicht ausgewertet.")
for doc in docs:
ordered.extend(text_content(e) for e in iter_elements(doc.root) if local(e.tag) == "style")
return StyleSheet(ordered)
def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: LinkResolver, notes: Any,
opts: Options, diag: Diagnostics, stylesheet: StyleSheet | None = None) -> _Rendered:
opts: Options, diag: Diagnostics) -> _Rendered:
"""Render all chapters (and the end-of-book notes file) to Markdown and register media."""
md = package.metadata
media = MediaRegistry(package, opts.images, opts.slug_language)
@ -283,23 +235,18 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver:
renderer = Renderer(
RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math,
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers,
keep_invisible_chars=opts.keep_invisible_chars, css_emphasis=opts.css_emphasis,
boxes=opts.boxes, styles=opts.styles, page_markers=opts.page_markers),
resolver, media, notes, diag, set(reader.names), reader.entry_problem, stylesheet,
base_style(stylesheet, [p for c in chapters for _, it in c.items if is_element(it)
for p in iter_elements(it) if local(p.tag) == "p"][:20000]) if stylesheet else None,
code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers),
resolver, media, notes, diag, set(reader.names), reader.entry_problem,
)
width = max(3, len(str(len(chapters) + 1)))
notes_title = "Anmerkungen" if language.startswith("de") else "Notes"
endnotes_number = 999 if len(chapters) < 999 else len(chapters) + 1
renderer.endnotes_file = f"chapters/{endnotes_number:0{width}d}-{slugify(notes_title, opts.slug_language)}.md"
renderer.book_code_language = _book_code_language(chapters)
for ch in chapters:
diag.step(f"Rendere {ch.filename}")
ch.markdown = renderer.render_chapter(ch)
ch.stats.word_count = _word_count(ch.markdown)
_check_chapter_size(ch, _base_level(opts), diag)
_check_chapter_size(ch, opts.chapter_level, diag)
_report_wrap_markers(renderer, diag)
if not opts.code_language:
_suggest_code_language(renderer, diag)
@ -315,20 +262,6 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver:
return _Rendered(renderer, media, cover_out, endnotes, endnotes_md, skipped)
def _book_code_language(chapters: list[Chapter]) -> str:
"""Pre-scan: the language of the book's code blocks, from the reliably detected blocks only."""
langs: list[str] = []
for ch in chapters:
for _, item in ch.items:
if not is_element(item):
continue
for el in iter_elements(item):
if local(el.tag) in ("pre", "listing", "xmp", "plaintext") or is_block_code_container(el):
lang, _ = detect_language(el)
langs.append(lang or detect_language_from_content(join_wrap_markers(code_text(el))[0]))
return dominant_language(langs)
def _suggest_code_language(renderer: Renderer, diag: Diagnostics) -> None:
"""Book-level hint: many unlabeled blocks while every recognized block has the same language."""
blocks = renderer.code_blocks
@ -405,8 +338,7 @@ def _assemble(input_path: Path, output_dir: Path, opts: Options, diag: Diagnosti
stats = _book_stats(all_chapters, media, renderer, diag)
if opts.toc:
files["README.md"] = writer.readme(md, chapters, endnotes, package.toc, resolver, stats, rendered.cover_out,
package.version, input_path.name, opts.command, str(output_dir),
renderer.boxes if opts.boxes == "callout" else 0)
package.version, input_path.name, opts.command, str(output_dir))
existing = {mf.output for mf in media.files.values()} if opts.extract_media else set()
if opts.copy_original:
existing.add(f"source/{input_path.name}")
@ -450,52 +382,16 @@ def _assign_filenames(chapters: list[Chapter], slug_language: str) -> None:
ch.filename = f"chapters/{candidate}.md"
def _base_level(opts: Options) -> int:
return 1 if opts.chapter_level == "auto" else int(opts.chapter_level)
def _build_chapters(docs: list[Doc], package: Any, opts: Options, diag: Diagnostics) -> list[Chapter]:
"""Chapter units at --chapter-level. With "auto", chapters over LARGE_CHAPTER_WORDS with at least two
sub-headings (parts of a novel, say) are split one level deeper; all other chapters stay whole."""
md = package.metadata
level = _base_level(opts)
if opts.chapter_level == "auto" and level < 6:
probe = ChapterBuilder(level, package.toc, Diagnostics(), md.title, md.authors).build(docs)
deep = {ch.title_el for ch in probe if _oversized(ch, level)}
if deep:
titles = ", ".join(f"„{ch.title}“" for ch in probe if ch.title_el in deep)
diag.info("chapter-split", f"Sehr große Kapitel ({titles}) werden an ihren Überschriften der Ebene "
f"{level + 1} aufgeteilt (--chapter-level auto).")
return ChapterBuilder(level, package.toc, diag, md.title, md.authors, deep_starts=deep).build(docs)
return ChapterBuilder(level, package.toc, diag, md.title, md.authors).build(docs)
def _oversized(ch: Chapter, level: int) -> bool:
"""Heading-started chapter (not glossary/index/…) with too many words and >= 2 next-level sub-headings."""
if ch.title_el is None or ch.title_level > level or SPECIAL_KINDS & set(ch.types):
return False
words = 0
sections = 0
for _, it in ch.items:
if isinstance(it, str):
words += len(it.split())
elif is_element(it):
words += len(text_content(it).split())
sections += sum(1 for e in iter_elements(it) if heading_level(e) == level + 1 and has_content(e))
return words > LARGE_CHAPTER_WORDS and sections >= 2
def _check_chapter_size(ch: Chapter, chapter_level: int, diag: Diagnostics) -> None:
"""Suggest a deeper --chapter-level for very large chapters with enough sub-headings."""
if ch.stats.word_count <= LARGE_CHAPTER_WORDS or chapter_level >= 6:
return
sections = len(re.findall(r"^## ", ch.markdown, re.M))
if sections >= 2:
deeper = max(chapter_level, ch.title_level or 1) + 1
diag.warn(
"chapter-large",
f"Kapitel „{ch.title}“ hat {ch.stats.word_count} Wörter und {sections} Unterabschnitte; "
f"--chapter-level {deeper} würde sie als eigene Dateien ausgeben.",
f"--chapter-level {chapter_level + 1} würde sie als eigene Dateien ausgeben.",
ch.docs[0] if ch.docs else "",
ch.filename,
)
@ -599,11 +495,6 @@ def _report(input_path: Path, sha: str, generated_at: str, package: Any, opts: O
"referenced": sum(1 for n in notes.notes.values() if n.ref_count),
"rendered": len(renderer.book_notes)},
"special_sections": special,
"text_cleanup": {"invisible_chars_removed": renderer.invisible_removed},
"css": {"rules": len(renderer.css.rules) if renderer.css else 0, "applied": dict(renderer.css_applied),
"hidden_elements": renderer.hidden_elements},
"layout": {"scene_breaks": renderer.scene_breaks, "poems": renderer.poems, "boxes": renderer.boxes,
"page_markers": renderer.page_markers},
"warnings": warnings,
"errors": errors,
"infos": [d.as_dict() for d in diag.items if d.level == "info"],

View file

@ -185,15 +185,6 @@ def text_content(el: Any) -> str:
return "".join(parts)
INVISIBLE = re.compile("[\u200b\u200c\u200d\u2060\ufeff\u00ad]") # zero-width chars, BOM, soft hyphen
def remove_invisible(text: str) -> tuple[str, int]:
"""Remove zero-width characters and soft hyphens; returns (text, number removed)."""
cleaned, n = INVISIBLE.subn("", text)
return cleaned, n
def norm_ws(text: str) -> str:
return re.sub(r"[ \t\r\n\f]+", " ", text or "").strip()

View file

@ -6,7 +6,6 @@ import json
import posixpath
import shlex
from collections import Counter
from pathlib import Path
from typing import Any
from . import TOOL_NAME, __version__
@ -16,10 +15,6 @@ from .models import BookMetadata, Chapter, NavEntry
from .references import LinkResolver
from .utils import rel_link, to_yaml, url_path
CALLOUT_FILTER = Path(__file__).resolve().parent.parent / "pandoc" / "callouts.lua"
PRINT_CSS = Path(__file__).resolve().parent.parent / "pandoc" / "print.css"
BOOK_FILTER = Path(__file__).resolve().parent.parent / "pandoc" / "book.lua"
SPECIAL_LABELS = {
"glossary": "Glossar",
"bibliography": "Literatur / Quellen",
@ -92,7 +87,7 @@ def _nav_lines(entries: list[NavEntry], resolver: LinkResolver, depth: int = 0)
def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, nav: list[NavEntry],
resolver: LinkResolver, stats: dict[str, int], cover: str, epub_version: str, source_name: str,
command: list[str], output_dir: str = "OUTPUT_VERZEICHNIS", callouts: int = 0) -> str:
command: list[str], output_dir: str = "OUTPUT_VERZEICHNIS") -> str:
out = [f"# {md_escape(md.title or source_name)}"]
if md.subtitle:
out.append(f"*{md_escape(md.subtitle)}*")
@ -135,38 +130,20 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None,
"[manifest.json](manifest.json), [Warnungen](diagnostics/warnings.md)")
stem = source_name[:-5] if source_name.lower().endswith(".epub") else source_name
math = " --mathml" if stats.get("formulas") else ""
lua = ""
if callouts and CALLOUT_FILTER.is_file(): # '> [!TIP]' boxes: Pandoc needs the filter to draw real boxes
lua = f" --lua-filter {shlex.quote(str(CALLOUT_FILTER))}"
# book.lua: table of contents after cover/title page/imprint instead of Pandoc's --toc at the very top
book = f" --lua-filter {shlex.quote(str(BOOK_FILTER))}" if BOOK_FILTER.is_file() else ""
toc = book or " --toc"
md_, html_, pdf_ = (shlex.quote(stem + ext) for ext in (".md", ".html", ".pdf"))
latex = [
"# PDF mit LaTeX über HTML: Pandoc übernimmt dann auch HTML-Tabellen und interne Sprungziele",
f"pandoc {md_} -s{math}{book}{lua} -o {html_} && pandoc {html_}{toc}{lua} --pdf-engine=xelatex -o {pdf_}",
]
weasy = [
"# PDF mit weasyprint: Farben, Kästen und HTML-Stile bleiben erhalten" + ("" if math else " (empfohlen)"),
f"pandoc {md_} -s{math}{toc}{lua} --css {shlex.quote(str(PRINT_CSS))} --pdf-engine=weasyprint -o {pdf_}",
] if PRINT_CSS.is_file() else []
if math:
latex[0] += " (empfohlen: weasyprint kann keine Formeln setzen)"
commands = [
"# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)",
f"python3 epub2md_merge.py {shlex.quote(output_dir)}",
"# danach in diesem Ordner:",
f"pandoc {md_} -s{math}{toc}{lua} -o {html_}",
f"pandoc {md_} --toc{math}{lua} -o {shlex.quote(stem + '.epub')}",
] + (latex + weasy if math else weasy + latex)
f"pandoc {shlex.quote(stem + '.md')} --toc -s{math} -o {shlex.quote(stem + '.html')}",
f"pandoc {shlex.quote(stem + '.md')} --toc -o {shlex.quote(stem + '.epub')}",
"# PDF über HTML: Pandoc übernimmt dann auch HTML-Tabellen und interne Sprungziele",
f"pandoc {shlex.quote(stem + '.md')} -s{math} -o {shlex.quote(stem + '.html')} && "
f"pandoc {shlex.quote(stem + '.html')} --toc --pdf-engine=xelatex -o {shlex.quote(stem + '.pdf')}",
]
out.append("## Weiterverarbeitung")
out.append("```bash\n" + "\n".join(commands) + "\n```")
if callouts:
noun = "Infokasten" if callouts == 1 else "Infokästen"
out.append(f"Das Buch enthält {callouts} {noun} (`> [!TIP]` …). Der Filter `callouts.lua` macht daraus "
"in HTML, EPUB und PDF gerahmte Kästen mit Titel; ohne ihn zeigt Pandoc „[!TIP]“ als Text.")
if stats.get("formulas"):
out.append("Das Buch enthält Formeln: Für HTML und EPUB `--mathml` (bei HTML auch `--mathjax`) verwenden, sonst belässt "
if math:
out.append("Das Buch enthält Formeln: Für HTML `--mathml` (oder `--mathjax`) verwenden, sonst belässt "
"Pandoc Matrizen und ähnliche Konstrukte als TeX-Text.")
out.append("## Reproduktion")
out.append("```bash\n" + " ".join(shlex.quote(c) for c in command) + "\n```")

View file

@ -1,272 +0,0 @@
--[[
book.lua – Pandoc filter for book files from epub2md_merge.py (HTML output, e.g. PDF via weasyprint).
Pandoc's --toc always puts the table of contents (and its own title block) before the whole text,
i.e. before the book's cover, title page and imprint. epub2md_merge.py marks the right places:
<div id="epub2md-titlepage"></div> after the cover, only if the book has no title page of its own
<div id="epub2md-toc"></div> after the front matter (cover, title page, imprint …)
This filter, for HTML output:
* builds the table of contents at the placeholder (use it INSTEAD of --toc; --toc-depth is honoured),
* drops Pandoc's title block when the book has its own title page, or moves it to the placeholder,
* shows the cover (first chapter with nothing but one picture) without the caption "Cover",
* turns the chapter anchors <a id="kap-001-…"></a> into <div class="chapter-start"> so that print.css can
start every chapter – including cover, title page and imprint – on a new page.
For LaTeX (PDF route md -> html -> pdf, filter in BOTH steps, no --toc) the table of contents becomes
\tableofcontents at the same place, every chapter starts with \clearpage and there is no \maketitle
before the cover. For other formats it only removes the placeholders (EPUB, DOCX, ODT: use --toc there).
Usage:
pandoc buch.md -s --lua-filter /pfad/zu/epub2md/pandoc/book.lua \
--lua-filter /pfad/zu/epub2md/pandoc/callouts.lua --css /pfad/zu/epub2md/pandoc/print.css \
--pdf-engine=weasyprint -o buch.pdf
]]
local TOC_ID, TITLE_ID = "epub2md-toc", "epub2md-titlepage"
local TOC_TITLE = { de = "Inhaltsverzeichnis", en = "Contents" }
local function is_placeholder(block, id)
return block.t == "Div" and block.identifier == id and #block.content == 0
end
local function esc(s)
return (s:gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;"))
end
-- <header id="title-block-header"> as Pandoc's HTML template writes it.
local function title_block(meta)
local html = { '<header id="title-block-header">', '<h1 class="title">' .. esc(pandoc.utils.stringify(meta.title)) .. "</h1>" }
if meta.subtitle then
html[#html + 1] = '<p class="subtitle">' .. esc(pandoc.utils.stringify(meta.subtitle)) .. "</p>"
end
local authors = meta.author
if authors and pandoc.utils.type(authors) ~= "List" then
authors = { authors }
end
for _, a in ipairs(authors or {}) do
html[#html + 1] = '<p class="author">' .. esc(pandoc.utils.stringify(a)) .. "</p>"
end
if meta.date then
html[#html + 1] = '<p class="date">' .. esc(pandoc.utils.stringify(meta.date)) .. "</p>"
end
html[#html + 1] = "</header>"
return pandoc.RawBlock("html", table.concat(html, "\n"))
end
-- Paragraph that only holds chapter start anchors (<a id="kap-001-cover"></a>) → id of the first one.
local function chapter_anchor(block)
if block.t ~= "Para" and block.t ~= "Plain" then
return nil
end
local id = nil
for _, inl in ipairs(block.content) do
if inl.t ~= "RawInline" then
return nil
end
local found = inl.text:match('^<a id="(kap%-%d%d%d%-[^"]*)">$') or inl.text:match('^<a id="(kap%-%d%d%d%-[^"]*)"></a>$')
or inl.text:match('^<a id="(%d%d%d%-[^"]*)">$') or inl.text:match('^<a id="(%d%d%d%-[^"]*)"></a>$') -- before 0.3.0
if found then
id = id or found
elseif inl.text ~= "</a>" then
return nil
end
end
return id
end
-- Characters in running text that even DejaVu lacks: typeset as math symbols ("↩︎": footnote back-links).
local LATEX_SYMBOLS = { ["↩"] = "$\\hookleftarrow$", ["ℓ"] = "$\\ell$", ["\u{2329}"] = "$\\langle$",
["\u{232A}"] = "$\\rangle$", ["\u{3008}"] = "$\\langle$", ["\u{3009}"] = "$\\rangle$",
["∖"] = "$\\setminus$", ["⋯"] = "$\\cdots$", ["\u{FE0E}"] = "" }
local LATEX_FONTS = { mainfont = "DejaVu Serif", monofont = "DejaVu Sans Mono", mathfont = "DejaVu Math TeX Gyre" }
local function font_installed(name)
local pipe = io.popen("fc-list -q " .. string.format("%q", name) .. " && echo yes")
local out = pipe and pipe:read("a") or ""
if pipe then
pipe:close()
end
return out:match("yes") ~= nil
end
local function latex_symbols(str)
local found = false
for _, c in utf8.codes(str.text) do
if LATEX_SYMBOLS[utf8.char(c)] then
found = true
break
end
end
if not found then
return nil
end
local out, buf = pandoc.Inlines({}), {}
for _, c in utf8.codes(str.text) do
local ch = utf8.char(c)
local tex = LATEX_SYMBOLS[ch]
if tex then
if #buf > 0 then
out:insert(pandoc.Str(table.concat(buf)))
buf = {}
end
if tex ~= "" then
out:insert(pandoc.RawInline("latex", tex))
end
else
buf[#buf + 1] = ch
end
end
if #buf > 0 then
out:insert(pandoc.Str(table.concat(buf)))
end
return out
end
-- LaTeX (2nd step of the PDF route md -> html -> pdf): the HTML from step 1 already holds the table of
-- contents (div#TOC.toc), the chapter starts (div.chapter-start) and, if moved, Pandoc's title block.
local function latex_book(doc)
local found = false
doc.blocks:walk({ Div = function(d)
if d.identifier == "TOC" and d.classes:includes("toc") or d.classes:includes("chapter-start") then
found = true
end
end })
if not found then
return nil
end
local blocks = pandoc.Blocks({})
for _, block in ipairs(doc.blocks) do
if block.t == "Div" and block.identifier == "TOC" and block.classes:includes("toc") then
blocks:insert(pandoc.RawBlock("latex", "\\clearpage\n\\tableofcontents\n\\clearpage"))
elseif block.t == "Div" and block.classes:includes("chapter-start") then
blocks:insert(pandoc.RawBlock("latex", "\\clearpage"))
blocks:insert(block)
elseif block.t == "Div" and block.classes:includes("cover") then
blocks:insert(pandoc.RawBlock("latex", "\\thispagestyle{empty}")) -- no page number on the cover
blocks:insert(block:walk({ Image = function(img) -- cover as large as the page allows
img.attributes.width = nil
img.attributes.height = "20cm"
return img
end }))
elseif block.t == "Div" and block.identifier == "title-block-header" then
blocks:insert(pandoc.RawBlock("latex", "\\clearpage\n\\begin{center}\n\\vspace*{3cm}"))
for _, b in ipairs(block.content) do
if b.t == "Header" then
local title = pandoc.Inlines({ pandoc.RawInline("latex", "{\\Huge\\bfseries ") })
title:extend(b.content)
title:insert(pandoc.RawInline("latex", "}"))
blocks:insert(pandoc.Para(title))
else
blocks:insert(b)
end
end
blocks:insert(pandoc.RawBlock("latex", "\\end{center}"))
else
blocks:insert(block)
end
end
doc.blocks = blocks:walk({ Str = latex_symbols })
-- fonts with a wide Unicode coverage (∈ λ ≠ ≥ □, thin spaces …), unless the user chose fonts himself
-- A4 with moderate margins, like print.css (Pandoc's LaTeX default: US letter, wide margins)
doc.meta.papersize = doc.meta.papersize or "a4"
doc.meta.geometry = doc.meta.geometry or "margin=2cm"
for var, font in pairs(LATEX_FONTS) do
if doc.meta[var] == nil and font_installed(font) then
doc.meta[var] = font
end
end
-- no \maketitle at the very top (before the cover); keep the PDF metadata
if doc.meta.title then
doc.meta["title-meta"] = pandoc.utils.stringify(doc.meta.title)
doc.meta.title = nil
end
if doc.meta.author then
doc.meta["author-meta"] = pandoc.utils.stringify(doc.meta.author)
doc.meta.author = nil
end
doc.meta.date = nil
return doc
end
function Pandoc(doc)
if FORMAT:match("latex") then
return latex_book(doc)
end
local html = FORMAT:match("html") and not FORMAT:match("epub")
local toc_at, title_at = nil, nil
for i, block in ipairs(doc.blocks) do
if is_placeholder(block, TOC_ID) then
toc_at = i
elseif is_placeholder(block, TITLE_ID) then
title_at = i
end
end
if not toc_at and not title_at then
return nil -- not a file from epub2md_merge.py
end
if not html then -- placeholders are top-level blocks: just drop them
local kept = pandoc.Blocks({})
for _, block in ipairs(doc.blocks) do
if not is_placeholder(block, TOC_ID) and not is_placeholder(block, TITLE_ID) then
kept:insert(block)
end
end
doc.blocks = kept
return doc
end
local blocks = pandoc.Blocks({})
local seen_chapter = false
for i, block in ipairs(doc.blocks) do
local anchor = chapter_anchor(block)
if i == toc_at then
if PANDOC_WRITER_OPTIONS.table_of_contents then
-- --toc given as well: Pandoc's own table of contents stays at the top, no second one
else
local lang = doc.meta.lang and pandoc.utils.stringify(doc.meta.lang):sub(1, 2):lower() or "de"
local toc = pandoc.structure.table_of_contents(doc.blocks, PANDOC_WRITER_OPTIONS)
blocks:insert(pandoc.Div({
pandoc.Header(1, TOC_TITLE[lang] or TOC_TITLE.de, pandoc.Attr("toc-title", { "unnumbered", "unlisted" })),
toc,
}, pandoc.Attr("TOC", { "toc" })))
end
elseif i == title_at then
if doc.meta.title then
blocks:insert(title_block(doc.meta))
end
elseif anchor then
blocks:insert(pandoc.Div({}, pandoc.Attr(anchor, { "chapter-start" })))
-- cover: the first chapter, if it holds nothing but one picture ('Cover', '[Titelinfo]', 'Deckblatt' …);
-- shown without the caption "Cover", centred on its own page
local nxt, after = doc.blocks[i + 1], doc.blocks[i + 2]
local ends = after == nil or chapter_anchor(after) ~= nil or is_placeholder(after, TOC_ID)
or is_placeholder(after, TITLE_ID)
if not seen_chapter and nxt and (nxt.t == "Figure" or nxt.t == "Para") and ends then
local imgs, text = {}, false
nxt:walk({ Image = function(img) imgs[#imgs + 1] = img end })
if nxt.t == "Para" then
for _, inl in ipairs(nxt.content) do
if inl.t ~= "Image" and inl.t ~= "Space" and inl.t ~= "SoftBreak" then
text = true
end
end
end
if #imgs == 1 and not text then
doc.blocks[i + 1] = pandoc.Div({ pandoc.Plain({ imgs[1] }) }, pandoc.Attr("", { "cover" }))
end
end
seen_chapter = true
else
blocks:insert(block)
end
end
doc.blocks = blocks
-- The book's own title page (or the moved block) replaces Pandoc's title block at the very top.
if doc.meta.title then
doc.meta.pagetitle = doc.meta.pagetitle or pandoc.utils.stringify(doc.meta.title)
doc.meta.title = nil
end
return doc
end

View file

@ -1,8 +0,0 @@
/* Info boxes written by epub2md (--boxes callout/html), used by pandoc/callouts.lua. */
.callout, .box { border: 1px solid #c8ccd2; border-left: 0.35em solid #4a7bd0; border-radius: 0.3em;
background: #f5f8fc; padding: 0.4em 1em; margin: 1em 0; }
.callout > p:first-child { margin-top: 0.4em; }
.callout-tip { border-left-color: #2e9a4f; background: #f3faf5; }
.callout-important { border-left-color: #8250df; background: #f8f5fe; }
.callout-warning { border-left-color: #c58a00; background: #fffaf0; }
.callout-caution { border-left-color: #cf222e; background: #fff5f5; }

View file

@ -1,174 +0,0 @@
--[[
callouts.lua – Pandoc filter for the info boxes written by epub2md (--boxes callout).
epub2md writes boxes as GitHub alerts / Obsidian callouts:
> [!TIP]
> **Title**
>
> Text …
Pandoc < 3.1.10 does not know this syntax and prints "[!TIP]" literally. This filter turns such
block quotes into real boxes:
* HTML, EPUB: <div class="callout callout-tip"> with a bold label, styled by callouts.css
(next to this filter; HTML: <style> at the start of the body, so -H cannot drop it;
EPUB: added to the book's stylesheets together with Pandoc's default epub.css)
* other formats (LaTeX/PDF, DOCX, ODT …): block quote with a bold label "Tipp: Title"
It also handles <div class="callout …"> coming back from HTML (md -> html -> pdf route) and styles
the <div class="box …"> blocks of --boxes html.
Usage:
pandoc buch.md -s --lua-filter /pfad/zu/epub2md/pandoc/callouts.lua -o buch.html
]]
local LABELS = {
de = { NOTE = "Hinweis", TIP = "Tipp", IMPORTANT = "Wichtig", WARNING = "Warnung", CAUTION = "Vorsicht" },
en = { NOTE = "Note", TIP = "Tip", IMPORTANT = "Important", WARNING = "Warning", CAUTION = "Caution" },
}
-- callouts.css next to this filter is the single source of the box styles.
local function read_file(path)
local fh = io.open(path, "r")
if not fh then
return nil
end
local text = fh:read("a")
fh:close()
return text
end
local CSS_PATH = (PANDOC_SCRIPT_FILE and PANDOC_SCRIPT_FILE:match("^(.*[/\\])") or "") .. "callouts.css"
local CSS = read_file(CSS_PATH)
if not CSS then
io.stderr:write("callouts.lua: " .. CSS_PATH .. " nicht gefunden – Kästen ohne Rahmen\n")
end
-- EPUB: a stylesheet in metadata `css` replaces Pandoc's default epub.css, so combine both in a temp file.
local function epub_stylesheet()
local default = ""
local pipe = io.popen("pandoc --print-default-data-file epub.css 2>/dev/null")
if pipe then
default = pipe:read("a") or ""
pipe:close()
end
local path = os.tmpname() -- small temp file; Pandoc reads it after the filter has finished
local fh = io.open(path, "w")
if not fh then
return nil
end
fh:write(default, "\n", CSS)
fh:close()
return path
end
local lang = "de"
local function is_html()
return FORMAT:match("html") ~= nil or FORMAT:match("epub") ~= nil
end
local function label_for(kind)
local labels = LABELS[lang] or LABELS.de
return labels[kind] or labels.NOTE
end
local function drop_leading_breaks(inlines)
while #inlines > 0 and (inlines[1].t == "SoftBreak" or inlines[1].t == "LineBreak" or inlines[1].t == "Space") do
inlines:remove(1)
end
return inlines
end
-- Build the box from its kind ("TIP" …), optional title inlines and body blocks.
local function make_box(kind, title, body)
local head = pandoc.Inlines({ pandoc.Str(label_for(kind)) })
if title and #title > 0 then
head:insert(pandoc.Str(":"))
head:insert(pandoc.Space())
head:extend(title)
end
local blocks = pandoc.Blocks({ pandoc.Para({ pandoc.Strong(head) }) })
blocks:extend(body)
if is_html() then
return pandoc.Div(blocks, pandoc.Attr("", { "callout", "callout-" .. kind:lower() }))
end
return pandoc.BlockQuote(blocks)
end
local function Meta(meta)
if meta.lang then
local l = pandoc.utils.stringify(meta.lang):lower():sub(1, 2)
if LABELS[l] then
lang = l
end
end
if CSS and FORMAT:match("epub") then
local css = meta.css
local path = nil
if css == nil then -- no own stylesheet: default epub.css + ours; otherwise just add ours
path = epub_stylesheet()
end
if css == nil then
css = pandoc.List({})
elseif pandoc.utils.type(css) ~= "List" then
css = pandoc.List({ css })
end
if path then
css:insert(pandoc.MetaString(path))
else
css:insert(pandoc.MetaString(CSS_PATH))
end
meta.css = css
end
return meta
end
local function BlockQuote(quote)
local first = quote.content[1]
if not first or first.t ~= "Para" or #first.content == 0 or first.content[1].t ~= "Str" then
return nil
end
local kind = first.content[1].text:match("^%[!(%a+)%]$")
if not kind then
return nil
end
kind = kind:upper()
local rest = drop_leading_breaks(first.content:clone())
rest:remove(1)
rest = drop_leading_breaks(rest)
local body = pandoc.Blocks({})
for i = 2, #quote.content do
body:insert(quote.content[i])
end
local title = nil
if #rest == 1 and rest[1].t == "Strong" then
title = rest[1].content -- "> **Title**" line
elseif #rest > 0 then
body:insert(1, pandoc.Para(rest)) -- text directly after the marker
end
return make_box(kind, title, body)
end
-- <div class="callout callout-tip"> read back from HTML: keep it as a box in non-HTML formats.
local function Div(div)
if not div.classes:includes("callout") or is_html() then
return nil
end
return pandoc.BlockQuote(div.content)
end
-- HTML: the stylesheet goes into the body, because a command-line -H replaces header-includes from a filter.
local function Pandoc(doc)
if CSS and FORMAT:match("html") and not FORMAT:match("epub") then
doc.blocks:insert(1, pandoc.RawBlock("html", "<style>\n" .. CSS .. "</style>"))
end
return doc
end
return {
{ Meta = Meta },
{ BlockQuote = BlockQuote, Div = Div },
{ Pandoc = Pandoc },
}

View file

@ -1,32 +0,0 @@
/* Print layout for PDFs from epub2md output via Pandoc + weasyprint:
pandoc buch.md -s --lua-filter pandoc/book.lua --lua-filter pandoc/callouts.lua --css pandoc/print.css \
--pdf-engine=weasyprint -o buch.pdf
book.lua places the table of contents after cover, title page and imprint and marks chapter starts. */
@page { size: A4; margin: 2cm 1.8cm; @bottom-center { content: counter(page); font-size: 9pt; } }
@page :first { @bottom-center { content: none; } } /* no page number on the cover / title page */
body { max-width: none; font-size: 10.5pt; line-height: 1.4; hyphens: auto; text-align: justify;
font-family: "DejaVu Serif", "Liberation Serif", Georgia, serif; }
#title-block-header, #title-block-header h1.title { text-align: center; margin-bottom: 2em; }
h1, h2, h3, h4 { text-align: left; hyphens: manual; page-break-after: avoid; }
pre, code { font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; hyphens: none; }
pre { background: #f6f8fa; padding: 0.5em 0.8em; border-radius: 0.3em; page-break-inside: auto; }
pre, pre code { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 8.5pt; } /* long code lines wrap */
pre { overflow: visible; }
blockquote { margin: 1em 0 1em 1.5em; padding-left: 0.8em; border-left: 2px solid #ccc; color: #444; }
a { color: inherit; text-decoration: none; }
img { max-width: 100%; max-height: 24cm; height: auto; }
table { font-size: 9pt; }
.cover { text-align: center; }
.cover img { max-height: 25cm; }
/* New pages: every chapter (book.lua: div.chapter-start, also cover, title page, imprint), the title
block, the table of contents; without book.lua every <h1>. No second break for the chapter's own <h1>. */
h1, .chapter-start, #title-block-header, #TOC { page-break-before: always; }
.chapter-start + h1, #title-block-header h1, #TOC h1 { page-break-before: auto; }
#TOC { page-break-after: always; }
/* Table of contents with dotted leaders and page numbers. */
#TOC ul { list-style: none; padding-left: 1.2em; margin: 0; }
#TOC > ul { padding-left: 0; }
#TOC li { text-align: left; }
#TOC a::after { content: leader(".") target-counter(attr(href), page); }

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