Compare commits

..
Author SHA1 Message Date
Dieter Schlüter
add17eecac .gitignore aufgeräumt: veraltete Einträge entfernt, History/, .ruff_cache/, *.png, *.sdr/ ergänzt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:30:02 +02:00
Dieter Schlüter
d52ac597fc --split, --from-md, --jpeg-quality, YAML-Kopf, --title/--author
- --split teile|kapitel: Einzeldateien in einem Ordner mit index.md
  (Reihenfolge) und images/; Titelei und Inhaltsverzeichnis als Vorspann;
  YAML-Kopf je Datei (title, part, chapter, order, book, author)
- --from-md DATEI|ORDNER: EPUB aus bearbeitetem Markdown bzw. Split-Ordner,
  Angaben aus dem YAML-Kopf; ersetzt den pandoc-Befehl aus Weg B
- Markdown beginnt mit YAML-Kopf (title, author, lang, cover); Cover wird
  mit --images immer abgelegt
- --jpeg-quality (Standard 85 statt fest 95): EPUB Band 1 3 MB statt 5 MB
- --title/--author überschreiben PDF-Metadaten
- Tests (tests/test_split.py), README, Bedienungsanleitung (Weg C),
  CLAUDE.md, TODO.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:04:57 +02:00
Dieter Schlüter
f0e0562451 Inhaltsverzeichnis einheitlich: Einträge als Absätze statt "\"-Zeilen
- Im Inhaltsverzeichnis wird jeder Eintrag ein eigener Absatz, auch wenn
  er als zeilengetreu erkannt wurde (vorher gemischte Abstände im EPUB)
- Zeilen, die wie Gliederungseinträge aussehen ("Teil V: …", "32 …"),
  werden auch ohne Überschrift "Inhalt" zu Absätzen (Vorschau Band Zwei)
- Tests, README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:32:20 +02:00
Dieter Schlüter
d322ee3523 Zeilengetreue Übernahme von Code, Registern, Versen und Adressen
Ein Zeilenumbruch gilt als weich (vom Satzprogramm), wenn das erste Wort
der nächsten Zeile nicht mehr gepasst hätte (Toleranz 5 % der Spalte);
sonst als hart, und die Zeile bleibt samt Einrückung erhalten.

- lines_to_md (Patch von text_to_md): harte/weiche Umbrüche aus der
  Geometrie; Blöcke mit überwiegend harten Umbrüchen zeilengetreu als
  Codeblock (auch Proportionalschrift, Einrückung rekonstruiert) oder als
  Zeilen mit "\" und geschützten Leerzeichen
- Blockgrenzen: freie Zeichen der letzten Zeile werden mitgegeben,
  normalize_markdown prüft damit das erste Wort des nächsten Blocks
