Compare commits

...
Author SHA1 Message Date
2526ee6789 feat: Sprachlabels für Codeblöcke aus Inhalt und Buchkontext (R1)
- Gedruckte Zeilennummern ("00  #include …") werden für die Erkennung
  ignoriert, bleiben aber im Block stehen.
- Python zusätzlich per ast.parse (Kontrollstrukturen, def/class, Import
  mit Code) und an >>>-Sitzungen erkannt; YAML zählt nicht.
- 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: C-Buch 518 -> 132 von 574, Python-Buch 492 -> 326 von 747.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:46:50 +02:00
29c6294584 docs: README, Bedienungsanleitung und CHANGELOG auf Stand 0.3.0
- EPUB mit Formeln: --mathml (Tabelle der Pandoc-Befehle, Beispiel Mathebuch)
- Formel-TeX aus Alternativtexten wird für Pandoc angepasst
- print.css: keine Seitenzahl auf dem Cover
- Bekannte Einschränkungen: CSS je Buch statt je Dokument, nicht umsetzbare
  Handkonstruktionen in TeX
- CHANGELOG: tools/qa unter „Unveröffentlicht“
- überlange Zeilen neu umbrochen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:19:04 +02:00
01f60a41c4 chore: QA-Skripte nach tools/qa übernommen
Die Werkzeuge der QA-Runden lagen nur im ignorierten .qa/: CLI-Matrix
(89 Fälle), Fixture-Generator, unabhängiger Markdown-Validator,
Wortvergleich gegen Datenverlust, Pandoc-Prüfung (HTML, EPUB, PDF).
Jetzt versioniert in tools/qa/ mit README und requirements.txt; erzeugte
Daten landen weiterhin in .qa/. Pfade auf die neue Lage angepasst
(Repo-Wurzel über parents[2], aufrufender Python-Interpreter).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:14:54 +02:00
57d485baa3 chore: Version 0.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:41:53 +02:00
64d0cb0357 fix: Pandoc-Weg – EPUB-Links, Formeln im EPUB, portables TeX (QA3-04 bis QA3-06)
Prüfung aller 5 Beispielbücher über die Befehle der Buch-README (HTML,
EPUB, PDF mit weasyprint und xelatex):

- QA3-04: Der EPUB-Befehl der Buch-README hatte kein --mathml; im
  Mathebuch blieben 1072 Formeln als TeX-Text. Jetzt 4904 als MathML.
- QA3-05: Kapitel-Startanker von epub2md_merge.py begannen mit einer
  Ziffer (039-…). Pandocs EPUB-Writer löst Links darauf nicht auf (C-Buch
  32, Mathebuch 20 tote Links). Jetzt kap-039-…; book.lua erkennt beide.
- QA3-06: portable_tex() passt Verlags-TeX für texmath an (\"u, \O,
  @{…} und \arraycolsep in array, leeres \displaystyle vor Zellenende).
  Mathebuch: 1 statt 9 nicht umsetzbare Formeln.
- Cover ohne Seitenzahl (print.css, book.lua).

Neue Tests: tests/test_pandoc.py (nur mit installiertem pandoc),
Parametertests für portable_tex. Andere Bücher byte-identisch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:41:13 +02:00
6fe422919f feat: --chapter-level auto teilt nur sehr große Kapitel (R2)
Neuer Standard auto: Ebene 1 wie bisher, aber Kapitel mit über 30 000
Wörtern und mindestens zwei Unterkapiteln der nächsten Ebene werden an
diesen geteilt (Teile eines Romans). Alle anderen Kapitel bleiben ganz;
Register, Literatur und Glossar werden nie zerlegt. Ein Probedurchlauf
des ChapterBuilder bestimmt die Kapitel, der zweite teilt sie (deep_starts).
Info chapter-split; eine feste Zahl verhält sich wie bisher.

Roman (Adler-Olsen): 78 statt 9 Dateien, wortgleich, 0 kaputte Links.
Klein, Krooß, Lerch, Liesen: Kapiteldateien byte-identisch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:23:47 +02:00
4b3d753b56 refactor: eigener Variablenname für Durchstreich-Marken (QA3-03)
In Renderer._inline_core stand marker erst für die Seitenmarke (str), dann
für das Paar der Durchstreich-Marken (tuple); mypy meldete den Typkonflikt.
Keine Verhaltensänderung (Ausgabe byte-identisch, mypy ohne Befund).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:09:20 +02:00
568844e8c4 fix: nur eingebundene Stylesheets auswerten (QA3-01)
Bisher wirkten alle CSS-Dateien des Manifests, auch mitgelieferte Layouts
anderer Buchreihen. Im C-Buch (21 CSS, nur main.css verlinkt) wurden fette
Buchstaben zu Inline-Code und Registerbuchstaben zu <mark>; eine fremde
display:none-Regel konnte Text löschen.

_load_stylesheet wertet jetzt nur per <link rel="stylesheet"> eingebundene
Stylesheets (transitiv inkl. @import) und <style>-Elemente aus; nicht
eingebundene meldet die Info css-unlinked.

Test-Builder: Doc.css, Testdokumente verlinken ihr CSS wie echte EPUBs
(QA3-02). Regressionstests in tests/test_styles.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:07:49 +02:00
344e0d12b6 docs: README und Bedienungsanleitung auf den aktuellen Stand
- Kapitelbildung: Cover-Erkennung am Inhalt, Titel "Deckblatt"/"Titel"
- Merge: --toc hinter dem Vorspann, unsichtbare Platzhalter für book.lua,
  Datumsformat im Buch-Kopf
