diff --git a/.gitignore b/.gitignore index 8924a81..7ac2e8c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ htmlcov/ build/ dist/ -# QA-Läufe (Prompt_Kontrolle.md) +# QA-Läufe (Prompt_Kontrolle.md): erzeugte Daten von tools/qa/ .qa/ .qa-output/ .qa-fixtures/ diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index afb00f6..f1e3639 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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`. -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: ```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 # an einen anderen Ort schreiben (Bildpfade werden automatisch angepasst) @@ -139,14 +141,21 @@ python3 epub2md_merge.py output/meinbuch --force ### Roman (Teile und Kapitel) -Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Standardmäßig entsteht eine Datei -pro **Teil**. Das können sehr große Dateien werden. Das Programm warnt dann mit `chapter-large` und -empfiehlt die passende Einstellung. Eine Datei pro **Kapitel** bekommst du so: +Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Das Programm erkennt sehr große +Teile (über 30 000 Wörter) selbst und legt dann eine kurze Datei für den Teil und eine Datei pro +**Kapitel** an; kurze Abschnitte wie Vorwort oder Anhang bleiben eine Datei. Du musst dafür nichts +einstellen. Soll wirklich jeder Abschnitt mit Unterüberschrift eine eigene Datei werden, gilt: ```bash python3 epub2md.py roman.epub output/roman --chapter-level 2 ``` +### Roman (Szenenwechsel, Gedichte) + +Szenenwechsel, die im Buch nur als Leerzeile zwischen zwei Absätzen erscheinen, werden als `* * *` +übernommen; sonst würden zwei Szenen nahtlos ineinanderlaufen. Gedichte und Liedtexte bleiben Zeile für +Zeile erhalten (als eingerückter Zitatblock, Strophen durch Leerzeile getrennt). + ### Programmierbuch ```bash @@ -154,8 +163,9 @@ python3 epub2md.py python-buch.epub output/python-buch ``` Programmcode bleibt zeichengenau erhalten, inklusive Einrückung. Die Programmiersprache wird nur -eingetragen, wenn sie eindeutig erkennbar ist. Ist das ganze Buch in **einer** Sprache geschrieben, -kannst du sie für alle übrigen Codeblöcke vorgeben: +eingetragen, wenn sie eindeutig erkennbar ist. Ist ein Buch erkennbar in **einer** Sprache geschrieben, +erhalten auch kurze Codeschnipsel dieses Label (etwa `char ch = 65;` in einem C-Buch). Programmausgaben +bleiben ohne Label. Willst du wirklich alle übrigen Blöcke kennzeichnen, gib die Sprache vor: ```bash python3 epub2md.py python-buch.epub output/python-buch --code-language python @@ -177,7 +187,9 @@ python3 epub2md.py mathe.epub output/mathe ``` Formeln, die im E-Book als echte Formeln (MathML oder TeX) vorliegen, werden zu TeX: `$…$` im Text, -`$$…$$` 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`. | Gewünschte Formeldarstellung | Option | @@ -203,6 +215,55 @@ python3 epub2md.py bildband.epub output/bildband --chapter-level 3 - Bilder, die im E-Book zwar enthalten, aber nirgends eingebunden sind, werden trotzdem gespeichert. Im Bericht sind sie als `unreferenced` gekennzeichnet. +### Infokästen (Tipps, Hinweise, Warnungen) + +Kästen aus dem Buch, etwa „Tipp“, „Achtung“, „Exkurs“ oder „Die MATLAB-Minute“, werden als hervorgehobene +Blöcke übernommen: + +```markdown +> [!TIP] +> **Erwartungen an dieses Buch** +> +> Text des Kastens … +``` + +GitHub und Obsidian zeigen das als farbigen Kasten mit Symbol; andere Programme als eingerückten Zitatblock. +Für Pandoc (HTML, EPUB, PDF) gibt es einen Filter, der echte Kästen zeichnet, siehe Kapitel 7 („Weiterverarbeiten“). +Andere Darstellungen wählst du mit `--boxes`: + +- `quote`: Zitatblock ohne `[!TIP]` +- `html`: HTML-`
` mit `
`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken. Literaturlisten mit eigenen Labels (`[BryL06]`) werden nicht doppelt nummeriert. -**Tipp:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), erzeugt -`--chapter-level 2` eine Datei pro Kapitel. Mit dem Standard `1` entsteht eine Datei pro Teil. -Ist eine Kapiteldatei größer als 30 000 Wörter und hat sie mindestens zwei `##`-Unterabschnitte, meldet -epub2md eine Warnung `chapter-large` mit der passenden `--chapter-level`-Empfehlung. +**Teile und Kapitel:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), teilt der +Standard `auto` jeden Teil mit über 30 000 Wörtern und mindestens zwei Kapiteln in eine kurze Datei für den +Teil (Überschrift, Einleitung) und eine Datei pro Kapitel; kleinere Kapitel (Vorwort, Anhang) bleiben ganz. +Verzeichnisse (Register, Literatur, Glossar) werden dabei nie zerlegt. `--chapter-level 2` teilt dagegen +jedes Kapitel des Buches an `h2`. Ist bei einer festen Zahl eine Kapiteldatei größer als 30 000 Wörter und +hat sie mindestens zwei `##`-Unterabschnitte, meldet epub2md eine Warnung `chapter-large` mit der +passenden `--chapter-level`-Empfehlung. ## Math-Modi @@ -234,9 +246,44 @@ weil CommonMark keine `[^…]`-Syntax kennt. - **Syntax-Highlighting:** Farb-Spans, ``/`` in Listings und Zeilennummern werden entfernt. Einrückung, Tabs, `<`, `>`, `&`, `$`, `|`, Backslashes und Backticks bleiben exakt erhalten. Die Sprache kommt aus Klassen/Attributen (`language-python`, `data-lang`, …) oder starken Inhaltssignalen - (`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. Bei einsprachigen - Büchern kann `--code-language python` (o. Ä.) alle übrigen Blöcke kennzeichnen. Achtung: Das betrifft - auch Programmausgaben und Shell-Befehle im Buch. + (`#include`, Shebang, `>>>`, Python-Code, der sich parsen lässt und `def`/`class`/Kontrollstrukturen + enthält, …). Gedruckte Zeilennummern als Text (`00 #include …`) stören die Erkennung nicht und bleiben + im Block stehen. Sind sich die sicher erkannten Blöcke einig (mindestens 5, davon 90 % eine Sprache), + genügen für die übrigen schwächere Signale: in einem Python-Buch Zuweisungen und Aufrufe, in einem + Buch mit C-artiger Sprache Anweisungen und Deklarationen mit `;` oder `{` (im Bericht + `detected_by: "book-context"`). Programmausgaben (Literale, Zahlen, Text) bleiben ohne Label. + `--code-language python` (o. Ä.) kennzeichnet alle übrigen Blöcke, auch Programmausgaben und + Shell-Befehle im Buch. +- **CSS-Formatierung:** Viele Verlage setzen Hervorhebungen nur über Stylesheets. epub2md wertet die + CSS-Dateien des Buches aus: Klassen-, Element- und ID-Selektoren, Nachfahren- und Kind-Kombinatoren, + Spezifität und `style`-Attribute. `@media`-Blöcke und Selektoren mit Pseudoklassen werden ignoriert. + Berücksichtigt werden nur Stylesheets, die ein Dokument per `` (oder `@import`) einbindet; + mitgelieferte Layouts anderer Buchreihen bleiben wie im E-Book-Reader wirkungslos (Info `css-unlinked`). + Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst (`*x**y*` → `*xy*`). +- **Farben und Schriften:** Markdown kennt keine Farben, Schriftarten oder -größen. Mit `--styles html` + bleiben sie als HTML-Stil erhalten. Übernommen wird nur, was vom Grundstil abweicht (häufigste Werte aller + Absätze; Schriftgröße erst ab ±15 %). Abgesetzte Formeln und Bildabsätze bekommen keine Ausrichtung. Werte + mit `url(...)`, `expression` usw. werden verworfen. +- **Seitenzahlen (`--page-markers`):** Erkannt werden `epub:type="pagebreak"`/`role="doc-pagebreak"` + (Nummer aus `aria-label`, `title` oder Text) und Seitenanker mit IDs wie `page_47`, `page37`, `seite-12` + oder – nur bei leeren ``/`` – `p17`. In Codeblöcken und Inline-Code stehen keine Marker; ein + Seitenwechsel in einer Überschrift steht als eigener Absatz davor. Die Anzahl steht im Bericht unter + `layout.page_markers`. +- **Infokästen:** + - **Erkennung:** `aside`/`epub:type` (sidebar, tip, warning …), Klassennamen (box, kasten, tipp, hinweis, + warnung, achtung, wichtig, merke, exkurs …) oder ein CSS-Rahmen bzw. -Hintergrund um kurzen Fließtext. + Folgen von Tipp-Absätzen ohne Rahmen werden zu einem Kasten zusammengefasst. + - **Titel und Symbole:** Kastenüberschriften werden zum fetten Titel und tauchen damit nicht mehr in der + Gliederung auf. Dekorative Symbolbilder entfallen. + - **Typ:** Er ergibt sich aus Klasse, Titel oder erstem Wort: TIP, IMPORTANT, WARNING, CAUTION, sonst NOTE. + - **Ausnahmen:** Umrahmte Codelistings und Bildrahmen gelten nicht als Kasten. +- **Szenenwechsel und Verse:** + - Leere Absätze zwischen zwei Textabsätzen werden zu `* * *`; mehrere hintereinander ergeben einen + Wechsel. Ebenso werden Ornament-Absätze wie `***` oder `⁂` zu `* * *`. + - Leere `div`s und Absätze, die nur Anker enthalten, gelten als Layout und nicht als Wechsel. + - Gedichte (`poem`, `verse`, `gedicht` … bzw. `z3998:poem`) werden zum Zitatblock mit einer Zeile pro Vers + und einer Leerzeile zwischen Strophen. + - Anzahl im Bericht unter `layout`. - **Gescannte Texte:** nur Bilder, kein Text (keine OCR). - **DRM:** wird erkannt und abgelehnt. - **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne @@ -263,14 +310,15 @@ verwendet und das Verzeichnis gibt den Namen vor. | --- | --- | | `-o`, `--output DATEI.md` | anderer Zielort; Medienpfade werden relativ zum Ziel umgeschrieben | | `--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) | | `--version` | Programmversion | Beim Zusammenfügen passiert Folgendes: - Die Frontmatter der einzelnen Kapitel wird entfernt. -- Jedes Kapitel erhält einen Startanker (``). +- Jedes Kapitel erhält einen Startanker (``). 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. - 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. @@ -279,8 +327,15 @@ Beim Zusammenfügen passiert Folgendes: - Codeblöcke und Inline-Code werden nie verändert. - Kapitelüberschriften bleiben `#`, jedes Kapitel beginnt also mit einer H1. Pandoc behandelt sie als Kapitel. +- Hinter dem Vorspann (Cover, Titelseite, Impressum und kurze Seiten dazwischen, bis vor den ersten Teil, + das Vorwort oder das erste nummerierte Kapitel) steht ein unsichtbarer Platzhalter + ``; hat das Buch keine eigene Titelseite, steht hinter dem Cover zusätzlich + ``. `pandoc/book.lua` setzt dort Inhaltsverzeichnis und Titelkopf ein; + GitHub, Obsidian und andere Programme zeigen die leeren `div`s nicht an. - Der Buch-Kopf enthält nur Felder, die im EPUB vorhanden sind (`title`, `subtitle`, `author`, `lang`, - `publisher`, `date`, `identifier`, `rights`, `description`). + `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 den Kapitelanfang und erzeugt eine Warnung auf stderr. Die Zusammenfassung nennt Kapitelanzahl, @@ -296,21 +351,54 @@ konsistente Anker: ```bash cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei -pandoc programmierbuch.md --toc -s -o programmierbuch.html -pandoc programmierbuch.md --toc -o programmierbuch.epub +P=~/Python_Programs/epub2md/pandoc # Pfad zum epub2md-Ordner anpassen +pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua \ + --css $P/print.css --pdf-engine=weasyprint -o programmierbuch.pdf # PDF (empfohlen) +pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o programmierbuch.html +pandoc programmierbuch.md --toc --lua-filter $P/callouts.lua -o programmierbuch.epub -cd output/mathematikbuch -pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html -# PDF über den Zwischenschritt HTML (siehe unten) -pandoc mathematikbuch.html --toc --pdf-engine=xelatex -o mathematikbuch.pdf +cd output/mathematikbuch # Formeln: PDF über HTML und LaTeX (weasyprint setzt kein MathML) +pandoc mathematikbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o mathematikbuch.html +pandoc mathematikbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex \ + -o mathematikbuch.pdf +pandoc mathematikbuch.md --toc --mathml --lua-filter $P/callouts.lua -o mathematikbuch.epub # EPUB: MathML ``` +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 (``, Kapiteltypen aus `manifest.json`) und, falls das Buch + keine eigene Titelseite hat, die Stelle hinter dem Cover (``). Der + Filter baut dort das Inhaltsverzeichnis (`--toc-depth` gilt), lässt Pandocs Titelkopf bei vorhandener + Titelseite weg, zeigt das Cover ohne Bildunterschrift und macht aus den Kapitelankern + `div.chapter-start` für Seitenumbrüche. Beim LaTeX-Weg (Filter in beiden Schritten) wird daraus + `\tableofcontents` an derselben Stelle, `\clearpage` vor jedem Kapitel und kein `\maketitle` vor dem Cover. + Außerdem A4 mit 2 cm Rand, das Cover in Seitengröße und – falls installiert und nicht selbst gewählt – + DejaVu-Schriften (`mainfont`, `monofont`, `mathfont`): Latin Modern fehlen Zeichen wie ∈ λ ≠ ≥ □ und + schmale Leerzeichen (im Mathebuch über 10.000 Stellen). ℓ, 〈 〉, ∖, ⋯ und „↩︎“ werden als Mathe-Symbole gesetzt. + Bei anderen Formaten entfernt er nur die Platzhalter. Wer selbst `--toc` angibt, bekommt Pandocs + Verzeichnis wie gewohnt. + - Der Vorspann reicht bis vor das erste Kapitel des Hauptteils (Teil, Vorwort, Einleitung, Prolog, + nummerierter Titel oder mehr als 1000 Wörter), sofern er Cover, Titelseite oder Impressum enthält. Ein + erstes Kapitel nur aus einem Bild gilt als Cover, ein Kapitel „Titel“ als Titelseite. +- **`print.css`** (weasyprint): A4, Seitenzahlen (nicht auf dem Cover), jedes Kapitel (auch Cover, + Titelseite, Impressum) und das Inhaltsverzeichnis auf neuer Seite, Inhaltsverzeichnis mit Punktlinie und + Seitenzahl, Silbentrennung (nicht in Code), umbrechende Codezeilen. Mit `--css` lässt Pandoc sein eigenes + Stylesheet weg, deshalb bringt `print.css` auch Grundschrift, Zeilenabstand und Titelkopf mit. Getestet + mit dem Python-Buch (362 Seiten). + +**Infokästen:** Pandoc kennt die Schreibweise `> [!TIP]` erst ab neueren Versionen (Erweiterung `alerts`); +3.1.3 (Ubuntu 24.04) zeigt „[!TIP]“ als Text. Der Filter `pandoc/callouts.lua` behebt das: In HTML und +EPUB entstehen gerahmte, farbige Kästen mit Titel („Tipp: …“, bei `lang: en` „Tip: …“), in LaTeX/PDF, Word +und ODT ein Zitatblock mit fettem Titel. Beim PDF-Weg über HTML den Filter in beiden Schritten angeben. +Die `README.md` eines konvertierten Buches mit Kästen enthält die Befehle bereits mit dem Filter. + **PDF immer über HTML erzeugen.** Beim direkten Weg Markdown → LaTeX verwirft Pandoc allen rohen HTML-Code. Dabei gehen HTML-Tabellen samt Inhalt verloren, und ``-Sprungziele fehlen, sodass interne Links im PDF ins Leere zeigen. Liest Pandoc dagegen die HTML-Fassung, macht es daraus echte Tabellen und Sprungziele. -Getestet mit dem C-Buch (405 Seiten, 34 Tabellen, 728 von 755 internen Links funktionieren; die übrigen -stehen in Codeblöcken innerhalb von Tabellen). Die `README.md` jedes konvertierten Buches enthält den -passenden Befehl. +Getestet mit dem C-Buch (322 Seiten, 34 Tabellen; alle 831 internen Links haben ein Ziel, kein Zeichen +fehlt). Die `README.md` jedes konvertierten Buches enthält den passenden Befehl. Alternativ direkt aus den Kapiteldateien: @@ -325,8 +413,10 @@ pandoc output/programmierbuch/chapters/*.md \ - Ausgaben mit `--math tex` (`\(…\)`) brauchen `-f markdown+tex_math_single_backslash`. - 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. -- Einzelne TeX-Befehle aus EPUBs (z. B. `\O` oder `@`-Konstrukte) kennt Pandocs Formelparser nicht. Er - belässt sie als TeX, mit der Warnung „Could not convert TeX math“. +- TeX aus dem E-Book wird für Pandocs Formelparser angepasst, ohne den Inhalt zu ändern: `\"u` → `ü`, + `\O` (leere Menge) → `\emptyset`, keine `@{…}`-Spaltentrenner und kein `\arraycolsep` in `array`, kein + leeres `\displaystyle` vor einem Zellenende. Seltene Handkonstruktionen (z. B. `\mathinner{…\raise…}`) + bleiben als TeX stehen, mit der Warnung „Could not convert TeX math“ (Mathebuch: 1 von 4986 Formeln). ## Bericht und Diagnose @@ -370,7 +460,12 @@ Verlustfreiheit behauptet (`lossless: false`). - Audio/Video sind implementiert, aber nur mit synthetischen Daten geprüft. Bildbände sind mit einem realen Buch geprüft (434 Bilder, 246 Figuren, ausführliche Alternativtexte). - Rein per CSS erzeugte Inhalte (`::before`/`content:`, z. B. automatische Schrittnummern) werden nicht - übernommen, weil CSS nicht ausgewertet wird. + übernommen, weil Pseudo-Elemente nicht ausgewertet werden. +- Die eingebundenen Stylesheets gelten für das ganze Buch, nicht je Kapiteldokument. Bindet nur ein + Kapitel ein bestimmtes Stylesheet ein, wirkt es auch auf die anderen. In Fußnoten greifen CSS-Regeln + mit Vorfahren-Selektoren nicht, weil die Noten vor dem Rendern aus dem Text gelöst werden. +- Von Hand gebaute TeX-Konstrukte (z. B. `\mathinner{…\raise…}`) kann Pandoc nicht in MathML umsetzen; + sie bleiben als TeX-Text stehen (Mathebuch: 1 von 4986 Formeln). ## Architektur @@ -386,6 +481,7 @@ epub2md/ ├── chapters.py Kapitelbildung (Spine + Überschriften + epub:type), Klassifikation ├── references.py Ziel-IDs → Kapiteldatei + Anker, Link-Auflösung ├── html_converter.py rekursiver XHTML→Markdown-Renderer (Blöcke, Inline, Tabellen, Listen, Bilder) +├── css.py Mini-CSS-Auswertung (Selektoren, Spezifität) für Hervorhebungen und Stile ├── codeblocks.py Codeerkennung, exakte Textextraktion, Spracherkennung, Fences ├── mathml.py konservative MathML→TeX-Konvertierung, TeX-Erkennung ├── media.py Medienregistry mit stabilen Namen @@ -395,6 +491,12 @@ epub2md/ ├── diagnostics.py zentrale Sammlung von Warnungen/Fehlern ├── models.py Dataclasses (Kapitel, Medien, Noten, Metadaten …) └── utils.py Slugs, YAML-Emitter, Pfadhelfer +pandoc/ +├── book.lua Pandoc-Filter: Inhaltsverzeichnis nach Cover/Titelseite/Impressum, Kapitel-Seitenumbrüche +├── callouts.lua Pandoc-Filter: '> [!TIP]'-Kästen → gerahmte Kästen (HTML/EPUB) bzw. Zitatblock mit Titel +├── callouts.css Gestaltung der Kästen (vom Filter eingebunden; muss neben callouts.lua liegen) +└── print.css Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite, Codeumbruch) +tools/qa/ QA-Skripte: CLI-Matrix, Validator, Wortvergleich, Pandoc-Prüfung (siehe tools/qa/README.md) ``` Statt BeautifulSoup wird direkt lxml verwendet: Es ist für die XHTML-Bäume ausreichend, schneller und @@ -412,7 +514,14 @@ ruff check epub2md tests && mypy epub2md --ignore-missing-imports # optional, Die Tests erzeugen synthetische Minimal-EPUBs (EPUB 2/3, NAV/NCX, Code, Tabellen, MathML, SVG, Fußnoten, Glossar/Literatur/Register, unsichere ZIP-Pfade, DRM, XXE, defekte Container, Kodierung) und prüfen die tatsächliche CLI-Ausgabe. `tests/test_merge.py` prüft die Gesamtdatei: Reihenfolge, -Anker-Umbenennung, Pfade, Fußnoten, unveränderter Code 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 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`. diff --git a/epub2md/__init__.py b/epub2md/__init__.py index 9aada3e..292d78c 100644 --- a/epub2md/__init__.py +++ b/epub2md/__init__.py @@ -1,4 +1,4 @@ """epub2md – konvertiert DRM-freie EPUB-Dateien in kapitelweise Markdown-Sammlungen.""" -__version__ = "0.1.0" +__version__ = "0.3.0" TOOL_NAME = "epub2md" diff --git a/epub2md/chapters.py b/epub2md/chapters.py index 5845852..8716c3b 100644 --- a/epub2md/chapters.py +++ b/epub2md/chapters.py @@ -9,7 +9,17 @@ from typing import Any from .diagnostics import Diagnostics from .models import Chapter, NavEntry from .normalize import Doc, has_content -from .utils import classes, epub_types, is_element, iter_elements, local, norm_ws, roles, text_content +from .utils import ( + classes, + epub_types, + is_element, + iter_elements, + local, + norm_ws, + remove_invisible, + roles, + text_content, +) HEADINGS = {"h1": 1, "h2": 2, "h3": 3, "h4": 4, "h5": 5, "h6": 6} CONTAINERS = {"body", "div", "section", "article", "aside", "main", "header", "footer", "nav", "span", "hgroup"} @@ -35,8 +45,8 @@ KIND_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ("appendix", re.compile(r"^(anhang|appendix)", re.I)), ("colophon", re.compile(r"^(impressum|colophon|kolophon)", re.I)), ("dedication", re.compile(r"^(widmung|dedication)", re.I)), - ("cover", re.compile(r"^(cover|umschlag|titelbild)\b", re.I)), - ("titlepage", re.compile(r"^(titelei|titelseite|title ?page)", re.I)), + ("cover", re.compile(r"^(cover|umschlag|titelbild|deckblatt)\b", re.I)), + ("titlepage", re.compile(r"^(titelei|titelseite|title ?page|titel\s*$|title\s*$)", re.I)), ("part", re.compile(r"^(teil|part)\s+([IVXLC]+|\d+|[a-zäöü]+)\b", re.I)), ("epilogue", re.compile(r"^(epilog|nachwort|epilogue|afterword)", re.I)), ("prologue", re.compile(r"^(prolog|prologue)", re.I)), @@ -95,9 +105,11 @@ def semantic_types(el: Any) -> list[str]: class ChapterBuilder: def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics, - book_title: str = "", authors: list[str] | None = None) -> None: + book_title: str = "", authors: list[str] | None = None, deep_starts: set[Any] | None = None) -> None: self.level = chapter_level self.diag = diag + self.deep_starts = deep_starts or set() # chapter headings whose sections are split one level deeper + self._deep = False # current chapter is one of them self._special_level = 0 # level of a split-out special heading whose section is still open self._in_special = False # current chapter itself is a bibliography/index/glossary self.book_title = book_title @@ -119,6 +131,12 @@ class ChapterBuilder: if lvl and has_content(el): special = _special_heading(el) if lvl <= self.level: + if update: + self._special_level = 0 + self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS + self._deep = el in self.deep_starts + return Marker(el, "heading", lvl) + if self._deep and lvl == self.level + 1: if update: self._special_level = 0 self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS @@ -186,7 +204,7 @@ class ChapterBuilder: body_segments = segments[1:] if _meaningful(head_items): first = _first_meaningful(head_items) - if chapters and leading_heading_level(first) > self.level 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) pending = [] if doc.path not in chapters[-1].docs: @@ -270,6 +288,7 @@ class ChapterBuilder: if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]): types.append("auxiliary") ch.types = list(dict.fromkeys(types)) + ch.title = remove_invisible(ch.title)[0] # titles feed file names, frontmatter and README return ch def _title_from_content(self, items: list[tuple[str, Any]], number: int) -> tuple[str, str]: @@ -296,12 +315,21 @@ class ChapterBuilder: kind = classify_title(ch.title) if kind and kind not in ch.types: ch.types.append(kind) + if ch.number == 1 and "cover" not in ch.types and _image_only(ch.items): + ch.types.append("cover") # first chapter with nothing but a picture, whatever its label ('[Titelinfo]') if "index" in ch.types and classify_title(ch.title) == "index-persons": ch.types.remove("index") if not ch.types: ch.types.append("chapter") +def _image_only(items: list[tuple[str, Any]]) -> bool: + """Content is one or more pictures without any text.""" + text = "".join(it if isinstance(it, str) else text_content(it) for _, it in items if isinstance(it, str) or is_element(it)) + return not text.strip() and any(is_element(it) and any(local(e.tag) in ("img", "svg", "image") for e in iter_elements(it)) + for _, it in items) + + @dataclass class _TypeCarrier: types: list[str] diff --git a/epub2md/cli.py b/epub2md/cli.py index 4679493..8066c45 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -46,6 +46,14 @@ def code_language(value: str) -> str: return lang +def _chapter_level(value: str) -> int | str: + if value == "auto": + return value + if value.isdigit() and 1 <= int(value) <= 6: + return int(value) + raise argparse.ArgumentTypeError("auto oder eine Zahl zwischen 1 und 6 erwartet") + + def build_parser() -> argparse.ArgumentParser: p = _Parser( prog="epub2md.py", @@ -61,9 +69,10 @@ def build_parser() -> argparse.ArgumentParser: help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)") p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False, help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)") - p.add_argument("--chapter-level", type=int, default=1, metavar="N", - help="Überschriftenebene 1–6, ab der neue Kapiteldateien entstehen (Standard: 1). " - "Für Bücher mit Teilen (h1) und Kapiteln (h2) ist 2 sinnvoll") + p.add_argument("--chapter-level", type=_chapter_level, default="auto", metavar="{auto,1-6}", + help="Überschriftenebene, ab der neue Kapiteldateien entstehen. auto (Standard): Ebene 1, " + "nur sehr große Kapitel (über 30000 Wörter, z. B. Teile eines Romans) werden an ihren " + "Unterkapiteln geteilt. Eine Zahl 1–6 gilt ausnahmslos für das ganze Buch") p.add_argument("--toc", action=argparse.BooleanOptionalAction, default=True, help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)") p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True, @@ -85,6 +94,23 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--keep-wrap-markers", action="store_true", help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte " "Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt") + p.add_argument("--boxes", choices=["callout", "quote", "html", "plain"], default="callout", + help="Infokästen (Tipp, Hinweis, Warnung, Exkurs …): callout = '> [!TIP]' (GitHub/Obsidian), " + "quote = Zitatblock mit fettem Titel, html =, plain = normaler Text " + "(Standard: callout)") + p.add_argument("--styles", choices=["semantic", "html"], default="semantic", + help="semantic: nur Bedeutung übernehmen (Hintergrundmarkierung → ); html: zusätzlich Farbe, " + "Hintergrund, Schriftgröße/-art, Kapitälchen, Ausrichtung und Fremdsprache als /" + " (wo sie vom normalen Text abweichen; GitHub zeigt das nicht an) (Standard: semantic)") + p.add_argument("--page-markers", choices=["none", "visible", "comment"], default="none", + help="Seitenzahlen der Druckausgabe: visible = '[S. 123]' im Text, comment = '' " + "(unsichtbar, durchsuchbar), none = weglassen (Standard: none)") + p.add_argument("--css-emphasis", action=argparse.BooleanOptionalAction, default=True, + help="Fett/kursiv/unterstrichen/hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS " + "setzt; per CSS ausgeblendete Elemente weglassen (Standard: an)") + p.add_argument("--keep-invisible-chars", action="store_true", + help="Nullbreiten-Zeichen und weiche Trennstriche (U+00AD) im Fließtext behalten; Standard: entfernen " + "(stören Suche und Weiterverarbeitung). Code bleibt immer unverändert") p.add_argument("--strict", action="store_true", help="Bei kritischen Fehlern abbrechen: fehlende/unlesbare Spine-Dokumente, nicht dekodierbares " "XHTML, unsichere ZIP-Pfade, übersprungene (verdächtige) Medien") @@ -119,6 +145,11 @@ def main(argv: list[str] | None = None) -> int: keep_html=args.keep_html, code_language=args.code_language or "", keep_wrap_markers=args.keep_wrap_markers, + keep_invisible_chars=args.keep_invisible_chars, + css_emphasis=args.css_emphasis, + boxes=args.boxes, + styles=args.styles, + page_markers=args.page_markers, strict=args.strict, verbose=args.verbose, dry_run=args.dry_run, diff --git a/epub2md/codeblocks.py b/epub2md/codeblocks.py index 0fcc883..7731a9b 100644 --- a/epub2md/codeblocks.py +++ b/epub2md/codeblocks.py @@ -2,8 +2,10 @@ from __future__ import annotations +import ast import json import re +import warnings from typing import Any from .utils import classes, is_element, local @@ -127,9 +129,114 @@ def detect_language(el: Any) -> tuple[str, str]: return "", "" +LINE_NUMBER_PREFIX = re.compile(r"^(\d{1,4})( +|$)") + + +def strip_line_numbers(code: str) -> str: + """Remove printed line numbers ("00 #include …") – for language detection only, the block keeps them. + + Applies when at least 80 % of the non-blank lines (at least two) start with consecutive numbers. + """ + lines = code.split("\n") + filled = [ln for ln in lines if ln.strip()] + matches = [LINE_NUMBER_PREFIX.match(ln) for ln in filled] + numbers = [int(m.group(1)) for m in matches if m] + if len(numbers) < 2 or len(numbers) < 0.8 * len(filled): + return code + if any(b != a + 1 for a, b in zip(numbers, numbers[1:])): + return code + width = min((len(m.group(2)) for m in matches if m and m.group(2)), default=0) + out = [] + for ln in lines: + m = LINE_NUMBER_PREFIX.match(ln) if ln.strip() else None + out.append(ln[len(m.group(1)) + min(width, len(m.group(2))):] if m else ln) + return "\n".join(out) + + +def _python_module(text: str) -> ast.Module | None: + """The parsed module, or None when the text is not valid Python (never executed, warnings suppressed).""" + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return ast.parse(text) + except (SyntaxError, ValueError, RecursionError, MemoryError): + return None + + +PY_COMPOUND = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.If, ast.For, ast.AsyncFor, ast.While, + ast.With, ast.AsyncWith, ast.Try) +PY_CODE_EXPR = (ast.Call, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.Lambda, ast.Await) + + +def _python_strength(text: str) -> int: + """2 = surely Python (block statement, or import plus code), 1 = Python code (assignment, call, + comprehension …), 0 = not Python or only literals/names – which is what program output looks like.""" + if any(ln.rstrip().endswith((";", "{")) for ln in text.split("\n")): + return 0 + module = _python_module(text) + if module is None or not module.body: + return 0 + imports = sum(isinstance(n, (ast.Import, ast.ImportFrom)) for n in module.body) + code = 0 + for node in module.body: + if isinstance(node, PY_COMPOUND): + return 2 + if isinstance(node, ast.Expr): + value = node.value + if isinstance(value, ast.Constant) and isinstance(value.value, str): + continue # docstring + if not isinstance(value, PY_CODE_EXPR): + return 0 # a bare name, number or literal line is program output ("Jane", "42", "[1, 4]") + code += 1 + elif isinstance(node, ast.AnnAssign) and node.value is None: + pass # "name: value" is YAML or a Makefile rule as often as Python + elif not isinstance(node, (ast.Import, ast.ImportFrom, ast.Pass)): + code += 1 + if imports and code: + return 2 + return 1 if code else 0 + + +CURLY_STATEMENT = re.compile(r"[=(]|^\s*[A-Za-z_]\w*[\s*]+[A-Za-z_*]") # assignment/call or "type name" + + +def _curly_signal(text: str) -> bool: + """C-family syntax: a statement or declaration ending with ';' or '{', or a function body on one line.""" + lines = [re.sub(r"(^|\s)//.*$", "", ln).rstrip() for ln in text.split("\n")] + if any(ln.endswith((";", "{")) and CURLY_STATEMENT.search(ln) for ln in lines): + return True + return bool(re.search(r"\w\s*\([^)]*\)\s*\{.*\}", text)) + + +CURLY_LANGS = {"c", "cpp", "java", "csharp", "javascript", "typescript", "go", "rust", "php", "kotlin", "swift", "scala"} + + +def detect_language_from_book(code: str, book_language: str) -> str: + """Weak content signals, accepted only when they fit the book's dominant language.""" + if not book_language: + return "" + text = strip_line_numbers(code).strip() + if not text: + return "" + if book_language == "python" and _python_strength(text) >= 1: + return "python" + if book_language in CURLY_LANGS and _curly_signal(text): + return book_language + return "" + + +def dominant_language(languages: list[str]) -> str: + """The book's language when the reliably detected blocks agree (at least 5 blocks, 90 %).""" + detected = [lang for lang in languages if lang and lang != "text"] + if len(detected) < 5: + return "" + lang = max(set(detected), key=detected.count) + return lang if detected.count(lang) >= 0.9 * len(detected) else "" + + def detect_language_from_content(code: str) -> str: """Very conservative content heuristics – only strong signals.""" - text = code.strip() + text = strip_line_numbers(code).strip() if not text: return "" 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((";", "{"))) if py_signals >= 2 and c_like == 0: return "python" + if re.search(r"^>>> \S", text, re.M) or _python_strength(text) == 2: + return "python" if re.match(r"\s*(SELECT|INSERT\s+INTO|CREATE\s+(TABLE|INDEX|VIEW)|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b", text): return "sql" if re.match(r"(?m)^FROM\s+\S+", text) and re.search(r"(?m)^(RUN|CMD|COPY|ENTRYPOINT)\s", text): diff --git a/epub2md/css.py b/epub2md/css.py new file mode 100644 index 0000000..d385d28 --- /dev/null +++ b/epub2md/css.py @@ -0,0 +1,286 @@ +"""Minimal CSS evaluation for formatting that publishers express only through stylesheets. + +Supports type/class/id selectors, descendant (` `) and child (`>`) combinators, specificity and source +order, and `style` attributes. Selectors with pseudo-classes, attribute selectors or sibling combinators +are ignored (conservative: an unknown rule never adds formatting). @-blocks (@media, @font-face, @page …) +are skipped. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +from .utils import classes, local + +COMPOUND = re.compile(r"^(\*|[a-zA-Z][\w-]*)?((?:[.#][\w-]+)*)$") +MONOSPACE = re.compile(r"monospace|courier|consolas|menlo|monaco|lucida console|source code|dejavu sans mono|" + r"liberation mono|fira mono|fira code|inconsolata|andale mono|\bmono\b", re.I) + + +@dataclass +class _Compound: + tag: str | None + classes: frozenset[str] + ids: frozenset[str] + + def matches(self, el: Any) -> bool: + if self.tag and self.tag != "*" and local(el.tag) != self.tag: + return False + if self.ids and (el.get("id") or "").lower() not in self.ids: + return False + return not self.classes or self.classes <= set(classes(el)) + + +@dataclass +class _Rule: + parts: list[tuple[str, _Compound]] # (combinator to the previous part, compound); first combinator is "" + specificity: tuple[int, int, int] + order: int + decls: dict[str, str] + + def matches(self, el: Any) -> bool: + if not self.parts[-1][1].matches(el): + return False + node = el + for i in range(len(self.parts) - 1, 0, -1): + comb = self.parts[i][0] + compound = self.parts[i - 1][1] + node = node.getparent() + if comb == ">": + if node is None or not compound.matches(node): + return False + else: # descendant + while node is not None and not compound.matches(node): + node = node.getparent() + if node is None: + return False + return True + + +def _parse_declarations(text: str) -> dict[str, str]: + decls: dict[str, str] = {} + for part in text.split(";"): + if ":" not in part: + continue + prop, value = part.split(":", 1) + prop = prop.strip().lower() + value = re.sub(r"\s*!important\s*$", "", value.strip(), flags=re.I).strip() + if prop and value: + decls[prop] = value + return decls + + +def _parse_selector(sel: str) -> list[tuple[str, _Compound]] | None: + sel = sel.strip().lower() + if not sel or re.search(r"[:\[\]+~]", sel): + return None + tokens = re.split(r"\s*(>)\s*|\s+", sel) + parts: list[tuple[str, _Compound]] = [] + comb = "" + for tok in tokens: + if tok is None or tok == "": + continue + if tok == ">": + comb = ">" + continue + m = COMPOUND.match(tok) + if not m: + return None + rest = m.group(2) or "" + parts.append((comb if parts else "", _Compound( + tag=m.group(1), + classes=frozenset(re.findall(r"\.([\w-]+)", rest)), + ids=frozenset(re.findall(r"#([\w-]+)", rest)), + ))) + comb = " " + return parts or None + + +def _strip_at_blocks(css: str) -> str: + """Remove @-rules including nested blocks (@media, @font-face, @supports, @page …).""" + out: list[str] = [] + i = 0 + while i < len(css): + if css[i] == "@": + brace = css.find("{", i) + semi = css.find(";", i) + if semi != -1 and (brace == -1 or semi < brace): # @import/@charset …; + i = semi + 1 + continue + if brace == -1: + break + depth, j = 0, brace + while j < len(css): + if css[j] == "{": + depth += 1 + elif css[j] == "}": + depth -= 1 + if depth == 0: + break + j += 1 + i = j + 1 + continue + out.append(css[i]) + i += 1 + return "".join(out) + + +class StyleSheet: + def __init__(self, css_texts: list[str]) -> None: + self.rules: list[_Rule] = [] + self._index: dict[str, list[_Rule]] = {} + self._cache: dict[Any, dict[str, str]] = {} + order = 0 + for css in css_texts: + css = re.sub(r"/\*.*?\*/", "", css, flags=re.S) + css = _strip_at_blocks(css) + for selectors, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css): + decls = _parse_declarations(body) + if not decls: + continue + for sel in selectors.split(","): + parts = _parse_selector(sel) + if not parts: + continue + spec = (sum(len(c.ids) for _, c in parts), sum(len(c.classes) for _, c in parts), + sum(1 for _, c in parts if c.tag and c.tag != "*")) + rule = _Rule(parts, spec, order, decls) + order += 1 + self.rules.append(rule) + last = parts[-1][1] + keys = [f".{c}" for c in last.classes] or ([last.tag] if last.tag and last.tag != "*" else ["*"]) + for key in keys[:1]: # one index entry suffices: every key must match anyway + self._index.setdefault(key, []).append(rule) + + def computed(self, el: Any) -> dict[str, str]: + """Declared properties for ``el`` (no inheritance), including its ``style`` attribute.""" + cached = self._cache.get(el) + if cached is not None: + return cached + candidates: list[_Rule] = [] + for key in [f".{c}" for c in classes(el)] + [local(el.tag), "*"]: + candidates.extend(self._index.get(key, ())) + result: dict[str, str] = {} + for rule in sorted({id(r): r for r in candidates}.values(), key=lambda r: (r.specificity, r.order)): + if rule.matches(el): + result.update(rule.decls) + if el.get("style"): + result.update(_parse_declarations(el.get("style"))) + self._cache[el] = result + return result + + +def inline_style(el: Any) -> dict[str, str]: + """Only the ``style`` attribute (used when no stylesheet is available).""" + return _parse_declarations(el.get("style") or "") + + +def emphasis(style: dict[str, str]) -> dict[str, bool]: + """Semantic formatting expressed by a computed style.""" + weight = style.get("font-weight", "").lower() + deco = (style.get("text-decoration-line", "") + " " + style.get("text-decoration", "")).lower() + valign = style.get("vertical-align", "").lower() + return { + "bold": weight in ("bold", "bolder") or (weight.isdigit() and int(weight) >= 600), + "italic": style.get("font-style", "").lower() in ("italic", "oblique"), + "underline": "underline" in deco, + "strike": "line-through" in deco, + "sup": valign == "super", + "sub": valign == "sub", + "mono": bool(MONOSPACE.search(style.get("font-family", ""))), + "hidden": style.get("display", "").lower() == "none", + } + + +# ---------------------------------------------------------------------------- visual styles (--styles html) + +STYLE_PROPS = ("color", "background-color", "font-size", "font-family", "font-variant", "text-align") +UNSAFE_VALUE = re.compile(r"url\(|expression|javascript|[;<>{}\\]", re.I) +TRIVIAL_BG = re.compile(r"^(transparent|none|inherit|initial|unset|white|#fff|#ffffff|rgba?\(\s*255\s*,\s*255\s*,\s*255.*)$") +COLOR_TOKEN = re.compile(r"(#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|\b[a-z]+\b)", re.I) +KEYWORD_SIZES = {"xx-small": 0.6, "x-small": 0.75, "small": 0.89, "medium": 1.0, "large": 1.2, "x-large": 1.5, + "xx-large": 2.0, "smaller": 0.83, "larger": 1.2} + + +def _font_size_em(value: str) -> float | None: + v = value.strip().lower() + if v in KEYWORD_SIZES: + return KEYWORD_SIZES[v] + m = re.fullmatch(r"([\d.]+)\s*(em|rem|%|px|pt)?", v) + if not m: + return None + n = float(m.group(1)) + return {"em": n, "rem": n, "%": n / 100, "px": n / 16, "pt": n / 12, None: None}.get(m.group(2), None) + + +def _background_color(style: dict[str, str]) -> str: + bg = style.get("background-color", "") + if not bg and style.get("background"): + for tok in COLOR_TOKEN.findall(style["background"]): + if tok.lower() not in ("none", "repeat", "no", "fixed", "scroll", "top", "left", "center", "right", + "bottom", "url"): + bg = tok + break + bg = bg.strip() + return "" if not bg or TRIVIAL_BG.match(bg.lower()) else bg + + +def _family(value: str) -> str: + parts = [p.strip().strip("'\"") for p in value.split(",") if p.strip()] + return ", ".join(f"'{p}'" if " " in p else p for p in parts) + + +def _is_black(value: str) -> bool: + return value.strip().lower() in ("black", "#000", "#000000", "rgb(0,0,0)", "rgb(0, 0, 0)", "windowtext") + + +def has_highlight(style: dict[str, str]) -> bool: + return bool(_background_color(style)) + + +def base_style(stylesheet: StyleSheet, paragraphs: list[Any]) -> dict[str, str]: + """Most frequent value per visual property over the book's paragraphs (the 'normal' text style).""" + counters: dict[str, dict[str, int]] = {p: {} for p in STYLE_PROPS} + for el in paragraphs: + style = stylesheet.computed(el) + for prop in STYLE_PROPS: + value = _background_color(style) if prop == "background-color" else style.get(prop, "") + counters[prop][value] = counters[prop].get(value, 0) + 1 + return {p: max(c, key=lambda v: c[v]) for p, c in counters.items() if c} + + +def style_props(style: dict[str, str], base: dict[str, str], block: bool = False) -> list[tuple[str, str]]: + """Visual properties of ``style`` that differ from the book's base text style (sanitized).""" + out: list[tuple[str, str]] = [] + for prop in STYLE_PROPS: + if prop == "text-align" and not block: + continue + value = _background_color(style) if prop == "background-color" else style.get(prop, "").strip() + if not value or UNSAFE_VALUE.search(value) or value.lower() in ("inherit", "initial", "unset", "currentcolor"): + continue + reference = base.get(prop, "") + if prop == "color" and not reference: + reference = "black" # no colour declared = default text colour + if prop == "color" and _is_black(value) and _is_black(reference): + continue + if prop == "font-size": + size, ref = _font_size_em(value), _font_size_em(reference) if reference else 1.0 + if size is None or ref is None or abs(size / (ref or 1.0) - 1) < 0.15: + continue + elif prop == "font-family": + if _family(value).lower() == _family(reference).lower(): + continue + value = _family(value) + elif prop == "font-variant": + if "small-caps" not in value.lower() or "small-caps" in reference.lower(): + continue + value = "small-caps" + elif prop == "text-align": + if value.lower() not in ("center", "right", "end") or value.lower() == reference.lower(): + continue + elif value.lower() == reference.lower(): + continue + out.append((prop, value)) + return out diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index 68c0c4d..b8de5ad 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -9,6 +9,7 @@ from __future__ import annotations import html import re +from collections import Counter from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Any @@ -17,9 +18,11 @@ from urllib.parse import unquote from lxml import etree from .chapters import IdCarrier, Marker, heading_level +from .css import StyleSheet, emphasis, has_highlight, inline_style, style_props from .codeblocks import ( code_text, detect_language, + detect_language_from_book, detect_language_from_content, fence_for, inline_code, @@ -33,6 +36,7 @@ from .footnotes import NoteIndex from .mathml import Unsupported, is_display_tex, looks_like_tex, mathml_to_tex, strip_tex_delimiters from .media import MediaRegistry from .models import Chapter, Note +from .normalize import PAGE_ATTR from .references import LinkResolver from .utils import ( MATHML_NS, @@ -45,7 +49,9 @@ from .utils import ( local, norm_ws, rel_link, + remove_invisible, resolve_href, + roles, text_content, url_path, ) @@ -93,6 +99,11 @@ class RenderOptions: language: str = "de" code_language: str = "" # --code-language: label for blocks without a detected language keep_wrap_markers: bool = False # --keep-wrap-markers: do not join ↪-continued code lines + keep_invisible_chars: bool = False # --keep-invisible-chars: keep zero-width chars and soft hyphens + boxes: str = "callout" # --boxes {callout,quote,html,plain} + styles: str = "semantic" # --styles {semantic,html} + page_markers: str = "none" # --page-markers {none,visible,comment}: print page numbers + css_emphasis: bool = True # --no-css-emphasis: ignore bold/italic/… that only the stylesheet expresses def md_escape(text: str, table: bool = False) -> str: @@ -124,9 +135,23 @@ def escape_line_starts(text: str) -> str: return "\n".join(out) +EMPH_SEP = "\x1f" # separates open marker, content and close marker inside an ("e", …) piece + + def join_inline(pieces: list[Piece], hard_break: str = "\\\n") -> str: out = "" + last_e: tuple[int, str, str, str, int] | None = None # (start, open, core, close, end) of the last emphasis for kind, s in pieces: + if kind == "e": + open_, core, close = s.split(EMPH_SEP) + if last_e and last_e[4] == len(out) and last_e[1] == open_ and last_e[3] == close: + # directly adjacent identical emphasis (xy): merge, "*x**y*" would break + out = out[: last_e[0]] + core = last_e[2] + core + start = len(out) + out += open_ + core + close + last_e = (start, open_, core, close, len(out)) + continue if kind == "t": s = WS.sub(" ", s) if not out or out.endswith((" ", "\n")): @@ -176,6 +201,49 @@ def _collapse_blank_lines(text: str) -> str: return "\n".join(out) +CONSUMED = "data-e2m-consumed" +BOX_TAGS = {"div", "section", "aside", "p"} +BOX_CLASS = re.compile(r"(?:^|[-_])(box[a-z]?|kasten|infobox|infokasten|tipps?|tip|hint|hinweis|notiz|warn|warning|" + r"warnung|achtung|caution|vorsicht|important|wichtig|merke|merksatz|exkurs|sidebar|callout|" + r"alert)(?:[-_\d]|$)") +CALLOUT = {"note": "NOTE", "tip": "TIP", "important": "IMPORTANT", "warning": "WARNING", "caution": "CAUTION"} +TYPE_WORDS = {"tipp", "tip", "tipps", "hinweis", "note", "notiz", "info", "achtung", "warnung", "warning", + "wichtig", "important", "vorsicht", "caution", "gefahr", "merke"} + + +def _box_type(hint: str) -> str: + """Callout type from class names, epub:type, a title or the first words of the box.""" + h = hint.lower() + if re.search(r"vorsicht|caution|gefahr|danger", h): + return "caution" + if re.search(r"warn|achtung|attention", h): + return "warning" + if re.search(r"wichtig|important|merke", h): + return "important" + if re.search(r"tipp|\btip|hint", h): + return "tip" + if re.search(r"hinweis|\bnote|notiz|anmerkung|\binfo", h): + return "note" + return "" + + +ORNAMENT = re.compile(r"(?:[*∗⁂❖✻✱✳◆◇•·~][ \t]*){1,9}") +POEM_CLASS = re.compile(r"(?:^|[-_])(poem|poetry|verse|vers|lyrics|stanza|gedicht|lied|song)(?:$|[-_\d])") +ITALIC_TAGS = {"em", "i", "cite", "dfn", "var"} +BOLD_TAGS = {"strong", "b"} +CSS_SKIP_TAGS = {"br", "wbr", "img", "svg", "image", "math", "code", "samp", "tt", "kbd", "pre"} + + +def _style_attr(props: list[tuple[str, str]]) -> str: + return html.escape("; ".join(f"{k}: {v}" for k, v in props), quote=False).replace('"', "'") + + +def _wrap_text(text: str, marker: str) -> str: + """Wrap a finished paragraph in emphasis markers (whitespace stays outside).""" + core = text.strip() + return f"{marker}{core}{marker}" if core else text + + _BLOCK_SYNTAX = ("```", "~~~", "|", "", "#", "$$", "\\[", "* * *") @@ -229,8 +297,20 @@ def serialize_mathml(math: Any) -> str: class Renderer: def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry, notes: NoteIndex, diag: Diagnostics, zip_names: set[str], - entry_problem: Callable[[str], str | None] | None = None) -> None: + entry_problem: Callable[[str], str | None] | None = None, + stylesheet: StyleSheet | None = None, base_style: dict[str, str] | None = None) -> None: + self.base_style = base_style or {} self.entry_problem = entry_problem or (lambda path: None) + self.css = stylesheet + self.scene_breaks = 0 + self.page_markers = 0 + self.marker_sink: list[str] | None = None # collects page markers instead of emitting them inline + self.poems = 0 + self.boxes = 0 + self.in_box = False + self.css_applied: Counter[str] = Counter() + self.hidden_elements = 0 + self.invisible_removed = 0 self.opt = options self.zip_names = zip_names self.in_table_html = False @@ -244,6 +324,7 @@ class Renderer: self.consumed_formula_images: set[str] = set() self.formulas: list[dict[str, str]] = [] self.code_blocks: list[dict[str, Any]] = [] + self.book_code_language = "" # dominant language of the reliably detected blocks (pre-scan) self.endnotes_file = "" # per chapter state self.ch: Chapter | None = None @@ -293,13 +374,13 @@ class Renderer: if doc: self.doc = doc if isinstance(item, str): - buf.append(("t", md_escape(item, self.in_table))) + buf.append(("t", self._text(item))) elif isinstance(item, IdCarrier): anchor = self.resolver.anchor_for(self.doc, item.element) if anchor: buf.append(("r", self._anchor(anchor))) - elif isinstance(item, Marker) or not is_element(item): - continue + elif isinstance(item, Marker) or not is_element(item) or item.get(CONSUMED): + continue # markers, comments, siblings already rendered as part of a box elif self.is_block(item): flush() blocks.extend(self.block(item)) @@ -332,6 +413,19 @@ class Renderer: def _anchor(self, name: str) -> str: return f'' + def page_marker(self, el: Any) -> str: + """Visible '[S. 12]' or '' for a print page-break element (--page-markers).""" + page = el.get(PAGE_ATTR) + if not page or self.opt.page_markers == "none": + return "" + self.page_markers += 1 + page = html.escape(page, quote=False).replace("--", "") + marker = f"" if self.opt.page_markers == "comment" else f"\\[S. {md_escape(page)}\\]" + if self.marker_sink is not None: + self.marker_sink.append(marker) + return "" + return marker + def anchors_of(self, el: Any, subtree: bool = False) -> str: nodes = iter_elements(el) if subtree else [el] names = [] @@ -361,6 +455,19 @@ class Renderer: def block(self, el: Any) -> list[str]: name = local(el.tag) + if self._hidden(el): + return [] + if name in ("p", "div") and not self.in_table: + if self._is_ornament(el) or self._is_scene_gap(el): + self.scene_breaks += 1 + anchor = self.anchors_of(el, subtree=True) + return ([anchor] if anchor else []) + ["* * *"] + if name in ("div", "section", "blockquote") and self._is_poem(el): + return self.poem(el) + if self.opt.boxes != "plain" and not self.in_box and not self.in_table and name in BOX_TAGS: + kind = self._box_kind(el) + if kind is not None: + return self.box(el, kind) ch = self.ch if ch is not None and el is ch.title_el and not heading_level(el): return self.heading(el, forced_level=1) @@ -395,15 +502,238 @@ class Renderer: return [self.clean_html(el, keep_root=True)] return self._flow_with_anchor(el) + # ------------------------------------------------------------------ scene breaks and verse (P4) + + @staticmethod + def _is_empty(el: Any) -> bool: + return not text_content(el).replace("\xa0", " ").strip() and \ + not any(local(e.tag) in ("img", "svg", "math", "image", "hr") for e in iter_elements(el)) + + def _is_blank_p(self, el: Any) -> bool: + """
with nothing but (non-breaking) spaces or
. Emptys are layout helpers (Springer's + div.ClearBoth) and paragraphs holding only anchors are index targets – neither is a scene break.""" + return local(el.tag) == "p" and self._is_empty(el) and \ + all(local(c.tag) == "br" for c in el if is_element(c)) + + def _is_prose(self, el: Any) -> bool: + return local(el.tag) in ("p", "div") and not self._is_empty(el) and not self._is_ornament(el) \ + and not self._is_poem(el) + + @staticmethod + def _element_siblings(el: Any, forward: bool) -> Any: + node = el.getnext() if forward else el.getprevious() + while node is not None: + if is_element(node): + yield node + node = node.getnext() if forward else node.getprevious() + + def _is_ornament(self, el: Any) -> bool: + """Paragraph that only contains a typographic separator such as *** or ⁂.""" + return bool(ORNAMENT.fullmatch(text_content(el).replace("\xa0", " ").strip())) + + def _is_scene_gap(self, el: Any) -> bool: + """Empty paragraph between two prose paragraphs (first of a run of empties) → scene break.""" + if not self._is_blank_p(el): + return False + prev = next(self._element_siblings(el, forward=False), None) + if prev is None or not self._is_prose(prev): + return False + for nxt in self._element_siblings(el, forward=True): + if self._is_blank_p(nxt): + continue # further empty paragraphs of the same gap + return self._is_prose(nxt) + return False + + def _is_poem(self, el: Any) -> bool: + types = set(epub_types(el)) + return bool(types & {"z3998:poem", "z3998:verse", "z3998:song", "z3998:lyrics"}) or \ + any(POEM_CLASS.search(c) for c in classes(el)) + + def poem(self, el: Any) -> list[str]: + """Verse: one quoted line per verse line, stanzas separated by an empty quote line.""" + anchor = self.anchors_of(el) + stanzas: list[list[str]] = [[]] + + def new_stanza() -> None: + if stanzas[-1]: + stanzas.append([]) + + def walk(container: Any) -> None: + for child in container: + if not is_element(child) or self._hidden(child): + continue + if local(child.tag) in ("div", "section") and any( + is_element(c) and local(c.tag) in ("p", "div", "span") for c in child): + new_stanza() # stanza container + walk(child) + new_stanza() + elif self._is_empty(child): + new_stanza() + else: + line = join_inline([("r", self.anchors_of(child))] + self.inline_children(child)) + stanzas[-1].append(escape_line_starts(line).replace("\n", "\n> ")) + + walk(el) + stanzas = [s for s in stanzas if s] + if not stanzas: + return self._flow_with_anchor(el) + self.poems += 1 + text = "\n>\n".join("\\\n".join(f"> {line}" for line in stanza) for stanza in stanzas) + return ([anchor] if anchor else []) + [text] + + # ------------------------------------------------------------------ info boxes (P2) + + def _box_kind(self, el: Any) -> str | None: + """'' for a box without type hint, a type keyword, or None if ``el`` is no box.""" + name = local(el.tag) + types = set(epub_types(el)) | {r.removeprefix("doc-") for r in roles(el)} + hint = " ".join(sorted(types)) + " " + " ".join(classes(el)) + semantic = types & {"sidebar", "tip", "notice", "warning", "caution", "important", "help", "example"} + by_class = any(BOX_CLASS.search(c) for c in classes(el)) + if name == "p": + return _box_type(hint) if by_class and not self._is_empty(el) else None + if not (semantic or by_class or self._css_framed(el)): + return None + if not text_content(el).strip() or any(local(e.tag) == "pre" for e in iter_elements(el)) \ + and not any(local(e.tag) == "p" and text_content(e).strip() for e in iter_elements(el)): + return None # bordered code listings or image frames are no info boxes + if self.ch is not None and any(e is self.ch.title_el for e in iter_elements(el)): + return None # never swallow the chapter heading + return _box_type(hint) + + def _css_framed(self, el: Any) -> bool: + """Block with a visible border or background in the book's CSS, holding short prose.""" + if self.css is None or local(el.tag) not in ("div", "section", "aside"): + return False + style = self.css.computed(el) + border = any(k.startswith("border") and not re.search(r"\bnone\b|^0(px)?\b|hidden", v) + for k, v in style.items() if k in ("border", "border-top", "border-left", "border-style", + "border-width", "border-bottom", "border-right")) + bg = style.get("background-color") or style.get("background") or "" + background = bool(bg) and not re.search(r"transparent|none|inherit|initial|^#?fff(fff)?$|white", bg.lower()) + if not (border or background): + return False + text = norm_ws(text_content(el)) + return 0 < len(text) <= 3000 and any(local(e.tag) == "p" for e in iter_elements(el)) \ + and not any(heading_level(e) in (1, 2) for e in iter_elements(el)) + + def box(self, el: Any, kind: str) -> list[str]: + anchor = self.anchors_of(el) + members = [el] + if local(el.tag) == "p": # paragraph-style boxes: group following siblings of the same kind + for sib in self._element_siblings(el, forward=True): + if local(sib.tag) == "p" and any(BOX_CLASS.search(c) for c in classes(sib)) \ + and _box_type(" ".join(classes(sib))) == kind: + members.append(sib) + else: + break + title_el = self._box_title(members) + title = "" + markers: list[str] = [] # page breaks in the title go before the box, like for headings + if title_el is not None: + pieces: list[Piece] = [("t", self._text(title_el.text))] if title_el.text else [] + self.marker_sink = markers + try: + for child in title_el: + if is_element(child) and not self._title_symbol(child): + pieces.extend(self.inline(child)) + if child.tail: + pieces.append(("t", self._text(child.tail))) + finally: + self.marker_sink = None + title = join_inline(pieces, hard_break=" ").strip() + kind = kind or _box_type(norm_ws(text_content(title_el))) + saved = self.in_box + self.in_box = True + try: + blocks: list[str] = [] + for member in members: + if member is title_el: + continue + if local(member.tag) == "p": + blocks.extend(self.flow(member)) + else: + blocks.extend(self.render_items( + [(None, x) for x in self._items_without(member, title_el) if not self._box_icon(x)])) + finally: + self.in_box = saved + for sib in members[1:]: + sib.set(CONSUMED, "1") + if not kind and blocks: + kind = _box_type(norm_ws(re.sub(r"[*_\\]", "", blocks[0]))[:30]) + kind = kind or "note" + if title and norm_ws(re.sub(r"[*_\\:]", "", title)).lower() in TYPE_WORDS: + title = "" # 'Tipp' as title of a TIP box adds nothing + self.boxes += 1 + out = ["".join(markers)] if markers else [] + if anchor: + out.append(anchor) + head = [f"**{title}**"] if title else [] + if self.opt.boxes == "html": + out.append(f'\n\n' + "\n\n".join(head + blocks) + "\n\n") + return out + body = "\n\n".join(head + blocks) + if self.opt.boxes == "callout": + body = f"[!{CALLOUT[kind]}]\n" + body + out.append("\n".join("> " + line if line.strip() else ">" for line in body.split("\n"))) + return out + + def _box_title(self, members: list[Any]) -> Any: + first = members[0] + if local(first.tag) == "p": + text = norm_ws(text_content(first)) + return first if len(members) > 1 and 0 < len(text) <= 40 and not text.endswith((".", "!", "?")) else None + for child in first: + if not is_element(child) or self._box_icon(child): + continue + if heading_level(child) or any(re.search(r"heading|title|head$|ueberschrift", c) for c in classes(child)): + return child + return None + return None + + @staticmethod + def _title_symbol(node: Any) -> bool: + """Decorative symbol in a box title, e.g. [»].""" + text = norm_ws(text_content(node)) + return any(re.search(r"icon|symbol", c) for c in classes(node)) and len(text) <= 5 \ + and not re.search(r"\w", text) + + @staticmethod + def _box_icon(node: Any) -> bool: + """Decorative icon container inside a box (e.g.).""" + if not is_element(node) or text_content(node).strip(): + return False + imgs = [e for e in iter_elements(node) if local(e.tag) == "img"] + return bool(imgs) and (any(re.search(r"icon|box_?img|symbol", c) for c in classes(node)) + or all((i.get("alt") or "").strip().lower() in ("", "image", "icon", "bild") for i in imgs)) + def _flow_with_anchor(self, el: Any) -> list[str]: anchor = self.anchors_of(el) if self.ch is not None and {"index", "index-persons", "glossary"} & set(self.ch.types): special = self._index_block(el, anchor) if special is not None: return special - blocks = self.flow(el) + italic = bold = False + if self.opt.css_emphasis and local(el.tag) in ("p", "div") and not self.in_heading \ + and not any(local(d.tag) in BLOCK_TAGS for d in iter_elements(el) if d is not el) \ + and text_content(el).strip(): + e = self._emphasis(el) + italic, bold = e["italic"] and not self.em, e["bold"] and not self.strong + self.em += italic + self.strong += bold + try: + blocks = self.flow(el) + finally: + self.em -= italic + self.strong -= bold + if (italic or bold) and len(blocks) == 1 and not _is_block_syntax(blocks[0]) and not blocks[0].startswith("<"): + for flag, marker, key in ((italic, "*", "italic"), (bold, "**", "bold")): + if flag: + blocks[0] = _wrap_text(blocks[0], marker) + self.css_applied[key] += 1 + blocks = self._block_styles(el, blocks) if anchor: - if blocks and not blocks[0].startswith(("#", "`", "|", "