- normalize_markdown: harte Umbrüche, Absatzenden ohne Satzzeichen,
  Codeblöcke über Seitengrenzen zusammenführen, wörtliches ``` maskieren
- EPUB-CSS: lange Codezeilen umbrechen (white-space: pre-wrap)
- Tests (tests/test_verbatim.py), README, Bedienungsanleitung, CLAUDE.md,
  TODO.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:39:06 +02:00
Dieter Schlüter
fa90050535 Bessere Standardwerte: EPUB-Sprache, Titel/Autor, Prozesszahl
- --lang ohne Angabe aus der ersten OCR-Sprache ableiten (eng -> en)
- Fehlen Titel/Autor in den PDF-Metadaten: aus dem Dateinamen nach dem
  Muster "Autor_-_Titel" übernehmen, sonst Dateiname mit Leerzeichen
- --jobs: halbe Kernzahl, höchstens 8 (bisher 4)
- Tests (tests/test_defaults.py), README und Bedienungsanleitung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:20:08 +02:00
Dieter Schlüter
619421ef10 URLs als anklickbare Links, zerbrochene URLs reparieren
- load_link_uris: Linkziele aus den Link-Annotationen der PDF lesen
- link_urls: URLs als Autolink <https://…> schreiben; beim Zeilenumbruch
  zerbrochene URLs mit den Linkzielen abgleichen (tolerant gegenüber
  Bindestrichen, Leerraum, "/" am Ende, Abfrageteil, .html) und
  utm-Parameter entfernen; ohne Linkziel aus dem gedruckten Text
  zusammensetzen
- Bindestrich am Zeilenende innerhalb einer URL bleibt erhalten
- pandoc liest mit +autolink_bare_uris
- Tests, README, Bedienungsanleitung und TODO ergänzt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:07:47 +02:00
Dieter Schlüter
f9dfa3a52e Inhaltsverzeichnis als Tabelle erhalten, Doku nach Probeläufen ergänzt
- normalize_markdown: Tabellenzeilen im Inhaltsverzeichnis unverändert
  übernehmen; der Verzeichnis-Modus endet mit der Tabelle (sonst zerfiel
  die Tabelle und die folgende Überschrift "Vorwort" ging verloren)
- Test für Verzeichnis als Tabelle
- README/Bedienungsanleitung: weiche Trennstriche, Courier-OCR-Ebenen,
  englische Bücher, --ocr always für alte Scans, Grenzen aus den
  Probeläufen mit fremden PDFs
- TODO.md: bekannte Schwächen (Zahlen als Überschrift, römische
  Seitenzahlen, gesperrte Überschriften)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:24:58 +02:00
Dieter Schlüter
217d1ee09b Weiche Trennstriche auflösen, Courier-OCR-Ebenen nicht als Code setzen
- Zeilenende mit U+00AD oder "¬" wird wie Silbentrennung behandelt
  ("wach­ senden" -> "wachsenden")
- Unsichtbarer Text (OCR-Ebene) verliert das Monospace-Flag, sonst setzt
  pymupdf4llm jedes Wort in Backticks
- Tests für beide Fälle und für sichtbaren Monospace-Text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:51:50 +02:00
Dieter Schlüter
a21d900e6b Aufräumen: alte Skriptstände und Info/ entfernt
- History/pdf2md_v1.py … v4.py entfernt (bleiben in der Git-Historie)
- Info/ entfernt
- README.md und CLAUDE.md angepasst

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:21:10 +02:00
Dieter Schlüter
b4b911d37f pdf2md.py als fester Name, v1–v4 nach History/ verschoben
- pdf2md_v5.py → pdf2md.py; Verweise in Doku und Tests angepasst
- pdf2md_v1.py … pdf2md_v4.py nach History/ verschoben
- ljutsch_meta.xml entfernt (nicht mehr verwendet)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:12:36 +02:00
18 changed files with 1595 additions and 2526 deletions

19
.gitignore vendored
View file

@ -1,19 +1,16 @@
# Große Binärdateien bleiben lokal # Bücher, Ergebnisse und Bilder bleiben lokal
*.pdf *.pdf
*.epub *.epub
*.torrent
*.sqlite
*.jpg *.jpg
ljutsch_files.xml *.png
Fertig/
*_images/ *_images/
*.sdr/
Output/
Fertig/
History/
# Python # Python
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
.ruff_cache/
# Leere Datei durch System-Bug .venv/
/3
# Ergebnisse zum Ansehen
Output/

View file

@ -1,6 +1,6 @@
# Bedienungsanleitung: pdf2md_v5.py – PDF → Markdown → EPUB # Bedienungsanleitung: pdf2md.py – PDF → Markdown → EPUB
`pdf2md_v5.py` wandelt PDF-Bücher in sauberes Markdown um: echte Absätze, Überschriften mit `pdf2md.py` wandelt PDF-Bücher in sauberes Markdown um: echte Absätze, Überschriften mit
richtiger Ebene, Bilder und ohne Seitenzahlen. Daraus entsteht auf Wunsch direkt ein EPUB für richtiger Ebene, Bilder und ohne Seitenzahlen. Daraus entsteht auf Wunsch direkt ein EPUB für
den PocketBook-Reader. Das funktioniert mit digitalen PDFs (Text lässt sich markieren) ebenso wie den PocketBook-Reader. Das funktioniert mit digitalen PDFs (Text lässt sich markieren) ebenso wie
mit gescannten PDFs (nur Seitenbilder, der Text wird per OCR erkannt). mit gescannten PDFs (nur Seitenbilder, der Text wird per OCR erkannt).
@ -10,6 +10,8 @@ Es gibt zwei Arbeitswege:
- **Weg A:** PDF → EPUB in einem Schritt, wenn das Ergebnis ohne Nacharbeit passt. - **Weg A:** PDF → EPUB in einem Schritt, wenn das Ergebnis ohne Nacharbeit passt.
- **Weg B:** PDF → Markdown → von Hand bearbeiten → EPUB, wenn du Überschriften, Text oder - **Weg B:** PDF → Markdown → von Hand bearbeiten → EPUB, wenn du Überschriften, Text oder
Bilder noch korrigieren willst. Bilder noch korrigieren willst.
- **Weg C:** wie Weg B, aber jeder Teil bzw. jedes Kapitel in einer eigenen Datei – zum
Umsortieren, für RAG oder andere Formate.
--- ---
@ -31,7 +33,7 @@ Prüfen, ob alles da ist:
```bash ```bash
tesseract --list-langs # muss "deu" enthalten tesseract --list-langs # muss "deu" enthalten
pandoc --version # Version 3.x pandoc --version # Version 3.x
python3 pdf2md_v5.py --help # zeigt alle Optionen python3 pdf2md.py --help # zeigt alle Optionen
``` ```
--- ---
@ -40,7 +42,7 @@ python3 pdf2md_v5.py --help # zeigt alle Optionen
```bash ```bash
cd ~/Downloads/ljutsch cd ~/Downloads/ljutsch
python3 pdf2md_v5.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md python3 pdf2md.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md
``` ```
Das Programm meldet zuerst, was es in der PDF gefunden hat: Das Programm meldet zuerst, was es in der PDF gefunden hat:
@ -65,10 +67,15 @@ Danach liegen in `Output/`:
| :-- | :-- | | :-- | :-- |
| `band1.md` | das Buch als Markdown | | `band1.md` | das Buch als Markdown |
| `band1_images/` | Bilder als JPEG, dazu `cover.jpg` (Seite 1 der PDF) | | `band1_images/` | Bilder als JPEG, dazu `cover.jpg` (Seite 1 der PDF) |
| `band1.epub` | das fertige E-Book (Titel und Autor aus der PDF) | | `band1.epub` | das fertige E-Book (Titel und Autor aus der PDF, siehe unten) |
Ein Band mit ca. 700 Seiten dauert etwa anderthalb Minuten. Ein Band mit ca. 700 Seiten dauert etwa anderthalb Minuten.
**Titel und Autor im EPUB** stammen aus den Metadaten der PDF. Fehlen sie, wertet das Programm
den Dateinamen nach dem Muster `Autor_-_Titel.pdf` aus: `Katja_Adler_-_Rolle_rückwärts.pdf` ergibt
Titel „Rolle rückwärts“ und Autor „Katja Adler“. Ohne ` - ` im Namen wird der ganze Dateiname
(mit Leerzeichen statt `_`) zum Titel. Es lohnt sich also, PDFs vorher passend umzubenennen.
--- ---
## 3. Weg B – Markdown von Hand bearbeiten, dann EPUB ## 3. Weg B – Markdown von Hand bearbeiten, dann EPUB
@ -80,10 +87,24 @@ EPUB zum Vergleichen:
```bash ```bash
cd ~/Downloads/ljutsch cd ~/Downloads/ljutsch
python3 pdf2md_v5.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md python3 pdf2md.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md
``` ```
Wenn du kein Cover und kein Probe-EPUB brauchst, reicht `--images` statt `--epub`. Wenn du kein Probe-EPUB brauchst, reicht `--images` statt `--epub` (das Cover wird trotzdem
abgelegt).
Am Anfang von `band1.md` steht ein **YAML-Kopf** mit den Angaben für das EPUB:
```yaml
---
title: "Operation Ljutsch"
author: "Reinhard Otto Kranz"
lang: "de"
cover: "band1_images/cover.jpg"
---
```
Hier kannst du Titel, Autor, Sprache oder Cover ändern. Werte immer in `"…"` schreiben.
### Schritt 2: `band1.md` bearbeiten ### Schritt 2: `band1.md` bearbeiten
@ -95,8 +116,12 @@ Wenn du kein Cover und kein Probe-EPUB brauchst, reicht `--images` statt `--epub
| Kapitel | `### 1 Der Notar` | eigenes Kapitel (eigene Datei im EPUB), unter dem Teil eingeordnet | | Kapitel | `### 1 Der Notar` | eigenes Kapitel (eigene Datei im EPUB), unter dem Teil eingeordnet |
| Absatz | durch **eine Leerzeile** vom nächsten getrennt | neuer Absatz | | Absatz | durch **eine Leerzeile** vom nächsten getrennt | neuer Absatz |
| Zeilenumbruch innerhalb eines Absatzes | einfacher Umbruch | wird zu einem Leerzeichen | | Zeilenumbruch innerhalb eines Absatzes | einfacher Umbruch | wird zu einem Leerzeichen |
| Zeile erzwingen (Register, Vers, Adresse) | `\` am Zeilenende | neue Zeile im selben Absatz |
| Einrückung außerhalb von Code | geschützte Leerzeichen (U+00A0) am Zeilenanfang | bleibt erhalten (normale Leerzeichen verschwinden) |
| Code | zwischen zwei Zeilen ```` ``` ````, Einrückung mit Leerzeichen | Schreibmaschinenschrift, Einrückung bleibt, lange Zeilen werden umbrochen |
| Bild | `![](band1_images/…jpg)` auf eigener Zeile | Bild an dieser Stelle | | Bild | `![](band1_images/…jpg)` auf eigener Zeile | Bild an dieser Stelle |
| kursiv / fett | `_Wort_` / `**Wort**` | kursiv / fett | | kursiv / fett | `_Wort_` / `**Wort**` | kursiv / fett |
| Link | `<https://beispiel.de/seite>` oder `[Text](https://beispiel.de/seite)` | anklickbarer Link |
Wichtig: Wichtig:
@ -129,23 +154,14 @@ Am nächsten Morgen ...
### Schritt 3: EPUB aus dem bearbeiteten Markdown erzeugen ### Schritt 3: EPUB aus dem bearbeiteten Markdown erzeugen
Im Ordner der Markdown-Datei starten (wichtig für die Bilder):
```bash ```bash
cd ~/Downloads/ljutsch/Output cd ~/Downloads/ljutsch
pandoc band1.md \ python3 pdf2md.py --from-md Output/band1.md
--from markdown-yaml_metadata_block --to epub3 \
--output band1.epub \
--resource-path . \
--split-level=3 --toc-depth=3 \
--metadata title="Operation Ljutsch" \
--metadata author="Reinhard Otto Kranz" \
--metadata lang=de \
--epub-cover-image band1_images/cover.jpg
``` ```
Das sind dieselben Einstellungen, die `pdf2md_v5.py --epub` verwendet. Titel und Autor trägst du Das erzeugt `Output/band1.epub` mit denselben Einstellungen wie `--epub`. Titel, Autor, Sprache
selbst ein. Ohne Cover lässt du die letzte Zeile weg und entfernst den `\` am Ende der Zeile davor. und Cover kommen aus dem YAML-Kopf; `--title`, `--author`, `--lang` oder `--epub-file PFAD`
überschreiben sie bei Bedarf.
Schritt 2 und 3 kannst du beliebig oft wiederholen. **Achtung:** Schritt 1 erneut auszuführen Schritt 2 und 3 kannst du beliebig oft wiederholen. **Achtung:** Schritt 1 erneut auszuführen
überschreibt deine Änderungen in `band1.md`. Leg dir vorher eine Kopie an oder nimm einen anderen überschreibt deine Änderungen in `band1.md`. Leg dir vorher eine Kopie an oder nimm einen anderen
@ -153,20 +169,62 @@ Dateinamen.
--- ---
## 4. Gescannte PDFs ## 4. Weg C – Teile oder Kapitel als Einzeldateien
```bash
cd ~/Downloads/ljutsch
python3 pdf2md.py --epub --split kapitel Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md
```
Statt `band1.md` entsteht der Ordner `Output/band1/`:
```
band1/
index.md Reihenfolge der Dateien + YAML-Kopf des Buchs
00_vorspann.md Titelei und Inhaltsverzeichnis
01_vorwort-berlin-2015.md
02_teil-i-das-vermaechtnis.md Teil-Überschrift (und ggf. Einleitung)
03_1-der-notar.md
…
40_impressum.md
images/ Bilder und cover.jpg
```
- `--split kapitel` schneidet an Teilen **und** Kapiteln, `--split teile` nur an Teilen (ein Teil
mit all seinen Kapiteln pro Datei).
- Jede Datei beginnt mit einem YAML-Kopf, z. B.:
```yaml
---
title: "3 Die Grauen nehmen die Spur auf"
part: "Teil I: Das Vermächtnis"
chapter: 3
order: 5
book: "Operation Ljutsch"
author: "Reinhard Otto Kranz"
---
```
- **Umsortieren:** in `index.md` die Zeilen verschieben. **Weglassen:** Zeile löschen.
Dateien dürfen umbenannt werden, wenn der Link in `index.md` mitgeändert wird.
- **EPUB neu bauen:** `python3 pdf2md.py --from-md Output/band1/` → `Output/band1.epub`
(Kapitel in der Reihenfolge von `index.md`).
---
## 5. Gescannte PDFs
Bei Scans liest das Programm den Text per OCR (Tesseract). Der Aufruf ist derselbe: Bei Scans liest das Programm den Text per OCR (Tesseract). Der Aufruf ist derselbe:
```bash ```bash
python3 pdf2md_v5.py --epub scan.pdf Output/scan.md python3 pdf2md.py --epub scan.pdf Output/scan.md
``` ```
| Situation | Option | | Situation | Option |
| :-- | :-- | | :-- | :-- |
| Buch in Deutsch (Standard) | – (`--ocr-language deu`) | | Buch in Deutsch (Standard) | – (`--ocr-language deu`) |
| Deutsch und Englisch gemischt | `--ocr-language deu+eng` (Sprachpaket `tesseract-ocr-eng` nötig) | | Deutsch und Englisch gemischt | `--ocr-language deu+eng` (Sprachpaket `tesseract-ocr-eng` nötig) |
| schneller auf vielen Kernen | `--jobs 8` (Standard 4) | | mehr oder weniger parallele OCR-Prozesse | `--jobs N` (Standard: halbe Kernzahl, höchstens 8) |
| OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` | | OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` |
| englisches Buch | `--ocr-language eng` (Sprachpaket `tesseract-ocr-eng` nötig; EPUB-Sprache wird automatisch `en`) |
| keine OCR | `--ocr never` oder `--no-ocr` | | keine OCR | `--ocr never` oder `--no-ocr` |
So entscheidet das Programm von selbst (`--ocr auto`): So entscheidet das Programm von selbst (`--ocr auto`):
@ -175,7 +233,9 @@ So entscheidet das Programm von selbst (`--ocr auto`):
also ca. 5 Minuten für 300 Seiten. also ca. 5 Minuten für 300 Seiten.
- **Scans mit vorhandener OCR-Ebene** (z. B. von `ocrmypdf`): Eine gerade Ebene wird - **Scans mit vorhandener OCR-Ebene** (z. B. von `ocrmypdf`): Eine gerade Ebene wird
übernommen, das geht sehr schnell. Eine schiefe Ebene (Scan nicht entzerrt) wird neu erkannt, übernommen, das geht sehr schnell. Eine schiefe Ebene (Scan nicht entzerrt) wird neu erkannt,
weil ihre Lesereihenfolge oft falsch ist. weil ihre Lesereihenfolge oft falsch ist. Alte OCR-Ebenen (z. B. aus Digitalisaten älterer
Bücher) sind oft schlecht: Kolumnentitel werden zu Überschriften, Buchstaben sind falsch
erkannt. Dann mit `--ocr always` neu erkennen lassen.
- **Reine Bildseiten in einem sonst digitalen PDF** (Fotos, Cover) werden nur übernommen, wenn eine - **Reine Bildseiten in einem sonst digitalen PDF** (Fotos, Cover) werden nur übernommen, wenn eine
Probe-OCR echten Fließtext liefert. Sonst bleiben sie Bild, und das Programm meldet einen Probe-OCR echten Fließtext liefert. Sonst bleiben sie Bild, und das Programm meldet einen
„Hinweis“. „Hinweis“.
@ -185,35 +245,41 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
--- ---
## 5. Alle Optionen ## 6. Alle Optionen
| Option | Bedeutung | | Option | Bedeutung |
| :-- | :-- | | :-- | :-- |
| `pdf` | Eingabe-PDF | | `pdf` | Eingabe-PDF (entfällt bei `--from-md`) |
| `markdown` | Ausgabe-Datei; ohne Angabe: gleicher Name wie die PDF mit `.md` | | `markdown` | Ausgabe-Datei; ohne Angabe: gleicher Name wie die PDF mit `.md` |
| `--ocr auto\|always\|never` | OCR-Entscheidung (Standard `auto`, siehe Abschnitt 4) | | `--ocr auto\|always\|never` | OCR-Entscheidung (Standard `auto`, siehe Abschnitt 5) |
| `--no-ocr` | Kurzform für `--ocr never` | | `--no-ocr` | Kurzform für `--ocr never` |
| `--ocr-language` | Tesseract-Sprache(n), z. B. `deu+eng` (Standard `deu`) | | `--ocr-language` | Tesseract-Sprache(n), z. B. `deu+eng` (Standard `deu`) |
| `--ocr-dpi` | Auflösung für die OCR (Standard 300) | | `--ocr-dpi` | Auflösung für die OCR (Standard 300) |
| `--jobs N` | parallele OCR-Prozesse (Standard 4) | | `--jobs N` | parallele OCR-Prozesse (Standard: halbe Kernzahl, höchstens 8) |
| `--images` | Bilder in `<name>_images/` speichern und im Markdown verlinken | | `--images` | Bilder in `<name>_images/` speichern und im Markdown verlinken |
| `--image-format jpg\|png` | Bildformat (Standard `jpg`; `png` verlustfrei, aber ca. 3× größer) | | `--image-format jpg\|png` | Bildformat (Standard `jpg`; `png` verlustfrei, aber ca. 3× größer) |
| `--jpeg-quality 1-100` | JPEG-Qualität (Standard 85; 95 fast verlustfrei, Bilder ca. 1,7× größer) |
| `--epub` | zusätzlich EPUB erzeugen (schaltet `--images` ein) | | `--epub` | zusätzlich EPUB erzeugen (schaltet `--images` ein) |
| `--epub-file PFAD` | anderer Pfad für das EPUB | | `--epub-file PFAD` | anderer Pfad für das EPUB |
| `--lang` | Sprache im EPUB (Standard `de`) | | `--split teile\|kapitel` | Teile bzw. Kapitel als Einzeldateien (Abschnitt 4) |
| `--from-md PFAD` | nur EPUB aus `.md`-Datei oder `--split`-Ordner (Abschnitt 3, 4) |
| `--title`, `--author` | Titel/Autor im EPUB vorgeben |
| `--lang` | Sprache im EPUB (Standard: aus `--ocr-language` abgeleitet, `deu`→`de`, `eng`→`en`; sonst `de`) |
| `--keep-headers` | Kolumnentitel/Seitenköpfe nicht entfernen | | `--keep-headers` | Kolumnentitel/Seitenköpfe nicht entfernen |
| `--keep-footers` | Seitenzahlen/Seitenfüße nicht entfernen | | `--keep-footers` | Seitenzahlen/Seitenfüße nicht entfernen |
--- ---
## 6. Ergebnis prüfen ## 7. Ergebnis prüfen
1. **Überschriften auflisten.** So sieht man schnell, ob Teile und Kapitel stimmen: 1. **Überschriften auflisten.** So sieht man schnell, ob Teile und Kapitel stimmen:
```bash ```bash
grep -n "^#" Output/band1.md grep -n "^#" Output/band1.md
``` ```
Erwartet: `## Teil …` und darunter `### 1 …`, `### 2 …` usw. Ganze Sätze als Überschrift Erwartet: `## Teil …` und darunter `### 1 …`, `### 2 …` usw. Ganze Sätze als Überschrift
(endend mit `.` oder `«`) sind Erkennungsfehler, die du in Weg B korrigierst. (endend mit `.` oder `«`) sind Erkennungsfehler, die du in Weg B korrigierst. Die Ebenen
kommen aus der Gliederung (Lesezeichen) der PDF; ist sie flach, stehen alle Kapitel auf
einer Ebene.
2. **Stichproben** am Anfang, in der Mitte und am Ende des Markdowns mit der PDF vergleichen: 2. **Stichproben** am Anfang, in der Mitte und am Ende des Markdowns mit der PDF vergleichen:
Fehlen Absätze, sind Absätze zusammengeklebt, stehen Bilder an der richtigen Stelle? Fehlen Absätze, sind Absätze zusammengeklebt, stehen Bilder an der richtigen Stelle?
3. **EPUB auf dem PocketBook testen:** Inhaltsverzeichnis, Kapitelsprünge, Bilder, Cover. 3. **EPUB auf dem PocketBook testen:** Inhaltsverzeichnis, Kapitelsprünge, Bilder, Cover.
@ -221,7 +287,7 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
--- ---
## 7. Häufige Probleme ## 8. Häufige Probleme
| Meldung / Problem | Lösung | | Meldung / Problem | Lösung |
| :-- | :-- | | :-- | :-- |
@ -231,6 +297,15 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
| Bilder fehlen im EPUB (Weg B) | pandoc im Ordner der `.md` starten (`cd Output`) bzw. `--resource-path` auf diesen Ordner setzen; der `_images`-Ordner muss daneben liegen | | Bilder fehlen im EPUB (Weg B) | pandoc im Ordner der `.md` starten (`cd Output`) bzw. `--resource-path` auf diesen Ordner setzen; der `_images`-Ordner muss daneben liegen |
| Gescannter Text fehlt, Meldung „Hinweis: … Bildseite(n)“ | die Seite wurde für ein Foto gehalten → `--ocr always` | | Gescannter Text fehlt, Meldung „Hinweis: … Bildseite(n)“ | die Seite wurde für ein Foto gehalten → `--ocr always` |
| Satz als Überschrift oder Kapitel ohne Überschrift | in Weg B von Hand korrigieren (Abschnitt 3) | | Satz als Überschrift oder Kapitel ohne Überschrift | in Weg B von Hand korrigieren (Abschnitt 3) |
| Überschrift besteht nur aus einer Zahl (`## 90`, `## XX`) | meist Fußnotenzeichen oder Seitenzahl → in Weg B löschen |
| Buchstaben fehlen („Offzielle“, „treff ch“) | Ligatur (fi, fl) ohne Textzuordnung in der PDF; von Hand korrigieren |
| Überschrift gesperrt („M e i n k l e i nes Stück“) | Buchstaben einzeln gesetzt in der PDF; von Hand korrigieren |
| Link führt ins Leere oder ist abgeschnitten | URL in der PDF nachsehen und in `<…>` korrigieren; manche PDFs enthalten selbst abgeschnittene Linkziele |
| Register, Verse oder Code zu einem Absatz verklebt | Zeilen in Weg B mit `\` am Ende trennen bzw. Code in ```` ``` ```` einschließen; das Programm erkennt nur Umbrüche, bei denen das nächste Wort noch in die Zeile gepasst hätte |
| Fließtext zerfällt in einzelne Zeilen mit `\` | in Weg B die `\` am Zeilenende entfernen |
| Formeln erscheinen als Bilder | bei Mathebüchern normal; dafür ist das Programm nicht gebaut |
| Alter Scan: falsche Überschriften, viele Lesefehler | `--ocr always` (vorhandene OCR-Ebene ignorieren) |
| Auf Scans fehlen Leerzeichen („deutetin“) | OCR-Fehler; höhere Auflösung versuchen (`--ocr-dpi 400`) oder von Hand korrigieren | | Auf Scans fehlen Leerzeichen („deutetin“) | OCR-Fehler; höhere Auflösung versuchen (`--ocr-dpi 400`) oder von Hand korrigieren |
| EPUB zu groß | `--image-format jpg` (Standard) verwenden; PNG ist ca. 3× größer | | EPUB zu groß | `--image-format jpg` (Standard) verwenden; PNG ist ca. 3× größer; `--jpeg-quality 70` spart weiter |
| `--from-md`: „In index.md genannt, aber nicht vorhanden“ | Datei umbenannt oder gelöscht → Link in `index.md` anpassen |
| Meine Änderungen in der `.md` sind weg | Schritt 1 wurde erneut ausgeführt → vorher Kopie anlegen | | Meine Änderungen in der `.md` sind weg | Schritt 1 wurde erneut ausgeführt → vorher Kopie anlegen |

View file

@ -1,19 +1,29 @@
# CLAUDE.md # CLAUDE.md
PDF → Markdown → EPUB für den PocketBook-Reader (digitale und gescannte PDFs). PDF → Markdown → EPUB für den PocketBook-Reader (digitale und gescannte PDFs).
Aktuelles Programm: `pdf2md_v5.py` (Einzeldatei). v1–v4 sind Historie, nicht ändern. Aktuelles Programm: `pdf2md.py` (Einzeldatei; Name bleibt fest, keine Versionsnummer im Dateinamen). Frühere Stände (v1–v4) nur noch in der Git-Historie.
## Befehle ## Befehle
- Tests: `python3 -m pytest` (Fixtures werden synthetisch erzeugt; OCR braucht `tesseract-ocr-deu`) - Tests: `python3 -m pytest` (Fixtures werden synthetisch erzeugt; OCR braucht `tesseract-ocr-deu`)
- Lint: `python3 -m ruff check pdf2md_v5.py tests/` - Lint: `python3 -m ruff check pdf2md.py tests/`
- Echttest: `python3 pdf2md_v5.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf <scratch>/band1.md` - Echttest: `python3 pdf2md.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf <scratch>/band1.md`
## Konventionen ## Konventionen
- Hilfetexte, Meldungen, Kommentare und Docstrings auf Deutsch (wie v4); Bezeichner Englisch. - Hilfetexte, Meldungen, Kommentare und Docstrings auf Deutsch (wie v4); Bezeichner Englisch.
- Echttests nie nach `Fertig/` schreiben (dort liegen die Ergebnisse des Nutzers). - Echttests nie nach `Fertig/` schreiben (dort liegen die Ergebnisse des Nutzers).
- PDFs/EPUBs/Bilder sind per `.gitignore` ausgeschlossen – keine Binärdateien committen. - PDFs/EPUBs/Bilder sind per `.gitignore` ausgeschlossen – keine Binärdateien committen.
- `pdf2md_v5.py` patcht zwei Funktionen in `pymupdf4llm.helpers.document_layout` - `pdf2md.py` patcht drei Funktionen in `pymupdf4llm.helpers.document_layout`
(`get_styled_text`, `text_to_md`). Nach einem pymupdf4llm-Update prüfen, ob (`get_styled_text`, `text_to_md`, `create_list_item_levels` – letztere nur, um pro Seite
`tests/test_normalize.py::test_pymupdf4llm_patch_keeps_compound_hyphen` noch grün ist. die Zeilengeometrie zu merken). Nach einem pymupdf4llm-Update prüfen, ob
`tests/test_normalize.py::test_pymupdf4llm_patch_keeps_compound_hyphen` und
`tests/test_verbatim.py` noch grün sind.
- Zwischen dem Patch (`lines_to_md`) und `normalize_markdown` laufen interne Markierungen
aus dem Private-Use-Bereich (`HARD_BREAK`, `BLOCK_END`, `PARAGRAPH_END`, `STANZA_BREAK`,
U+E002–U+E005); sie dürfen nie in der Ausgabe landen.
- Ausgabe-Markdown beginnt mit einem YAML-Kopf, den `make_frontmatter`/`split_frontmatter`
selbst schreiben und lesen; pandoc bekommt nur den Text (`markdown-yaml_metadata_block`),
weil `---`-Zeilen im Buchtext sonst als YAML gelesen würden.
- Regressionsprüfung bei Änderungen am Umbruch: Band 1/2 vorher/nachher diffen; Fließtext
darf sich nur an echten Absatz- oder Zeilenwechseln der PDF ändern.

102
README.md
View file

@ -1,10 +1,13 @@
# ljutsch – PDF → Markdown → EPUB # ljutsch – PDF → Markdown → EPUB
Werkzeug, um PDF-Bücher (hier: *Operation Ljutsch*, Band 1 und 2) in sauberes Markdown Werkzeug, um PDF-Bücher in sauberes Markdown und anschließend in ein EPUB für den
und anschließend in ein EPUB für den PocketBook-Reader umzuwandeln – mit Überschriften, PocketBook-Reader umzuwandeln – mit Überschriften, Bildern und echten Absätzen. Funktioniert
Bildern und echten Absätzen. Funktioniert für **digitale** PDFs (Textebene vorhanden) für **digitale** PDFs (Textebene vorhanden) ebenso wie für **gescannte** PDFs (nur Bilder,
ebenso wie für **gescannte** PDFs (nur Bilder, per OCR) und für Scans, die bereits eine per OCR) und für Scans, die bereits eine OCR-Ebene haben (z. B. von `ocrmypdf`).
OCR-Ebene haben (z. B. von `ocrmypdf`).
Entwickelt an *Operation Ljutsch* (Band 1 und 2), außerdem erprobt an einem deutschen
Sachbuch, einem englischen Handbuch, einem Mathematikbuch, einem Bericht mit Fußnoten und
einem englischen Scan von 1893 (Ergebnisse siehe [Grenzen](#grenzen)).
## Installation (Ubuntu 24.04) ## Installation (Ubuntu 24.04)
@ -22,13 +25,24 @@ dann EPUB“: **[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md)**.
```bash ```bash
# digitales oder gescanntes PDF -> Markdown (OCR wird automatisch entschieden) # digitales oder gescanntes PDF -> Markdown (OCR wird automatisch entschieden)
python3 pdf2md_v5.py buch.pdf buch.md python3 pdf2md.py buch.pdf buch.md
# zusätzlich Bilder und EPUB (Titel/Autor aus den PDF-Metadaten, Seite 1 als Cover) # zusätzlich Bilder und EPUB (Titel/Autor aus den PDF-Metadaten bzw. dem Dateinamen
python3 pdf2md_v5.py --epub buch.pdf buch.md # "Autor_-_Titel.pdf", Seite 1 als Cover)
python3 pdf2md.py --epub buch.pdf buch.md
# Scan in Deutsch und Englisch, 8 parallele OCR-Prozesse # Scan in Deutsch und Englisch, 8 parallele OCR-Prozesse
python3 pdf2md_v5.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md python3 pdf2md.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md
# Kapitel als Einzeldateien (Ordner buch/ mit index.md), dazu EPUB
python3 pdf2md.py --epub --split kapitel buch.pdf buch.md
# EPUB aus bearbeitetem Markdown bzw. aus einem --split-Ordner (Reihenfolge: index.md)
python3 pdf2md.py --from-md buch.md
python3 pdf2md.py --from-md buch/
# englisches Buch; alter Scan mit schlechter OCR-Ebene neu erkennen
python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EPUB-Sprache: en
``` ```
| Option | Bedeutung | | Option | Bedeutung |
@ -36,29 +50,52 @@ python3 pdf2md_v5.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md
| `--ocr auto` (Standard) | Scanseiten per OCR lesen; vorhandene, gerade OCR-Ebenen übernehmen, schiefe neu erkennen; reine Bildseiten in digitalen PDFs (Fotos, Cover) nur nach erfolgreicher Probe-OCR | | `--ocr auto` (Standard) | Scanseiten per OCR lesen; vorhandene, gerade OCR-Ebenen übernehmen, schiefe neu erkennen; reine Bildseiten in digitalen PDFs (Fotos, Cover) nur nach erfolgreicher Probe-OCR |
| `--ocr always` / `--ocr never` (`--no-ocr`) | alle Seiten neu erkennen / keine OCR | | `--ocr always` / `--ocr never` (`--no-ocr`) | alle Seiten neu erkennen / keine OCR |
| `--ocr-language`, `--ocr-dpi` | Tesseract-Sprache(n) (`deu`), Auflösung (300) | | `--ocr-language`, `--ocr-dpi` | Tesseract-Sprache(n) (`deu`), Auflösung (300) |
| `--jobs N` | parallele OCR-Prozesse (Standard 4); reine Textdokumente laufen immer sequenziell | | `--jobs N` | parallele OCR-Prozesse (Standard: halbe Kernzahl, höchstens 8); reine Textdokumente laufen immer sequenziell |
| `--images` | Grafiken in `<name>_images/` speichern und verlinken | | `--images` | Grafiken in `<name>_images/` speichern und verlinken |
| `--image-format jpg\|png` | Bildformat (Standard jpg; png verlustfrei, aber ca. 3× größer) | | `--image-format jpg\|png` | Bildformat (Standard jpg; png verlustfrei, aber ca. 3× größer) |
| `--jpeg-quality 1-100` | JPEG-Qualität (Standard 85; 95 fast verlustfrei, Bilder ca. 1,7× größer) |
| `--epub`, `--epub-file PFAD` | EPUB 3 mit pandoc erzeugen (schaltet `--images` ein) | | `--epub`, `--epub-file PFAD` | EPUB 3 mit pandoc erzeugen (schaltet `--images` ein) |
| `--lang` | Sprache im EPUB (`de`) | | `--split teile\|kapitel` | Teile bzw. Kapitel als Einzeldateien in einem Ordner `<name>/` mit `index.md` (Reihenfolge) und `images/` |
| `--from-md PFAD` | nur EPUB erzeugen – aus einer (bearbeiteten) `.md` oder einem `--split`-Ordner |
| `--title`, `--author` | Titel/Autor im EPUB vorgeben (statt PDF-Metadaten bzw. YAML-Kopf) |
| `--lang` | Sprache im EPUB (Standard: aus `--ocr-language`, `deu`→`de`, `eng`→`en`) |
| `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen | | `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen |
## Was die Nachbearbeitung macht ## Was die Nachbearbeitung macht
- **Absätze:** Zeilen werden verbunden; eine Absatzgrenze gilt nur nach Satzende - **Fließtext oder zeilengetreu:** Ein Zeilenumbruch gilt als *weich* (vom Satzprogramm),
(`.` `!` `?` `…`, auch mit `«` `“` `»` usw. dahinter). Auf Scans werden Absätze am wenn das erste Wort der nächsten Zeile nicht mehr in die Zeile gepasst hätte – dann
Einzug erkannt. werden die Zeilen zu Fließtext verbunden. Sonst hat ihn der Autor gesetzt (*hart*), und
die Zeile bleibt samt Einrückung erhalten. So bleiben **Code** (auch in Proportionalschrift,
als ```-Block mit Einrückung), **Register, Glossare, Verse, Adressen, Lebensläufe** zeilengetreu
(Markdown: `\` am Zeilenende, Einrückung als geschütztes Leerzeichen). Codeblöcke über
eine Seitengrenze werden zusammengeführt; lange Codezeilen bricht der Reader um.
- **Absätze:** eine Absatzgrenze gilt nach Satzende (`.` `!` `?` `…`, auch mit `«` `“` `»`
usw. dahinter) oder an einem harten Umbruch; auf Scans werden Absätze am Einzug erkannt.
- **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit - **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit
Bindestrich, `Arbeits- und` bleibt. Bindestrich, `Arbeits- und` bleibt. Weiche Trennstriche (U+00AD) und `¬` am Zeilenende
werden ebenfalls aufgelöst (`wach­|senden` → `wachsenden`).
- **Überschriften:** Ebenen aus den PDF-Lesezeichen (Teile `##`, Kapitel `###`); - **Überschriften:** Ebenen aus den PDF-Lesezeichen (Teile `##`, Kapitel `###`);
Satzreste, die fälschlich als Überschrift erkannt wurden, kommen zurück in den Text. Satzreste, die fälschlich als Überschrift erkannt wurden, kommen zurück in den Text.
- **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt. - **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt; jeder Eintrag wird ein
eigener Absatz (auch bei Verzeichnissen ohne Überschrift „Inhalt“, z. B. „Vorschau Band
Zwei“), nie eine Zeile mit `\`. Liegt das Verzeichnis als Tabelle vor, bleibt die Tabelle
erhalten.
- **OCR-Ebenen** in Courier werden nicht als Programmcode (`` `…` ``) gesetzt.
- **Links:** URLs werden als `<https://…>` geschrieben und sind damit in Markdown-Viewern
und im EPUB anklickbar. Beim Zeilenumbruch zerbrochene URLs (`deutsche-|sehen`,
`…/ youtube-…`) werden mit den Linkzielen der PDF abgeglichen und repariert;
Tracking-Parameter (`utm_…`) entfallen. Ohne Linkziel wird die URL aus dem gedruckten
Text zusammengesetzt.
- **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt. - **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt.
- **YAML-Kopf:** Jede Markdown-Datei beginnt mit `title`, `author`, `lang`, `cover`
(Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `book`, `author`).
`--from-md` liest daraus die EPUB-Angaben.
## Tests ## Tests
```bash ```bash
python3 -m pytest # ca. 30 s; OCR-/EPUB-Tests werden ohne Tesseract/pandoc übersprungen python3 -m pytest # ca. 45 s; OCR-/EPUB-Tests werden ohne Tesseract/pandoc übersprungen
``` ```
Die Test-PDFs (digital, gescannt, mit OCR-Ebene, gemischt, Fotoseite) werden zur Laufzeit Die Test-PDFs (digital, gescannt, mit OCR-Ebene, gemischt, Fotoseite) werden zur Laufzeit
@ -66,17 +103,28 @@ synthetisch erzeugt.
## Grenzen ## Grenzen
- Die Ergebnisqualität hängt von pymupdf4llm/pymupdf-layout ab. Zwei Schwächen der - Die Ergebnisqualität hängt von pymupdf4llm/pymupdf-layout ab. Einige Schwächen der
Bibliothek gleicht `pdf2md_v5.py` per Patch aus (Bindestriche am Zeilenende, Absätze in Bibliothek gleicht `pdf2md.py` per Patch aus (Bindestriche und weiche Trennstriche am
Scan-Blöcken). Eine weitere ist bekannt: Bei Schriften mit Ligaturen (ff, fi) mit Zeilenende, Absätze in Scan-Blöcken, OCR-Ebenen in Courier). Eine weitere ist bekannt:
übergroßer Glyphenbox kann pymupdf4llm einzelne Zeilen verlieren. Bei den Ljutsch-Bänden Bei Schriften mit Ligaturen (ff, fi) mit übergroßer Glyphenbox kann pymupdf4llm einzelne
tritt das nicht auf (geprüft per Wortabgleich PDF ↔ Markdown). Zeilen verlieren. Bei den Ljutsch-Bänden tritt das nicht auf (geprüft per Wortabgleich
- OCR-Fehler (z. B. fehlende Leerzeichen) werden nicht korrigiert. PDF ↔ Markdown).
- Bilder werden als JPEG (Qualität 95) gespeichert; EPUB Band 1 ca. 5 MB. - OCR-Fehler (z. B. fehlende Leerzeichen) werden nicht korrigiert. Bei alten Scans mit
mitgelieferter OCR-Ebene liefert `--ocr always` meist deutlich besseren Text.
- **Mathematische Formeln** werden als Bilder übernommen; für Mathebücher ist ein anderes
Werkzeug besser geeignet (z. B. Marker, Nougat).
- **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“),
gesperrt gesetzte Überschriften („M e i n k l e i nes …“), flache PDF-Gliederungen
(alle Kapitel auf einer Ebene).
- Links mit anderem Text als der URL (z. B. „hier“) werden nicht übernommen.
- Zeilengetreue Erkennung: Verszeilen mit Satzzeichen am Ende werden zu eigenen Absätzen
statt zu Zeilen; einzelne Konfigurations- oder Ausgabezeilen neben Code (`[dependencies]`)
stehen als Zeilen statt im Codeblock.
- Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen).
Weitere bekannte Schwächen: [TODO.md](TODO.md).
- Bilder werden als JPEG (Qualität 85) gespeichert; EPUB Band 1 ca. 3 MB.
## Dateien ## Dateien
- `pdf2md_v5.py` – aktuelle Version - `pdf2md.py` – aktuelle Version
- `pdf2md_v1.py` … `pdf2md_v4.py` – frühere Entwicklungsstände (siehe `Info/`)
- `Info/` – Recherche und Entstehungsgeschichte der Skripte
- `tests/` – pytest-Suite - `tests/` – pytest-Suite