- book.lua gilt für HTML und beide PDF-Wege
- Tests: test_styles.py und neue Merge-Tests beschrieben

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:08:37 +02:00
86aa93c12c feat: Buchanfang Cover → Titel → Impressum → Inhaltsverzeichnis für alle Bücher
- Kapitelerkennung: erstes Kapitel nur aus einem Bild = Cover ("[Titelinfo]",
  "Deckblatt"), Titel "Titel" = Titelseite
- epub2md_merge: Vorspann reicht bis vor den Hauptteil (Teil, Vorwort,
  Einleitung, nummerierter Titel, > 1000 Wörter), kurze Zwischenseiten
  (Auflage, Verlagsbrief, "Über dieses Buch") bleiben davor
- book.lua: Cover am Inhalt erkannt; LaTeX-Weg (Filter in beiden Schritten):
  \tableofcontents hinter dem Vorspann, \clearpage je Kapitel, kein \maketitle
  vor dem Cover, A4, seitengroßes Cover, DejaVu-Schriften und Mathe-Symbole
  für fehlende Glyphen (Mathebuch: >10.000 fehlende Zeichen → 0)
- Buch-README: bei Formeln LaTeX-Weg empfohlen, sonst weasyprint
- Alle sechs Beispielbücher geprüft: Seite 1 Cover, Verzeichnis nach dem Vorspann

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:48:18 +02:00
99c3c609ca feat: Inhaltsverzeichnis nach Cover, Titelseite und Impressum (pandoc/book.lua)
Pandocs --toc setzt Inhaltsverzeichnis und Titelkopf immer vor den
Buchtext, also vor Cover, Titelseite und Impressum.

- epub2md_merge.py: Kapiteltypen aus manifest.json; Platzhalter
  <div id="epub2md-toc"> hinter dem Vorspann und, ohne eigene Titelseite,
  <div id="epub2md-titlepage"> hinter dem Cover; --toc-Liste an derselben Stelle
- pandoc/book.lua (HTML/weasyprint, statt --toc): baut dort das
  Inhaltsverzeichnis, lässt Pandocs Titelkopf bei eigener Titelseite weg,
  Cover ohne Bildunterschrift, Kapitelanker → div.chapter-start;
  andere Formate: nur Platzhalter entfernen
- print.css: jedes Kapitel (auch Cover/Titelseite/Impressum) und das
  Verzeichnis auf neuer Seite, Verzeichnis mit Punktlinie und Seitenzahl
- Buch-README, Bedienungsanleitung, README: Befehle mit book.lua

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:27:27 +02:00
65c90c67ca feat: epub2md_merge kürzt ISO-Datum im Buch-Kopf (13.01.2025)
display_date(): '2025-01-13T08:00:00+00:00' → '13.01.2025' bei deutschen
Büchern, sonst '2025-01-13'; andere Formate unverändert. metadata.yaml
behält den Originalwert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:09:10 +02:00
13617c8d3e feat: pandoc/print.css – Druck-Layout für PDFs mit weasyprint
- A4, Seitenzahlen, jedes Kapitel auf neuer Seite, Inhaltsverzeichnis auf
  eigener Seite, Silbentrennung (nicht in Code), umbrechende Codezeilen;
  bringt Grundschrift und Titelkopf mit, da --css Pandocs Standard-CSS ersetzt
- Buch-README: zusätzlicher weasyprint-Befehl mit Filter und print.css
- Doku: Bedienungsanleitung und README um den weasyprint-Weg ergänzt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:03:55 +02:00
c761a53728 fix: Kasten-CSS des Pandoc-Filters wirkt auch mit -H und in EPUB-Kapiteln
- CSS in pandoc/callouts.css (eine Quelle), vom Filter neben sich gelesen
- HTML: <style> am Dokumentanfang statt header-includes, das ein -H auf der
  Kommandozeile ersetzt hat (PDF über weasyprint mit Druck-CSS: Kästen ohne Rahmen)
- EPUB: Kasten-CSS als Stylesheet aller Kapitel (zusammen mit Pandocs
  Standard-epub.css oder zu eigenen css:-Angaben); vorher nur auf der Titelseite

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:55:25 +02:00
6e3d05fef1 fix: Seitenmarken aus Kastentiteln vor den Kasten setzen; <mark> dokumentiert
- Marken im Kastentitel landen über marker_sink vor dem Kasten (wie bei
  Überschriften) statt im fetten Titel ("**\[S. 62\]Wozu …**")
- Doku: --no-css-emphasis lässt <mark> bestehen, das steuert --styles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:41:55 +02:00
38 changed files with 2496 additions and 163 deletions

2
.gitignore vendored
View file

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

View file

@ -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,9 +141,10 @@ 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
@ -160,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
@ -183,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 |
@ -222,7 +228,7 @@ Blöcke übernommen:
``` ```
GitHub und Obsidian zeigen das als farbigen Kasten mit Symbol; andere Programme als eingerückten Zitatblock. 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. 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`: Andere Darstellungen wählst du mit `--boxes`:
- `quote`: Zitatblock ohne `[!TIP]` - `quote`: Zitatblock ohne `[!TIP]`
@ -309,45 +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.
#### Infokästen richtig darstellen
Pandoc in der Ubuntu-Version kennt die Kasten-Schreibweise `> [!TIP]` nicht und druckt „[!TIP]“ einfach mit.
Dafür liegt im epub2md-Ordner der Filter `pandoc/callouts.lua`. Hänge ihn an jeden Pandoc-Befehl an:
```bash
F=~/Python_Programs/epub2md/pandoc/callouts.lua # Pfad zu deinem epub2md-Ordner
pandoc meinbuch.md -s --toc --lua-filter $F -o meinbuch.html
pandoc meinbuch.md --toc --lua-filter $F -o meinbuch.epub
pandoc meinbuch.md -s --lua-filter $F -o meinbuch.html && pandoc meinbuch.html --toc --lua-filter $F --pdf-engine=xelatex -o meinbuch.pdf
```
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 wird ein eingerückter Block mit fettem Titel daraus. Du musst nicht
selbst nachsehen, ob dein Buch Kästen hat: Die `README.md` im Ausgabeordner enthält in diesem Fall die
Befehle bereits mit Filter.
#### Direkt aus den Kapiteldateien #### Direkt aus den Kapiteldateien
@ -377,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.
--- ---
@ -503,14 +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 | | `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten |
| `--boxes callout/quote/html/plain` | Darstellung von Infokästen | | `--boxes callout/quote/html/plain` | Darstellung von Infokästen |
| `--styles semantic/html` | Farben, Schriften, Größen, Ausrichtung und Sprache als HTML übernehmen (`html`) | | `--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 -->` | | `--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 | | `--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 |

