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
|
|
|
|
"""Seitenklassifizierung und OCR-Planung."""
|
|
|
|
|
|
|
|
|
|
|
|
import pymupdf
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
2026-10-02 13:12:36 +02:00
|
|
|
|
import pdf2md as p
|
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
|
|
|
|
from conftest import needs_tesseract
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def kinds_of(path):
|
|
|
|
|
|
with pymupdf.open(path) as doc:
|
|
|
|
|
|
return [p.classify_page(page) for page in doc]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_digital_pages_are_text(pdfs):
|
|
|
|
|
|
assert kinds_of(pdfs["digital"]) == [p.PAGE_TEXT] * 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_scanned_pages_are_scans(pdfs):
|
|
|
|
|
|
assert kinds_of(pdfs["scanned"]) == [p.PAGE_SCAN] * 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_tesseract
|
|
|
|
|
|
def test_scans_with_ocr_layer_are_detected(pdfs):
|
|
|
|
|
|
assert kinds_of(pdfs["scanned_ocr"]) == [p.PAGE_OCR_LAYER] * 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mixed_document(pdfs):
|
|
|
|
|
|
assert kinds_of(pdfs["mixed"]) == [p.PAGE_TEXT, p.PAGE_TEXT, p.PAGE_SCAN, p.PAGE_SCAN]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_blank_page_is_empty(pdfs):
|
|
|
|
|
|
assert kinds_of(pdfs["blank"]) == [p.PAGE_EMPTY]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plan_never_uses_no_ocr(pdfs):
|
|
|
|
|
|
with pymupdf.open(pdfs["scanned"]) as doc:
|
|
|
|
|
|
_, plan = p.plan_pages(doc, "never", "deu", 300)
|
|
|
|
|
|
assert plan == [p.RUN_PLAIN] * 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_tesseract
|
|
|
|
|
|
def test_plan_auto_scanned(pdfs):
|
|
|
|
|
|
with pymupdf.open(pdfs["scanned"]) as doc:
|
|
|
|
|
|
_, plan = p.plan_pages(doc, "auto", "deu", 300)
|
|
|
|
|
|
assert plan == [p.RUN_OCR] * 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_tesseract
|
|
|
|
|
|
def test_plan_auto_reuses_existing_ocr_layer(pdfs):
|
|
|
|
|
|
with pymupdf.open(pdfs["scanned_ocr"]) as doc:
|
|
|
|
|
|
_, plan = p.plan_pages(doc, "auto", "deu", 300)
|
|
|
|
|
|
assert plan == [p.RUN_REUSE] * 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _scan_with_text_layer(path, angle):
|
|
|
|
|
|
"""Scanseite mit unsichtbarer, um `angle` Grad gedrehter Textebene."""
|
|
|
|
|
|
doc = pymupdf.open()
|
|
|
|
|
|
page = doc.new_page(width=420, height=595)
|
|
|
|
|
|
pix = pymupdf.Pixmap(pymupdf.csGRAY, pymupdf.IRect(0, 0, 200, 280), 0)
|
|
|
|
|
|
pix.set_rect(pix.irect, (230,))
|
|
|
|
|
|
page.insert_image(page.rect, pixmap=pix)
|
|
|
|
|
|
for i in range(20):
|
|
|
|
|
|
point = pymupdf.Point(50, 80 + 20 * i)
|
|
|
|
|
|
page.insert_text(
|
|
|
|
|
|
point,
|
|
|
|
|
|
"Eine Zeile der vorhandenen OCR-Ebene",
|
|
|
|
|
|
render_mode=3,
|
|
|
|
|
|
morph=(point, pymupdf.Matrix(1, 1).prerotate(-angle)),
|
|
|
|
|
|
)
|
|
|
|
|
|
doc.save(path)
|
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_tesseract
|
|
|
|
|
|
@pytest.mark.parametrize("angle, expected", [(0, p.RUN_REUSE), (0.5, p.RUN_OCR)])
|
|
|
|
|
|
def test_plan_auto_redoes_skewed_ocr_layer(tmp_path, angle, expected):
|
|
|
|
|
|
path = _scan_with_text_layer(tmp_path / f"layer_{angle}.pdf", angle)
|
|
|
|
|
|
with pymupdf.open(path) as doc:
|
|
|
|
|
|
kinds, plan = p.plan_pages(doc, "auto", "deu", 300)
|
|
|
|
|
|
assert kinds == [p.PAGE_OCR_LAYER]
|
|
|
|
|
|
assert plan == [expected]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_tesseract
|
|
|
|
|
|
def test_plan_auto_mixed_probes_scan_pages(pdfs):
|
|
|
|
|
|
# 2 Text- zu 2 Scanseiten gilt nicht als Scan-Dokument; die Scanseiten
|
|
|
|
|
|
# werden per Probe-OCR geprüft und bestehen sie.
|
|
|
|
|
|
with pymupdf.open(pdfs["mixed"]) as doc:
|
|
|
|
|
|
_, plan = p.plan_pages(doc, "auto", "deu", 300)
|
|
|
|
|
|
assert plan == [p.RUN_PLAIN, p.RUN_PLAIN, p.RUN_OCR, p.RUN_OCR]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_tesseract
|
|
|
|
|
|
def test_plan_auto_skips_photo_page(pdfs):
|
|
|
|
|
|
with pymupdf.open(pdfs["photo"]) as doc:
|
|
|
|
|
|
kinds, plan = p.plan_pages(doc, "auto", "deu", 300)
|
|
|
|
|
|
assert kinds[-1] == p.PAGE_SCAN
|
|
|
|
|
|
assert plan == [p.RUN_PLAIN] * 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_tesseract
|
|
|
|
|
|
def test_plan_always(pdfs):
|
|
|
|
|
|
with pymupdf.open(pdfs["digital"]) as doc:
|
|
|
|
|
|
_, plan = p.plan_pages(doc, "always", "deu", 300)
|
|
|
|
|
|
assert plan == [p.RUN_OCR] * 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_language_gives_clear_error():
|
|
|
|
|
|
if not pymupdf.get_tessdata():
|
|
|
|
|
|
pytest.skip("Tesseract nicht installiert")
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="tesseract-ocr-xyz"):
|
|
|
|
|
|
p.check_ocr_language("deu+xyz")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"text, language, expected",
|
|
|
|
|
|
[
|
|
|
|
|
|
("Der Brief lag auf dem Tisch. " * 10, "deu", True),
|
|
|
|
|
|
("Am Abend saß er lange in der Küche und trank einen Tee. Die Gedanken "
|
|
|
|
|
|
"an den Brief ließen ihn nicht los.", "deu", True),
|
|
|
|
|
|
("The letter was lying on the table in the kitchen, and he read it "
|
|
|
|
|
|
"twice before he put it away.", "deu+eng", True),
|
|
|
|
|
|
# Cover mit Titel und Autor: zu wenig Wörter
|
|
|
|
|
|
("Reinhard Otto Kranz OPERATION LJUTSCH Band I Schlüssel zur Deutschen Einheit", "deu", False),
|
|
|
|
|
|
("fi@ |l1 ;; ¦¦ ~~ ‚, ., " * 20, "deu", False),
|
|
|
|
|
|
# echte Tesseract-Ausgabe einer Rauschseite
|
|
|
|
|
|
("©Se te\nansa |\naa\n.\nerNee =\nae\neea\nBa:Ben\nBena\needaaf\neeee\naar.\nEl eeBR\neen\n", "deu", False),
|
|
|
|
|
|
("Zu kurz.", "deu", False),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
def test_looks_like_text(text, language, expected):
|
|
|
|
|
|
assert p.looks_like_text(text, language) is expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_make_tasks_groups_and_chunks():
|
|
|
|
|
|
plan = [p.RUN_PLAIN] * 3 + [p.RUN_OCR] * 12 + [p.RUN_PLAIN]
|
|
|
|
|
|
tasks = p.make_tasks(plan)
|
|
|
|
|
|
assert [(run, pages[0], pages[-1]) for run, pages in tasks] == [
|
|
|
|
|
|
(p.RUN_PLAIN, 0, 2),
|
|
|
|
|
|
(p.RUN_OCR, 3, 12),
|
|
|
|
|
|
(p.RUN_OCR, 13, 14),
|
|
|
|
|
|
(p.RUN_PLAIN, 15, 15),
|
|
|
|
|
|
]
|