83
TODO.md
View file

@ -1,63 +1,28 @@
# TODO # TODO
## 1. Teile und Kapitel als separate Markdown-Dateien (`--split`) ## 1. Ideen
**Ziel:** Beim Konvertieren PDF → Markdown jeden Teil bzw. jedes Kapitel als eigene MD-Datei - `--split`: Seitenzahlen der PDF ins Frontmatter (`source_pages`); pymupdf4llm liefert sie
speichern. Damit lassen sich: mit `page_chunks=True`, müsste aber durch die ganze Nachbearbeitung getragen werden.
- `--split`: Bücher ohne erkennbare Gliederung werden nicht geteilt (nur `00_vorspann.md`);
- Teile/Kapitel einzeln ändern, denkbar wäre ein Schnitt nach Seitenzahl oder Länge.
- Teile/Kapitel vertauschen oder umsortieren,
- die MD-Dateien für RAG verarbeiten (ein Kapitel = ein Dokument),
- daraus HTML-Dateien oder andere Formate erzeugen.
**Skizze:**
- Option `--split teile|kapitel`; Ausgabe als Ordner `<name>/` statt einer einzelnen `.md`.
- Dateinamen mit Nummer und Titel, z. B.
```
band1/
index.md # Reihenfolge der Dateien (bzw. book.yaml)
00_vorspann.md # Titelei, Inhaltsverzeichnis, Vorwort
01_teil-i_das-vermaechtnis.md
02_kapitel-01_der-notar.md
…
99_nachspann.md # Der Autor, Impressum
images/
```
- YAML-Frontmatter pro Datei (für RAG und Umsortieren):
```yaml
---
title: "1 Der Notar"
part: "Teil I: Das Vermächtnis"
chapter: 1
source_pages: [12, 25]
book: "Operation Ljutsch"
author: "Reinhard Otto Kranz"
---
```
- EPUB aus den Einzeldateien: pandoc nimmt mehrere Eingabedateien in der Reihenfolge von
`index.md`; Umsortieren = Reihenfolge im Index ändern.
**Ansatzpunkte im Code (`pdf2md_v5.py`):**
- Schnitt **nach** `normalize_markdown()` an den Überschriften `##` (Teil) und `###` (Kapitel);
die Ebenen kommen aus `load_outline()`.
- Bildpfade pro Datei anpassen (`make_image_links_relative()`).
- Vorspann (alles vor dem ersten Teil/Kapitel) und Nachspann als eigene Dateien.
**Offene Fragen:**
- Ein Ordner pro Teil oder alles flach in einem Ordner?
- Seitenzahlen ins Frontmatter? (pymupdf4llm liefert sie mit `page_chunks=True`.)
- Bücher ohne PDF-Gliederung: auf die erkannten Überschriften zurückfallen?
- Umkehrweg „Einzeldateien → ein MD/EPUB“ als eigene Option oder eigenes Skript?
**Tests:** Aufteilung des synthetischen Test-PDFs (Teil I, Kapitel 1–2) prüfen; Einzeldateien
wieder zusammengesetzt = Gesamt-Markdown.
## 2. Weitere Ideen
- `--from-md buch.md`: nur den EPUB-Schritt aus einem (von Hand bearbeiteten) Markdown
ausführen; Titel, Autor und Cover automatisch übernehmen.
- JPEG-Qualität einstellbar machen (z. B. `--jpeg-quality 85`), damit das EPUB noch kleiner wird.
- Remote für das Git-Repo einrichten (z. B. GitHub), damit die Commits gesichert sind. - Remote für das Git-Repo einrichten (z. B. GitHub), damit die Commits gesichert sind.
## 2. Bekannte Schwächen (aus Probeläufen mit fremden PDFs)
- Reine Zahlen als Überschrift: pymupdf4llm macht Fußnotenzeichen zu Überschriften
(„## 90“ im Zensur-Report). Nicht pauschal entfernen – im DDR-Buch sind „## 1“, „## 2“
echte Kapitelnummern. Möglicher Ansatz: Zahl-Überschrift nur behalten, wenn direkt eine
Überschrift folgt.
- Römische Seitenzahlen („## XX“, „## XXIV“) werden nicht als Seitenzahl erkannt; Vorsicht
wegen echter Kapitel „I.“, „II.“.
- Gesperrte Überschriften („M e i n k l e i nes Stück D D R“) bleiben zerrissen.
- Alte Scans mit eigener OCR-Ebene: `--ocr always` liefert meist besseren Text.
- Zeilengetreu: Verszeilen mit Satzzeichen am Ende werden zu Absätzen (split_by_indent
wertet kurze Zeile + Satzende als Absatzende); Codefragmente ohne Code-Merkmale
(`[dependencies]`, Programmausgaben) landen als `\`-Zeilen neben dem Codeblock statt darin.
- Markdown-Sonderzeichen werden nicht maskiert: `__add__` im Register wird fett gesetzt.
- Links: Setzt sich eine URL in einem neuen Block fort (z. B. als Listenpunkt „- juuuport-…“),
bleibt sie abgeschnitten. Links mit eigenem Text („hier“) gehen verloren; Abhilfe wäre,
die Link-Rechtecke der PDF dem Text zuzuordnen (`[hier](…)`). Die Heuristik ohne Linkziel
hängt Wörter mit „/“ an („https://a.de/ und/oder“ → eine URL).

View file

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<identifier>ljutsch</identifier>
<mediatype>texts</mediatype>
<collection>opensource</collection>
<collection>community</collection>
<description>Reinhard Otto Kranz&lt;br /&gt;</description>
<scanner>Internet Archive HTML5 Uploader 1.6.4</scanner>
<subject>ljutsch</subject>
<title>Ljutsch</title>
<uploader>schamhaar.friseur@gmail.com</uploader>
<publicdate>2022-05-30 15:09:50</publicdate>
<addeddate>2022-05-30 15:09:50</addeddate>
<identifier-access>http://archive.org/details/ljutsch</identifier-access>
<identifier-ark>ark:/13960/s2jf47qc96n</identifier-ark>
<ppi>300</ppi>
<curation>[curator]validator@archive.org[/curator][date]20220530154119[/date][comment]checked for malware[/comment]</curation>
<ocr>tesseract 5.0.0-1-g862e</ocr>
<ocr_parameters>-l deu+Latin</ocr_parameters>
<ocr_module_version>0.0.15</ocr_module_version>
<ocr_autonomous>true</ocr_autonomous>
<ocr_detected_script>Latin</ocr_detected_script>
<ocr_detected_script_conf>1.0000</ocr_detected_script_conf>
<ocr_detected_lang>de</ocr_detected_lang>
<ocr_detected_lang_conf>1.0000</ocr_detected_lang_conf>
<language>deu</language>
</metadata>

1958
pdf2md.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,146 +0,0 @@
#!/usr/bin/env python3
"""PDF nach Markdown konvertieren und weiche Zeilenumbrüche entfernen."""
import argparse
import re
import sys
from pathlib import Path
import pymupdf4llm
HEADING = re.compile(r"^\s{0,3}#{1,6}\s+")
LIST_ITEM = re.compile(r"^\s{0,3}(?:[-+*]|\d+[.)])\s+")
BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?")
FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
RULE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$")
IMAGE = re.compile(r"^\s*!\[.*?\]\(.*\)\s*$")
HTML_BLOCK = re.compile(r"^\s*</?(?:table|tr|td|th|div|pre|p|h[1-6])\b", re.I)
def is_structural(line: str) -> bool:
"""Zeilen, die nicht mit dem vorherigen Fließtext verbunden werden."""
return bool(
HEADING.match(line)
or LIST_ITEM.match(line)
or BLOCKQUOTE.match(line)
or RULE.match(line)
or IMAGE.match(line)
or HTML_BLOCK.match(line)
or line.lstrip().startswith("|")
or line.startswith((" ", "\t"))
)
def normalize_markdown(markdown: str) -> str:
result = []
paragraph = []
in_fence = False
fence_char = ""
fence_length = 0
def flush_paragraph():
if paragraph:
result.append(" ".join(paragraph))
paragraph.clear()
for original in markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = original.rstrip()
marker = FENCE.match(line)
if in_fence:
result.append(line)
if marker:
token = marker.group(1)
if token[0] == fence_char and len(token) >= fence_length:
in_fence = False
continue
if marker:
flush_paragraph()
result.append(line)
in_fence = True
fence_char = marker.group(1)[0]
fence_length = len(marker.group(1))
continue
if not line.strip():
flush_paragraph()
if result and result[-1] != "":
result.append("")
continue
if is_structural(line):
flush_paragraph()
result.append(line)
continue
paragraph.append(line.strip())
flush_paragraph()
return "\n".join(result).strip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser(
description="PDF in Markdown umwandeln; Zeilen innerhalb von Absätzen verbinden."
)
parser.add_argument("pdf", type=Path, help="Eingabe-PDF")
parser.add_argument(
"markdown",
type=Path,
nargs="?",
help="Ausgabe-Datei; Standard: gleicher Name mit .md",
)
parser.add_argument(
"--ocr-language",
default="deu",
help="Tesseract-Sprachcode bei erforderlicher OCR (Standard: deu)",
)
parser.add_argument(
"--no-ocr",
action="store_true",
help="OCR abschalten, wenn die PDF sicher durchsuchbaren Text enthält",
)
parser.add_argument(
"--keep-headers",
action="store_true",
help="Wiederkehrende Seitenköpfe in die Ausgabe übernehmen",
)
parser.add_argument(
"--keep-footers",
action="store_true",
help="Wiederkehrende Seitenfüße in die Ausgabe übernehmen",
)
args = parser.parse_args()
source = args.pdf.expanduser()
target = (args.markdown or source.with_suffix(".md")).expanduser()
if not source.is_file():
parser.error(f"Eingabedatei nicht gefunden: {source}")
if source.resolve() == target.resolve():
parser.error("Eingabe- und Ausgabedatei müssen verschieden sein.")
try:
markdown = pymupdf4llm.to_markdown(
str(source),
header=args.keep_headers,
footer=args.keep_footers,
use_ocr=not args.no_ocr,
ocr_language=args.ocr_language,
)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(normalize_markdown(markdown), encoding="utf-8")
except Exception as exc:
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
return 1
print(f"Markdown geschrieben: {target}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,181 +0,0 @@
#!/usr/bin/env python3
"""PDF nach Markdown konvertieren und Absätze anhand von Satzenden verbinden."""
import argparse
import re
import sys
from pathlib import Path
import pymupdf4llm
from pymupdf4llm.ocr import tesseract_api
HEADING = re.compile(r"^\s{0,3}#{1,6}\s+")
LIST_ITEM = re.compile(r"^\s{0,3}(?:[-+*]|\d+[.)])\s+")
BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?")
FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
RULE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$")
IMAGE = re.compile(r"^\s*!\[.*?\]\(.*\)\s*$")
HTML_BLOCK = re.compile(
r"^\s*</?(?:table|tr|td|th|div|pre|p|h[1-6])\b",
re.I,
)
# Satzzeichen, optional gefolgt von schließenden Anführungszeichen/Klammern.
SENTENCE_END = re.compile(r"""[.!?…]+[”’"'»›)\]]*$""")
def ends_sentence(text: str) -> bool:
return bool(SENTENCE_END.search(text.rstrip()))
def is_structural(line: str) -> bool:
"""Markdown-Zeilen, die nicht in Fließtext aufgehen sollen."""
return bool(
LIST_ITEM.match(line)
or BLOCKQUOTE.match(line)
or RULE.match(line)
or IMAGE.match(line)
or HTML_BLOCK.match(line)
or line.lstrip().startswith("|")
or line.startswith((" ", "\t"))
)
def normalize_markdown(markdown: str) -> str:
result: list[str] = []
paragraph: list[str] = []
in_fence = False
fence_char = ""
fence_length = 0
def add_blank_line() -> None:
if result and result[-1] != "":
result.append("")
def flush_paragraph() -> None:
if paragraph:
result.append(" ".join(paragraph))
paragraph.clear()
for original in markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = original.rstrip()
marker = FENCE.match(line)
if in_fence:
result.append(line)
if marker:
token = marker.group(1)
if token[0] == fence_char and len(token) >= fence_length:
in_fence = False
continue
if marker:
flush_paragraph()
add_blank_line()
result.append(line)
in_fence = True
fence_char = marker.group(1)[0]
fence_length = len(marker.group(1))
continue
if HEADING.match(line):
flush_paragraph()
add_blank_line()
result.append(line)
result.append("")
continue
if is_structural(line):
flush_paragraph()
result.append(line)
continue
if not line.strip():
# Eine Leerzeile ist nur nach einem Satzende eine Absatzgrenze.
# Andernfalls bleibt paragraph bestehen und die nächste
# Textzeile wird mit einem Leerzeichen angehängt.
if paragraph and ends_sentence(paragraph[-1]):
flush_paragraph()
add_blank_line()
elif not paragraph:
add_blank_line()
continue
paragraph.append(line.strip())
flush_paragraph()
return "\n".join(result).strip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"PDF in Markdown umwandeln; Absätze nur nach Satzende trennen."
)
)
parser.add_argument("pdf", type=Path, help="Eingabe-PDF")
parser.add_argument(
"markdown",
type=Path,
nargs="?",
help="Ausgabe-Datei; Standard: gleicher Name mit .md",
)
parser.add_argument(
"--ocr-language",
default="deu",
help="Tesseract-Sprachcode bei OCR (Standard: deu)",
)
parser.add_argument(
"--no-ocr",
action="store_true",
help="OCR deaktivieren",
)
parser.add_argument(
"--keep-headers",
action="store_true",
help="Wiederkehrende Seitenköpfe übernehmen",
)
parser.add_argument(
"--keep-footers",
action="store_true",
help="Wiederkehrende Seitenfüße übernehmen",
)
args = parser.parse_args()
source = args.pdf.expanduser()
target = (args.markdown or source.with_suffix(".md")).expanduser()
if not source.is_file():
parser.error(f"Eingabedatei nicht gefunden: {source}")
if source.resolve() == target.resolve():
parser.error("Eingabe- und Ausgabedatei müssen verschieden sein.")
try:
markdown = pymupdf4llm.to_markdown(
str(source),
header=args.keep_headers,
footer=args.keep_footers,
use_ocr=not args.no_ocr,
ocr_language=args.ocr_language,
ocr_function=(
tesseract_api.exec_ocr if not args.no_ocr else None
),
)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(normalize_markdown(markdown), encoding="utf-8")
except Exception as exc:
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
return 1
print(f"Markdown geschrieben: {target}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,249 +0,0 @@
#!/usr/bin/env python3
"""PDF nach Markdown konvertieren und Fließtext nach Satzenden gliedern."""
import argparse
import re
import sys
from pathlib import Path
import pymupdf4llm
from pymupdf4llm.ocr import tesseract_api
HEADING = re.compile(r"^\s{0,3}#{1,6}\s+\S")
# Punkt, Fragezeichen, Ausrufezeichen oder Auslassungspunkte;
# danach dürfen schließende Anführungszeichen/Klammern folgen.
SENTENCE_END = re.compile(r"""[.!?…]+[”’"'»›)\]]*$""")
FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
LIST_ITEM = re.compile(r"^\s{0,3}(?:[-+*]|\d+[.)])\s+")
BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?")
RULE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$")
IMAGE = re.compile(r"^\s*!\[.*?\]\(.*\)\s*$")
# Beispiel:
# **==> picture [426 x 480] intentionally omitted <==**
OMITTED_IMAGE = re.compile(
r"^\s*(?:\*\*|__)?\s*==>\s*"
r"(?:picture|image|bild)\b.*?"
r"(?:intentionally omitted|omitted|ausgelassen)\s*"
r"<==\s*(?:\*\*|__)?\s*$",
re.IGNORECASE,
)
CONTENTS_TITLE = re.compile(
r"^\s*(?:#{1,6}\s+)?"
r"(?:inhaltsverzeichnis|inhalt|contents|table of contents)"
r"\s*$",
re.IGNORECASE,
)
# Inhaltsverzeichnis mit Füllpunkten oder Abstand vor einer Seitenzahl:
# Kapitel 1 .......... 12
# Kapitel 1 12
# Kapitel 1 xiv
TOC_WITH_PAGE = re.compile(
r"^\s*(?:[-*]\s+)?\S.+?"
r"(?:\.{2,}|\s{2,})"
r"(?:\d+|[ivxlcdm]+)\s*$",
re.IGNORECASE,
)
# Einträge ohne Seitenzahl, sofern bereits ein Inhaltsverzeichnis erkannt wurde:
# 1 Einleitung
# 1.2 Vorgeschichte
# Kapitel 3 – Die Reise
TOC_NUMBERED = re.compile(
r"^\s*(?:[-*]\s+)?"
r"(?:\d+(?:\.\d+)*\.?\s+|Kapitel\s+\d+\b|Chapter\s+\d+\b)"
r".+",
re.IGNORECASE,
)
HTML_BLOCK = re.compile(
r"^\s*</?(?:table|tr|td|th|div|pre|p|h[1-6])\b",
re.IGNORECASE,
)
def ends_sentence(text: str) -> bool:
return bool(SENTENCE_END.search(text.rstrip()))
def is_other_structure(line: str) -> bool:
"""Markdown-Strukturen, die nicht zu Fließtext verbunden werden."""
return bool(
LIST_ITEM.match(line)
or BLOCKQUOTE.match(line)
or RULE.match(line)
or IMAGE.match(line)
or HTML_BLOCK.match(line)
or line.lstrip().startswith("|")
or line.startswith((" ", "\t"))
)
def normalize_markdown(markdown: str) -> str:
result: list[str] = []
paragraph: list[str] = []
in_fence = False
fence_char = ""
fence_length = 0
in_contents = False
def blank_line() -> None:
if result and result[-1] != "":
result.append("")
def flush_paragraph() -> None:
if paragraph:
result.append(" ".join(paragraph))
paragraph.clear()
def standalone(line: str) -> None:
flush_paragraph()
blank_line()
result.append(line)
result.append("")
lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n")
for original in lines:
line = original.rstrip()
marker = FENCE.match(line)
if in_fence:
result.append(line)
if marker:
token = marker.group(1)
if token[0] == fence_char and len(token) >= fence_length:
in_fence = False
continue
if marker:
flush_paragraph()
blank_line()
result.append(line)
in_fence = True
fence_char = marker.group(1)[0]
fence_length = len(marker.group(1))
continue
if not line.strip():
# Leerzeile nur dann als Absatzgrenze behandeln,
# wenn der bisherige Fließtext mit einem Satzende endet.
if paragraph and ends_sentence(paragraph[-1]):
flush_paragraph()
blank_line()
elif not paragraph:
blank_line()
continue
if CONTENTS_TITLE.match(line):
standalone(line)
in_contents = True
continue
if HEADING.match(line):
standalone(line)
in_contents = False
continue
if OMITTED_IMAGE.match(line):
standalone(line)
continue
if TOC_WITH_PAGE.match(line):
standalone(line)
continue
if in_contents and TOC_NUMBERED.match(line):
standalone(line)
continue
if in_contents and ends_sentence(line):
# Erster normaler Satz nach einem erkannten Inhaltsverzeichnis.
in_contents = False
if is_other_structure(line):
flush_paragraph()
result.append(line)
continue
# Einfache Zeilenumbrüche und nicht bestätigte Absatzgrenzen
# werden beim späteren Flush durch Leerzeichen ersetzt.
paragraph.append(line.strip())
flush_paragraph()
return "\n".join(result).strip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser(
description="PDF in Markdown umwandeln; Fließtext nur nach Satzende trennen."
)
parser.add_argument("pdf", type=Path, help="Eingabe-PDF")
parser.add_argument(
"markdown",
type=Path,
nargs="?",
help="Ausgabe-Datei; Standard: gleicher Name mit .md",
)
parser.add_argument(
"--ocr-language",
default="deu",
help="Tesseract-Sprachcode bei OCR (Standard: deu)",
)
parser.add_argument(
"--no-ocr",
action="store_true",
help="OCR deaktivieren",
)
parser.add_argument(
"--keep-headers",
action="store_true",
help="Wiederkehrende Seitenköpfe übernehmen",
)
parser.add_argument(
"--keep-footers",
action="store_true",
help="Wiederkehrende Seitenfüße übernehmen",
)
args = parser.parse_args()
source = args.pdf.expanduser()
target = (args.markdown or source.with_suffix(".md")).expanduser()
if not source.is_file():
parser.error(f"Eingabedatei nicht gefunden: {source}")
if source.resolve() == target.resolve():
parser.error("Eingabe- und Ausgabedatei müssen verschieden sein.")
try:
markdown = pymupdf4llm.to_markdown(
str(source),
header=args.keep_headers,
footer=args.keep_footers,
use_ocr=not args.no_ocr,
ocr_language=args.ocr_language,
ocr_function=(
tesseract_api.exec_ocr if not args.no_ocr else None
),
)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(normalize_markdown(markdown), encoding="utf-8")
except Exception as exc:
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
return 1
print(f"Markdown geschrieben: {target}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,269 +0,0 @@
#!/usr/bin/env python3
"""PDF nach Markdown konvertieren, Absätze gliedern und optional Bilder speichern."""
import argparse
import re
import sys
from pathlib import Path
import pymupdf4llm
from pymupdf4llm.ocr import tesseract_api
HEADING = re.compile(r"^\s{0,3}#{1,6}\s+\S")
# Satzzeichen, optional gefolgt von schließenden Anführungszeichen/Klammern.
SENTENCE_END = re.compile(r"""[.!?…]+[”’"'»›)\]]*$""")
FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
LIST_ITEM = re.compile(r"^\s{0,3}(?:[-+*]|\d+[.)])\s+")
BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?")
RULE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$")
IMAGE = re.compile(r"^\s*!\[.*?\]\(.*\)\s*$")
# Beispiel: **==> picture [426 x 480] intentionally omitted <==**
OMITTED_IMAGE = re.compile(
r"^\s*(?:\*\*|__)?\s*==>\s*"
r"(?:picture|image|bild)\b.*?"
r"(?:intentionally omitted|omitted|ausgelassen)\s*"
r"<==\s*(?:\*\*|__)?\s*$",
re.IGNORECASE,
)
CONTENTS_TITLE = re.compile(
r"^\s*(?:#{1,6}\s+)?"
r"(?:inhaltsverzeichnis|inhalt|contents|table of contents)"
r"\s*$",
re.IGNORECASE,
)
TOC_WITH_PAGE = re.compile(
r"^\s*(?:[-*]\s+)?\S.+?"
r"(?:\.{2,}|\s{2,})"
r"(?:\d+|[ivxlcdm]+)\s*$",
re.IGNORECASE,
)
TOC_NUMBERED = re.compile(
r"^\s*(?:[-*]\s+)?"
r"(?:\d+(?:\.\d+)*\.?\s+|Kapitel\s+\d+\b|Chapter\s+\d+\b)"
r".+",
re.IGNORECASE,
)
HTML_BLOCK = re.compile(
r"^\s*</?(?:table|tr|td|th|div|pre|p|h[1-6])\b",
re.IGNORECASE,
)
def ends_sentence(text: str) -> bool:
return bool(SENTENCE_END.search(text.rstrip()))
def is_other_structure(line: str) -> bool:
"""Markdown-Strukturen, die nicht zu Fließtext verbunden werden."""
return bool(
LIST_ITEM.match(line)
or BLOCKQUOTE.match(line)
or RULE.match(line)
or IMAGE.match(line)
or HTML_BLOCK.match(line)
or line.lstrip().startswith("|")
or line.startswith((" ", "\t"))
)
def normalize_markdown(markdown: str) -> str:
result: list[str] = []
paragraph: list[str] = []
in_fence = False
fence_char = ""
fence_length = 0
in_contents = False
def blank_line() -> None:
if result and result[-1] != "":
result.append("")
def flush_paragraph() -> None:
if paragraph:
result.append(" ".join(paragraph))
paragraph.clear()
def standalone(line: str) -> None:
flush_paragraph()
blank_line()
result.append(line)
result.append("")
lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n")
for original in lines:
line = original.rstrip()
marker = FENCE.match(line)
if in_fence:
result.append(line)
if marker:
token = marker.group(1)
if token[0] == fence_char and len(token) >= fence_length:
in_fence = False
continue
if marker:
flush_paragraph()
blank_line()
result.append(line)
in_fence = True
fence_char = marker.group(1)[0]
fence_length = len(marker.group(1))
continue
if not line.strip():
if paragraph and ends_sentence(paragraph[-1]):
flush_paragraph()
blank_line()
elif not paragraph:
blank_line()
continue
if CONTENTS_TITLE.match(line):
standalone(line)
in_contents = True
continue
if HEADING.match(line):
standalone(line)
in_contents = False
continue
if OMITTED_IMAGE.match(line):
standalone(line)
continue
if TOC_WITH_PAGE.match(line):
standalone(line)
continue
if in_contents and TOC_NUMBERED.match(line):
standalone(line)
continue
if in_contents and ends_sentence(line):
in_contents = False
if is_other_structure(line):
flush_paragraph()
result.append(line)
continue
paragraph.append(line.strip())
flush_paragraph()
return "\n".join(result).strip() + "\n"
def make_image_links_relative(markdown: str, image_dir: Path) -> str:
"""Absolute Bildpfade durch Pfade relativ zur Markdown-Datei ersetzen."""
absolute_dir = image_dir.resolve().as_posix()
relative_dir = image_dir.name
return markdown.replace(
absolute_dir + "/",
relative_dir + "/",
)
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"PDF in Markdown umwandeln; Fließtext nach Satzenden gliedern "
"und optional Grafiken speichern."
)
)
parser.add_argument("pdf", type=Path, help="Eingabe-PDF")
parser.add_argument(
"markdown",
type=Path,
nargs="?",
help="Ausgabe-Datei; Standard: gleicher Name mit .md",
)
parser.add_argument(
"--ocr-language",
default="deu",
help="Tesseract-Sprachcode bei OCR (Standard: deu)",
)
parser.add_argument(
"--no-ocr",
action="store_true",
help="OCR deaktivieren",
)
parser.add_argument(
"--images",
action="store_true",
help="Grafiken speichern und in Markdown verlinken",
)
parser.add_argument(
"--keep-headers",
action="store_true",
help="Wiederkehrende Seitenköpfe übernehmen",
)
parser.add_argument(
"--keep-footers",
action="store_true",
help="Wiederkehrende Seitenfüße übernehmen",
)
args = parser.parse_args()
source = args.pdf.expanduser()
target = (args.markdown or source.with_suffix(".md")).expanduser()
if not source.is_file():
parser.error(f"Eingabedatei nicht gefunden: {source}")
if source.resolve() == target.resolve():
parser.error("Eingabe- und Ausgabedatei müssen verschieden sein.")
image_dir = target.parent / f"{target.stem}_images"
try:
target.parent.mkdir(parents=True, exist_ok=True)
if args.images:
image_dir.mkdir(parents=True, exist_ok=True)
markdown = pymupdf4llm.to_markdown(
str(source),
header=args.keep_headers,
footer=args.keep_footers,
use_ocr=not args.no_ocr,
ocr_language=args.ocr_language,
ocr_function=(
tesseract_api.exec_ocr if not args.no_ocr else None
),
write_images=args.images,
image_path=str(image_dir.resolve()) if args.images else "",
image_format="png",
)
if args.images:
markdown = make_image_links_relative(markdown, image_dir)
target.write_text(normalize_markdown(markdown), encoding="utf-8")
except Exception as exc:
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
return 1
print(f"Markdown geschrieben: {target}")
if args.images:
print(f"Bilder gespeichert in: {image_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -3,7 +3,7 @@
import pymupdf import pymupdf
import pytest import pytest
import pdf2md_v5 as p import pdf2md as p
from conftest import needs_tesseract from conftest import needs_tesseract

51
tests/test_defaults.py Normal file
View file

@ -0,0 +1,51 @@
"""Standardwerte: EPUB-Sprache, Prozesszahl, Titel/Autor aus dem Dateinamen."""
import zipfile
import pymupdf
import pytest
import pdf2md as p
from conftest import needs_pandoc
from test_e2e import run
@pytest.mark.parametrize(
"ocr_language, expected",
[("deu", "de"), ("eng", "en"), ("eng+deu", "en"), ("fra", "fr"), ("xyz", "de")],
)
def test_epub_language_follows_ocr_language(ocr_language, expected):
assert p.epub_language(ocr_language) == expected
@pytest.mark.parametrize("cores, expected", [(None, 1), (1, 1), (4, 2), (24, 8)])
def test_default_jobs(monkeypatch, cores, expected):
monkeypatch.setattr(p.os, "cpu_count", lambda: cores)
assert p.default_jobs() == expected
@pytest.mark.parametrize(
"stem, expected",
[
("Katja_Adler_-_Rolle_rückwärts_DDR", ("Rolle rückwärts DDR", "Katja Adler")),
("Zensur_Netzwerk_Deutschland_de-report-de", ("Zensur Netzwerk Deutschland de-report-de", "")),
("A_-_B_-_C", ("B - C", "A")),
],
)
def test_title_author_from_filename(stem, expected):
assert p.title_author_from_filename(stem) == expected
@needs_pandoc
def test_epub_without_metadata_uses_filename_and_ocr_language(pdfs, tmp_path):
doc = pymupdf.open(pdfs["digital"])
doc.set_metadata({})
source = tmp_path / "Max_Muster_-_Ein_Buch.pdf"
doc.save(source)
run(source, tmp_path / "buch.md", "--epub", "--ocr-language", "eng")
with zipfile.ZipFile(tmp_path / "buch.epub") as z:
opf = z.read("EPUB/content.opf").decode()
assert ">Ein Buch<" in opf
assert ">Max Muster<" in opf
assert "<dc:language>en</dc:language>" in opf

View file

@ -9,8 +9,9 @@ import zipfile
import pytest import pytest
from conftest import ROOT, TITLE, needs_pandoc, needs_tesseract from conftest import ROOT, TITLE, needs_pandoc, needs_tesseract
from pdf2md import split_frontmatter
SCRIPT = ROOT / "pdf2md_v5.py" SCRIPT = ROOT / "pdf2md.py"
def run(*args, cwd=None) -> subprocess.CompletedProcess: def run(*args, cwd=None) -> subprocess.CompletedProcess:
@ -49,7 +50,9 @@ def test_digital_structure(digital_md):
assert "Dort sprach er lange mit dem Geheimdienst über die Lage" in digital_md assert "Dort sprach er lange mit dem Geheimdienst über die Lage" in digital_md
# keine Seitenzahlen, keine Kolumnentitel außer der Titelüberschrift # keine Seitenzahlen, keine Kolumnentitel außer der Titelüberschrift
assert not re.search(r"^\d+$", digital_md, re.MULTILINE) assert not re.search(r"^\d+$", digital_md, re.MULTILINE)
assert digital_md.count(TITLE) == 1 meta, body = split_frontmatter(digital_md)
assert meta["title"] == TITLE and meta["lang"] == "de"
assert body.count(TITLE) == 1
@needs_tesseract @needs_tesseract
@ -132,7 +135,7 @@ def test_png_on_request(pdfs, tmp_path):
def test_image_links_relative_to_markdown_with_relative_output(pdfs, tmp_path): def test_image_links_relative_to_markdown_with_relative_output(pdfs, tmp_path):
# Aufruf wie "python3 pdf2md_v5.py --images buch.pdf Output/buch.md" aus dem Projektordner # Aufruf wie "python3 pdf2md.py --images buch.pdf Output/buch.md" aus dem Projektordner
run(pdfs["digital"], "Output/buch.md", "--images", cwd=tmp_path) run(pdfs["digital"], "Output/buch.md", "--images", cwd=tmp_path)
md = (tmp_path / "Output" / "buch.md").read_text(encoding="utf-8") md = (tmp_path / "Output" / "buch.md").read_text(encoding="utf-8")
links = re.findall(r"!\[\]\(([^)]+)\)", md) links = re.findall(r"!\[\]\(([^)]+)\)", md)

View file

@ -5,7 +5,7 @@ from pathlib import Path
import pymupdf import pymupdf
import pytest import pytest
import pdf2md_v5 as p import pdf2md as p
def norm(md: str, outline=None, **kwargs) -> str: def norm(md: str, outline=None, **kwargs) -> str:
@ -74,6 +74,37 @@ def test_pymupdf4llm_patch_keeps_compound_hyphen():
assert output.strip() == "Der Militär-Geheimdienst wusste es. Arbeits- und Kriegergeschrei." assert output.strip() == "Der Militär-Geheimdienst wusste es. Arbeits- und Kriegergeschrei."
def test_pymupdf4llm_patch_resolves_soft_hyphens():
# Weicher Trennstrich (U+00AD) bzw. "¬" am Zeilenende, auch vor Großbuchstaben
spans = [
{"text": "einer wach­", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 0},
{"text": "senden Demo¬", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 1},
{"text": "kratie und Lebens­ ", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 2},
{"text": "Läufe.", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 3},
]
output, _ = p.document_layout.get_styled_text(spans)
assert output.strip() == "einer wachsenden Demokratie und LebensLäufe."
def test_pymupdf4llm_patch_ignores_monospace_of_invisible_text():
# OCR-Ebene in Courier: unsichtbar, aber mit Monospace-Flag
spans = [
{"text": "Nature reioicej", "flags": 8, "char_flags": 0, "alpha": 0, "font": "Courier",
"block": 0, "line": 0},
{"text": "of many frendes", "flags": 8, "char_flags": 0, "alpha": 0, "font": "Courier",
"block": 0, "line": 1},
]
output, _ = p.document_layout.get_styled_text(spans)
assert output.strip() == "Nature reioicej of many frendes"
def test_visible_monospace_stays_code():
span = {"text": "print()", "flags": 8, "char_flags": int(p.VISIBLE_TEXT), "font": "Courier",
"block": 0, "line": 0}
output, _ = p.document_layout.get_styled_text([span])
assert output.strip() == "`print()`"
# --- Überschriften ----------------------------------------------------------------- # --- Überschriften -----------------------------------------------------------------
def test_heading_without_space_and_bold_is_cleaned(): def test_heading_without_space_and_bold_is_cleaned():
@ -186,6 +217,22 @@ def test_contents_block_keeps_entries_and_headings_follow():
) )
def test_contents_table_stays_table_and_contents_end_after_it():
outline = {"vorwort": 1, "der staat": 1}
md = (
"## Inhaltsverzeichnis\n\n"
"|Vorwort|3|\n|---|---|\n|Der Staat|19|\n\n"
"Dieser Bericht steht unter CC BY-SA 4.0 (Lizenz)\n\n"
"Vorwort\n\nDer Text beginnt hier.\n"
)
assert norm(md, outline) == (
"## Inhaltsverzeichnis\n\n"
"|Vorwort|3|\n|---|---|\n|Der Staat|19|\n\n"
"Dieser Bericht steht unter CC BY-SA 4.0 (Lizenz)\n\n"
"# Vorwort\n\nDer Text beginnt hier.\n"
)
def test_heading_formatted_contents_entries_stay_plain(): def test_heading_formatted_contents_entries_stay_plain():
outline = {"1 der notar": 3} outline = {"1 der notar": 3}
md = "## Inhaltsverzeichnis\n\n## 1 Der Notar\n\n## 1 Der Notar\n\nText.\n" md = "## Inhaltsverzeichnis\n\n## 1 Der Notar\n\n## 1 Der Notar\n\nText.\n"
@ -279,3 +326,78 @@ def test_make_image_links_relative(tmp_path, monkeypatch):
"![](buch_images/a.jpg)\n\n![](buch_images/b.jpg)\n\n" "![](buch_images/a.jpg)\n\n![](buch_images/b.jpg)\n\n"
"![](https://example.org/c.jpg)\n" "![](https://example.org/c.jpg)\n"
) )
# --- Links -------------------------------------------------------------------------
def test_link_urls_repairs_broken_urls_with_pdf_targets():
uris = [
"https://www.theeuropean.de/insa-umfrage-deutsche-sehen/",
"https://www.fsm.de/en/fsm/netzdg/",
]
md = (
"Quelle: https://www.theeuropean.de/insa-umfrage-deutschesehen/.\n"
"Siehe https://www.fsm.de/en/fsm/ netzdg/. Danach Text.\n"
)
assert p.link_urls(md, uris) == (
"Quelle: <https://www.theeuropean.de/insa-umfrage-deutsche-sehen/>.\n"
"Siehe <https://www.fsm.de/en/fsm/netzdg/>. Danach Text.\n"
)
def test_link_urls_without_pdf_targets():
md = "Mehr unter https://example.org/a_b, www.example.org und (https://x.de/y).\n"
assert p.link_urls(md) == (
"Mehr unter <https://example.org/a_b>, [www.example.org](https://www.example.org)"
" und (<https://x.de/y>).\n"
)
def test_link_urls_leaves_images_and_existing_links_alone():
md = "![](bilder/x.jpg)\n\n<https://a.de/> und [b](https://b.de/)\n"
assert p.link_urls(md) == md
def test_pymupdf4llm_patch_keeps_hyphen_inside_url():
spans = [
{"text": "Siehe https://a.de/umfrage-deutsche-", "flags": 0, "char_flags": 16,
"font": "x", "block": 0, "line": 0},
{"text": "sehen/ und mehr.", "flags": 0, "char_flags": 16, "font": "x",
"block": 0, "line": 1},
]
output, _ = p.document_layout.get_styled_text(spans)
assert output.strip() == "Siehe https://a.de/umfrage-deutsche-sehen/ und mehr."
def test_load_link_uris():
doc = pymupdf.open()
page = doc.new_page()
for uri in ("https://a.de/x", "mailto:a@b.de", "https://a.de/x"):
page.insert_link({"kind": pymupdf.LINK_URI, "from": pymupdf.Rect(0, 0, 10, 10),
"uri": uri})
doc = pymupdf.open("pdf", doc.tobytes())
assert p.load_link_uris(doc) == ["https://a.de/x"]
def test_link_urls_tolerates_printed_variants():
uris = [
"https://a.de/artikel?utm_source=chatgpt.com",
"https://b.de/news/Gesetz-Kritik-3718401.html",
"https://c.de/seite",
]
md = "Siehe https://a.de/artikel/, https://b.de/news/ Gesetz-Kritik-3718401 und https://c.de/seite/.\n"
assert p.link_urls(md, uris) == (
"Siehe <https://a.de/artikel>, <https://b.de/news/Gesetz-Kritik-3718401.html> und "
"<https://c.de/seite>.\n"
)
def test_link_urls_joins_printed_continuation_without_pdf_target():
md = (
"Siehe https://a.org/digital_ dispatches/towards-x/. und https://b.de/ "
"Docs/artikel. html. Oder https://c.de/ und mehr.\n"
)
assert p.link_urls(md) == (
"Siehe <https://a.org/digital_dispatches/towards-x/>. und "
"<https://b.de/Docs/artikel.html>. Oder <https://c.de/> und mehr.\n"
)

146
tests/test_split.py Normal file
View file

@ -0,0 +1,146 @@
"""--split, --from-md, YAML-Kopf und --jpeg-quality."""
import zipfile
import pytest
import pdf2md as p
from conftest import needs_pandoc
from test_e2e import run
BOOK = """# Das Buch
## Inhaltsverzeichnis
Teil I
1 Anfang
## Teil I: Der Anfang
Einleitung zum Teil.
### 1 Anfang
Text eins.
```
# kein Kapitel, nur ein Kommentar
```
### 2 Weiter
Text zwei.
## Impressum
Alle Rechte vorbehalten.
"""
def test_frontmatter_round_trip():
meta = {"title": "Ein \"Buch\": Teil 1", "author": "Max Muster", "chapter": 3, "leer": ""}
text = p.make_frontmatter(meta) + "# Text\n"
parsed, body = p.split_frontmatter(text)
assert parsed == {"title": "Ein \"Buch\": Teil 1", "author": "Max Muster", "chapter": 3}
assert body == "# Text\n"
def test_text_without_frontmatter_is_unchanged():
assert p.split_frontmatter("---\n\nText\n") == ({}, "---\n\nText\n")
def test_slugify():
assert p.slugify("2 Ein Brief – eine Liste – ein Vermächtnis") == "2-ein-brief-eine-liste-ein-vermaechtnis"
assert p.slugify("Größe & Maß") == "groesse-mass"
assert p.slugify("!!!") == "abschnitt"
def test_split_by_chapters():
sections = p.split_markdown(BOOK, "kapitel")
assert [s["title"] for s in sections] == [
"Vorspann", "Teil I: Der Anfang", "1 Anfang", "2 Weiter", "Impressum",
]
assert sections[0]["body"].startswith("# Das Buch") and "## Inhaltsverzeichnis" in sections[0]["body"]
assert sections[2]["part"] == "Teil I: Der Anfang" and sections[2]["chapter"] == 1
assert "# kein Kapitel" in sections[2]["body"] # Codeblock wird nicht geschnitten
assert "".join(s["body"] for s in sections).replace("\n", "") == BOOK.replace("\n", "")
def test_split_by_parts():
sections = p.split_markdown(BOOK, "teile")
assert [s["title"] for s in sections] == ["Vorspann", "Teil I: Der Anfang", "Impressum"]
assert "### 2 Weiter" in sections[1]["body"]
def test_write_split_and_read_back(tmp_path):
sections = p.split_markdown(BOOK, "kapitel")
files = p.write_split(tmp_path / "buch", sections, {"title": "Das Buch", "author": "Max"})
assert [f.name for f in files][:3] == ["00_vorspann.md", "01_teil-i-der-anfang.md", "02_1-anfang.md"]
meta, body, base = p.read_markdown_source(tmp_path / "buch")
assert meta["title"] == "Das Buch" and base == tmp_path / "buch"
assert body.replace("\n", "") == BOOK.replace("\n", "")
head, _ = p.split_frontmatter(files[2].read_text())
assert head == {"title": "1 Anfang", "part": "Teil I: Der Anfang", "chapter": 1,
"order": 2, "book": "Das Buch", "author": "Max"}
def test_missing_index_entry_is_reported(tmp_path):
folder = tmp_path / "buch"
p.write_split(folder, p.split_markdown(BOOK, "teile"), {"title": "X"})
(folder / "01_teil-i-der-anfang.md").unlink()
with pytest.raises(RuntimeError, match="nicht vorhanden"):
p.read_markdown_source(folder)
# --- Ende zu Ende ------------------------------------------------------------------
def test_cli_split_writes_folder(pdfs, tmp_path):
run(pdfs["digital"], tmp_path / "buch.md", "--split", "kapitel", "--images")
folder = tmp_path / "buch"
names = sorted(f.name for f in folder.glob("*.md"))
assert names[0] == "00_vorspann.md" and "index.md" in names
assert any(n.endswith("_1-der-notar.md") for n in names)
assert (folder / "images" / "cover.jpg").is_file()
text = "".join(f.read_text() for f in folder.glob("*.md"))
assert "](images/" in text and "_images/" not in text
@needs_pandoc
def test_from_md_folder_follows_index_order(pdfs, tmp_path):
run(pdfs["digital"], tmp_path / "buch.md", "--split", "kapitel", "--epub")
index = tmp_path / "buch" / "index.md"
lines = index.read_text().split("\n")
i = next(n for n, line in enumerate(lines) if "1-der-notar" in line)
j = next(n for n, line in enumerate(lines) if "2-ein-brief" in line)
lines[i], lines[j] = lines[j], lines[i]
index.write_text("\n".join(lines))
run("--from-md", tmp_path / "buch", "--epub-file", tmp_path / "neu.epub")
with zipfile.ZipFile(tmp_path / "neu.epub") as z:
nav = z.read("EPUB/nav.xhtml").decode()
opf = z.read("EPUB/content.opf").decode()
assert nav.index("2 Ein Brief") < nav.index("1 Der Notar")
assert "Erika Muster" in opf and "cover" in opf
@needs_pandoc
def test_from_md_file_uses_frontmatter_and_overrides(pdfs, tmp_path):
run(pdfs["digital"], tmp_path / "buch.md", "--images")
md = tmp_path / "buch.md"
md.write_text(md.read_text().replace("Der Regen", "Der starke Regen"))
run("--from-md", md, "--title", "Neuer Titel")
with zipfile.ZipFile(tmp_path / "buch.epub") as z:
opf = z.read("EPUB/content.opf").decode()
text = "".join(z.read(n).decode() for n in z.namelist() if n.endswith(".xhtml"))
assert ">Neuer Titel<" in opf and "Erika Muster" in opf and "cover" in opf
assert "Der starke Regen" in text and "title:" not in text
def test_jpeg_quality_changes_image_size(pdfs, tmp_path):
def size(quality):
out = tmp_path / f"q{quality}" / "buch.md"
run(pdfs["digital"], out, "--images", "--jpeg-quality", str(quality))
return sum(f.stat().st_size for f in (out.parent / "buch_images").glob("*.jpg"))
assert size(30) < size(95)

163
tests/test_verbatim.py Normal file
View file

@ -0,0 +1,163 @@
"""Zeilengetreue Abschnitte: Register, Code, Verse – Fließtext bleibt Fließtext."""
import zipfile
import pymupdf
import pytest
import pdf2md as p
from conftest import needs_pandoc
from test_e2e import run
NBSP = " "
PROSE = (
"<p>Dieser Absatz ist ganz normaler Fließtext im Flattersatz. Er wird vom "
"Satzprogramm umbrochen, weil die Zeilen voll sind, und muss deshalb in der "
"Ausgabe wieder zu einer einzigen Zeile zusammengefügt werden, ohne harte "
"Umbrüche und ohne Leerzeilen dazwischen.</p>"
)
def make_pdf(path, lines, html=PROSE):
"""Fließtext oben (legt die Spaltenbreite fest), darunter Zeilen an festen Positionen."""
doc = pymupdf.open()
page = doc.new_page(width=420, height=595)
page.insert_htmlbox(pymupdf.Rect(50, 40, 370, 200), html)
y = 240
for x, text in lines:
page.insert_text((x, y), text, fontname="tiro", fontsize=11)
y += 14
doc.save(path)
return path
def convert(tmp_path, lines, html=PROSE) -> str:
source = make_pdf(tmp_path / "probe.pdf", lines, html)
out = tmp_path / "probe.md"
run(source, out)
return out.read_text(encoding="utf-8")
def test_prose_stays_one_paragraph(tmp_path):
md = convert(tmp_path, [])
assert "Fließtext im Flattersatz. Er wird vom Satzprogramm umbrochen" in md
assert "\\" not in md
def test_register_keeps_lines_and_indentation(tmp_path):
md = convert(tmp_path, [
(50, "Backslash"),
(62, "Maskierung, 79"),
(50, "BaseException, 227"),
(50, "Basisklasse, 206"),
(62, "abstrakte, 212"),
])
# "Backslash" hält das Layout-Modell hier für eine Überschrift.
assert (
f"{NBSP * 4}Maskierung, 79\\\n"
"BaseException, 227\\\n"
"Basisklasse, 206\\\n"
f"{NBSP * 4}abstrakte, 212"
) in md
def test_code_in_proportional_font_becomes_code_block(tmp_path):
md = convert(tmp_path, [
(50, "def quadrat(x):"),
(62, "y = x * x"),
(62, "return y"),
(50, "print(quadrat(3))"),
])
assert "```\ndef quadrat(x):\n y = x * x\n return y\nprint(quadrat(3))\n```" in md
def test_poem_keeps_line_breaks(tmp_path):
html = PROSE + (
"<p>Der Mond ist aufgegangen,<br>die goldnen Sternlein prangen<br>"
"am Himmel hell und klar.</p>"
)
md = convert(tmp_path, [], html)
assert "Der Mond ist aufgegangen,\\\ndie goldnen Sternlein prangen\\\nam Himmel hell und klar." in md
# --- normalize_markdown mit Markierungen -------------------------------------------
H, P, B = p.HARD_BREAK, p.PARAGRAPH_END, p.BLOCK_END
def test_hard_breaks_become_backslashes_except_last():
md = f"Erste{H}\n\nZweite{H}\n\nDritte{H}\n\nDanach Fließtext.\n"
assert p.normalize_markdown(md) == "Erste\\\nZweite\\\nDritte\n\nDanach Fließtext.\n"
def test_block_end_continues_when_word_did_not_fit():
md = f"Er sprach mit dem{B}3{B}\n\nGeheimdienstchef über alles.\n"
assert p.normalize_markdown(md) == "Er sprach mit dem Geheimdienstchef über alles.\n"
def test_block_end_is_hard_when_word_would_have_fit():
md = f"Moskau, im Mai{B}40{B}\n\nDer Zug fuhr ab.\n"
assert p.normalize_markdown(md) == "Moskau, im Mai\n\nDer Zug fuhr ab.\n"
def test_multiline_block_without_sentence_end_ends_paragraph():
md = f"Ein Absatz mit Fußnote.[8 ]{B}30P{B}\n\nDer nächste Absatz.\n"
assert p.normalize_markdown(md) == "Ein Absatz mit Fußnote.[8 ]\n\nDer nächste Absatz.\n"
def test_paragraph_end_closes_verbatim_run_without_blank():
md = f"Die Wahrheit erzählen,{H}\ndas ist der größte Witz.{P}\n\nWeiter.\n"
assert p.normalize_markdown(md) == (
"Die Wahrheit erzählen,\\\ndas ist der größte Witz.\n\nWeiter.\n"
)
def test_code_blocks_across_page_break_are_merged():
md = "```\nfn main() {\n```\n\n17\n\n```\n}\n```\n"
assert p.normalize_markdown(md) == "```\nfn main() {\n}\n```\n"
@pytest.mark.parametrize("text", ["fn main() {", "use std::io;", "def f(x):", "print(x)"])
def test_code_hints(text):
assert p.CODE_HINT.search(text)
@pytest.mark.parametrize("text", ["Maskierung, 79", "Der Mond ist aufgegangen,", "Kapitel (1)"])
def test_no_code_hints(text):
assert not p.CODE_HINT.search(text)
@needs_pandoc
def test_epub_wraps_long_code_lines(pdfs, tmp_path):
run(pdfs["digital"], tmp_path / "buch.md", "--epub")
with zipfile.ZipFile(tmp_path / "buch.epub") as z:
css = "".join(z.read(n).decode() for n in z.namelist() if n.endswith(".css"))
assert "white-space: pre-wrap" in css
def test_literal_fence_text_is_escaped_and_markers_never_leak():
md = f"```{B}66{B}\n\nText danach.\n\nRest{H}\n"
out = p.normalize_markdown(md)
assert out.startswith("\\```")
assert not p.MARKERS.search(out)
def test_contents_entries_are_uniform_paragraphs():
outline = {"vorwort": 2, "1 der notar": 3, "2 ein brief": 3}
md = (
f"## Inhaltsverzeichnis\n\nTitel{H}\nVorwort{H}\n1 Der Notar{P}\n\n"
f"2 Ein Brief{B}40{B}\n\n## Vorwort\n\nText.\n"
)
assert p.normalize_markdown(md, outline) == (
"## Inhaltsverzeichnis\n\nTitel\n\nVorwort\n\n1 Der Notar\n\n2 Ein Brief\n\n"
"## Vorwort\n\nText.\n"
)
def test_outline_like_lines_outside_contents_become_paragraphs():
md = f"## Vorschau Band Zwei\n\n32 Oberst a.D.{H}\n33 Wolga-Attacke{P}\n\nTeil VI: Polen{H}\nOperation Morgenlicht{P}\n"
assert p.normalize_markdown(md) == (
"## Vorschau Band Zwei\n\n32 Oberst a.D.\n\n33 Wolga-Attacke\n\n"
"Teil VI: Polen\n\nOperation Morgenlicht\n"
)