pdf2md v5: gescannte und digitale PDFs, EPUB-Export, Tests
- Seitenklassifizierung (Text / Scan / OCR-Ebene / leer) mit OCR-Plan pro Seite:
Scans per Tesseract, gerade OCR-Ebenen übernehmen, schiefe neu erkennen,
Foto-/Coverseiten nur nach Probe-OCR; --ocr auto|always|never, --jobs
- Nachbearbeitung: Satzende-Erkennung mit « “ ‹, Silbentrennung, Überschriften-
ebenen aus PDF-Lesezeichen, Rückstufung von Satzresten, Inhaltsverzeichnis
trennen, Seitenzahlen/Kolumnentitel/Bildtext entfernen
- Patches für pymupdf4llm: Bindestrich-Komposita am Zeilenende, Absätze per
Einzug in Scan-Blöcken
- --epub über pandoc (Metadaten, Cover, Kapitelaufteilung)
- pytest-Suite mit synthetischen Test-PDFs (79 Tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:33:21 +02:00
|
|
|
"""Ende-zu-Ende: Kommandozeilenaufruf auf den Test-PDFs."""
|
|
|
|
|
|
|
|
|
|
import difflib
|
|
|
|
|
import re
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import zipfile
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from conftest import ROOT, TITLE, needs_pandoc, needs_tesseract
|
|
|
|
|
|
2026-10-02 13:12:36 +02:00
|
|
|
SCRIPT = ROOT / "pdf2md.py"
|
pdf2md v5: gescannte und digitale PDFs, EPUB-Export, Tests
- Seitenklassifizierung (Text / Scan / OCR-Ebene / leer) mit OCR-Plan pro Seite:
Scans per Tesseract, gerade OCR-Ebenen übernehmen, schiefe neu erkennen,
Foto-/Coverseiten nur nach Probe-OCR; --ocr auto|always|never, --jobs
- Nachbearbeitung: Satzende-Erkennung mit « “ ‹, Silbentrennung, Überschriften-
ebenen aus PDF-Lesezeichen, Rückstufung von Satzresten, Inhaltsverzeichnis
trennen, Seitenzahlen/Kolumnentitel/Bildtext entfernen
- Patches für pymupdf4llm: Bindestrich-Komposita am Zeilenende, Absätze per
Einzug in Scan-Blöcken
- --epub über pandoc (Metadaten, Cover, Kapitelaufteilung)
- pytest-Suite mit synthetischen Test-PDFs (79 Tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:33:21 +02:00
|
|
|
|
|
|
|
|
|
2026-10-02 01:23:36 +02:00
|
|
|
def run(*args, cwd=None) -> subprocess.CompletedProcess:
|
pdf2md v5: gescannte und digitale PDFs, EPUB-Export, Tests
- Seitenklassifizierung (Text / Scan / OCR-Ebene / leer) mit OCR-Plan pro Seite:
Scans per Tesseract, gerade OCR-Ebenen übernehmen, schiefe neu erkennen,
Foto-/Coverseiten nur nach Probe-OCR; --ocr auto|always|never, --jobs
- Nachbearbeitung: Satzende-Erkennung mit « “ ‹, Silbentrennung, Überschriften-
ebenen aus PDF-Lesezeichen, Rückstufung von Satzresten, Inhaltsverzeichnis
trennen, Seitenzahlen/Kolumnentitel/Bildtext entfernen
- Patches für pymupdf4llm: Bindestrich-Komposita am Zeilenende, Absätze per
Einzug in Scan-Blöcken
- --epub über pandoc (Metadaten, Cover, Kapitelaufteilung)
- pytest-Suite mit synthetischen Test-PDFs (79 Tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:33:21 +02:00
|
|
|
done = subprocess.run(
|
|
|
|
|
[sys.executable, str(SCRIPT), *map(str, args)],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=600,
|
2026-10-02 01:23:36 +02:00
|
|
|
cwd=cwd,
|
pdf2md v5: gescannte und digitale PDFs, EPUB-Export, Tests
- Seitenklassifizierung (Text / Scan / OCR-Ebene / leer) mit OCR-Plan pro Seite:
Scans per Tesseract, gerade OCR-Ebenen übernehmen, schiefe neu erkennen,
Foto-/Coverseiten nur nach Probe-OCR; --ocr auto|always|never, --jobs
- Nachbearbeitung: Satzende-Erkennung mit « “ ‹, Silbentrennung, Überschriften-
ebenen aus PDF-Lesezeichen, Rückstufung von Satzresten, Inhaltsverzeichnis
trennen, Seitenzahlen/Kolumnentitel/Bildtext entfernen
- Patches für pymupdf4llm: Bindestrich-Komposita am Zeilenende, Absätze per
Einzug in Scan-Blöcken
- --epub über pandoc (Metadaten, Cover, Kapitelaufteilung)
- pytest-Suite mit synthetischen Test-PDFs (79 Tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:33:21 +02:00
|
|
|
)
|
|
|
|
|
assert done.returncode == 0, done.stderr
|
|
|
|
|
return done
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def words(text: str) -> list[str]:
|
|
|
|
|
return re.findall(r"[^\W\d_]+", text.lower())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
|
|
|
def digital_md(pdfs, tmp_path_factory):
|
|
|
|
|
out = tmp_path_factory.mktemp("digital") / "digital.md"
|
|
|
|
|
run(pdfs["digital"], out)
|
|
|
|
|
return out.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_digital_structure(digital_md):
|
|
|
|
|
headings = re.findall(r"^#+ .*$", digital_md, re.MULTILINE)
|
|
|
|
|
assert headings == [
|
|
|
|
|
"# Das Testbuch",
|
|
|
|
|
"## Inhaltsverzeichnis",
|
|
|
|
|
"## Teil I: Der Anfang",
|
|
|
|
|
"### 1 Der Notar",
|
|
|
|
|
"### 2 Ein Brief",
|
|
|
|
|
]
|
|
|
|
|
# Absatz über die Seitengrenze mit Silbentrennung
|
|
|
|
|
assert "Dort sprach er lange mit dem Geheimdienst über die Lage" in digital_md
|
|
|
|
|
# keine Seitenzahlen, keine Kolumnentitel außer der Titelüberschrift
|
|
|
|
|
assert not re.search(r"^\d+$", digital_md, re.MULTILINE)
|
|
|
|
|
assert digital_md.count(TITLE) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_tesseract
|
|
|
|
|
def test_scanned_matches_digital(pdfs, tmp_path, digital_md):
|
|
|
|
|
out = tmp_path / "scanned.md"
|
|
|
|
|
done = run(pdfs["scanned"], out, "--jobs", "2")
|
|
|
|
|
assert "4 Seiten werden per OCR gelesen" in done.stdout
|
|
|
|
|
md = out.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
ratio = difflib.SequenceMatcher(None, words(digital_md), words(md), autojunk=False).ratio()
|
|
|
|
|
assert ratio >= 0.9, ratio
|
|
|
|
|
assert "Geheimdienst über die Lage" in md
|
|
|
|
|
assert re.search(r"^#+ 1 Der Notar$", md, re.MULTILINE)
|
|
|
|
|
assert re.search(r"^#+ 2 Ein Brief$", md, re.MULTILINE)
|
|
|
|
|
assert not re.search(r"^\d+$", md, re.MULTILINE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_tesseract
|
|
|
|
|
def test_existing_ocr_layer_is_reused(pdfs, tmp_path, digital_md):
|
|
|
|
|
out = tmp_path / "scanned_ocr.md"
|
|
|
|
|
done = run(pdfs["scanned_ocr"], out)
|
|
|
|
|
assert "4 mit OCR-Ebene" in done.stdout
|
|
|
|
|
assert "0 Seiten werden per OCR gelesen" in done.stdout
|
|
|
|
|
md = out.read_text(encoding="utf-8")
|
|
|
|
|
ratio = difflib.SequenceMatcher(None, words(digital_md), words(md), autojunk=False).ratio()
|
|
|
|
|
assert ratio >= 0.9, ratio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_tesseract
|
|
|
|
|
def test_mixed_keeps_page_order(pdfs, tmp_path):
|
|
|
|
|
out = tmp_path / "mixed.md"
|
|
|
|
|
done = run(pdfs["mixed"], out)
|
|
|
|
|
assert "2 Seiten werden per OCR gelesen" in done.stdout
|
|
|
|
|
md = out.read_text(encoding="utf-8")
|
|
|
|
|
positions = [md.find(s) for s in ("Albrecht van Oie", "Geheimdienst", "Teurer Albrecht", "nächsten Morgen")]
|
|
|
|
|
assert -1 not in positions
|
|
|
|
|
assert positions == sorted(positions)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_tesseract
|
|
|
|
|
def test_photo_page_produces_no_garbage(pdfs, tmp_path, digital_md):
|
|
|
|
|
out = tmp_path / "photo.md"
|
|
|
|
|
done = run(pdfs["photo"], out)
|
|
|
|
|
assert "0 Seiten werden per OCR gelesen" in done.stdout
|
|
|
|
|
assert "Hinweis: 1 Bildseite" in done.stdout
|
|
|
|
|
md = out.read_text(encoding="utf-8")
|
|
|
|
|
assert words(md)[-5:] == words(digital_md)[-5:]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_ocr_on_scan_yields_hint(pdfs, tmp_path):
|
|
|
|
|
out = tmp_path / "noocr.md"
|
|
|
|
|
done = run(pdfs["scanned"], out, "--no-ocr")
|
|
|
|
|
assert "Hinweis: 4 Bildseite(n)" in done.stdout
|
|
|
|
|
assert "Albrecht" not in out.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_pandoc
|
|
|
|
|
def test_epub(pdfs, tmp_path):
|
|
|
|
|
out = tmp_path / "buch.md"
|
|
|
|
|
run(pdfs["digital"], out, "--epub")
|
|
|
|
|
epub = tmp_path / "buch.epub"
|
2026-10-02 00:47:42 +02:00
|
|
|
images = tmp_path / "buch_images"
|
|
|
|
|
assert (images / "cover.jpg").is_file()
|
|
|
|
|
assert {f.suffix for f in images.iterdir()} == {".jpg"}
|
pdf2md v5: gescannte und digitale PDFs, EPUB-Export, Tests
- Seitenklassifizierung (Text / Scan / OCR-Ebene / leer) mit OCR-Plan pro Seite:
Scans per Tesseract, gerade OCR-Ebenen übernehmen, schiefe neu erkennen,
Foto-/Coverseiten nur nach Probe-OCR; --ocr auto|always|never, --jobs
- Nachbearbeitung: Satzende-Erkennung mit « “ ‹, Silbentrennung, Überschriften-
ebenen aus PDF-Lesezeichen, Rückstufung von Satzresten, Inhaltsverzeichnis
trennen, Seitenzahlen/Kolumnentitel/Bildtext entfernen
- Patches für pymupdf4llm: Bindestrich-Komposita am Zeilenende, Absätze per
Einzug in Scan-Blöcken
- --epub über pandoc (Metadaten, Cover, Kapitelaufteilung)
- pytest-Suite mit synthetischen Test-PDFs (79 Tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:33:21 +02:00
|
|
|
with zipfile.ZipFile(epub) as z:
|
|
|
|
|
assert z.read("mimetype") == b"application/epub+zip"
|
|
|
|
|
nav = z.read("EPUB/nav.xhtml").decode()
|
|
|
|
|
opf = z.read("EPUB/content.opf").decode()
|
|
|
|
|
for title in ("Teil I: Der Anfang", "1 Der Notar", "2 Ein Brief"):
|
|
|
|
|
assert title in nav
|
|
|
|
|
assert "Erika Muster" in opf
|
|
|
|
|
assert "cover" in opf
|
2026-10-02 00:47:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_png_on_request(pdfs, tmp_path):
|
|
|
|
|
out = tmp_path / "png.md"
|
|
|
|
|
run(pdfs["digital"], out, "--images", "--image-format", "png")
|
|
|
|
|
md = out.read_text(encoding="utf-8")
|
|
|
|
|
assert re.search(r"!\[\]\(png_images/[^)]+\.png\)", md)
|
2026-10-02 01:23:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_image_links_relative_to_markdown_with_relative_output(pdfs, tmp_path):
|
2026-10-02 13:12:36 +02:00
|
|
|
# Aufruf wie "python3 pdf2md.py --images buch.pdf Output/buch.md" aus dem Projektordner
|
2026-10-02 01:23:36 +02:00
|
|
|
run(pdfs["digital"], "Output/buch.md", "--images", cwd=tmp_path)
|
|
|
|
|
md = (tmp_path / "Output" / "buch.md").read_text(encoding="utf-8")
|
|
|
|
|
links = re.findall(r"!\[\]\(([^)]+)\)", md)
|
|
|
|
|
assert links
|
|
|
|
|
for link in links:
|
|
|
|
|
assert link.startswith("buch_images/"), link
|
|
|
|
|
assert (tmp_path / "Output" / link).is_file()
|