View file

@ -3,6 +3,75 @@
Alle nennenswerten Änderungen an epub2md. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), 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/). 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 ## [0.2.0] – 2026-10-06
Schwerpunkt: Typografie und Gestaltung des Buches im Markdown abbilden. Schwerpunkt: Typografie und Gestaltung des Buches im Markdown abbilden.
@ -49,5 +118,7 @@ Erste Fassung.
- Alle Befunde der QA-Prüfungen (R1–R7, QA2-01 bis QA2-19) behoben oder abgemildert. - Alle Befunde der QA-Prüfungen (R1–R7, QA2-01 bis QA2-19) behoben oder abgemildert.
- README und Bedienungsanleitung. - 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.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 [0.1.0]: https://kitux.de/forgejo/dschlueter/epub2md/src/tag/v0.1.0

128
README.md
View file

@ -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) |
@ -100,7 +100,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
| `--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 | | `--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 | | `--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 | | `--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` | | `--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-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) |
@ -158,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
@ -170,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
@ -184,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
@ -239,12 +246,19 @@ 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-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, 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. 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*`). Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst (`*x**y*` → `*xy*`).
- **Farben und Schriften:** Markdown kennt keine Farben, Schriftarten oder -größen. Mit `--styles html` - **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 bleiben sie als HTML-Stil erhalten. Übernommen wird nur, was vom Grundstil abweicht (häufigste Werte aller
@ -296,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.
@ -312,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,
@ -329,16 +351,43 @@ konsistente Anker:
```bash ```bash
cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei
F=~/Python_Programs/epub2md/pandoc/callouts.lua # Pfad zum epub2md-Ordner anpassen P=~/Python_Programs/epub2md/pandoc # Pfad zum epub2md-Ordner anpassen
pandoc programmierbuch.md --toc -s --lua-filter $F -o programmierbuch.html pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua \
pandoc programmierbuch.md --toc --lua-filter $F -o programmierbuch.epub --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`); **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 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 EPUB entstehen gerahmte, farbige Kästen mit Titel („Tipp: …“, bei `lang: en` „Tip: …“), in LaTeX/PDF, Word
@ -348,9 +397,8 @@ Die `README.md` eines konvertierten Buches mit Kästen enthält die Befehle bere
**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:
@ -365,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
@ -411,6 +461,11 @@ Verlustfreiheit behauptet (`lossless: false`).
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 Pseudo-Elemente nicht ausgewertet werden. ü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
@ -437,7 +492,11 @@ epub2md/
├── 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/ pandoc/
└── callouts.lua Pandoc-Filter: '> [!TIP]'-Kästen → gerahmte Kästen (HTML/EPUB) bzw. Zitatblock mit Titel ├── 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
@ -455,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`.

View file

@ -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.2.0" __version__ = "0.3.0"
TOOL_NAME = "epub2md" TOOL_NAME = "epub2md"

View file

@ -45,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)),
@ -105,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
@ -129,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
@ -196,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:
@ -307,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]

View file

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

View file

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

View file

@ -22,6 +22,7 @@ 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,
@ -303,6 +304,7 @@ class Renderer:
self.css = stylesheet self.css = stylesheet
self.scene_breaks = 0 self.scene_breaks = 0
self.page_markers = 0 self.page_markers = 0
self.marker_sink: list[str] | None = None # collects page markers instead of emitting them inline
self.poems = 0 self.poems = 0
self.boxes = 0 self.boxes = 0
self.in_box = False self.in_box = False
@ -322,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
@ -417,7 +420,11 @@ class Renderer:
return "" return ""
self.page_markers += 1 self.page_markers += 1
page = html.escape(page, quote=False).replace("--", "") page = html.escape(page, quote=False).replace("--", "")
return f"<!-- S. {page} -->" if self.opt.page_markers == "comment" else f"\\[S. {md_escape(page)}\\]" 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]
@ -622,13 +629,18 @@ class Renderer:
break break
title_el = self._box_title(members) title_el = self._box_title(members)
title = "" title = ""
markers: list[str] = [] # page breaks in the title go before the box, like for headings
if title_el is not None: if title_el is not None:
pieces: list[Piece] = [("t", self._text(title_el.text))] if title_el.text else [] pieces: list[Piece] = [("t", self._text(title_el.text))] if title_el.text else []
self.marker_sink = markers
try:
for child in title_el: for child in title_el:
if is_element(child) and not self._title_symbol(child): if is_element(child) and not self._title_symbol(child):
pieces.extend(self.inline(child)) pieces.extend(self.inline(child))
if child.tail: if child.tail:
pieces.append(("t", self._text(child.tail))) pieces.append(("t", self._text(child.tail)))
finally:
self.marker_sink = None
title = join_inline(pieces, hard_break=" ").strip() title = join_inline(pieces, hard_break=" ").strip()
kind = kind or _box_type(norm_ws(text_content(title_el))) kind = kind or _box_type(norm_ws(text_content(title_el)))
saved = self.in_box saved = self.in_box
@ -653,7 +665,9 @@ class Renderer:
if title and norm_ws(re.sub(r"[*_\\:]", "", title)).lower() in TYPE_WORDS: if title and norm_ws(re.sub(r"[*_\\:]", "", title)).lower() in TYPE_WORDS:
title = "" # 'Tipp' as title of a TIP box adds nothing title = "" # 'Tipp' as title of a TIP box adds nothing
self.boxes += 1 self.boxes += 1
out = [anchor] if anchor else [] out = ["".join(markers)] if markers else []
if anchor:
out.append(anchor)
head = [f"**{title}**"] if title else [] head = [f"**{title}**"] if title else []
if self.opt.boxes == "html": if self.opt.boxes == "html":
out.append(f'<div class="box box-{kind}">\n\n' + "\n\n".join(head + blocks) + "\n\n</div>") out.append(f'<div class="box box-{kind}">\n\n' + "\n\n".join(head + blocks) + "\n\n</div>")
@ -787,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:
@ -1221,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)]

View file

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

View file

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

View file

@ -13,9 +13,12 @@ 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 .css import StyleSheet, base_style, emphasis from .css import StyleSheet, base_style, emphasis
from .epub_reader import EpubReader, decode_markup, parse_xml from .epub_reader import EpubReader, decode_markup, parse_xml
@ -25,9 +28,10 @@ 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_element, is_inside, iter_elements, local, sha256_file, slugify, text_content 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")
@ -39,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"
@ -80,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():
@ -213,10 +217,9 @@ def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics,
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]] = []
@ -233,18 +236,39 @@ 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: def _load_stylesheet(reader: EpubReader, package: Any, docs: list[Doc], diag: Diagnostics) -> StyleSheet:
"""All CSS of the book: manifest stylesheets (manifest order) plus <style> elements of the documents.""" """The book's CSS: manifest stylesheets that a spine document links (directly or via @import), in manifest
texts: list[str] = [] order, plus <style> elements of the documents. Unlinked stylesheets (publishers ship the layouts of other
for item in package.manifest.values(): series) are ignored – a reading system would not apply them either."""
if item.media_type == "text/css" or item.path.lower().endswith(".css"): pending: list[str] = []
try:
texts.append(decode_markup(reader.read(item.path), item.path, diag))
except (KeyError, ContainerError):
diag.warn("css-unreadable", f"Stylesheet {item.path} nicht lesbar; seine Formatierung entfällt.", item.path)
for doc in docs: for doc in docs:
texts.extend(text_content(e) for e in iter_elements(doc.root) if local(e.tag) == "style") for e in iter_elements(doc.root):
return StyleSheet(texts) 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,
@ -270,11 +294,12 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver:
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)
@ -290,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
@ -411,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,
) )

View file

@ -17,6 +17,8 @@ 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" 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",
@ -136,16 +138,27 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None,
lua = "" lua = ""
if callouts and CALLOUT_FILTER.is_file(): # '> [!TIP]' boxes: Pandoc needs the filter to draw real boxes 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))}" 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}{lua} -o {shlex.quote(stem + '.html')}", f"pandoc {md_} -s{math}{toc}{lua} -o {html_}",
f"pandoc {shlex.quote(stem + '.md')} --toc{lua} -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}{lua} -o {shlex.quote(stem + '.html')} && "
f"pandoc {shlex.quote(stem + '.html')} --toc{lua} --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 callouts: if callouts:
@ -153,7 +166,7 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None,
out.append(f"Das Buch enthält {callouts} {noun} (`> [!TIP]` …). Der Filter `callouts.lua` macht daraus " 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.") "in HTML, EPUB und PDF gerahmte Kästen mit Titel; ohne ihn zeigt Pandoc „[!TIP]“ als Text.")
if stats.get("formulas"): if stats.get("formulas"):
out.append("Das Buch enthält Formeln: Für HTML `--mathml` (oder `--mathjax`) verwenden, sonst belässt " 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
View 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("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;"))
end
-- <header id="title-block-header"> as Pandoc's HTML template writes it.
local function title_block(meta)
local html = { '<header id="title-block-header">', '<h1 class="title">' .. esc(pandoc.utils.stringify(meta.title)) .. "</h1>" }
if meta.subtitle then
html[#html + 1] = '<p class="subtitle">' .. esc(pandoc.utils.stringify(meta.subtitle)) .. "</p>"
end
local authors = meta.author
if authors and pandoc.utils.type(authors) ~= "List" then
authors = { authors }
end
for _, a in ipairs(authors or {}) do
html[#html + 1] = '<p class="author">' .. esc(pandoc.utils.stringify(a)) .. "</p>"
end
if meta.date then
html[#html + 1] = '<p class="date">' .. esc(pandoc.utils.stringify(meta.date)) .. "</p>"
end
html[#html + 1] = "</header>"
return pandoc.RawBlock("html", table.concat(html, "\n"))
end
-- Paragraph that only holds chapter start anchors (<a id="kap-001-cover"></a>) → id of the first one.
local function chapter_anchor(block)
if block.t ~= "Para" and block.t ~= "Plain" then
return nil
end
local id = nil
for _, inl in ipairs(block.content) do
if inl.t ~= "RawInline" then
return nil
end
local found = inl.text:match('^<a id="(kap%-%d%d%d%-[^"]*)">$') or inl.text:match('^<a id="(kap%-%d%d%d%-[^"]*)"></a>$')
or inl.text:match('^<a id="(%d%d%d%-[^"]*)">$') or inl.text:match('^<a id="(%d%d%d%-[^"]*)"></a>$') -- before 0.3.0
if found then
id = id or found
elseif inl.text ~= "</a>" then
return nil
end
end
return id
end
-- Characters in running text that even DejaVu lacks: typeset as math symbols ("↩︎": footnote back-links).
local LATEX_SYMBOLS = { ["↩"] = "$\\hookleftarrow$", ["ℓ"] = "$\\ell$", ["\u{2329}"] = "$\\langle$",
["\u{232A}"] = "$\\rangle$", ["\u{3008}"] = "$\\langle$", ["\u{3009}"] = "$\\rangle$",
["∖"] = "$\\setminus$", ["⋯"] = "$\\cdots$", ["\u{FE0E}"] = "" }
local LATEX_FONTS = { mainfont = "DejaVu Serif", monofont = "DejaVu Sans Mono", mathfont = "DejaVu Math TeX Gyre" }
local function font_installed(name)
local pipe = io.popen("fc-list -q " .. string.format("%q", name) .. " && echo yes")
local out = pipe and pipe:read("a") or ""
if pipe then
pipe:close()
end
return out:match("yes") ~= nil
end
local function latex_symbols(str)
local found = false
for _, c in utf8.codes(str.text) do
if LATEX_SYMBOLS[utf8.char(c)] then
found = true
break
end
end
if not found then
return nil
end
local out, buf = pandoc.Inlines({}), {}
for _, c in utf8.codes(str.text) do
local ch = utf8.char(c)
local tex = LATEX_SYMBOLS[ch]
if tex then
if #buf > 0 then
out:insert(pandoc.Str(table.concat(buf)))
buf = {}
end
if tex ~= "" then
out:insert(pandoc.RawInline("latex", tex))
end
else
buf[#buf + 1] = ch
end
end
if #buf > 0 then
out:insert(pandoc.Str(table.concat(buf)))
end
return out
end
-- LaTeX (2nd step of the PDF route md -> html -> pdf): the HTML from step 1 already holds the table of
-- contents (div#TOC.toc), the chapter starts (div.chapter-start) and, if moved, Pandoc's title block.
local function latex_book(doc)
local found = false
doc.blocks:walk({ Div = function(d)
if d.identifier == "TOC" and d.classes:includes("toc") or d.classes:includes("chapter-start") then
found = true
end
end })
if not found then
return nil
end
local blocks = pandoc.Blocks({})
for _, block in ipairs(doc.blocks) do
if block.t == "Div" and block.identifier == "TOC" and block.classes:includes("toc") then
blocks:insert(pandoc.RawBlock("latex", "\\clearpage\n\\tableofcontents\n\\clearpage"))
elseif block.t == "Div" and block.classes:includes("chapter-start") then
blocks:insert(pandoc.RawBlock("latex", "\\clearpage"))
blocks:insert(block)
elseif block.t == "Div" and block.classes:includes("cover") then
blocks:insert(pandoc.RawBlock("latex", "\\thispagestyle{empty}")) -- no page number on the cover
blocks:insert(block:walk({ Image = function(img) -- cover as large as the page allows
img.attributes.width = nil
img.attributes.height = "20cm"
return img
end }))
elseif block.t == "Div" and block.identifier == "title-block-header" then
blocks:insert(pandoc.RawBlock("latex", "\\clearpage\n\\begin{center}\n\\vspace*{3cm}"))
for _, b in ipairs(block.content) do
if b.t == "Header" then
local title = pandoc.Inlines({ pandoc.RawInline("latex", "{\\Huge\\bfseries ") })
title:extend(b.content)
title:insert(pandoc.RawInline("latex", "}"))
blocks:insert(pandoc.Para(title))
else
blocks:insert(b)
end
end
blocks:insert(pandoc.RawBlock("latex", "\\end{center}"))
else
blocks:insert(block)
end
end
doc.blocks = blocks:walk({ Str = latex_symbols })
-- fonts with a wide Unicode coverage (∈ λ ≠ ≥ □, thin spaces …), unless the user chose fonts himself
-- A4 with moderate margins, like print.css (Pandoc's LaTeX default: US letter, wide margins)
doc.meta.papersize = doc.meta.papersize or "a4"
doc.meta.geometry = doc.meta.geometry or "margin=2cm"
for var, font in pairs(LATEX_FONTS) do
if doc.meta[var] == nil and font_installed(font) then
doc.meta[var] = font
end
end
-- no \maketitle at the very top (before the cover); keep the PDF metadata
if doc.meta.title then
doc.meta["title-meta"] = pandoc.utils.stringify(doc.meta.title)
doc.meta.title = nil
end
if doc.meta.author then
doc.meta["author-meta"] = pandoc.utils.stringify(doc.meta.author)
doc.meta.author = nil
end
doc.meta.date = nil
return doc
end
function Pandoc(doc)
if FORMAT:match("latex") then
return latex_book(doc)
end
local html = FORMAT:match("html") and not FORMAT:match("epub")
local toc_at, title_at = nil, nil
for i, block in ipairs(doc.blocks) do
if is_placeholder(block, TOC_ID) then
toc_at = i
elseif is_placeholder(block, TITLE_ID) then
title_at = i
end
end
if not toc_at and not title_at then
return nil -- not a file from epub2md_merge.py
end
if not html then -- placeholders are top-level blocks: just drop them
local kept = pandoc.Blocks({})
for _, block in ipairs(doc.blocks) do
if not is_placeholder(block, TOC_ID) and not is_placeholder(block, TITLE_ID) then
kept:insert(block)
end
end
doc.blocks = kept
return doc
end
local blocks = pandoc.Blocks({})
local seen_chapter = false
for i, block in ipairs(doc.blocks) do
local anchor = chapter_anchor(block)
if i == toc_at then
if PANDOC_WRITER_OPTIONS.table_of_contents then
-- --toc given as well: Pandoc's own table of contents stays at the top, no second one
else
local lang = doc.meta.lang and pandoc.utils.stringify(doc.meta.lang):sub(1, 2):lower() or "de"
local toc = pandoc.structure.table_of_contents(doc.blocks, PANDOC_WRITER_OPTIONS)
blocks:insert(pandoc.Div({
pandoc.Header(1, TOC_TITLE[lang] or TOC_TITLE.de, pandoc.Attr("toc-title", { "unnumbered", "unlisted" })),
toc,
}, pandoc.Attr("TOC", { "toc" })))
end
elseif i == title_at then
if doc.meta.title then
blocks:insert(title_block(doc.meta))
end
elseif anchor then
blocks:insert(pandoc.Div({}, pandoc.Attr(anchor, { "chapter-start" })))
-- cover: the first chapter, if it holds nothing but one picture ('Cover', '[Titelinfo]', 'Deckblatt' …);
-- shown without the caption "Cover", centred on its own page
local nxt, after = doc.blocks[i + 1], doc.blocks[i + 2]
local ends = after == nil or chapter_anchor(after) ~= nil or is_placeholder(after, TOC_ID)
or is_placeholder(after, TITLE_ID)
if not seen_chapter and nxt and (nxt.t == "Figure" or nxt.t == "Para") and ends then
local imgs, text = {}, false
nxt:walk({ Image = function(img) imgs[#imgs + 1] = img end })
if nxt.t == "Para" then
for _, inl in ipairs(nxt.content) do
if inl.t ~= "Image" and inl.t ~= "Space" and inl.t ~= "SoftBreak" then
text = true
end
end
end
if #imgs == 1 and not text then
doc.blocks[i + 1] = pandoc.Div({ pandoc.Plain({ imgs[1] }) }, pandoc.Attr("", { "cover" }))
end
end
seen_chapter = true
else
blocks:insert(block)
end
end
doc.blocks = blocks
-- The book's own title page (or the moved block) replaces Pandoc's title block at the very top.
if doc.meta.title then
doc.meta.pagetitle = doc.meta.pagetitle or pandoc.utils.stringify(doc.meta.title)
doc.meta.title = nil
end
return doc
end

8
pandoc/callouts.css Normal file
View 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; }

View file

@ -11,7 +11,9 @@ epub2md writes boxes as GitHub alerts / Obsidian callouts:
Pandoc < 3.1.10 does not know this syntax and prints "[!TIP]" literally. This filter turns such Pandoc < 3.1.10 does not know this syntax and prints "[!TIP]" literally. This filter turns such
block quotes into real boxes: block quotes into real boxes:
* HTML, EPUB: <div class="callout callout-tip"> with a bold label and a small stylesheet * 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" * 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 It also handles <div class="callout …"> coming back from HTML (md -> html -> pdf route) and styles
@ -26,17 +28,40 @@ local LABELS = {
en = { NOTE = "Note", TIP = "Tip", IMPORTANT = "Important", WARNING = "Warning", CAUTION = "Caution" }, en = { NOTE = "Note", TIP = "Tip", IMPORTANT = "Important", WARNING = "Warning", CAUTION = "Caution" },
} }
local CSS = [[ -- callouts.css next to this filter is the single source of the box styles.
<style> local function read_file(path)
.callout, .box { border: 1px solid #c8ccd2; border-left: 0.35em solid #4a7bd0; border-radius: 0.3em; local fh = io.open(path, "r")
background: #f5f8fc; padding: 0.4em 1em; margin: 1em 0; } if not fh then
.callout > p:first-child { margin-top: 0.4em; } return nil
.callout-tip { border-left-color: #2e9a4f; background: #f3faf5; } end
.callout-important { border-left-color: #8250df; background: #f8f5fe; } local text = fh:read("a")
.callout-warning { border-left-color: #c58a00; background: #fffaf0; } fh:close()
.callout-caution { border-left-color: #cf222e; background: #fff5f5; } return text
</style> 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 lang = "de"
@ -79,15 +104,23 @@ local function Meta(meta)
lang = l lang = l
end end
end end
if is_html() then if CSS and FORMAT:match("epub") then
local includes = meta["header-includes"] local css = meta.css
if includes == nil then local path = nil
includes = pandoc.MetaList({}) if css == nil then -- no own stylesheet: default epub.css + ours; otherwise just add ours
elseif includes.t ~= "MetaList" then path = epub_stylesheet()
includes = pandoc.MetaList({ includes })
end end
includes:insert(pandoc.MetaBlocks({ pandoc.RawBlock("html", CSS) })) if css == nil then
meta["header-includes"] = includes 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 end
return meta return meta
end end
@ -126,7 +159,16 @@ local function Div(div)
return pandoc.BlockQuote(div.content) return pandoc.BlockQuote(div.content)
end 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 { return {
{ Meta = Meta }, { Meta = Meta },
{ BlockQuote = BlockQuote, Div = Div }, { BlockQuote = BlockQuote, Div = Div },
{ Pandoc = Pandoc },
} }

32
pandoc/print.css Normal file
View 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); }

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import posixpath
import sys import sys
import zipfile import zipfile
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -25,7 +26,7 @@ SVG = b'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect wid
XHTML = """<?xml version="1.0" encoding="utf-8"?> XHTML = """<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="de"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="de">
<head><title>{title}</title></head> <head><title>{title}</title>{head}</head>
<body>{body}</body> <body>{body}</body>
</html>""" </html>"""
@ -36,6 +37,7 @@ class Doc:
body: str body: str
title: str = "" title: str = ""
linear: bool = True linear: bool = True
css: list[str] | None = None # linked stylesheets (OEBPS-relative) – default: every .css in Book.media
@dataclass @dataclass
@ -90,7 +92,7 @@ def build_epub(path: Path, book: Book) -> Path:
<spine{' toc="ncx"' if book.ncx else ''}>{''.join(spine)}{book.spine_extra}</spine> <spine{' toc="ncx"' if book.ncx else ''}>{''.join(spine)}{book.spine_extra}</spine>
</package>""" </package>"""
nav_xhtml = XHTML.format( nav_xhtml = XHTML.format(
title="Inhalt", title="Inhalt", head="",
body='<nav epub:type="toc"><ol>' + "".join(f'<li><a href="{h}">{lbl}</a></li>' for lbl, h in nav_entries) + "</ol></nav>", body='<nav epub:type="toc"><ol>' + "".join(f'<li><a href="{h}">{lbl}</a></li>' for lbl, h in nav_entries) + "</ol></nav>",
) )
ncx = ( ncx = (
@ -114,7 +116,10 @@ def build_epub(path: Path, book: Book) -> Path:
if book.ncx: if book.ncx:
z.writestr("OEBPS/toc.ncx", ncx) z.writestr("OEBPS/toc.ncx", ncx)
for d in book.docs: for d in book.docs:
z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, body=d.body)) css = d.css if d.css is not None else [n for n in book.media if n.lower().endswith(".css")]
head = "".join(f'<link rel="stylesheet" type="text/css" href="{posixpath.relpath(c, posixpath.dirname(d.name) or ".")}"/>'
for c in css)
z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, head=head, body=d.body))
for name, data in book.media.items(): for name, data in book.media.items():
z.writestr(f"OEBPS/{name}", data) z.writestr(f"OEBPS/{name}", data)
for name, data in book.extra_entries.items(): for name, data in book.extra_entries.items():

View file

@ -127,7 +127,7 @@ def test_glossary_anchor_is_placed_before_bold_term(convert):
def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert): def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert):
words = " ".join(["Wort"] * 11000) words = " ".join(["Wort"] * 11000)
body = "<h1>Teil 1</h1>" + "".join(f"<h2>Kapitel {i}</h2><p>{words}</p>" for i in range(3)) body = "<h1>Teil 1</h1>" + "".join(f"<h2>Kapitel {i}</h2><p>{words}</p>" for i in range(3))
run = convert(Book(docs=[Doc("t.xhtml", body)])) run = convert(Book(docs=[Doc("t.xhtml", body)]), "--chapter-level", "1")
hints = [w for w in run.report["warnings"] if w["code"] == "chapter-large"] hints = [w for w in run.report["warnings"] if w["code"] == "chapter-large"]
assert len(hints) == 1 assert len(hints) == 1
assert "--chapter-level 2" in hints[0]["message"] and "3 Unterabschnitte" in hints[0]["message"] assert "--chapter-level 2" in hints[0]["message"] and "3 Unterabschnitte" in hints[0]["message"]
@ -135,6 +135,33 @@ def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert):
assert not any(w["code"] == "chapter-large" for w in small.report["warnings"]) assert not any(w["code"] == "chapter-large" for w in small.report["warnings"])
def test_auto_level_splits_only_oversized_chapters(convert):
"""Default --chapter-level auto: a part over 30000 words is split at its h2, other chapters stay whole (R2)."""
words = " ".join(["Wort"] * 11000)
docs = [
Doc("v.xhtml", "<h1>Vorwort</h1><p>kurz</p><h2>Dank</h2><p>danke</p><h2>Hinweise</h2><p>lesen</p>"),
Doc("t1.xhtml", "<h1>Teil 1</h1><p>Einleitung</p>"),
*[Doc(f"k{i}.xhtml", f"<h2>Kapitel {i}</h2><p>{words}</p><h3>Abschnitt</h3><p>x</p>") for i in range(1, 4)],
Doc("t2.xhtml", "<h1>Teil 2</h1><h2>Kapitel 4</h2><p>kurz</p><h2>Kapitel 5</h2><p>kurz</p>"),
]
run = convert(Book(docs=docs))
assert [p.name for p in run.chapters()] == [
"001-vorwort.md", "002-teil-1.md", "003-kapitel-1.md", "004-kapitel-2.md", "005-kapitel-3.md", "006-teil-2.md"]
assert "## Dank" in run.chapter("001") and "## Kapitel 5" in run.chapter("006")
assert "Einleitung" in run.chapter("002") and "## Abschnitt" in run.chapter("003")
assert run.chapter("003").count("\n# ") == 1
assert not any(w["code"] == "chapter-large" for w in run.report["warnings"])
assert any(i["code"] == "chapter-split" for i in run.report["infos"])
assert run.report["options"]["chapter_level"] == "auto"
explicit = convert(Book(docs=docs), "--chapter-level", "1")
assert len(explicit.chapters()) == 3
def test_chapter_level_rejects_invalid_values(convert):
for value in ("0", "7", "x"):
assert convert(Book(docs=[Doc("a.xhtml", "<h1>A</h1>")]), "--chapter-level", value).rc == 1
def test_titles_derived_from_content_when_no_other_source(convert): def test_titles_derived_from_content_when_no_other_source(convert):
docs = [ docs = [
Doc("ccover.xhtml", '<div><img src="c.png" alt=""/></div>'), Doc("ccover.xhtml", '<div><img src="c.png" alt=""/></div>'),
@ -185,3 +212,18 @@ def test_special_chapter_is_not_split_further(convert):
"<h1>Register</h1><h2>A</h2><p>Apfel</p><h2>Index</h2><p>x</p>") "<h1>Register</h1><h2>A</h2><p>Apfel</p><h2>Index</h2><p>x</p>")
run = convert(Book(docs=[Doc("a.xhtml", body)])) run = convert(Book(docs=[Doc("a.xhtml", body)]))
assert [p.name for p in run.chapters()] == ["001-literatur.md", "002-register.md"] assert [p.name for p in run.chapters()] == ["001-literatur.md", "002-register.md"]
def test_first_image_only_chapter_is_cover_and_titel_is_title_page(convert):
from conftest import PNG
docs = [Doc("info.xhtml", '<div><img src="c.png" alt=""/></div>', "[Titelinfo]"),
Doc("t.xhtml", "<p>Erika Musterfrau</p><p>Testbuch</p>", "Titel"),
Doc("d.xhtml", '<div><img src="c.png" alt="Deckblatt"/></div>', "Deckblatt"),
Doc("c1.xhtml", "<h1>Eins</h1><p>Text</p>")]
run = convert(Book(docs=docs, nav=[("Eins", "c1.xhtml")], media={"c.png": PNG}))
types = {c["file"]: c["types"] for c in run.report["chapters"]}
first, second, third = sorted(types)[:3]
assert "cover" in types[first]
assert "titlepage" in types[second]
assert "cover" in types[third] # 'Deckblatt' by title
assert "cover" not in types[sorted(types)[3]]

View file

@ -118,3 +118,57 @@ def test_print_wrap_markers_can_be_kept(convert):
run = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-wrap-markers") run = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-wrap-markers")
(_, _, code), = fenced_blocks(run.chapter("001")) (_, _, code), = fenced_blocks(run.chapter("001"))
assert code == "a = (1,\n↪ 2)" assert code == "a = (1,\n↪ 2)"
def test_printed_line_numbers_do_not_hide_the_language(convert):
listing = ("00 // Kapitel1/hallo.c\n01 #include &lt;stdio.h&gt;\n \n02 int main(void) {\n"
"03 printf(\"Hallo\\n\");\n04 return 0;\n05 }")
run = convert(Book(docs=[Doc("c.xhtml", f"<h1>N</h1><pre>{listing}</pre>")]))
(_, lang, code), = fenced_blocks(run.chapter("001"))
assert lang == "c"
assert code.startswith("00 // Kapitel1/hallo.c\n01 #include <stdio.h>") # numbers stay in the block
def test_python_is_recognized_by_parsing(convert):
blocks = ["from collections import ChainMap\n\nchain = ChainMap({}, {})\nprint(chain)",
"zahl = -1\nif zahl &lt; 0:\n print(\"negativ\")",
"&gt;&gt;&gt; 2 + 3\n5",
"name: epub2md\nversion: 1", # YAML is valid Python syntax, but no Python code
"[1, 4, 9]", # program output
"x = compute()"] # Python, but too weak without book context
body = "<h1>P</h1>" + "".join(f"<pre>{b}</pre>" for b in blocks)
run = convert(Book(docs=[Doc("c.xhtml", body)]))
langs = [lang for _, lang, _ in fenced_blocks(run.chapter("001"))]
assert langs == ["python", "python", "python", "", "", ""]
def test_weak_signals_follow_the_book_language(convert):
c_listing = "#include &lt;stdio.h&gt;\nint main(void) { return 0; }"
snippets = ["char ch = 65; // 'A'", "if (a || b) {\n x();\n}", "int remove(const char *pathname);",
"struct node {\n int value;\n};", "Ergebnis: 12345", "|00003.14|", "gcc -Wall -o prog prog.c",
"Dezimal: 65; Zeichen: A"]
body = "<h1>C</h1>" + "".join(f"<pre>{c_listing}</pre>" for _ in range(5))
body += "".join(f"<pre>{s}</pre>" for s in snippets)
run = convert(Book(docs=[Doc("c.xhtml", body)]))
langs = [lang for _, lang, _ in fenced_blocks(run.chapter("001"))][5:]
assert langs == ["c", "c", "c", "c", "", "", "", ""]
by = [item["detected_by"] for item in run.report["code_blocks"]["items"]][5:9]
assert by == ["book-context"] * 4
def test_python_book_labels_assignments_but_not_output(convert):
py = "def f(x):\n return x"
snippets = ["foo = decorate(foo)", "random.sample(population, k)", "[x * x for x in range(5)]",
"True", "('A', 'B', 'C')", "Hallo Welt", "4\n3\nnohtyP", "Jane\n42\nPerson(name='Jane')"]
body = "<h1>P</h1>" + "".join(f"<pre>{py}</pre>" for _ in range(5))
body += "".join(f"<pre>{s}</pre>" for s in snippets)
run = convert(Book(docs=[Doc("c.xhtml", body)]))
langs = [lang for _, lang, _ in fenced_blocks(run.chapter("001"))][5:]
assert langs == ["python", "python", "python", "", "", "", "", ""]
def test_mixed_book_gets_no_context_labels(convert):
body = "<h1>M</h1>" + "<pre>def f(x):\n return x</pre>" * 3
body += "<pre>#include &lt;stdio.h&gt;\nint main(void) { return 0; }</pre>" * 3 + "<pre>x = compute();</pre>"
run = convert(Book(docs=[Doc("c.xhtml", body)]))
assert fenced_blocks(run.chapter("001"))[-1][1] == ""

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