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>
This commit is contained in:
parent
f2965c5225
commit
354dfca871
1 changed files with 1885 additions and 2 deletions
144
tests/test_classify.py
Normal file
144
tests/test_classify.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""Seitenklassifizierung und OCR-Planung."""
|
||||
|
||||
import pymupdf
|
||||
import pytest
|
||||
|
||||
import pdf2md_v5 as p
|
||||
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),
|
||||
]
|
||||
Loading…
Reference in a new issue