Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2526ee6789 | |||
| 29c6294584 | |||
| 01f60a41c4 | |||
| 57d485baa3 | |||
| 64d0cb0357 | |||
| 6fe422919f | |||
| 4b3d753b56 | |||
| 568844e8c4 | |||
| 344e0d12b6 | |||
| 86aa93c12c | |||
| 99c3c609ca | |||
| 65c90c67ca | |||
| 13617c8d3e | |||
| c761a53728 | |||
| 6e3d05fef1 | |||
| 20494742a3 | |||
| e8fd6c2fb9 | |||
| f7cceeba25 | |||
| 16e2d186a4 | |||
| 5cef9d7885 | |||
| 4992672ca6 | |||
| 9b0f060af2 | |||
| 5f7c352df1 | |||
| a54fdda2fa |
41 changed files with 3836 additions and 138 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -12,7 +12,7 @@ htmlcov/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# QA-Läufe (Prompt_Kontrolle.md)
|
# QA-Läufe (Prompt_Kontrolle.md): erzeugte Daten von tools/qa/
|
||||||
.qa/
|
.qa/
|
||||||
.qa-output/
|
.qa-output/
|
||||||
.qa-fixtures/
|
.qa-fixtures/
|
||||||
|
|
|
||||||
|
|
@ -115,12 +115,14 @@ python3 epub2md_merge.py output/meinbuch
|
||||||
```
|
```
|
||||||
|
|
||||||
Ergebnis: `output/meinbuch/meinbuch.md`. Die Datei heißt wie das Original-E-Book, nur mit `.md`.
|
Ergebnis: `output/meinbuch/meinbuch.md`. Die Datei heißt wie das Original-E-Book, nur mit `.md`.
|
||||||
Bilder und Querverweise funktionieren darin weiter.
|
Bilder und Querverweise funktionieren darin weiter. Am Anfang stehen die Buchdaten (Titel, Autor, Verlag,
|
||||||
|
Datum als `13.01.2025`), die Pandoc für die Titelseite verwendet.
|
||||||
|
|
||||||
Nützliche Varianten:
|
Nützliche Varianten:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# mit Inhaltsverzeichnis am Anfang
|
# mit Inhaltsverzeichnis (Liste der Kapitel) hinter Cover, Titelseite und Impressum –
|
||||||
|
# für PDFs mit Pandoc nicht nötig, dort baut book.lua ein Verzeichnis mit Seitenzahlen (Kapitel 7)
|
||||||
python3 epub2md_merge.py output/meinbuch --toc
|
python3 epub2md_merge.py output/meinbuch --toc
|
||||||
|
|
||||||
# an einen anderen Ort schreiben (Bildpfade werden automatisch angepasst)
|
# an einen anderen Ort schreiben (Bildpfade werden automatisch angepasst)
|
||||||
|
|
@ -139,14 +141,21 @@ python3 epub2md_merge.py output/meinbuch --force
|
||||||
|
|
||||||
### Roman (Teile und Kapitel)
|
### Roman (Teile und Kapitel)
|
||||||
|
|
||||||
Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Standardmäßig entsteht eine Datei
|
Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Das Programm erkennt sehr große
|
||||||
pro **Teil**. Das können sehr große Dateien werden. Das Programm warnt dann mit `chapter-large` und
|
Teile (über 30 000 Wörter) selbst und legt dann eine kurze Datei für den Teil und eine Datei pro
|
||||||
empfiehlt die passende Einstellung. Eine Datei pro **Kapitel** bekommst du so:
|
**Kapitel** an; kurze Abschnitte wie Vorwort oder Anhang bleiben eine Datei. Du musst dafür nichts
|
||||||
|
einstellen. Soll wirklich jeder Abschnitt mit Unterüberschrift eine eigene Datei werden, gilt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 epub2md.py roman.epub output/roman --chapter-level 2
|
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
|
### Programmierbuch
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -154,8 +163,9 @@ python3 epub2md.py python-buch.epub output/python-buch
|
||||||
```
|
```
|
||||||
|
|
||||||
Programmcode bleibt zeichengenau erhalten, inklusive Einrückung. Die Programmiersprache wird nur
|
Programmcode bleibt zeichengenau erhalten, inklusive Einrückung. Die Programmiersprache wird nur
|
||||||
eingetragen, wenn sie eindeutig erkennbar ist. Ist das ganze Buch in **einer** Sprache geschrieben,
|
eingetragen, wenn sie eindeutig erkennbar ist. Ist ein Buch erkennbar in **einer** Sprache geschrieben,
|
||||||
kannst du sie für alle übrigen Codeblöcke vorgeben:
|
erhalten auch kurze Codeschnipsel dieses Label (etwa `char ch = 65;` in einem C-Buch). Programmausgaben
|
||||||
|
bleiben ohne Label. Willst du wirklich alle übrigen Blöcke kennzeichnen, gib die Sprache vor:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 epub2md.py python-buch.epub output/python-buch --code-language python
|
python3 epub2md.py python-buch.epub output/python-buch --code-language python
|
||||||
|
|
@ -177,7 +187,9 @@ python3 epub2md.py mathe.epub output/mathe
|
||||||
```
|
```
|
||||||
|
|
||||||
Formeln, die im E-Book als echte Formeln (MathML oder TeX) vorliegen, werden zu TeX: `$…$` im Text,
|
Formeln, die im E-Book als echte Formeln (MathML oder TeX) vorliegen, werden zu TeX: `$…$` im Text,
|
||||||
`$$…$$` als eigene Zeile. Formeln, die nur als Bild vorliegen, bleiben Bilder und erscheinen in
|
`$$…$$` als eigene Zeile. Viele Verlage legen das TeX einer Formel in den Alternativtext eines Formelbildes;
|
||||||
|
epub2md übernimmt es und passt Schreibweisen an, die Pandoc nicht versteht (z. B. `\"u` → `ü`), ohne den
|
||||||
|
Inhalt zu ändern. Formeln, die nur als Bild vorliegen, bleiben Bilder und erscheinen in
|
||||||
`diagnostics/warnings.md`.
|
`diagnostics/warnings.md`.
|
||||||
|
|
||||||
| Gewünschte Formeldarstellung | Option |
|
| Gewünschte Formeldarstellung | Option |
|
||||||
|
|
@ -203,6 +215,55 @@ python3 epub2md.py bildband.epub output/bildband --chapter-level 3
|
||||||
- Bilder, die im E-Book zwar enthalten, aber nirgends eingebunden sind, werden trotzdem gespeichert.
|
- Bilder, die im E-Book zwar enthalten, aber nirgends eingebunden sind, werden trotzdem gespeichert.
|
||||||
Im Bericht sind sie als `unreferenced` gekennzeichnet.
|
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
|
### Buch mit vielen Fußnoten
|
||||||
|
|
||||||
Standardmäßig stehen die Fußnoten am Ende des jeweiligen Kapitels. Alternativen:
|
Standardmäßig stehen die Fußnoten am Ende des jeweiligen Kapitels. Alternativen:
|
||||||
|
|
@ -254,28 +315,57 @@ cd output/meinbuch
|
||||||
```
|
```
|
||||||
|
|
||||||
`meinbuch.md` steht im Folgenden für den Namen der Gesamtdatei. Er entspricht dem Namen des
|
`meinbuch.md` steht im Folgenden für den Namen der Gesamtdatei. Er entspricht dem Namen des
|
||||||
Original-E-Books.
|
Original-E-Books. `$P` ist der Ordner mit den Pandoc-Hilfsdateien von epub2md; lege ihn einmal fest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
P=~/Python_Programs/epub2md/pandoc # Pfad zu deinem epub2md-Ordner anpassen
|
||||||
|
```
|
||||||
|
|
||||||
| Ziel | Befehl |
|
| Ziel | Befehl |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Webseite (HTML) | `pandoc meinbuch.md -s --toc -o meinbuch.html` |
|
| 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 mit Formeln | `pandoc meinbuch.md -s --toc --mathml -o meinbuch.html` |
|
| Webseite (HTML) | `pandoc meinbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html` |
|
||||||
| E-Book (EPUB) | `pandoc meinbuch.md --toc -o meinbuch.epub` |
|
| Webseite mit Formeln | wie HTML, zusätzlich `--mathml` |
|
||||||
| Word | `pandoc meinbuch.md -o meinbuch.docx` |
|
| E-Book (EPUB) | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.epub` |
|
||||||
| LibreOffice | `pandoc meinbuch.md -o meinbuch.odt` |
|
| E-Book mit Formeln | wie EPUB, zusätzlich `--mathml` |
|
||||||
| PDF mit LaTeX | `pandoc meinbuch.md -s -o meinbuch.html && pandoc meinbuch.html --toc --pdf-engine=xelatex -o meinbuch.pdf` |
|
| Word | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.docx` |
|
||||||
| PDF ohne LaTeX | `pandoc meinbuch.md --toc --pdf-engine=weasyprint -o meinbuch.pdf` |
|
| LibreOffice | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.odt` |
|
||||||
| Kindle (AZW3) | `pandoc meinbuch.md --toc -o meinbuch.epub && ebook-convert meinbuch.epub meinbuch.azw3` |
|
| PDF mit LaTeX (Bücher mit Formeln) | `pandoc meinbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html && pandoc meinbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex -o meinbuch.pdf` |
|
||||||
|
| Kindle (AZW3) | erst EPUB wie oben, dann `ebook-convert meinbuch.epub meinbuch.azw3` |
|
||||||
|
|
||||||
|
Die drei Hilfsdateien:
|
||||||
|
|
||||||
|
- **`book.lua`** ordnet den Buchanfang wie im gedruckten Buch: Cover, Titelseite, Impressum, dann das
|
||||||
|
Inhaltsverzeichnis, jeweils auf einer neuen Seite. Ohne den Filter setzt Pandoc mit `--toc` das
|
||||||
|
Inhaltsverzeichnis (und seinen eigenen Titelkopf) vor das Cover. Mit `book.lua` deshalb **kein** `--toc`
|
||||||
|
angeben. Hat das Buch keine eigene Titelseite, setzt der Filter Pandocs Titelkopf hinter das Cover. Das
|
||||||
|
gilt für HTML und für beide PDF-Wege (beim LaTeX-Weg den Filter in beiden Schritten angeben); EPUB, Word
|
||||||
|
und LibreOffice bauen ihr Verzeichnis mit `--toc` selbst. Kurze Seiten zwischen Titelseite und Impressum
|
||||||
|
(Auflagenvermerk, Verlagsbrief, „Über dieses Buch“) bleiben im Vorspann; das Inhaltsverzeichnis folgt vor
|
||||||
|
dem ersten Teil, Vorwort oder nummerierten Kapitel.
|
||||||
|
- **`callouts.lua`** zeichnet die Infokästen (`> [!TIP]`). Pandoc in der Ubuntu-Version kennt diese
|
||||||
|
Schreibweise nicht und druckt „[!TIP]“ sonst einfach mit. In HTML, EPUB und im PDF über weasyprint werden
|
||||||
|
daraus gerahmte, farbige Kästen mit Titel („Tipp: …“), im PDF über LaTeX und in Word/LibreOffice ein
|
||||||
|
eingerückter Block mit fettem Titel.
|
||||||
|
- **`print.css`** liefert das Druck-Layout für weasyprint: A4, Seitenzahlen (nicht auf dem Cover), jedes
|
||||||
|
Kapitel auf einer neuen Seite, Inhaltsverzeichnis mit Seitenzahlen, Silbentrennung und umbrechende lange
|
||||||
|
Codezeilen. Ohne sie
|
||||||
|
übernimmt weasyprint das Bildschirmlayout, und lange Codezeilen werden am Seitenrand abgeschnitten.
|
||||||
|
|
||||||
|
Du musst die Befehle nicht abtippen: Die `README.md` im Ausgabeordner enthält sie fertig, mit allen Pfaden.
|
||||||
|
|
||||||
**Zum PDF:**
|
**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
|
- **Mit LaTeX** immer den Zwischenschritt über HTML nehmen, wie in der Tabelle. Beim direkten Weg von
|
||||||
Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Bei
|
Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Für
|
||||||
Büchern mit Formeln ist der LaTeX-Weg die beste Wahl.
|
Bücher mit Formeln ist das der richtige Weg, weil weasyprint keine Formeln setzen kann. Farben und
|
||||||
- **Mit weasyprint** genügt ein Schritt; Tabellen bleiben erhalten. Für Bücher mit vielen Formeln ist der
|
Kastenrahmen gehen dabei verloren; Kästen erscheinen als eingerückter Block mit fettem Titel. `book.lua`
|
||||||
LaTeX-Weg sicherer.
|
stellt dabei A4 ein und wählt die Schrift DejaVu, die auch Zeichen wie ∈, λ oder ≠ im Fließtext enthält.
|
||||||
|
|
||||||
Bei Büchern mit Formeln für HTML zusätzlich `--mathml` angeben, sonst bleiben Matrizen als TeX-Text stehen.
|
Bei Büchern mit Formeln für HTML und EPUB zusätzlich `--mathml` angeben, sonst bleiben Matrizen und
|
||||||
|
ähnliche Formeln als TeX-Text stehen.
|
||||||
|
|
||||||
#### Direkt aus den Kapiteldateien
|
#### Direkt aus den Kapiteldateien
|
||||||
|
|
||||||
|
|
@ -305,8 +395,8 @@ gilt hier derselbe Umweg über HTML.
|
||||||
- Die einzelnen Kapiteldateien eignen sich als Abschnitte. Die Metadaten im Datenkopf (Buch, Kapitel,
|
- Die einzelnen Kapiteldateien eignen sich als Abschnitte. Die Metadaten im Datenkopf (Buch, Kapitel,
|
||||||
Herkunft) helfen beim Zuordnen von Fundstellen.
|
Herkunft) helfen beim Zuordnen von Fundstellen.
|
||||||
- `manifest.json` listet alle Kapitel mit Titel, Datei, Wortanzahl und Ankern.
|
- `manifest.json` listet alle Kapitel mit Titel, Datei, Wortanzahl und Ankern.
|
||||||
- Achte auf die Warnung `chapter-large`: Sehr große Kapiteldateien lassen sich schlecht zerlegen. Dann
|
- Sehr große Kapitel teilt das Programm selbst an ihren Unterkapiteln. Hast du `--chapter-level` fest
|
||||||
mit höherem `--chapter-level` neu umwandeln.
|
eingestellt und erscheint die Warnung `chapter-large`, dann mit dem empfohlenen Wert neu umwandeln.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -431,9 +521,14 @@ python3 epub2md_merge.py --help
|
||||||
| `--dry-run` | nur anzeigen, nichts schreiben |
|
| `--dry-run` | nur anzeigen, nichts schreiben |
|
||||||
| `--verbose` | ausführliche Ausgabe |
|
| `--verbose` | ausführliche Ausgabe |
|
||||||
| `--force` | frühere Ausgabe ersetzen |
|
| `--force` | frühere Ausgabe ersetzen |
|
||||||
| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) |
|
| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard `auto`: Ebene 1, sehr große Kapitel werden an ihren Unterkapiteln geteilt) |
|
||||||
| `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache |
|
| `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache |
|
||||||
| `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen |
|
| `--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 |
|
| `--math auto/tex/mathml/image` | Formeldarstellung |
|
||||||
| `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung |
|
| `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung |
|
||||||
| `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme |
|
| `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme |
|
||||||
|
|
|
||||||
124
CHANGELOG.md
Normal file
124
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# Änderungsprotokoll
|
||||||
|
|
||||||
|
Alle nennenswerten Änderungen an epub2md. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
|
||||||
|
Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/).
|
||||||
|
|
||||||
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- `tools/qa/`: QA-Skripte versioniert (CLI-Matrix mit 89 Fällen, Generator für Test-EPUBs, unabhängiger
|
||||||
|
Markdown-Validator, Wortvergleich gegen Datenverlust, Prüfung des Pandoc-Wegs für HTML, EPUB und PDF).
|
||||||
|
Beschreibung in `tools/qa/README.md`; erzeugte Daten landen in `.qa/`.
|
||||||
|
|
||||||
|
### Verbessert
|
||||||
|
|
||||||
|
- Sprachlabels für Codeblöcke (R1): Gedruckte Zeilennummern (`00 #include …`) verdecken die Sprache
|
||||||
|
nicht mehr. Python wird zusätzlich per `ast.parse` erkannt (Kontrollstrukturen, `def`/`class`, Import
|
||||||
|
mit Code) sowie an `>>>`-Sitzungen. Ein Vordurchlauf bestimmt die Sprache des Buches aus den sicher
|
||||||
|
erkannten Blöcken. Passt sie, reichen für kurze Schnipsel schwächere Signale (`detected_by:
|
||||||
|
"book-context"`); Programmausgaben bleiben ohne Label. Ohne Label waren vorher im C-Buch 518 von 574
|
||||||
|
Blöcken, jetzt 132 (fast nur Ausgaben). Im Python-Buch waren es 492 von 747, jetzt 326 (Ausgaben).
|
||||||
|
|
||||||
|
## [0.3.0] – 2026-10-06
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- `pandoc/book.lua`: Inhaltsverzeichnis wie im gedruckten Buch nach Cover, Titelseite und Impressum statt
|
||||||
|
davor (Pandocs `--toc` setzt es immer an den Anfang). `epub2md_merge.py` markiert dafür das Ende des
|
||||||
|
Vorspanns; ohne eigene Titelseite kommt Pandocs Titelkopf hinter das Cover. Jedes Kapitel, auch Cover,
|
||||||
|
Titelseite und Impressum, beginnt mit `print.css` auf einer neuen Seite; das Inhaltsverzeichnis hat
|
||||||
|
Seitenzahlen. Mit `epub2md_merge.py --toc` steht dessen Verzeichnis an derselben Stelle. Gilt auch für den
|
||||||
|
PDF-Weg über LaTeX (`\tableofcontents` hinter dem Vorspann); bei Büchern mit Formeln empfiehlt die
|
||||||
|
Buch-README diesen Weg. Dort setzt der Filter außerdem A4, ein seitengroßes Cover und DejaVu-Schriften,
|
||||||
|
sodass keine Zeichen mehr fehlen (Mathebuch: vorher über 10.000 fehlende Glyphen wie ∈, λ, ≠).
|
||||||
|
- Kapitelerkennung: Ein erstes Kapitel nur aus einem Bild ist das Cover (auch „[Titelinfo]“, „Deckblatt“),
|
||||||
|
ein Kapitel „Titel“ die Titelseite. Der Vorspann darf kurze Zwischenseiten enthalten.
|
||||||
|
- `pandoc/print.css`: Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite,
|
||||||
|
Silbentrennung außer in Code, umbrechende Codezeilen). Die Buch-README enthält den passenden Befehl.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- `--chapter-level auto` ist der neue Standard: Ebene 1 wie bisher, aber Kapitel mit über 30 000 Wörtern
|
||||||
|
und mindestens zwei Unterkapiteln (Teile eines Romans) werden an ihren Unterkapiteln geteilt; alle
|
||||||
|
anderen Kapitel bleiben ganz (Info `chapter-split`). Der Roman im Test ergibt 78 statt 9 Dateien, die
|
||||||
|
übrigen Beispielbücher bleiben unverändert. Eine feste Zahl verhält sich wie bisher (R2).
|
||||||
|
- `epub2md_merge.py` kürzt ISO-Zeitstempel im Buch-Kopf (`2025-01-13T08:00:00+00:00` → `13.01.2025`, bei
|
||||||
|
nicht-deutschen Büchern `2025-01-13`), damit die Pandoc-Titelseite ein lesbares Datum zeigt.
|
||||||
|
- Seitenmarken (`--page-markers`) aus einem Kastentitel stehen jetzt vor dem Kasten statt im fetten Titel,
|
||||||
|
wie bei Überschriften.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- `epub2md_merge.py`: Kapitel-Startanker heißen jetzt `kap-003-name` statt `003-name`. Pandocs EPUB-Ausgabe
|
||||||
|
löste Links auf IDs mit führender Ziffer nicht auf (C-Buch 32, Mathebuch 20 tote Links im EPUB).
|
||||||
|
`pandoc/book.lua` erkennt beide Formen (QA3-05).
|
||||||
|
- Buch-README: Der EPUB-Befehl enthält bei Büchern mit Formeln `--mathml`. Ohne blieben im Mathebuch
|
||||||
|
1072 Formeln als TeX-Text stehen (QA3-04).
|
||||||
|
- TeX aus `alt`-Texten wird für Pandoc portabel gemacht (`\"u` → `ü`, `\O` → `\emptyset`, `@{…}` und
|
||||||
|
`\arraycolsep` in `array`, leeres `\displaystyle` vor Zellenende). Im Mathebuch kann Pandoc damit 4985 statt
|
||||||
|
4977 Formeln setzen; vorher blieben 9 als TeX-Text stehen (QA3-06).
|
||||||
|
- `pandoc/print.css` und `pandoc/book.lua`: keine Seitenzahl auf dem Cover.
|
||||||
|
- CSS-Auswertung: Es zählen nur noch Stylesheets, die ein Dokument per `<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
169
README.md
|
|
@ -7,7 +7,7 @@ YAML-Metadaten, Inhaltsverzeichnis, maschinenlesbarem Manifest und einem Konvert
|
||||||
Gedacht für die Weiterverarbeitung mit Markdown-Renderern, Pandoc, Obsidian/Zettelkasten, Git,
|
Gedacht für die Weiterverarbeitung mit Markdown-Renderern, Pandoc, Obsidian/Zettelkasten, Git,
|
||||||
RAG-/Embedding-Pipelines, Volltextsuche und lokale LLMs.
|
RAG-/Embedding-Pipelines, Volltextsuche und lokale LLMs.
|
||||||
|
|
||||||
Eine Schritt-für-Schritt-Anleitung für Anwender steht in der [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md).
|
Eine Schritt-für-Schritt-Anleitung für Anwender steht in der [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md). Änderungen je Version: [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
Das Projekt enthält zwei Programme:
|
Das Projekt enthält zwei Programme:
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ python3 epub2md.py \
|
||||||
--toc \
|
--toc \
|
||||||
--verbose
|
--verbose
|
||||||
|
|
||||||
# Roman mit Teilen (h1) und Kapiteln (h2): ein File pro Kapitel
|
# Jedes Kapitel unterhalb von h2 als eigene Datei (sonst teilt auto nur sehr große Kapitel)
|
||||||
python3 epub2md.py roman.epub output/roman --chapter-level 2
|
python3 epub2md.py roman.epub output/roman --chapter-level 2
|
||||||
|
|
||||||
# Nur analysieren, nichts schreiben
|
# 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 |
|
| `--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) |
|
| `--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 |
|
| `--copy-original` / `--no-copy-original` | aus | Original-EPUB nach `source/` kopieren |
|
||||||
| `--chapter-level N` | `1` | Überschriftenebene (1–6), ab der eine neue Kapiteldatei beginnt |
|
| `--chapter-level {auto,1-6}` | `auto` | Überschriftenebene, ab der eine neue Kapiteldatei beginnt. `auto`: Ebene 1; nur Kapitel mit über 30 000 Wörtern und mindestens zwei Unterkapiteln (z. B. Teile eines Romans) werden an ihren Unterkapiteln geteilt (Info `chapter-split`). Eine Zahl gilt ausnahmslos für das ganze Buch |
|
||||||
| `--toc` / `--no-toc` | an | `README.md` mit Inhaltsverzeichnis, Statistik und Reproduktionsbefehl |
|
| `--toc` / `--no-toc` | an | `README.md` mit Inhaltsverzeichnis, Statistik und Reproduktionsbefehl |
|
||||||
| `--frontmatter` / `--no-frontmatter` | an | YAML-Frontmatter in jeder Kapiteldatei |
|
| `--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) |
|
| `--footnotes {keep-links,end-of-chapter,end-of-book,inline}` | `end-of-chapter` | siehe [Fußnoten](#fußnoten-modi) |
|
||||||
|
|
@ -97,6 +97,11 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
|
||||||
| `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt |
|
| `--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"` |
|
| `--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`) |
|
| `--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`) |
|
| `--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) |
|
| `--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) |
|
| `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) |
|
||||||
|
|
@ -153,9 +158,9 @@ output/mein-buch/
|
||||||
## Wie Kapitel gebildet werden
|
## Wie Kapitel gebildet werden
|
||||||
|
|
||||||
1. Die **OPF-Spine** bestimmt die Lesereihenfolge (nicht Dateinamen, nicht das Inhaltsverzeichnis).
|
1. Die **OPF-Spine** bestimmt die Lesereihenfolge (nicht Dateinamen, nicht das Inhaltsverzeichnis).
|
||||||
2. Überschriften `h1`…`hN` (N = `--chapter-level`) und semantische Abschnitte (`epub:type`/`role`:
|
2. Überschriften `h1`…`hN` (N = `--chapter-level`, bei `auto` 1 bzw. 2 in sehr großen Kapiteln) und
|
||||||
chapter, part, appendix, glossary, bibliography, index, preface, …) beginnen neue Kapiteldateien,
|
semantische Abschnitte (`epub:type`/`role`: chapter, part, appendix, glossary, bibliography, index,
|
||||||
auch mitten in einer XHTML-Datei.
|
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
|
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.
|
`1.2`), wird an das laufende Kapitel angehängt.
|
||||||
4. Dateien ohne Überschrift (Cover, Titelei, Widmung) werden eigene Kapitel. Ihr Titel kommt aus
|
4. Dateien ohne Überschrift (Cover, Titelei, Widmung) werden eigene Kapitel. Ihr Titel kommt aus
|
||||||
|
|
@ -165,6 +170,10 @@ output/mein-buch/
|
||||||
- ©/ISBN/„urheberrechtlich“ → „Impressum“
|
- ©/ISBN/„urheberrechtlich“ → „Impressum“
|
||||||
- sonst die ersten acht Wörter
|
- sonst die ersten acht Wörter
|
||||||
- erst als letzte Notlösung der Dateiname
|
- 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).
|
5. Ein HTML-Inhaltsverzeichnis wird übersprungen, wenn NAV/NCX vorhanden ist (keine doppelte Ausgabe).
|
||||||
Links darauf zeigen auf `README.md`.
|
Links darauf zeigen auf `README.md`.
|
||||||
6. Verzeichnisse erhalten immer eine eigene Datei, auch unterhalb von `--chapter-level`, z. B. ein
|
6. Verzeichnisse erhalten immer eine eigene Datei, auch unterhalb von `--chapter-level`, z. B. ein
|
||||||
|
|
@ -179,10 +188,13 @@ Verlagsspezifische Heuristiken: Layout-Tabellen mit Klassen wie `Head1`/`Title_d
|
||||||
Überschriften. `<pre>` mit `<br/>`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken.
|
Überschriften. `<pre>` mit `<br/>`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken.
|
||||||
Literaturlisten mit eigenen Labels (`[BryL06]`) werden nicht doppelt nummeriert.
|
Literaturlisten mit eigenen Labels (`[BryL06]`) werden nicht doppelt nummeriert.
|
||||||
|
|
||||||
**Tipp:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), erzeugt
|
**Teile und Kapitel:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), teilt der
|
||||||
`--chapter-level 2` eine Datei pro Kapitel. Mit dem Standard `1` entsteht eine Datei pro Teil.
|
Standard `auto` jeden Teil mit über 30 000 Wörtern und mindestens zwei Kapiteln in eine kurze Datei für den
|
||||||
Ist eine Kapiteldatei größer als 30 000 Wörter und hat sie mindestens zwei `##`-Unterabschnitte, meldet
|
Teil (Überschrift, Einleitung) und eine Datei pro Kapitel; kleinere Kapitel (Vorwort, Anhang) bleiben ganz.
|
||||||
epub2md eine Warnung `chapter-large` mit der passenden `--chapter-level`-Empfehlung.
|
Verzeichnisse (Register, Literatur, Glossar) werden dabei nie zerlegt. `--chapter-level 2` teilt dagegen
|
||||||
|
jedes Kapitel des Buches an `h2`. Ist bei einer festen Zahl eine Kapiteldatei größer als 30 000 Wörter und
|
||||||
|
hat sie mindestens zwei `##`-Unterabschnitte, meldet epub2md eine Warnung `chapter-large` mit der
|
||||||
|
passenden `--chapter-level`-Empfehlung.
|
||||||
|
|
||||||
## Math-Modi
|
## Math-Modi
|
||||||
|
|
||||||
|
|
@ -234,9 +246,44 @@ weil CommonMark keine `[^…]`-Syntax kennt.
|
||||||
- **Syntax-Highlighting:** Farb-Spans, `<strong>`/`<em>` in Listings und Zeilennummern werden entfernt.
|
- **Syntax-Highlighting:** Farb-Spans, `<strong>`/`<em>` in Listings und Zeilennummern werden entfernt.
|
||||||
Einrückung, Tabs, `<`, `>`, `&`, `$`, `|`, Backslashes und Backticks bleiben exakt erhalten. Die Sprache
|
Einrückung, Tabs, `<`, `>`, `&`, `$`, `|`, Backslashes und Backticks bleiben exakt erhalten. Die Sprache
|
||||||
kommt aus Klassen/Attributen (`language-python`, `data-lang`, …) oder starken Inhaltssignalen
|
kommt aus Klassen/Attributen (`language-python`, `data-lang`, …) oder starken Inhaltssignalen
|
||||||
(`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. Bei einsprachigen
|
(`#include`, Shebang, `>>>`, Python-Code, der sich parsen lässt und `def`/`class`/Kontrollstrukturen
|
||||||
Büchern kann `--code-language python` (o. Ä.) alle übrigen Blöcke kennzeichnen. Achtung: Das betrifft
|
enthält, …). Gedruckte Zeilennummern als Text (`00 #include …`) stören die Erkennung nicht und bleiben
|
||||||
auch Programmausgaben und Shell-Befehle im Buch.
|
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`.
|
||||||
- **Gescannte Texte:** nur Bilder, kein Text (keine OCR).
|
- **Gescannte Texte:** nur Bilder, kein Text (keine OCR).
|
||||||
- **DRM:** wird erkannt und abgelehnt.
|
- **DRM:** wird erkannt und abgelehnt.
|
||||||
- **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne
|
- **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne
|
||||||
|
|
@ -263,14 +310,15 @@ verwendet und das Verzeichnis gibt den Namen vor.
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `-o`, `--output DATEI.md` | anderer Zielort; Medienpfade werden relativ zum Ziel umgeschrieben |
|
| `-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) |
|
| `--frontmatter` / `--no-frontmatter` | ein YAML-Kopf mit Buchmetadaten (`title`, `author`, `lang`, …) für Pandoc (Standard: an) |
|
||||||
| `--toc` | Inhaltsverzeichnis mit Sprunglinks auf die Kapitel einfügen |
|
| `--toc` | Inhaltsverzeichnis mit Sprunglinks auf die Kapitel einfügen, hinter Cover, Titelseite und Impressum (für Pandoc-PDFs besser `pandoc/book.lua`, das Seitenzahlen und Unterabschnitte liefert) |
|
||||||
| `--force` | bestehende Zieldatei überschreiben (sonst Abbruch mit Exit 1) |
|
| `--force` | bestehende Zieldatei überschreiben (sonst Abbruch mit Exit 1) |
|
||||||
| `--version` | Programmversion |
|
| `--version` | Programmversion |
|
||||||
|
|
||||||
Beim Zusammenfügen passiert Folgendes:
|
Beim Zusammenfügen passiert Folgendes:
|
||||||
|
|
||||||
- Die Frontmatter der einzelnen Kapitel wird entfernt.
|
- Die Frontmatter der einzelnen Kapitel wird entfernt.
|
||||||
- Jedes Kapitel erhält einen Startanker (`<a id="003-kapitelname"></a>`).
|
- 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.
|
||||||
- Links zwischen Kapiteln (`003-x.md#anker`) werden zu Sprunglinks in der Gesamtdatei.
|
- 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
|
- 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.
|
ab dem zweiten Vorkommen in `c012-Sec1` umbenannt, samt aller Links darauf.
|
||||||
|
|
@ -279,8 +327,15 @@ Beim Zusammenfügen passiert Folgendes:
|
||||||
- Codeblöcke und Inline-Code werden nie verändert.
|
- Codeblöcke und Inline-Code werden nie verändert.
|
||||||
- Kapitelüberschriften bleiben `#`, jedes Kapitel beginnt also mit einer H1. Pandoc behandelt sie als
|
- Kapitelüberschriften bleiben `#`, jedes Kapitel beginnt also mit einer H1. Pandoc behandelt sie als
|
||||||
Kapitel.
|
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`,
|
- Der Buch-Kopf enthält nur Felder, die im EPUB vorhanden sind (`title`, `subtitle`, `author`, `lang`,
|
||||||
`publisher`, `date`, `identifier`, `rights`, `description`).
|
`publisher`, `date`, `identifier`, `rights`, `description`). Ein ISO-Zeitstempel als Datum wird gekürzt,
|
||||||
|
damit Pandoc auf der Titelseite nicht `2025-01-13T08:00:00+00:00` druckt: bei deutschen Büchern zu
|
||||||
|
`13.01.2025`, sonst zu `2025-01-13`. `metadata.yaml` behält den Originalwert.
|
||||||
|
|
||||||
Am Ende werden alle Links der Gesamtdatei geprüft. Ein Link auf einen nicht vorhandenen Anker zeigt auf
|
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,
|
den Kapitelanfang und erzeugt eine Warnung auf stderr. Die Zusammenfassung nennt Kapitelanzahl,
|
||||||
|
|
@ -296,21 +351,54 @@ konsistente Anker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei
|
cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei
|
||||||
pandoc programmierbuch.md --toc -s -o programmierbuch.html
|
P=~/Python_Programs/epub2md/pandoc # Pfad zum epub2md-Ordner anpassen
|
||||||
pandoc programmierbuch.md --toc -o programmierbuch.epub
|
pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua \
|
||||||
|
--css $P/print.css --pdf-engine=weasyprint -o programmierbuch.pdf # PDF (empfohlen)
|
||||||
|
pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o programmierbuch.html
|
||||||
|
pandoc programmierbuch.md --toc --lua-filter $P/callouts.lua -o programmierbuch.epub
|
||||||
|
|
||||||
cd output/mathematikbuch
|
cd output/mathematikbuch # Formeln: PDF über HTML und LaTeX (weasyprint setzt kein MathML)
|
||||||
pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html
|
pandoc mathematikbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o mathematikbuch.html
|
||||||
# PDF über den Zwischenschritt HTML (siehe unten)
|
pandoc mathematikbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex \
|
||||||
pandoc mathematikbuch.html --toc --pdf-engine=xelatex -o mathematikbuch.pdf
|
-o mathematikbuch.pdf
|
||||||
|
pandoc mathematikbuch.md --toc --mathml --lua-filter $P/callouts.lua -o mathematikbuch.epub # EPUB: MathML
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Hilfsdateien in `pandoc/`:
|
||||||
|
|
||||||
|
- **`book.lua`** (HTML und beide PDF-Wege; statt `--toc`): Pandocs `--toc` setzt Inhaltsverzeichnis und
|
||||||
|
Titelkopf immer an den Anfang, vor Cover, Titelseite und Impressum. `epub2md_merge.py` markiert deshalb das
|
||||||
|
Ende des Vorspanns (`<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.
|
**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
|
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.
|
ins Leere zeigen. Liest Pandoc dagegen die HTML-Fassung, macht es daraus echte Tabellen und Sprungziele.
|
||||||
Getestet mit dem C-Buch (405 Seiten, 34 Tabellen, 728 von 755 internen Links funktionieren; die übrigen
|
Getestet mit dem C-Buch (322 Seiten, 34 Tabellen; alle 831 internen Links haben ein Ziel, kein Zeichen
|
||||||
stehen in Codeblöcken innerhalb von Tabellen). Die `README.md` jedes konvertierten Buches enthält den
|
fehlt). Die `README.md` jedes konvertierten Buches enthält den passenden Befehl.
|
||||||
passenden Befehl.
|
|
||||||
|
|
||||||
Alternativ direkt aus den Kapiteldateien:
|
Alternativ direkt aus den Kapiteldateien:
|
||||||
|
|
||||||
|
|
@ -325,8 +413,10 @@ pandoc output/programmierbuch/chapters/*.md \
|
||||||
- Ausgaben mit `--math tex` (`\(…\)`) brauchen `-f markdown+tex_math_single_backslash`.
|
- 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
|
- 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.
|
direkt zusammen verarbeitet, sollte mit `--no-frontmatter` konvertieren oder die Gesamtdatei verwenden.
|
||||||
- Einzelne TeX-Befehle aus EPUBs (z. B. `\O` oder `@`-Konstrukte) kennt Pandocs Formelparser nicht. Er
|
- TeX aus dem E-Book wird für Pandocs Formelparser angepasst, ohne den Inhalt zu ändern: `\"u` → `ü`,
|
||||||
belässt sie als TeX, mit der Warnung „Could not convert TeX math“.
|
`\O` (leere Menge) → `\emptyset`, keine `@{…}`-Spaltentrenner und kein `\arraycolsep` in `array`, kein
|
||||||
|
leeres `\displaystyle` vor einem Zellenende. Seltene Handkonstruktionen (z. B. `\mathinner{…\raise…}`)
|
||||||
|
bleiben als TeX stehen, mit der Warnung „Could not convert TeX math“ (Mathebuch: 1 von 4986 Formeln).
|
||||||
|
|
||||||
## Bericht und Diagnose
|
## Bericht und Diagnose
|
||||||
|
|
||||||
|
|
@ -370,7 +460,12 @@ Verlustfreiheit behauptet (`lossless: false`).
|
||||||
- Audio/Video sind implementiert, aber nur mit synthetischen Daten geprüft. Bildbände sind mit einem
|
- 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).
|
realen Buch geprüft (434 Bilder, 246 Figuren, ausführliche Alternativtexte).
|
||||||
- Rein per CSS erzeugte Inhalte (`::before`/`content:`, z. B. automatische Schrittnummern) werden nicht
|
- Rein per CSS erzeugte Inhalte (`::before`/`content:`, z. B. automatische Schrittnummern) werden nicht
|
||||||
übernommen, weil CSS nicht ausgewertet wird.
|
übernommen, weil Pseudo-Elemente nicht ausgewertet werden.
|
||||||
|
- Die eingebundenen Stylesheets gelten für das ganze Buch, nicht je Kapiteldokument. Bindet nur ein
|
||||||
|
Kapitel ein bestimmtes Stylesheet ein, wirkt es auch auf die anderen. In Fußnoten greifen CSS-Regeln
|
||||||
|
mit Vorfahren-Selektoren nicht, weil die Noten vor dem Rendern aus dem Text gelöst werden.
|
||||||
|
- Von Hand gebaute TeX-Konstrukte (z. B. `\mathinner{…\raise…}`) kann Pandoc nicht in MathML umsetzen;
|
||||||
|
sie bleiben als TeX-Text stehen (Mathebuch: 1 von 4986 Formeln).
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
|
|
@ -386,6 +481,7 @@ epub2md/
|
||||||
├── chapters.py Kapitelbildung (Spine + Überschriften + epub:type), Klassifikation
|
├── chapters.py Kapitelbildung (Spine + Überschriften + epub:type), Klassifikation
|
||||||
├── references.py Ziel-IDs → Kapiteldatei + Anker, Link-Auflösung
|
├── references.py Ziel-IDs → Kapiteldatei + Anker, Link-Auflösung
|
||||||
├── html_converter.py rekursiver XHTML→Markdown-Renderer (Blöcke, Inline, Tabellen, Listen, Bilder)
|
├── 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
|
├── codeblocks.py Codeerkennung, exakte Textextraktion, Spracherkennung, Fences
|
||||||
├── mathml.py konservative MathML→TeX-Konvertierung, TeX-Erkennung
|
├── mathml.py konservative MathML→TeX-Konvertierung, TeX-Erkennung
|
||||||
├── media.py Medienregistry mit stabilen Namen
|
├── media.py Medienregistry mit stabilen Namen
|
||||||
|
|
@ -395,6 +491,12 @@ epub2md/
|
||||||
├── diagnostics.py zentrale Sammlung von Warnungen/Fehlern
|
├── diagnostics.py zentrale Sammlung von Warnungen/Fehlern
|
||||||
├── models.py Dataclasses (Kapitel, Medien, Noten, Metadaten …)
|
├── models.py Dataclasses (Kapitel, Medien, Noten, Metadaten …)
|
||||||
└── utils.py Slugs, YAML-Emitter, Pfadhelfer
|
└── 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
|
Statt BeautifulSoup wird direkt lxml verwendet: Es ist für die XHTML-Bäume ausreichend, schneller und
|
||||||
|
|
@ -412,7 +514,14 @@ ruff check epub2md tests && mypy epub2md --ignore-missing-imports # optional,
|
||||||
Die Tests erzeugen synthetische Minimal-EPUBs (EPUB 2/3, NAV/NCX, Code, Tabellen, MathML, SVG,
|
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
|
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,
|
prüfen die tatsächliche CLI-Ausgabe. `tests/test_merge.py` prüft die Gesamtdatei: Reihenfolge,
|
||||||
Anker-Umbenennung, Pfade, Fußnoten, unveränderter Code und Überschreibschutz. Liegen EPUBs unter `Beispiele/`, werden sie zusätzlich auf
|
Anker-Umbenennung, Pfade, Fußnoten, unveränderter Code, Überschreibschutz, Datumsformat und die Stelle des
|
||||||
|
Inhaltsverzeichnisses hinter dem Vorspann. `tests/test_styles.py` prüft die Typografie: CSS-Hervorhebungen,
|
||||||
|
unsichtbare Zeichen, Szenenwechsel, Gedichte, Infokästen, `--styles`, `--page-markers` und die Pandoc-Befehle
|
||||||
|
in der Buch-README. Liegen EPUBs unter `Beispiele/`, werden sie zusätzlich auf
|
||||||
Struktur-Invarianten geprüft: keine kaputten Links, balancierte Code-Fences, gültiges YAML, alle Medien
|
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
|
vorhanden. Dabei werden keine Buchtexte verglichen. `Beispiele/` ist per `.gitignore` vom Repository
|
||||||
ausgeschlossen.
|
ausgeschlossen.`tests/test_pandoc.py` prüft den Pandoc-Weg (nur mit installiertem Pandoc).
|
||||||
|
|
||||||
|
Für gründlichere Prüfungen nach `Prompt_Kontrolle.md` gibt es `tools/qa/`: eine CLI-Matrix über alle Optionen
|
||||||
|
und Fehlercodes, einen unabhängigen Markdown-Validator, einen Wortvergleich gegen Datenverlust und die
|
||||||
|
Prüfung des Pandoc-Wegs (HTML, EPUB, PDF) für alle Beispielbücher. Details in `tools/qa/README.md`.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""epub2md – konvertiert DRM-freie EPUB-Dateien in kapitelweise Markdown-Sammlungen."""
|
"""epub2md – konvertiert DRM-freie EPUB-Dateien in kapitelweise Markdown-Sammlungen."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.3.0"
|
||||||
TOOL_NAME = "epub2md"
|
TOOL_NAME = "epub2md"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,17 @@ from typing import Any
|
||||||
from .diagnostics import Diagnostics
|
from .diagnostics import Diagnostics
|
||||||
from .models import Chapter, NavEntry
|
from .models import Chapter, NavEntry
|
||||||
from .normalize import Doc, has_content
|
from .normalize import Doc, has_content
|
||||||
from .utils import classes, epub_types, is_element, iter_elements, local, norm_ws, roles, text_content
|
from .utils import (
|
||||||
|
classes,
|
||||||
|
epub_types,
|
||||||
|
is_element,
|
||||||
|
iter_elements,
|
||||||
|
local,
|
||||||
|
norm_ws,
|
||||||
|
remove_invisible,
|
||||||
|
roles,
|
||||||
|
text_content,
|
||||||
|
)
|
||||||
|
|
||||||
HEADINGS = {"h1": 1, "h2": 2, "h3": 3, "h4": 4, "h5": 5, "h6": 6}
|
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"}
|
CONTAINERS = {"body", "div", "section", "article", "aside", "main", "header", "footer", "nav", "span", "hgroup"}
|
||||||
|
|
@ -35,8 +45,8 @@ KIND_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||||
("appendix", re.compile(r"^(anhang|appendix)", re.I)),
|
("appendix", re.compile(r"^(anhang|appendix)", re.I)),
|
||||||
("colophon", re.compile(r"^(impressum|colophon|kolophon)", re.I)),
|
("colophon", re.compile(r"^(impressum|colophon|kolophon)", re.I)),
|
||||||
("dedication", re.compile(r"^(widmung|dedication)", re.I)),
|
("dedication", re.compile(r"^(widmung|dedication)", re.I)),
|
||||||
("cover", re.compile(r"^(cover|umschlag|titelbild)\b", re.I)),
|
("cover", re.compile(r"^(cover|umschlag|titelbild|deckblatt)\b", re.I)),
|
||||||
("titlepage", re.compile(r"^(titelei|titelseite|title ?page)", re.I)),
|
("titlepage", re.compile(r"^(titelei|titelseite|title ?page|titel\s*$|title\s*$)", re.I)),
|
||||||
("part", re.compile(r"^(teil|part)\s+([IVXLC]+|\d+|[a-zäöü]+)\b", re.I)),
|
("part", re.compile(r"^(teil|part)\s+([IVXLC]+|\d+|[a-zäöü]+)\b", re.I)),
|
||||||
("epilogue", re.compile(r"^(epilog|nachwort|epilogue|afterword)", re.I)),
|
("epilogue", re.compile(r"^(epilog|nachwort|epilogue|afterword)", re.I)),
|
||||||
("prologue", re.compile(r"^(prolog|prologue)", re.I)),
|
("prologue", re.compile(r"^(prolog|prologue)", re.I)),
|
||||||
|
|
@ -95,9 +105,11 @@ def semantic_types(el: Any) -> list[str]:
|
||||||
|
|
||||||
class ChapterBuilder:
|
class ChapterBuilder:
|
||||||
def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics,
|
def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics,
|
||||||
book_title: str = "", authors: list[str] | None = None) -> None:
|
book_title: str = "", authors: list[str] | None = None, deep_starts: set[Any] | None = None) -> None:
|
||||||
self.level = chapter_level
|
self.level = chapter_level
|
||||||
self.diag = diag
|
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._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._in_special = False # current chapter itself is a bibliography/index/glossary
|
||||||
self.book_title = book_title
|
self.book_title = book_title
|
||||||
|
|
@ -119,6 +131,12 @@ class ChapterBuilder:
|
||||||
if lvl and has_content(el):
|
if lvl and has_content(el):
|
||||||
special = _special_heading(el)
|
special = _special_heading(el)
|
||||||
if lvl <= self.level:
|
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:
|
if update:
|
||||||
self._special_level = 0
|
self._special_level = 0
|
||||||
self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS
|
self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS
|
||||||
|
|
@ -186,7 +204,7 @@ class ChapterBuilder:
|
||||||
body_segments = segments[1:]
|
body_segments = segments[1:]
|
||||||
if _meaningful(head_items):
|
if _meaningful(head_items):
|
||||||
first = _first_meaningful(head_items)
|
first = _first_meaningful(head_items)
|
||||||
if chapters and leading_heading_level(first) > self.level and not semantic_types(doc.body):
|
if chapters and leading_heading_level(first) > self.level + self._deep and not semantic_types(doc.body):
|
||||||
chapters[-1].items.extend(pending + head_items)
|
chapters[-1].items.extend(pending + head_items)
|
||||||
pending = []
|
pending = []
|
||||||
if doc.path not in chapters[-1].docs:
|
if doc.path not in chapters[-1].docs:
|
||||||
|
|
@ -270,6 +288,7 @@ class ChapterBuilder:
|
||||||
if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]):
|
if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]):
|
||||||
types.append("auxiliary")
|
types.append("auxiliary")
|
||||||
ch.types = list(dict.fromkeys(types))
|
ch.types = list(dict.fromkeys(types))
|
||||||
|
ch.title = remove_invisible(ch.title)[0] # titles feed file names, frontmatter and README
|
||||||
return ch
|
return ch
|
||||||
|
|
||||||
def _title_from_content(self, items: list[tuple[str, Any]], number: int) -> tuple[str, str]:
|
def _title_from_content(self, items: list[tuple[str, Any]], number: int) -> tuple[str, str]:
|
||||||
|
|
@ -296,12 +315,21 @@ class ChapterBuilder:
|
||||||
kind = classify_title(ch.title)
|
kind = classify_title(ch.title)
|
||||||
if kind and kind not in ch.types:
|
if kind and kind not in ch.types:
|
||||||
ch.types.append(kind)
|
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":
|
if "index" in ch.types and classify_title(ch.title) == "index-persons":
|
||||||
ch.types.remove("index")
|
ch.types.remove("index")
|
||||||
if not ch.types:
|
if not ch.types:
|
||||||
ch.types.append("chapter")
|
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
|
@dataclass
|
||||||
class _TypeCarrier:
|
class _TypeCarrier:
|
||||||
types: list[str]
|
types: list[str]
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,14 @@ def code_language(value: str) -> str:
|
||||||
return lang
|
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:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
p = _Parser(
|
p = _Parser(
|
||||||
prog="epub2md.py",
|
prog="epub2md.py",
|
||||||
|
|
@ -61,9 +69,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)")
|
help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)")
|
||||||
p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False,
|
p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False,
|
||||||
help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)")
|
help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)")
|
||||||
p.add_argument("--chapter-level", type=int, default=1, metavar="N",
|
p.add_argument("--chapter-level", type=_chapter_level, default="auto", metavar="{auto,1-6}",
|
||||||
help="Überschriftenebene 1–6, ab der neue Kapiteldateien entstehen (Standard: 1). "
|
help="Überschriftenebene, ab der neue Kapiteldateien entstehen. auto (Standard): Ebene 1, "
|
||||||
"Für Bücher mit Teilen (h1) und Kapiteln (h2) ist 2 sinnvoll")
|
"nur sehr große Kapitel (über 30000 Wörter, z. B. Teile eines Romans) werden an ihren "
|
||||||
|
"Unterkapiteln geteilt. Eine Zahl 1–6 gilt ausnahmslos für das ganze Buch")
|
||||||
p.add_argument("--toc", action=argparse.BooleanOptionalAction, default=True,
|
p.add_argument("--toc", action=argparse.BooleanOptionalAction, default=True,
|
||||||
help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)")
|
help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)")
|
||||||
p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True,
|
p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True,
|
||||||
|
|
@ -85,6 +94,23 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
p.add_argument("--keep-wrap-markers", action="store_true",
|
p.add_argument("--keep-wrap-markers", action="store_true",
|
||||||
help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte "
|
help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte "
|
||||||
"Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt")
|
"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",
|
p.add_argument("--strict", action="store_true",
|
||||||
help="Bei kritischen Fehlern abbrechen: fehlende/unlesbare Spine-Dokumente, nicht dekodierbares "
|
help="Bei kritischen Fehlern abbrechen: fehlende/unlesbare Spine-Dokumente, nicht dekodierbares "
|
||||||
"XHTML, unsichere ZIP-Pfade, übersprungene (verdächtige) Medien")
|
"XHTML, unsichere ZIP-Pfade, übersprungene (verdächtige) Medien")
|
||||||
|
|
@ -119,6 +145,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
keep_html=args.keep_html,
|
keep_html=args.keep_html,
|
||||||
code_language=args.code_language or "",
|
code_language=args.code_language or "",
|
||||||
keep_wrap_markers=args.keep_wrap_markers,
|
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,
|
strict=args.strict,
|
||||||
verbose=args.verbose,
|
verbose=args.verbose,
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import warnings
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .utils import classes, is_element, local
|
from .utils import classes, is_element, local
|
||||||
|
|
@ -127,9 +129,114 @@ def detect_language(el: Any) -> tuple[str, str]:
|
||||||
return "", ""
|
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:
|
def detect_language_from_content(code: str) -> str:
|
||||||
"""Very conservative content heuristics – only strong signals."""
|
"""Very conservative content heuristics – only strong signals."""
|
||||||
text = code.strip()
|
text = strip_line_numbers(code).strip()
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
first = text.split("\n", 1)[0]
|
first = text.split("\n", 1)[0]
|
||||||
|
|
@ -167,6 +274,8 @@ def detect_language_from_content(code: str) -> str:
|
||||||
c_like = sum(1 for ln in lines if ln.rstrip().endswith((";", "{")))
|
c_like = sum(1 for ln in lines if ln.rstrip().endswith((";", "{")))
|
||||||
if py_signals >= 2 and c_like == 0:
|
if py_signals >= 2 and c_like == 0:
|
||||||
return "python"
|
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):
|
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"
|
return "sql"
|
||||||
if re.match(r"(?m)^FROM\s+\S+", text) and re.search(r"(?m)^(RUN|CMD|COPY|ENTRYPOINT)\s", text):
|
if re.match(r"(?m)^FROM\s+\S+", text) and re.search(r"(?m)^(RUN|CMD|COPY|ENTRYPOINT)\s", text):
|
||||||
|
|
|
||||||
286
epub2md/css.py
Normal file
286
epub2md/css.py
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
"""Minimal CSS evaluation for formatting that publishers express only through stylesheets.
|
||||||
|
|
||||||
|
Supports type/class/id selectors, descendant (` `) and child (`>`) combinators, specificity and source
|
||||||
|
order, and `style` attributes. Selectors with pseudo-classes, attribute selectors or sibling combinators
|
||||||
|
are ignored (conservative: an unknown rule never adds formatting). @-blocks (@media, @font-face, @page …)
|
||||||
|
are skipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .utils import classes, local
|
||||||
|
|
||||||
|
COMPOUND = re.compile(r"^(\*|[a-zA-Z][\w-]*)?((?:[.#][\w-]+)*)$")
|
||||||
|
MONOSPACE = re.compile(r"monospace|courier|consolas|menlo|monaco|lucida console|source code|dejavu sans mono|"
|
||||||
|
r"liberation mono|fira mono|fira code|inconsolata|andale mono|\bmono\b", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Compound:
|
||||||
|
tag: str | None
|
||||||
|
classes: frozenset[str]
|
||||||
|
ids: frozenset[str]
|
||||||
|
|
||||||
|
def matches(self, el: Any) -> bool:
|
||||||
|
if self.tag and self.tag != "*" and local(el.tag) != self.tag:
|
||||||
|
return False
|
||||||
|
if self.ids and (el.get("id") or "").lower() not in self.ids:
|
||||||
|
return False
|
||||||
|
return not self.classes or self.classes <= set(classes(el))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Rule:
|
||||||
|
parts: list[tuple[str, _Compound]] # (combinator to the previous part, compound); first combinator is ""
|
||||||
|
specificity: tuple[int, int, int]
|
||||||
|
order: int
|
||||||
|
decls: dict[str, str]
|
||||||
|
|
||||||
|
def matches(self, el: Any) -> bool:
|
||||||
|
if not self.parts[-1][1].matches(el):
|
||||||
|
return False
|
||||||
|
node = el
|
||||||
|
for i in range(len(self.parts) - 1, 0, -1):
|
||||||
|
comb = self.parts[i][0]
|
||||||
|
compound = self.parts[i - 1][1]
|
||||||
|
node = node.getparent()
|
||||||
|
if comb == ">":
|
||||||
|
if node is None or not compound.matches(node):
|
||||||
|
return False
|
||||||
|
else: # descendant
|
||||||
|
while node is not None and not compound.matches(node):
|
||||||
|
node = node.getparent()
|
||||||
|
if node is None:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_declarations(text: str) -> dict[str, str]:
|
||||||
|
decls: dict[str, str] = {}
|
||||||
|
for part in text.split(";"):
|
||||||
|
if ":" not in part:
|
||||||
|
continue
|
||||||
|
prop, value = part.split(":", 1)
|
||||||
|
prop = prop.strip().lower()
|
||||||
|
value = re.sub(r"\s*!important\s*$", "", value.strip(), flags=re.I).strip()
|
||||||
|
if prop and value:
|
||||||
|
decls[prop] = value
|
||||||
|
return decls
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_selector(sel: str) -> list[tuple[str, _Compound]] | None:
|
||||||
|
sel = sel.strip().lower()
|
||||||
|
if not sel or re.search(r"[:\[\]+~]", sel):
|
||||||
|
return None
|
||||||
|
tokens = re.split(r"\s*(>)\s*|\s+", sel)
|
||||||
|
parts: list[tuple[str, _Compound]] = []
|
||||||
|
comb = ""
|
||||||
|
for tok in tokens:
|
||||||
|
if tok is None or tok == "":
|
||||||
|
continue
|
||||||
|
if tok == ">":
|
||||||
|
comb = ">"
|
||||||
|
continue
|
||||||
|
m = COMPOUND.match(tok)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
rest = m.group(2) or ""
|
||||||
|
parts.append((comb if parts else "", _Compound(
|
||||||
|
tag=m.group(1),
|
||||||
|
classes=frozenset(re.findall(r"\.([\w-]+)", rest)),
|
||||||
|
ids=frozenset(re.findall(r"#([\w-]+)", rest)),
|
||||||
|
)))
|
||||||
|
comb = " "
|
||||||
|
return parts or None
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_at_blocks(css: str) -> str:
|
||||||
|
"""Remove @-rules including nested blocks (@media, @font-face, @supports, @page …)."""
|
||||||
|
out: list[str] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(css):
|
||||||
|
if css[i] == "@":
|
||||||
|
brace = css.find("{", i)
|
||||||
|
semi = css.find(";", i)
|
||||||
|
if semi != -1 and (brace == -1 or semi < brace): # @import/@charset …;
|
||||||
|
i = semi + 1
|
||||||
|
continue
|
||||||
|
if brace == -1:
|
||||||
|
break
|
||||||
|
depth, j = 0, brace
|
||||||
|
while j < len(css):
|
||||||
|
if css[j] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif css[j] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
break
|
||||||
|
j += 1
|
||||||
|
i = j + 1
|
||||||
|
continue
|
||||||
|
out.append(css[i])
|
||||||
|
i += 1
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
class StyleSheet:
|
||||||
|
def __init__(self, css_texts: list[str]) -> None:
|
||||||
|
self.rules: list[_Rule] = []
|
||||||
|
self._index: dict[str, list[_Rule]] = {}
|
||||||
|
self._cache: dict[Any, dict[str, str]] = {}
|
||||||
|
order = 0
|
||||||
|
for css in css_texts:
|
||||||
|
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||||
|
css = _strip_at_blocks(css)
|
||||||
|
for selectors, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
|
||||||
|
decls = _parse_declarations(body)
|
||||||
|
if not decls:
|
||||||
|
continue
|
||||||
|
for sel in selectors.split(","):
|
||||||
|
parts = _parse_selector(sel)
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
spec = (sum(len(c.ids) for _, c in parts), sum(len(c.classes) for _, c in parts),
|
||||||
|
sum(1 for _, c in parts if c.tag and c.tag != "*"))
|
||||||
|
rule = _Rule(parts, spec, order, decls)
|
||||||
|
order += 1
|
||||||
|
self.rules.append(rule)
|
||||||
|
last = parts[-1][1]
|
||||||
|
keys = [f".{c}" for c in last.classes] or ([last.tag] if last.tag and last.tag != "*" else ["*"])
|
||||||
|
for key in keys[:1]: # one index entry suffices: every key must match anyway
|
||||||
|
self._index.setdefault(key, []).append(rule)
|
||||||
|
|
||||||
|
def computed(self, el: Any) -> dict[str, str]:
|
||||||
|
"""Declared properties for ``el`` (no inheritance), including its ``style`` attribute."""
|
||||||
|
cached = self._cache.get(el)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
candidates: list[_Rule] = []
|
||||||
|
for key in [f".{c}" for c in classes(el)] + [local(el.tag), "*"]:
|
||||||
|
candidates.extend(self._index.get(key, ()))
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for rule in sorted({id(r): r for r in candidates}.values(), key=lambda r: (r.specificity, r.order)):
|
||||||
|
if rule.matches(el):
|
||||||
|
result.update(rule.decls)
|
||||||
|
if el.get("style"):
|
||||||
|
result.update(_parse_declarations(el.get("style")))
|
||||||
|
self._cache[el] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def inline_style(el: Any) -> dict[str, str]:
|
||||||
|
"""Only the ``style`` attribute (used when no stylesheet is available)."""
|
||||||
|
return _parse_declarations(el.get("style") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def emphasis(style: dict[str, str]) -> dict[str, bool]:
|
||||||
|
"""Semantic formatting expressed by a computed style."""
|
||||||
|
weight = style.get("font-weight", "").lower()
|
||||||
|
deco = (style.get("text-decoration-line", "") + " " + style.get("text-decoration", "")).lower()
|
||||||
|
valign = style.get("vertical-align", "").lower()
|
||||||
|
return {
|
||||||
|
"bold": weight in ("bold", "bolder") or (weight.isdigit() and int(weight) >= 600),
|
||||||
|
"italic": style.get("font-style", "").lower() in ("italic", "oblique"),
|
||||||
|
"underline": "underline" in deco,
|
||||||
|
"strike": "line-through" in deco,
|
||||||
|
"sup": valign == "super",
|
||||||
|
"sub": valign == "sub",
|
||||||
|
"mono": bool(MONOSPACE.search(style.get("font-family", ""))),
|
||||||
|
"hidden": style.get("display", "").lower() == "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- visual styles (--styles html)
|
||||||
|
|
||||||
|
STYLE_PROPS = ("color", "background-color", "font-size", "font-family", "font-variant", "text-align")
|
||||||
|
UNSAFE_VALUE = re.compile(r"url\(|expression|javascript|[;<>{}\\]", re.I)
|
||||||
|
TRIVIAL_BG = re.compile(r"^(transparent|none|inherit|initial|unset|white|#fff|#ffffff|rgba?\(\s*255\s*,\s*255\s*,\s*255.*)$")
|
||||||
|
COLOR_TOKEN = re.compile(r"(#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|\b[a-z]+\b)", re.I)
|
||||||
|
KEYWORD_SIZES = {"xx-small": 0.6, "x-small": 0.75, "small": 0.89, "medium": 1.0, "large": 1.2, "x-large": 1.5,
|
||||||
|
"xx-large": 2.0, "smaller": 0.83, "larger": 1.2}
|
||||||
|
|
||||||
|
|
||||||
|
def _font_size_em(value: str) -> float | None:
|
||||||
|
v = value.strip().lower()
|
||||||
|
if v in KEYWORD_SIZES:
|
||||||
|
return KEYWORD_SIZES[v]
|
||||||
|
m = re.fullmatch(r"([\d.]+)\s*(em|rem|%|px|pt)?", v)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
n = float(m.group(1))
|
||||||
|
return {"em": n, "rem": n, "%": n / 100, "px": n / 16, "pt": n / 12, None: None}.get(m.group(2), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _background_color(style: dict[str, str]) -> str:
|
||||||
|
bg = style.get("background-color", "")
|
||||||
|
if not bg and style.get("background"):
|
||||||
|
for tok in COLOR_TOKEN.findall(style["background"]):
|
||||||
|
if tok.lower() not in ("none", "repeat", "no", "fixed", "scroll", "top", "left", "center", "right",
|
||||||
|
"bottom", "url"):
|
||||||
|
bg = tok
|
||||||
|
break
|
||||||
|
bg = bg.strip()
|
||||||
|
return "" if not bg or TRIVIAL_BG.match(bg.lower()) else bg
|
||||||
|
|
||||||
|
|
||||||
|
def _family(value: str) -> str:
|
||||||
|
parts = [p.strip().strip("'\"") for p in value.split(",") if p.strip()]
|
||||||
|
return ", ".join(f"'{p}'" if " " in p else p for p in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_black(value: str) -> bool:
|
||||||
|
return value.strip().lower() in ("black", "#000", "#000000", "rgb(0,0,0)", "rgb(0, 0, 0)", "windowtext")
|
||||||
|
|
||||||
|
|
||||||
|
def has_highlight(style: dict[str, str]) -> bool:
|
||||||
|
return bool(_background_color(style))
|
||||||
|
|
||||||
|
|
||||||
|
def base_style(stylesheet: StyleSheet, paragraphs: list[Any]) -> dict[str, str]:
|
||||||
|
"""Most frequent value per visual property over the book's paragraphs (the 'normal' text style)."""
|
||||||
|
counters: dict[str, dict[str, int]] = {p: {} for p in STYLE_PROPS}
|
||||||
|
for el in paragraphs:
|
||||||
|
style = stylesheet.computed(el)
|
||||||
|
for prop in STYLE_PROPS:
|
||||||
|
value = _background_color(style) if prop == "background-color" else style.get(prop, "")
|
||||||
|
counters[prop][value] = counters[prop].get(value, 0) + 1
|
||||||
|
return {p: max(c, key=lambda v: c[v]) for p, c in counters.items() if c}
|
||||||
|
|
||||||
|
|
||||||
|
def style_props(style: dict[str, str], base: dict[str, str], block: bool = False) -> list[tuple[str, str]]:
|
||||||
|
"""Visual properties of ``style`` that differ from the book's base text style (sanitized)."""
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
for prop in STYLE_PROPS:
|
||||||
|
if prop == "text-align" and not block:
|
||||||
|
continue
|
||||||
|
value = _background_color(style) if prop == "background-color" else style.get(prop, "").strip()
|
||||||
|
if not value or UNSAFE_VALUE.search(value) or value.lower() in ("inherit", "initial", "unset", "currentcolor"):
|
||||||
|
continue
|
||||||
|
reference = base.get(prop, "")
|
||||||
|
if prop == "color" and not reference:
|
||||||
|
reference = "black" # no colour declared = default text colour
|
||||||
|
if prop == "color" and _is_black(value) and _is_black(reference):
|
||||||
|
continue
|
||||||
|
if prop == "font-size":
|
||||||
|
size, ref = _font_size_em(value), _font_size_em(reference) if reference else 1.0
|
||||||
|
if size is None or ref is None or abs(size / (ref or 1.0) - 1) < 0.15:
|
||||||
|
continue
|
||||||
|
elif prop == "font-family":
|
||||||
|
if _family(value).lower() == _family(reference).lower():
|
||||||
|
continue
|
||||||
|
value = _family(value)
|
||||||
|
elif prop == "font-variant":
|
||||||
|
if "small-caps" not in value.lower() or "small-caps" in reference.lower():
|
||||||
|
continue
|
||||||
|
value = "small-caps"
|
||||||
|
elif prop == "text-align":
|
||||||
|
if value.lower() not in ("center", "right", "end") or value.lower() == reference.lower():
|
||||||
|
continue
|
||||||
|
elif value.lower() == reference.lower():
|
||||||
|
continue
|
||||||
|
out.append((prop, value))
|
||||||
|
return out
|
||||||
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
|
from collections import Counter
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -17,9 +18,11 @@ from urllib.parse import unquote
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from .chapters import IdCarrier, Marker, heading_level
|
from .chapters import IdCarrier, Marker, heading_level
|
||||||
|
from .css import StyleSheet, emphasis, has_highlight, inline_style, style_props
|
||||||
from .codeblocks import (
|
from .codeblocks import (
|
||||||
code_text,
|
code_text,
|
||||||
detect_language,
|
detect_language,
|
||||||
|
detect_language_from_book,
|
||||||
detect_language_from_content,
|
detect_language_from_content,
|
||||||
fence_for,
|
fence_for,
|
||||||
inline_code,
|
inline_code,
|
||||||
|
|
@ -33,6 +36,7 @@ from .footnotes import NoteIndex
|
||||||
from .mathml import Unsupported, is_display_tex, looks_like_tex, mathml_to_tex, strip_tex_delimiters
|
from .mathml import Unsupported, is_display_tex, looks_like_tex, mathml_to_tex, strip_tex_delimiters
|
||||||
from .media import MediaRegistry
|
from .media import MediaRegistry
|
||||||
from .models import Chapter, Note
|
from .models import Chapter, Note
|
||||||
|
from .normalize import PAGE_ATTR
|
||||||
from .references import LinkResolver
|
from .references import LinkResolver
|
||||||
from .utils import (
|
from .utils import (
|
||||||
MATHML_NS,
|
MATHML_NS,
|
||||||
|
|
@ -45,7 +49,9 @@ from .utils import (
|
||||||
local,
|
local,
|
||||||
norm_ws,
|
norm_ws,
|
||||||
rel_link,
|
rel_link,
|
||||||
|
remove_invisible,
|
||||||
resolve_href,
|
resolve_href,
|
||||||
|
roles,
|
||||||
text_content,
|
text_content,
|
||||||
url_path,
|
url_path,
|
||||||
)
|
)
|
||||||
|
|
@ -93,6 +99,11 @@ class RenderOptions:
|
||||||
language: str = "de"
|
language: str = "de"
|
||||||
code_language: str = "" # --code-language: label for blocks without a detected language
|
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_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:
|
def md_escape(text: str, table: bool = False) -> str:
|
||||||
|
|
@ -124,9 +135,23 @@ def escape_line_starts(text: str) -> str:
|
||||||
return "\n".join(out)
|
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:
|
def join_inline(pieces: list[Piece], hard_break: str = "\\\n") -> str:
|
||||||
out = ""
|
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:
|
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":
|
if kind == "t":
|
||||||
s = WS.sub(" ", s)
|
s = WS.sub(" ", s)
|
||||||
if not out or out.endswith((" ", "\n")):
|
if not out or out.endswith((" ", "\n")):
|
||||||
|
|
@ -176,6 +201,49 @@ def _collapse_blank_lines(text: str) -> str:
|
||||||
return "\n".join(out)
|
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", "- ", "> ", "#", "$$", "\\[", "* * *")
|
_BLOCK_SYNTAX = ("```", "~~~", "|", "<table", "- ", "> ", "#", "$$", "\\[", "* * *")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -229,8 +297,20 @@ def serialize_mathml(math: Any) -> str:
|
||||||
class Renderer:
|
class Renderer:
|
||||||
def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry,
|
def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry,
|
||||||
notes: NoteIndex, diag: Diagnostics, zip_names: set[str],
|
notes: NoteIndex, diag: Diagnostics, zip_names: set[str],
|
||||||
entry_problem: Callable[[str], str | None] | None = None) -> None:
|
entry_problem: Callable[[str], str | None] | None = None,
|
||||||
|
stylesheet: StyleSheet | None = None, base_style: dict[str, str] | None = None) -> None:
|
||||||
|
self.base_style = base_style or {}
|
||||||
self.entry_problem = entry_problem or (lambda path: None)
|
self.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.opt = options
|
||||||
self.zip_names = zip_names
|
self.zip_names = zip_names
|
||||||
self.in_table_html = False
|
self.in_table_html = False
|
||||||
|
|
@ -244,6 +324,7 @@ class Renderer:
|
||||||
self.consumed_formula_images: set[str] = set()
|
self.consumed_formula_images: set[str] = set()
|
||||||
self.formulas: list[dict[str, str]] = []
|
self.formulas: list[dict[str, str]] = []
|
||||||
self.code_blocks: list[dict[str, Any]] = []
|
self.code_blocks: list[dict[str, Any]] = []
|
||||||
|
self.book_code_language = "" # dominant language of the reliably detected blocks (pre-scan)
|
||||||
self.endnotes_file = ""
|
self.endnotes_file = ""
|
||||||
# per chapter state
|
# per chapter state
|
||||||
self.ch: Chapter | None = None
|
self.ch: Chapter | None = None
|
||||||
|
|
@ -293,13 +374,13 @@ class Renderer:
|
||||||
if doc:
|
if doc:
|
||||||
self.doc = doc
|
self.doc = doc
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
buf.append(("t", md_escape(item, self.in_table)))
|
buf.append(("t", self._text(item)))
|
||||||
elif isinstance(item, IdCarrier):
|
elif isinstance(item, IdCarrier):
|
||||||
anchor = self.resolver.anchor_for(self.doc, item.element)
|
anchor = self.resolver.anchor_for(self.doc, item.element)
|
||||||
if anchor:
|
if anchor:
|
||||||
buf.append(("r", self._anchor(anchor)))
|
buf.append(("r", self._anchor(anchor)))
|
||||||
elif isinstance(item, Marker) or not is_element(item):
|
elif isinstance(item, Marker) or not is_element(item) or item.get(CONSUMED):
|
||||||
continue
|
continue # markers, comments, siblings already rendered as part of a box
|
||||||
elif self.is_block(item):
|
elif self.is_block(item):
|
||||||
flush()
|
flush()
|
||||||
blocks.extend(self.block(item))
|
blocks.extend(self.block(item))
|
||||||
|
|
@ -332,6 +413,19 @@ class Renderer:
|
||||||
def _anchor(self, name: str) -> str:
|
def _anchor(self, name: str) -> str:
|
||||||
return f'<a id="{name}"></a>'
|
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:
|
def anchors_of(self, el: Any, subtree: bool = False) -> str:
|
||||||
nodes = iter_elements(el) if subtree else [el]
|
nodes = iter_elements(el) if subtree else [el]
|
||||||
names = []
|
names = []
|
||||||
|
|
@ -361,6 +455,19 @@ class Renderer:
|
||||||
|
|
||||||
def block(self, el: Any) -> list[str]:
|
def block(self, el: Any) -> list[str]:
|
||||||
name = local(el.tag)
|
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
|
ch = self.ch
|
||||||
if ch is not None and el is ch.title_el and not heading_level(el):
|
if ch is not None and el is ch.title_el and not heading_level(el):
|
||||||
return self.heading(el, forced_level=1)
|
return self.heading(el, forced_level=1)
|
||||||
|
|
@ -395,15 +502,238 @@ class Renderer:
|
||||||
return [self.clean_html(el, keep_root=True)]
|
return [self.clean_html(el, keep_root=True)]
|
||||||
return self._flow_with_anchor(el)
|
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]:
|
def _flow_with_anchor(self, el: Any) -> list[str]:
|
||||||
anchor = self.anchors_of(el)
|
anchor = self.anchors_of(el)
|
||||||
if self.ch is not None and {"index", "index-persons", "glossary"} & set(self.ch.types):
|
if self.ch is not None and {"index", "index-persons", "glossary"} & set(self.ch.types):
|
||||||
special = self._index_block(el, anchor)
|
special = self._index_block(el, anchor)
|
||||||
if special is not None:
|
if special is not None:
|
||||||
return special
|
return special
|
||||||
blocks = self.flow(el)
|
italic = bold = False
|
||||||
|
if self.opt.css_emphasis and local(el.tag) in ("p", "div") and not self.in_heading \
|
||||||
|
and not any(local(d.tag) in BLOCK_TAGS for d in iter_elements(el) if d is not el) \
|
||||||
|
and text_content(el).strip():
|
||||||
|
e = self._emphasis(el)
|
||||||
|
italic, bold = e["italic"] and not self.em, e["bold"] and not self.strong
|
||||||
|
self.em += italic
|
||||||
|
self.strong += bold
|
||||||
|
try:
|
||||||
|
blocks = self.flow(el)
|
||||||
|
finally:
|
||||||
|
self.em -= italic
|
||||||
|
self.strong -= bold
|
||||||
|
if (italic or bold) and len(blocks) == 1 and not _is_block_syntax(blocks[0]) and not blocks[0].startswith("<"):
|
||||||
|
for flag, marker, key in ((italic, "*", "italic"), (bold, "**", "bold")):
|
||||||
|
if flag:
|
||||||
|
blocks[0] = _wrap_text(blocks[0], marker)
|
||||||
|
self.css_applied[key] += 1
|
||||||
|
blocks = self._block_styles(el, blocks)
|
||||||
if anchor:
|
if anchor:
|
||||||
if blocks and not blocks[0].startswith(("#", "`", "|", "<table", "> ", "- ", "$$", "\\[", "* * *")) \
|
if blocks and not blocks[0].startswith(("#", "`", "|", "<table", "<div", "> ", "- ", "$$", "\\[", "* * *")) \
|
||||||
and not re.match(r"\d+\. ", blocks[0]):
|
and not re.match(r"\d+\. ", blocks[0]):
|
||||||
blocks[0] = anchor + blocks[0]
|
blocks[0] = anchor + blocks[0]
|
||||||
else:
|
else:
|
||||||
|
|
@ -440,13 +770,14 @@ class Renderer:
|
||||||
level = heading_level(el) - (ch.shift if ch else 0)
|
level = heading_level(el) - (ch.shift if ch else 0)
|
||||||
level = max(self.min_level, min(6, level))
|
level = max(self.min_level, min(6, level))
|
||||||
anchors = self.anchors_of(el, subtree=True)
|
anchors = self.anchors_of(el, subtree=True)
|
||||||
|
markers = "".join(self.page_marker(e) for e in iter_elements(el))
|
||||||
self.in_heading = True
|
self.in_heading = True
|
||||||
try:
|
try:
|
||||||
text = join_inline(self.inline_children(el), hard_break=" ")
|
text = join_inline(self.inline_children(el), hard_break=" ")
|
||||||
finally:
|
finally:
|
||||||
self.in_heading = False
|
self.in_heading = False
|
||||||
text = re.sub(r"<a id=\"[^\"]*\"></a>", "", text).strip()
|
text = re.sub(r"<a id=\"[^\"]*\"></a>", "", text).strip()
|
||||||
out = []
|
out = [markers] if markers else []
|
||||||
if anchors:
|
if anchors:
|
||||||
out.append(anchors)
|
out.append(anchors)
|
||||||
if not text:
|
if not text:
|
||||||
|
|
@ -470,6 +801,9 @@ class Renderer:
|
||||||
if not lang:
|
if not lang:
|
||||||
lang = detect_language_from_content(code)
|
lang = detect_language_from_content(code)
|
||||||
how = "content" if lang else ""
|
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:
|
if not lang and self.opt.code_language:
|
||||||
lang, how = self.opt.code_language, "option"
|
lang, how = self.opt.code_language, "option"
|
||||||
if self.ch:
|
if self.ch:
|
||||||
|
|
@ -755,24 +1089,125 @@ class Renderer:
|
||||||
def inline_children(self, el: Any) -> list[Piece]:
|
def inline_children(self, el: Any) -> list[Piece]:
|
||||||
pieces: list[Piece] = []
|
pieces: list[Piece] = []
|
||||||
if el.text:
|
if el.text:
|
||||||
pieces.append(("t", md_escape(el.text, self.in_table)))
|
pieces.append(("t", self._text(el.text)))
|
||||||
for child in el:
|
for child in el:
|
||||||
if is_element(child):
|
if is_element(child):
|
||||||
pieces.extend(self.inline(child))
|
pieces.extend(self.inline(child))
|
||||||
if child.tail:
|
if child.tail:
|
||||||
pieces.append(("t", md_escape(child.tail, self.in_table)))
|
pieces.append(("t", self._text(child.tail)))
|
||||||
return pieces
|
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]:
|
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)
|
name = local(el.tag)
|
||||||
anchor = self.anchors_of(el) if name not in ("a",) else ""
|
anchor = self.anchors_of(el) if name not in ("a",) else ""
|
||||||
pre: list[Piece] = [("r", anchor)] if anchor and not self.in_heading 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":
|
if name == "br":
|
||||||
return pre + ([("t", " ")] if self.in_heading else [("br", "")])
|
return pre + ([("t", " ")] if self.in_heading else [("br", "")])
|
||||||
if name == "wbr":
|
if name == "wbr":
|
||||||
return pre
|
return pre
|
||||||
if name == "a":
|
if name == "a":
|
||||||
return self.link(el)
|
return pre[:1] + self.link(el) if marker else self.link(el)
|
||||||
if name == "img":
|
if name == "img":
|
||||||
return pre + self.image(el)
|
return pre + self.image(el)
|
||||||
if name == "image":
|
if name == "image":
|
||||||
|
|
@ -803,8 +1238,8 @@ class Renderer:
|
||||||
finally:
|
finally:
|
||||||
self.strong -= 1
|
self.strong -= 1
|
||||||
if name in ("del", "s", "strike"):
|
if name in ("del", "s", "strike"):
|
||||||
marker = ("~~", "~~") if self.opt.format == "gfm" else ("<del>", "</del>")
|
del_open, del_close = ("~~", "~~") if self.opt.format == "gfm" else ("<del>", "</del>")
|
||||||
return pre + self._wrap(self.inline_children(el), marker[0], marker[1])
|
return pre + self._wrap(self.inline_children(el), del_open, del_close)
|
||||||
if name in ("u", "ins", "mark", "kbd", "sup", "sub"):
|
if name in ("u", "ins", "mark", "kbd", "sup", "sub"):
|
||||||
if name in ("sup", "sub"):
|
if name in ("sup", "sub"):
|
||||||
elems = [c for c in el if is_element(c)]
|
elems = [c for c in el if is_element(c)]
|
||||||
|
|
@ -841,7 +1276,7 @@ class Renderer:
|
||||||
core = core[: -len(hard)].rstrip()
|
core = core[: -len(hard)].rstrip()
|
||||||
if not core:
|
if not core:
|
||||||
return [("t", lead or trail)] if (lead or trail) else []
|
return [("t", lead or trail)] if (lead or trail) else []
|
||||||
return [("t", lead), ("r", f"{open_}{core}{close}"), ("t", trail)]
|
return [("t", lead), ("e", f"{open_}{EMPH_SEP}{core}{EMPH_SEP}{close}"), ("t", trail)]
|
||||||
|
|
||||||
# ------------------------------------------------------------------ links
|
# ------------------------------------------------------------------ links
|
||||||
|
|
||||||
|
|
@ -1046,7 +1481,16 @@ class Renderer:
|
||||||
title_part = f' "{title.replace(chr(34), chr(39))}"' if title else ""
|
title_part = f' "{title.replace(chr(34), chr(39))}"' if title else ""
|
||||||
return [("r", f")}{title_part})")]
|
return [("r", f")}{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:
|
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()
|
return WS.sub(" ", alt).replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]").strip()
|
||||||
|
|
||||||
def _is_formula_image(self, el: Any, alt: str, path: str) -> bool:
|
def _is_formula_image(self, el: Any, alt: str, path: str) -> bool:
|
||||||
|
|
@ -1189,6 +1633,12 @@ class Renderer:
|
||||||
return out.strip()
|
return out.strip()
|
||||||
|
|
||||||
def _html(self, el: Any, in_pre: bool, keep: bool = False) -> str:
|
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)
|
name = local(el.tag)
|
||||||
if name == "math":
|
if name == "math":
|
||||||
# Markdown math ($…$, \(…\)) is not rendered inside an HTML block; MathML is.
|
# Markdown math ($…$, \(…\)) is not rendered inside an HTML block; MathML is.
|
||||||
|
|
@ -1219,11 +1669,31 @@ class Renderer:
|
||||||
if keep and re.fullmatch(r"[a-z][a-z0-9-]*", name):
|
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()
|
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])
|
if re.fullmatch(r"[A-Za-z_:][-A-Za-z0-9_:.]*", k.rsplit("}", 1)[-1])
|
||||||
and not k.rsplit("}", 1)[-1].lower().startswith("on") and k.rsplit("}", 1)[-1] != "style"
|
and not k.rsplit("}", 1)[-1].lower().startswith(("on", "data-e2m-")) and k.rsplit("}", 1)[-1] != "style"
|
||||||
and not v.strip().lower().startswith("javascript:")]
|
and not v.strip().lower().startswith("javascript:")]
|
||||||
return f"<{name}{(' ' + ' '.join(safe)) if safe else ''}>{content}</{name}>"
|
return f"<{name}{(' ' + ' '.join(safe)) if safe else ''}>{content}</{name}>"
|
||||||
if name not in HTML_KEEP_TAGS or name == "span":
|
if name not in HTML_KEEP_TAGS or name == "span":
|
||||||
anchor = self.anchors_of(el)
|
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
|
return anchor + content
|
||||||
attrs = []
|
attrs = []
|
||||||
for k, v in el.attrib.items():
|
for k, v in el.attrib.items():
|
||||||
|
|
|
||||||
|
|
@ -365,7 +365,54 @@ def strip_tex_delimiters(text: str) -> str:
|
||||||
if t.startswith(o) and t.endswith(c) and len(t) >= len(o) + len(c):
|
if t.startswith(o) and t.endswith(c) and len(t) >= len(o) + len(c):
|
||||||
t = t[len(o) : len(t) - len(c)]
|
t = t[len(o) : len(t) - len(c)]
|
||||||
break
|
break
|
||||||
return t.strip()
|
return portable_tex(t.strip())
|
||||||
|
|
||||||
|
|
||||||
|
UMLAUTS = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"}
|
||||||
|
|
||||||
|
|
||||||
|
def portable_tex(tex: str) -> str:
|
||||||
|
"""Publisher TeX → TeX that Pandoc's formula parser (texmath, MathJax, KaTeX) understands, still valid LaTeX:
|
||||||
|
text accents \\"u → ü, \\O (empty set in Springer books) → \\emptyset, no @{…} column separators in
|
||||||
|
array specifications, no \\arraycolsep assignments, no empty \\displaystyle before a cell end. Only changes
|
||||||
|
spacing, never content."""
|
||||||
|
tex = re.sub(r'\\"(?:\{([aouAOU])\}|([aouAOU]))', lambda m: UMLAUTS[m.group(1) or m.group(2)], tex)
|
||||||
|
tex = re.sub(r"\\(?:text|mbox|mathrm)\s*\{\s*\\O\s*\}", r"\\emptyset", tex)
|
||||||
|
tex = re.sub(r"\\O(?![A-Za-z])", r"\\emptyset", tex)
|
||||||
|
tex = re.sub(r"\\arraycolsep\s*-?\d*\.?\d+\s*(?:pt|em|ex|mm|cm)", "", tex)
|
||||||
|
tex = re.sub(r"\\displaystyle\s*(?=\\\\|&|\\end\b)", "", tex) # no-op before a cell end; texmath fails
|
||||||
|
out: list[str] = []
|
||||||
|
pos = 0
|
||||||
|
for m in re.finditer(r"\\begin\{array\}\{", tex):
|
||||||
|
if m.start() < pos:
|
||||||
|
continue
|
||||||
|
end = _closing_brace(tex, m.end() - 1)
|
||||||
|
if end < 0:
|
||||||
|
break
|
||||||
|
spec = tex[m.end():end]
|
||||||
|
i = spec.find("@{")
|
||||||
|
while i >= 0:
|
||||||
|
close = _closing_brace(spec, i + 1)
|
||||||
|
if close < 0:
|
||||||
|
break
|
||||||
|
spec = spec[:i] + spec[close + 1:]
|
||||||
|
i = spec.find("@{")
|
||||||
|
out.append(tex[pos:m.end()] + spec)
|
||||||
|
pos = end
|
||||||
|
return "".join(out) + tex[pos:]
|
||||||
|
|
||||||
|
|
||||||
|
def _closing_brace(text: str, open_pos: int) -> int:
|
||||||
|
"""Index of the '}' matching the '{' at ``open_pos`` (-1 if unbalanced)."""
|
||||||
|
depth = 0
|
||||||
|
for i in range(open_pos, len(text)):
|
||||||
|
if text[i] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif text[i] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
def is_display_tex(text: str) -> bool:
|
def is_display_tex(text: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ class ChapterFile:
|
||||||
body: str = ""
|
body: str = ""
|
||||||
start_anchor: str = ""
|
start_anchor: str = ""
|
||||||
anchor_map: dict[str, str] = field(default_factory=dict)
|
anchor_map: dict[str, str] = field(default_factory=dict)
|
||||||
|
types: list[str] = field(default_factory=list)
|
||||||
|
words: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -74,12 +76,13 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]:
|
||||||
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
|
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
|
||||||
if manifest_path.is_file():
|
if manifest_path.is_file():
|
||||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
entries = [(c["number"], c["title"], c["file"]) for c in manifest.get("chapters", [])]
|
entries = [(c["number"], c["title"], c["file"], c.get("types") or [], c.get("word_count", 0))
|
||||||
|
for c in manifest.get("chapters", [])]
|
||||||
else:
|
else:
|
||||||
files = sorted((out_dir / "chapters").glob("*.md"))
|
files = sorted((out_dir / "chapters").glob("*.md"))
|
||||||
entries = [(i + 1, p.stem, f"chapters/{p.name}") for i, p in enumerate(files)]
|
entries = [(i + 1, p.stem, f"chapters/{p.name}", [], 0) for i, p in enumerate(files)]
|
||||||
chapters = [ChapterFile(n, t, rel) for n, t, rel in entries if (out_dir / rel).is_file()]
|
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()]
|
missing = [rel for _, _, rel, _, _ in entries if not (out_dir / rel).is_file()]
|
||||||
if missing:
|
if missing:
|
||||||
raise MergeError(f"Kapiteldateien fehlen: {', '.join(missing[:5])}")
|
raise MergeError(f"Kapiteldateien fehlen: {', '.join(missing[:5])}")
|
||||||
if not chapters:
|
if not chapters:
|
||||||
|
|
@ -89,6 +92,49 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]:
|
||||||
return report, chapters
|
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:
|
def default_target(out_dir: Path, report: dict) -> Path:
|
||||||
name = (report.get("input") or {}).get("name") or (out_dir.name + ".epub")
|
name = (report.get("input") or {}).get("name") or (out_dir.name + ".epub")
|
||||||
stem = name[:-5] if name.lower().endswith(".epub") else name
|
stem = name[:-5] if name.lower().endswith(".epub") else name
|
||||||
|
|
@ -142,7 +188,7 @@ def assign_anchors(chapters: list[ChapterFile]) -> int:
|
||||||
for m in TOKEN.finditer(seg):
|
for m in TOKEN.finditer(seg):
|
||||||
if m.group("attr") and m.group("aname") == "id" and m.group("avalue") not in ids:
|
if m.group("attr") and m.group("aname") == "id" and m.group("avalue") not in ids:
|
||||||
ids.append(m.group("avalue"))
|
ids.append(m.group("avalue"))
|
||||||
start = stems[ch.rel]
|
start = "kap-" + stems[ch.rel] # ids must start with a letter (Pandoc's EPUB writer breaks '#039-…')
|
||||||
while start in used or start in ids:
|
while start in used or start in ids:
|
||||||
start = "kapitel-" + start
|
start = "kapitel-" + start
|
||||||
ch.start_anchor = start
|
ch.start_anchor = start
|
||||||
|
|
@ -225,18 +271,31 @@ def merge(out_dir: Path, target: Path | None = None, *, frontmatter: bool = True
|
||||||
("publisher", "publisher"), ("date", "published"), ("identifier", "identifier"),
|
("publisher", "publisher"), ("date", "published"), ("identifier", "identifier"),
|
||||||
("rights", "rights"), ("description", "description")):
|
("rights", "rights"), ("description", "description")):
|
||||||
if md.get(src):
|
if md.get(src):
|
||||||
fm[key] = md[src]
|
fm[key] = display_date(md[src], md.get("language", "")) if key == "date" else md[src]
|
||||||
if fm:
|
if fm:
|
||||||
parts.append("---\n" + to_yaml(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:
|
if toc:
|
||||||
lines = ["## Inhalt" if (report.get("metadata") or {}).get("language", "de").startswith("de") else "## Contents", ""]
|
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]
|
lines += [f"{ch.number}. [{_escape_link_text(ch.title)}](#{ch.start_anchor})" for ch in chapters]
|
||||||
parts.append("\n".join(lines))
|
toc_block = "\n".join(lines)
|
||||||
for ch in chapters:
|
else:
|
||||||
|
toc_block = TOC_PLACEHOLDER
|
||||||
|
if not has_title_page and not cover_first:
|
||||||
|
parts.append(TITLE_PLACEHOLDER)
|
||||||
|
if front == 0:
|
||||||
|
parts.append(toc_block)
|
||||||
|
for i, ch in enumerate(chapters):
|
||||||
segments = [(is_code, seg if is_code else rewrite_segment(seg, ch)) for is_code, seg in _non_code_segments(ch.body)]
|
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)
|
body, n = _drop_duplicate_definitions(_join(segments), seen_defs)
|
||||||
dropped += n
|
dropped += n
|
||||||
parts.append(f'<a id="{ch.start_anchor}"></a>\n\n{body.strip()}')
|
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"
|
text = "\n\n".join(parts).rstrip() + "\n"
|
||||||
|
|
||||||
existing = {p.resolve().as_posix() for p in out_dir.rglob("*") if p.is_file()}
|
existing = {p.resolve().as_posix() for p in out_dir.rglob("*") if p.is_file()}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -15,6 +16,9 @@ HEAD_CLASS_RE = re.compile(r"^(?:head|heading|h|ueberschrift|uberschrift)[_-]?(\
|
||||||
TITLE_CLASS_RE = re.compile(r"^(?:title[_-]?document|chapter[_-]?title|document[_-]?number|chaptertitle)$")
|
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)
|
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"}
|
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
|
@dataclass
|
||||||
|
|
@ -77,7 +81,8 @@ def book_uses_title_tables(docs: list[Doc]) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None:
|
def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool,
|
||||||
|
hidden: Callable[[Any], bool] | None = None) -> None:
|
||||||
body = doc.body
|
body = doc.body
|
||||||
for el in list(iter_elements(body)):
|
for el in list(iter_elements(body)):
|
||||||
if not _attached(el, body):
|
if not _attached(el, body):
|
||||||
|
|
@ -88,9 +93,11 @@ def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None:
|
||||||
continue
|
continue
|
||||||
types = epub_types(el)
|
types = epub_types(el)
|
||||||
if "pagebreak" in types or "doc-pagebreak" in roles(el):
|
if "pagebreak" in types or "doc-pagebreak" in roles(el):
|
||||||
_replace_with_anchor(el)
|
_replace_with_anchor(el, _pagebreak_label(el))
|
||||||
doc.pagebreaks += 1
|
doc.pagebreaks += 1
|
||||||
continue
|
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":
|
if name == "table":
|
||||||
_maybe_heading_table(el, title_tables)
|
_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])):
|
elif name in ("p", "div") and len(classes(el)) == 1 and (m := HEAD_CLASS_RE.match(classes(el)[0])):
|
||||||
|
|
@ -102,19 +109,19 @@ def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None:
|
||||||
doc.toc_ids.update(e.get("id") for e in iter_elements(el) if e.get("id"))
|
doc.toc_ids.update(e.get("id") for e in iter_elements(el) if e.get("id"))
|
||||||
remove_keep_tail(el)
|
remove_keep_tail(el)
|
||||||
diag.info("html-toc-removed", "HTML-Inhaltsverzeichnis entfernt (ersetzt durch README.md).", doc.path)
|
diag.info("html-toc-removed", "HTML-Inhaltsverzeichnis entfernt (ersetzt durch README.md).", doc.path)
|
||||||
separated = _separate_field_spans(body)
|
separated = _separate_field_spans(body, hidden or (lambda el: False))
|
||||||
if separated:
|
if separated:
|
||||||
diag.info("inline-separator-added",
|
diag.info("inline-separator-added",
|
||||||
f"{separated} Leerzeichen zwischen aneinanderstoßenden Feld-Spans eingefügt.", doc.path)
|
f"{separated} Leerzeichen zwischen aneinanderstoßenden Feld-Spans eingefügt.", doc.path)
|
||||||
|
|
||||||
|
|
||||||
def _separate_field_spans(body: Any) -> int:
|
def _separate_field_spans(body: Any, hidden: Callable[[Any], bool]) -> int:
|
||||||
"""Insert a space between adjacent class-styled <span> "fields" that touch without whitespace.
|
"""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
|
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
|
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
|
carry different classes, no text between them, both have >= 2 characters and the right one starts
|
||||||
with an uppercase letter or digit.
|
with an uppercase letter or digit. Spans hidden by the book's CSS (display:none) get no separator.
|
||||||
"""
|
"""
|
||||||
count = 0
|
count = 0
|
||||||
for parent in iter_elements(body):
|
for parent in iter_elements(body):
|
||||||
|
|
@ -127,7 +134,7 @@ def _separate_field_spans(body: Any) -> int:
|
||||||
lt, rt = text_content(left), text_content(right)
|
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():
|
if len(lt.strip()) < 2 or len(rt.strip()) < 2 or lt[-1].isspace() or rt[0].isspace():
|
||||||
continue
|
continue
|
||||||
if rt[0].isupper() or rt[0].isdigit():
|
if (rt[0].isupper() or rt[0].isdigit()) and not hidden(left) and not hidden(right):
|
||||||
left.tail = " "
|
left.tail = " "
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
@ -142,13 +149,35 @@ def _attached(el: Any, body: Any) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _replace_with_anchor(el: Any) -> None:
|
def _pagebreak_label(el: Any) -> str:
|
||||||
|
"""Page number of an epub:type="pagebreak" element (aria-label, title, text or id)."""
|
||||||
|
for value in (el.get("aria-label"), el.get("title"), text_content(el)):
|
||||||
|
value = norm_ws(value or "")
|
||||||
|
if value and len(value) <= 12:
|
||||||
|
return re.sub(r"^(?:seite|page|s\.|p\.)\s*", "", value, flags=re.I)
|
||||||
|
m = PAGE_ID_RE.match(el.get("id") or "") or PAGE_ID_SHORT_RE.match(el.get("id") or "")
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _page_from_id(el: Any) -> str:
|
||||||
|
el_id = el.get("id") or ""
|
||||||
|
if m := PAGE_ID_RE.match(el_id):
|
||||||
|
return m.group(1)
|
||||||
|
if (m := PAGE_ID_SHORT_RE.match(el_id)) and not norm_ws(text_content(el)) and len(el) == 0:
|
||||||
|
return m.group(1)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_with_anchor(el: Any, page: str = "") -> None:
|
||||||
ids = [e.get("id") for e in iter_elements(el) if e.get("id")]
|
ids = [e.get("id") for e in iter_elements(el) if e.get("id")]
|
||||||
if not ids:
|
if not ids and not page:
|
||||||
remove_keep_tail(el)
|
remove_keep_tail(el)
|
||||||
return
|
return
|
||||||
anchor = _make(el, "a")
|
anchor = _make(el, "a")
|
||||||
anchor.set("id", ids[0])
|
if ids:
|
||||||
|
anchor.set("id", ids[0])
|
||||||
|
if page:
|
||||||
|
anchor.set(PAGE_ATTR, page)
|
||||||
anchor.tail = el.tail
|
anchor.tail = el.tail
|
||||||
el.getparent().replace(el, anchor)
|
el.getparent().replace(el, anchor)
|
||||||
|
|
||||||
|
|
@ -177,6 +206,7 @@ def _maybe_heading_table(table: Any, title_tables: bool) -> None:
|
||||||
level = max(1, min(6, level))
|
level = max(1, min(6, level))
|
||||||
texts = []
|
texts = []
|
||||||
ids = [e.get("id") for e in iter_elements(table) if e.get("id")]
|
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:
|
for cell in cells:
|
||||||
parts = []
|
parts = []
|
||||||
for node in cell.iter():
|
for node in cell.iter():
|
||||||
|
|
@ -193,6 +223,8 @@ def _maybe_heading_table(table: Any, title_tables: bool) -> None:
|
||||||
for anchor_id in ids:
|
for anchor_id in ids:
|
||||||
a = _make(table, "a")
|
a = _make(table, "a")
|
||||||
a.set("id", anchor_id)
|
a.set("id", anchor_id)
|
||||||
|
if anchor_id in pages:
|
||||||
|
a.set(PAGE_ATTR, pages[anchor_id])
|
||||||
heading.append(a)
|
heading.append(a)
|
||||||
if len(heading):
|
if len(heading):
|
||||||
heading[-1].tail = " ".join(texts)
|
heading[-1].tail = " ".join(texts)
|
||||||
|
|
|
||||||
|
|
@ -8,24 +8,30 @@ import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from . import TOOL_NAME, __version__
|
from . import TOOL_NAME, __version__
|
||||||
from .chapters import ChapterBuilder
|
from .chapters import SPECIAL_KINDS, ChapterBuilder, heading_level
|
||||||
|
from .codeblocks import (code_text, detect_language, detect_language_from_content, dominant_language,
|
||||||
|
is_block_code_container, join_wrap_markers)
|
||||||
from .diagnostics import Diagnostics
|
from .diagnostics import Diagnostics
|
||||||
from .epub_reader import EpubReader, parse_xml
|
from .css import StyleSheet, base_style, emphasis
|
||||||
|
from .epub_reader import EpubReader, decode_markup, parse_xml
|
||||||
from .errors import ContainerError, InputError, StructureError
|
from .errors import ContainerError, InputError, StructureError
|
||||||
from .footnotes import collect_notes
|
from .footnotes import collect_notes
|
||||||
from .html_converter import RenderOptions, Renderer
|
from .html_converter import RenderOptions, Renderer
|
||||||
from .linkcheck import check_links
|
from .linkcheck import check_links
|
||||||
from .media import MediaRegistry
|
from .media import MediaRegistry
|
||||||
from .models import Chapter
|
from .models import Chapter
|
||||||
from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail
|
from .normalize import (Doc, book_uses_title_tables, has_content, is_html_toc_doc, make_doc, normalize_doc,
|
||||||
|
remove_keep_tail)
|
||||||
from .references import LinkResolver
|
from .references import LinkResolver
|
||||||
from .utils import is_inside, iter_elements, sha256_file, slugify
|
from .utils import is_element, is_inside, iter_elements, local, resolve_href, sha256_file, slugify, text_content
|
||||||
from . import writer
|
from . import writer
|
||||||
|
|
||||||
MARKER_FILES = ("conversion-report.json", "manifest.json")
|
MARKER_FILES = ("conversion-report.json", "manifest.json")
|
||||||
|
|
@ -37,7 +43,7 @@ class Options:
|
||||||
format: str = "gfm"
|
format: str = "gfm"
|
||||||
extract_media: bool = True
|
extract_media: bool = True
|
||||||
copy_original: bool = False
|
copy_original: bool = False
|
||||||
chapter_level: int = 1
|
chapter_level: int | str = "auto" # 1–6, or "auto": level 1 plus oversized chapters split one level deeper
|
||||||
toc: bool = True
|
toc: bool = True
|
||||||
frontmatter: bool = True
|
frontmatter: bool = True
|
||||||
footnotes: str = "end-of-chapter"
|
footnotes: str = "end-of-chapter"
|
||||||
|
|
@ -47,6 +53,11 @@ class Options:
|
||||||
keep_html: bool = False
|
keep_html: bool = False
|
||||||
code_language: str = ""
|
code_language: str = ""
|
||||||
keep_wrap_markers: bool = False
|
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
|
strict: bool = False
|
||||||
verbose: bool = False
|
verbose: bool = False
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
|
@ -73,8 +84,8 @@ def check_paths(input_path: Path, output_dir: Path, opts: Options) -> None:
|
||||||
raise InputError(f"Eingabedatei nicht gefunden oder keine Datei: {input_path}")
|
raise InputError(f"Eingabedatei nicht gefunden oder keine Datei: {input_path}")
|
||||||
if not os.access(input_path, os.R_OK):
|
if not os.access(input_path, os.R_OK):
|
||||||
raise InputError(f"Eingabedatei nicht lesbar: {input_path}")
|
raise InputError(f"Eingabedatei nicht lesbar: {input_path}")
|
||||||
if not 1 <= opts.chapter_level <= 6:
|
if opts.chapter_level != "auto" and not (isinstance(opts.chapter_level, int) and 1 <= opts.chapter_level <= 6):
|
||||||
raise InputError("--chapter-level muss zwischen 1 und 6 liegen.")
|
raise InputError("--chapter-level muss auto oder eine Zahl zwischen 1 und 6 sein.")
|
||||||
if opts.dry_run:
|
if opts.dry_run:
|
||||||
return
|
return
|
||||||
if output_dir.exists() and not output_dir.is_dir():
|
if output_dir.exists() and not output_dir.is_dir():
|
||||||
|
|
@ -133,9 +144,11 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
|
||||||
package = reader.load_package()
|
package = reader.load_package()
|
||||||
diag.step(f"EPUB {package.version}: {package.metadata.title!r}, {len(package.spine)} Spine-Dokumente")
|
diag.step(f"EPUB {package.version}: {package.metadata.title!r}, {len(package.spine)} Spine-Dokumente")
|
||||||
docs = _load_docs(reader, package, diag)
|
docs = _load_docs(reader, package, diag)
|
||||||
_prepare_docs(docs, 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)
|
||||||
notes, chapters, resolver = _build_structure(docs, package, opts, diag)
|
notes, chapters, resolver = _build_structure(docs, package, opts, diag)
|
||||||
rendered = _render(reader, package, chapters, resolver, notes, opts, diag)
|
rendered = _render(reader, package, chapters, resolver, notes, opts, diag, stylesheet)
|
||||||
files, all_chapters, report = _assemble(input_path, output_dir, opts, diag, generated_at, sha, package, docs,
|
files, all_chapters, report = _assemble(input_path, output_dir, opts, diag, generated_at, sha, package, docs,
|
||||||
notes, chapters, resolver, rendered)
|
notes, chapters, resolver, rendered)
|
||||||
if opts.dry_run:
|
if opts.dry_run:
|
||||||
|
|
@ -177,11 +190,12 @@ class _Rendered:
|
||||||
skipped_formula_images: int
|
skipped_formula_images: int
|
||||||
|
|
||||||
|
|
||||||
def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics) -> None:
|
def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics,
|
||||||
|
hidden: Callable[[Any], bool] | None = None) -> None:
|
||||||
"""Normalize publisher markup and drop HTML tables of contents that NAV/NCX already cover."""
|
"""Normalize publisher markup and drop HTML tables of contents that NAV/NCX already cover."""
|
||||||
title_tables = book_uses_title_tables(docs)
|
title_tables = book_uses_title_tables(docs)
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
normalize_doc(doc, diag, title_tables)
|
normalize_doc(doc, diag, title_tables, hidden)
|
||||||
landmark_toc_docs = {lm.path for lm in package.landmarks if lm.type == "toc" and lm.path and not lm.fragment}
|
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:
|
for lm in package.landmarks:
|
||||||
if lm.type == "toc" and lm.fragment:
|
if lm.type == "toc" and lm.fragment:
|
||||||
|
|
@ -203,10 +217,9 @@ def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics) -> None:
|
||||||
def _build_structure(docs: list[Doc], package: Any, opts: Options,
|
def _build_structure(docs: list[Doc], package: Any, opts: Options,
|
||||||
diag: Diagnostics) -> tuple[Any, list[Chapter], LinkResolver]:
|
diag: Diagnostics) -> tuple[Any, list[Chapter], LinkResolver]:
|
||||||
"""Notes, chapter units with file names, and the link resolver (incl. NAV/NCX targets)."""
|
"""Notes, chapter units with file names, and the link resolver (incl. NAV/NCX targets)."""
|
||||||
md = package.metadata
|
|
||||||
notes = collect_notes(docs, opts.footnotes, diag)
|
notes = collect_notes(docs, opts.footnotes, diag)
|
||||||
diag.step(f"{len(notes.notes)} Anmerkungen erkannt")
|
diag.step(f"{len(notes.notes)} Anmerkungen erkannt")
|
||||||
chapters = ChapterBuilder(opts.chapter_level, package.toc, diag, md.title, md.authors).build(docs)
|
chapters = _build_chapters(docs, package, opts, diag)
|
||||||
_assign_filenames(chapters, opts.slug_language)
|
_assign_filenames(chapters, opts.slug_language)
|
||||||
resolver = LinkResolver(docs, chapters, notes, "README.md" if opts.toc else None)
|
resolver = LinkResolver(docs, chapters, notes, "README.md" if opts.toc else None)
|
||||||
nav_targets: list[tuple[str, str]] = []
|
nav_targets: list[tuple[str, str]] = []
|
||||||
|
|
@ -223,8 +236,43 @@ def _build_structure(docs: list[Doc], package: Any, opts: Options,
|
||||||
return notes, chapters, resolver
|
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,
|
def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: LinkResolver, notes: Any,
|
||||||
opts: Options, diag: Diagnostics) -> _Rendered:
|
opts: Options, diag: Diagnostics, stylesheet: StyleSheet | None = None) -> _Rendered:
|
||||||
"""Render all chapters (and the end-of-book notes file) to Markdown and register media."""
|
"""Render all chapters (and the end-of-book notes file) to Markdown and register media."""
|
||||||
md = package.metadata
|
md = package.metadata
|
||||||
media = MediaRegistry(package, opts.images, opts.slug_language)
|
media = MediaRegistry(package, opts.images, opts.slug_language)
|
||||||
|
|
@ -235,18 +283,23 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver:
|
||||||
renderer = Renderer(
|
renderer = Renderer(
|
||||||
RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math,
|
RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math,
|
||||||
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
|
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
|
||||||
code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers),
|
code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers,
|
||||||
resolver, media, notes, diag, set(reader.names), reader.entry_problem,
|
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,
|
||||||
)
|
)
|
||||||
width = max(3, len(str(len(chapters) + 1)))
|
width = max(3, len(str(len(chapters) + 1)))
|
||||||
notes_title = "Anmerkungen" if language.startswith("de") else "Notes"
|
notes_title = "Anmerkungen" if language.startswith("de") else "Notes"
|
||||||
endnotes_number = 999 if len(chapters) < 999 else len(chapters) + 1
|
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.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:
|
for ch in chapters:
|
||||||
diag.step(f"Rendere {ch.filename}")
|
diag.step(f"Rendere {ch.filename}")
|
||||||
ch.markdown = renderer.render_chapter(ch)
|
ch.markdown = renderer.render_chapter(ch)
|
||||||
ch.stats.word_count = _word_count(ch.markdown)
|
ch.stats.word_count = _word_count(ch.markdown)
|
||||||
_check_chapter_size(ch, opts.chapter_level, diag)
|
_check_chapter_size(ch, _base_level(opts), diag)
|
||||||
_report_wrap_markers(renderer, diag)
|
_report_wrap_markers(renderer, diag)
|
||||||
if not opts.code_language:
|
if not opts.code_language:
|
||||||
_suggest_code_language(renderer, diag)
|
_suggest_code_language(renderer, diag)
|
||||||
|
|
@ -262,6 +315,20 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver:
|
||||||
return _Rendered(renderer, media, cover_out, endnotes, endnotes_md, skipped)
|
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:
|
def _suggest_code_language(renderer: Renderer, diag: Diagnostics) -> None:
|
||||||
"""Book-level hint: many unlabeled blocks while every recognized block has the same language."""
|
"""Book-level hint: many unlabeled blocks while every recognized block has the same language."""
|
||||||
blocks = renderer.code_blocks
|
blocks = renderer.code_blocks
|
||||||
|
|
@ -338,7 +405,8 @@ def _assemble(input_path: Path, output_dir: Path, opts: Options, diag: Diagnosti
|
||||||
stats = _book_stats(all_chapters, media, renderer, diag)
|
stats = _book_stats(all_chapters, media, renderer, diag)
|
||||||
if opts.toc:
|
if opts.toc:
|
||||||
files["README.md"] = writer.readme(md, chapters, endnotes, package.toc, resolver, stats, rendered.cover_out,
|
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))
|
package.version, input_path.name, opts.command, str(output_dir),
|
||||||
|
renderer.boxes if opts.boxes == "callout" else 0)
|
||||||
existing = {mf.output for mf in media.files.values()} if opts.extract_media else set()
|
existing = {mf.output for mf in media.files.values()} if opts.extract_media else set()
|
||||||
if opts.copy_original:
|
if opts.copy_original:
|
||||||
existing.add(f"source/{input_path.name}")
|
existing.add(f"source/{input_path.name}")
|
||||||
|
|
@ -382,16 +450,52 @@ def _assign_filenames(chapters: list[Chapter], slug_language: str) -> None:
|
||||||
ch.filename = f"chapters/{candidate}.md"
|
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:
|
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."""
|
"""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:
|
if ch.stats.word_count <= LARGE_CHAPTER_WORDS or chapter_level >= 6:
|
||||||
return
|
return
|
||||||
sections = len(re.findall(r"^## ", ch.markdown, re.M))
|
sections = len(re.findall(r"^## ", ch.markdown, re.M))
|
||||||
if sections >= 2:
|
if sections >= 2:
|
||||||
|
deeper = max(chapter_level, ch.title_level or 1) + 1
|
||||||
diag.warn(
|
diag.warn(
|
||||||
"chapter-large",
|
"chapter-large",
|
||||||
f"Kapitel „{ch.title}“ hat {ch.stats.word_count} Wörter und {sections} Unterabschnitte; "
|
f"Kapitel „{ch.title}“ hat {ch.stats.word_count} Wörter und {sections} Unterabschnitte; "
|
||||||
f"--chapter-level {chapter_level + 1} würde sie als eigene Dateien ausgeben.",
|
f"--chapter-level {deeper} würde sie als eigene Dateien ausgeben.",
|
||||||
ch.docs[0] if ch.docs else "",
|
ch.docs[0] if ch.docs else "",
|
||||||
ch.filename,
|
ch.filename,
|
||||||
)
|
)
|
||||||
|
|
@ -495,6 +599,11 @@ 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),
|
"referenced": sum(1 for n in notes.notes.values() if n.ref_count),
|
||||||
"rendered": len(renderer.book_notes)},
|
"rendered": len(renderer.book_notes)},
|
||||||
"special_sections": special,
|
"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,
|
"warnings": warnings,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
"infos": [d.as_dict() for d in diag.items if d.level == "info"],
|
"infos": [d.as_dict() for d in diag.items if d.level == "info"],
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,15 @@ def text_content(el: Any) -> str:
|
||||||
return "".join(parts)
|
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:
|
def norm_ws(text: str) -> str:
|
||||||
return re.sub(r"[ \t\r\n\f]+", " ", text or "").strip()
|
return re.sub(r"[ \t\r\n\f]+", " ", text or "").strip()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import json
|
||||||
import posixpath
|
import posixpath
|
||||||
import shlex
|
import shlex
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from . import TOOL_NAME, __version__
|
from . import TOOL_NAME, __version__
|
||||||
|
|
@ -15,6 +16,10 @@ from .models import BookMetadata, Chapter, NavEntry
|
||||||
from .references import LinkResolver
|
from .references import LinkResolver
|
||||||
from .utils import rel_link, to_yaml, url_path
|
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 = {
|
SPECIAL_LABELS = {
|
||||||
"glossary": "Glossar",
|
"glossary": "Glossar",
|
||||||
"bibliography": "Literatur / Quellen",
|
"bibliography": "Literatur / Quellen",
|
||||||
|
|
@ -87,7 +92,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],
|
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,
|
resolver: LinkResolver, stats: dict[str, int], cover: str, epub_version: str, source_name: str,
|
||||||
command: list[str], output_dir: str = "OUTPUT_VERZEICHNIS") -> str:
|
command: list[str], output_dir: str = "OUTPUT_VERZEICHNIS", callouts: int = 0) -> str:
|
||||||
out = [f"# {md_escape(md.title or source_name)}"]
|
out = [f"# {md_escape(md.title or source_name)}"]
|
||||||
if md.subtitle:
|
if md.subtitle:
|
||||||
out.append(f"*{md_escape(md.subtitle)}*")
|
out.append(f"*{md_escape(md.subtitle)}*")
|
||||||
|
|
@ -130,20 +135,38 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None,
|
||||||
"[manifest.json](manifest.json), [Warnungen](diagnostics/warnings.md)")
|
"[manifest.json](manifest.json), [Warnungen](diagnostics/warnings.md)")
|
||||||
stem = source_name[:-5] if source_name.lower().endswith(".epub") else source_name
|
stem = source_name[:-5] if source_name.lower().endswith(".epub") else source_name
|
||||||
math = " --mathml" if stats.get("formulas") else ""
|
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 = [
|
commands = [
|
||||||
"# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)",
|
"# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)",
|
||||||
f"python3 epub2md_merge.py {shlex.quote(output_dir)}",
|
f"python3 epub2md_merge.py {shlex.quote(output_dir)}",
|
||||||
"# danach in diesem Ordner:",
|
"# danach in diesem Ordner:",
|
||||||
f"pandoc {shlex.quote(stem + '.md')} --toc -s{math} -o {shlex.quote(stem + '.html')}",
|
f"pandoc {md_} -s{math}{toc}{lua} -o {html_}",
|
||||||
f"pandoc {shlex.quote(stem + '.md')} --toc -o {shlex.quote(stem + '.epub')}",
|
f"pandoc {md_} --toc{math}{lua} -o {shlex.quote(stem + '.epub')}",
|
||||||
"# PDF über HTML: Pandoc übernimmt dann auch HTML-Tabellen und interne Sprungziele",
|
] + (latex + weasy if math else weasy + latex)
|
||||||
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("## Weiterverarbeitung")
|
||||||
out.append("```bash\n" + "\n".join(commands) + "\n```")
|
out.append("```bash\n" + "\n".join(commands) + "\n```")
|
||||||
if math:
|
if callouts:
|
||||||
out.append("Das Buch enthält Formeln: Für HTML `--mathml` (oder `--mathjax`) verwenden, sonst belässt "
|
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 "
|
||||||
"Pandoc Matrizen und ähnliche Konstrukte als TeX-Text.")
|
"Pandoc Matrizen und ähnliche Konstrukte als TeX-Text.")
|
||||||
out.append("## Reproduktion")
|
out.append("## Reproduktion")
|
||||||
out.append("```bash\n" + " ".join(shlex.quote(c) for c in command) + "\n```")
|
out.append("```bash\n" + " ".join(shlex.quote(c) for c in command) + "\n```")
|
||||||
|
|
|
||||||
272
pandoc/book.lua
Normal file
272
pandoc/book.lua
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
--[[
|
||||||
|
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("&", "&"):gsub("<", "<"):gsub(">", ">"))
|
||||||
|
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
|
||||||
8
pandoc/callouts.css
Normal file
8
pandoc/callouts.css
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/* 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; }
|
||||||
174
pandoc/callouts.lua
Normal file
174
pandoc/callouts.lua
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
--[[
|
||||||
|
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 },
|
||||||
|
}
|
||||||
32
pandoc/print.css
Normal file
32
pandoc/print.css
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
/* Print layout for PDFs from epub2md output via Pandoc + weasyprint:
|
||||||
|
pandoc buch.md -s --lua-filter pandoc/book.lua --lua-filter pandoc/callouts.lua --css pandoc/print.css \
|
||||||
|
--pdf-engine=weasyprint -o buch.pdf
|
||||||
|
book.lua places the table of contents after cover, title page and imprint and marks chapter starts. */
|
||||||
|
@page { size: A4; margin: 2cm 1.8cm; @bottom-center { content: counter(page); font-size: 9pt; } }
|
||||||
|
@page :first { @bottom-center { content: none; } } /* no page number on the cover / title page */
|
||||||
|
body { max-width: none; font-size: 10.5pt; line-height: 1.4; hyphens: auto; text-align: justify;
|
||||||
|
font-family: "DejaVu Serif", "Liberation Serif", Georgia, serif; }
|
||||||
|
#title-block-header, #title-block-header h1.title { text-align: center; margin-bottom: 2em; }
|
||||||
|
h1, h2, h3, h4 { text-align: left; hyphens: manual; page-break-after: avoid; }
|
||||||
|
pre, code { font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; hyphens: none; }
|
||||||
|
pre { background: #f6f8fa; padding: 0.5em 0.8em; border-radius: 0.3em; page-break-inside: auto; }
|
||||||
|
pre, pre code { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 8.5pt; } /* long code lines wrap */
|
||||||
|
pre { overflow: visible; }
|
||||||
|
blockquote { margin: 1em 0 1em 1.5em; padding-left: 0.8em; border-left: 2px solid #ccc; color: #444; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
img { max-width: 100%; max-height: 24cm; height: auto; }
|
||||||
|
table { font-size: 9pt; }
|
||||||
|
.cover { text-align: center; }
|
||||||
|
.cover img { max-height: 25cm; }
|
||||||
|
|
||||||
|
/* New pages: every chapter (book.lua: div.chapter-start, also cover, title page, imprint), the title
|
||||||
|
block, the table of contents; without book.lua every <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
Loading…
Reference in a new issue