Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add17eecac | ||
|
|
d52ac597fc | ||
|
|
f0e0562451 | ||
|
|
d322ee3523 | ||
|
|
fa90050535 | ||
|
|
619421ef10 | ||
|
|
f9dfa3a52e | ||
|
|
217d1ee09b | ||
|
|
a21d900e6b | ||
|
|
b4b911d37f |
18 changed files with 1595 additions and 2526 deletions
19
.gitignore
vendored
19
.gitignore
vendored
|
|
@ -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/
|
|
||||||
|
|
|
||||||
|
|
@ -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 | `` auf eigener Zeile | Bild an dieser Stelle |
|
| Bild | `` 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 |
|
||||||
|
|
|
||||||
22
CLAUDE.md
22
CLAUDE.md
|
|
@ -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.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
102
README.md
102
README.md
|
|
@ -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
83
TODO.md
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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<br /></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>
|
|
||||||
146
pdf2md_v1.py
146
pdf2md_v1.py
|
|
@ -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())
|
|
||||||
|
|
||||||
181
pdf2md_v2.py
181
pdf2md_v2.py
|
|
@ -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())
|
|
||||||
|
|
||||||
249
pdf2md_v3.py
249
pdf2md_v3.py
|
|
@ -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())
|
|
||||||
|
|
||||||
269
pdf2md_v4.py
269
pdf2md_v4.py
|
|
@ -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())
|
|
||||||
|
|
||||||
|
|
@ -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
51
tests/test_defaults.py
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
"\n\n\n\n"
|
"\n\n\n\n"
|
||||||
"\n"
|
"\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 = "\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
146
tests/test_split.py
Normal 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
163
tests/test_verbatim.py
Normal 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"
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue