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