- 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>
151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
"""Synthetische Test-PDFs: digital, gescannt, gescannt mit OCR-Ebene, gemischt."""
|
|
|
|
import random
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pymupdf
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
TITLE = "Das Testbuch"
|
|
|
|
CSS = """
|
|
body { font-family: serif; font-size: 12pt; }
|
|
h1 { font-size: 24pt; font-weight: bold; margin-bottom: 12pt; }
|
|
h2 { font-size: 18pt; font-weight: bold; margin-top: 14pt; margin-bottom: 10pt; }
|
|
p { margin-top: 0; margin-bottom: 8pt; text-align: left; }
|
|
"""
|
|
|
|
PAGES = [
|
|
f"""
|
|
<h1>{TITLE}</h1>
|
|
<p>Ein kurzer Roman zum Testen der Umwandlung.</p>
|
|
<h2>Inhaltsverzeichnis</h2>
|
|
<p>Teil I: Der Anfang<br>1 Der Notar<br>2 Ein Brief</p>
|
|
""",
|
|
"""
|
|
<h2>Teil I: Der Anfang</h2>
|
|
<h2>1 Der Notar</h2>
|
|
<p>Albrecht van Oie stand am Fenster und blickte auf die nasse Straße
|
|
hinunter. Der Regen hatte am Morgen begonnen und wollte nicht aufhören.</p>
|
|
<p>Er dachte an die alten Zeiten in Berlin, an die Freunde von damals und
|
|
an die vielen Briefe, die er nie beantwortet hatte.</p>
|
|
<p>Am Nachmittag ging er zum Notar in der Kantstraße. Dort sprach er
|
|
lange mit dem Geheim-</p>
|
|
""",
|
|
"""
|
|
<p>dienst über die Lage in der Stadt und über die Zukunft des Landes.</p>
|
|
<h2>2 Ein Brief</h2>
|
|
<p>Der Brief lag auf dem Tisch. Oie nahm ihn langsam zur Hand und las die
|
|
ersten Zeilen mit wachsender Spannung.</p>
|
|
<p>»Teurer Albrecht!« stand da in einer schönen alten Handschrift.</p>
|
|
""",
|
|
"""
|
|
<p>Am Abend saß er lange in der Küche und trank einen Tee. Die Gedanken
|
|
an den Brief ließen ihn nicht los.</p>
|
|
<p>Schließlich ging er zu Bett und schlief tief und fest bis zum
|
|
nächsten Morgen.</p>
|
|
""",
|
|
]
|
|
|
|
OUTLINE = [
|
|
[1, TITLE, 1],
|
|
[2, "Teil I: Der Anfang", 2],
|
|
[2, "1 Der Notar", 2],
|
|
[2, "2 Ein Brief", 3],
|
|
]
|
|
|
|
needs_tesseract = pytest.mark.skipif(
|
|
not (pymupdf.get_tessdata() and (Path(pymupdf.get_tessdata()) / "deu.traineddata").is_file()),
|
|
reason="Tesseract mit deutschen Sprachdaten nicht installiert",
|
|
)
|
|
needs_pandoc = pytest.mark.skipif(
|
|
shutil.which("pandoc") is None, reason="pandoc nicht installiert"
|
|
)
|
|
|
|
|
|
def _make_digital() -> pymupdf.Document:
|
|
doc = pymupdf.open()
|
|
for number, html in enumerate(PAGES, start=1):
|
|
page = doc.new_page(width=420, height=595)
|
|
body = pymupdf.Rect(50, 60, 370, 540)
|
|
spare, _scale = page.insert_htmlbox(body, html, css=CSS, scale_low=1)
|
|
assert spare >= 0, f"Testseite {number} zu lang"
|
|
if number > 1:
|
|
# Kolumnentitel und Seitenzahl wie in einem gedruckten Buch
|
|
page.insert_text((170, 35), TITLE, fontsize=9)
|
|
page.insert_text((205, 570), str(number), fontsize=9)
|
|
# Ein Bild auf Seite 3
|
|
pix = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 120, 80), 0)
|
|
pix.set_rect(pix.irect, (40, 90, 160))
|
|
pix.set_rect(pymupdf.IRect(20, 20, 100, 60), (230, 200, 60))
|
|
doc[2].insert_image(pymupdf.Rect(150, 420, 270, 500), pixmap=pix)
|
|
doc.set_toc(OUTLINE)
|
|
doc.set_metadata({"title": TITLE, "author": "Erika Muster"})
|
|
return doc
|
|
|
|
|
|
def _scan_page(target: pymupdf.Document, page: pymupdf.Page) -> None:
|
|
pix = page.get_pixmap(dpi=300, colorspace=pymupdf.csGRAY)
|
|
new = target.new_page(width=page.rect.width, height=page.rect.height)
|
|
new.insert_image(new.rect, pixmap=pix)
|
|
|
|
|
|
def _ocr_page(target: pymupdf.Document, page: pymupdf.Page) -> None:
|
|
# Achtung: MuPDFs OCR liefert bei Graustufen-Pixmaps keinen Text -> RGB.
|
|
pix = page.get_pixmap(dpi=300)
|
|
with pymupdf.open("pdf", pix.pdfocr_tobytes(language="deu")) as ocr:
|
|
target.insert_pdf(ocr)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def pdfs(tmp_path_factory) -> dict[str, Path]:
|
|
folder = tmp_path_factory.mktemp("pdfs")
|
|
paths = {}
|
|
|
|
digital = _make_digital()
|
|
paths["digital"] = folder / "digital.pdf"
|
|
digital.save(paths["digital"])
|
|
|
|
scanned = pymupdf.open()
|
|
for page in digital:
|
|
_scan_page(scanned, page)
|
|
paths["scanned"] = folder / "scanned.pdf"
|
|
scanned.save(paths["scanned"])
|
|
|
|
mixed = pymupdf.open()
|
|
mixed.insert_pdf(digital, from_page=0, to_page=1)
|
|
for page in list(digital)[2:]:
|
|
_scan_page(mixed, page)
|
|
paths["mixed"] = folder / "mixed.pdf"
|
|
mixed.save(paths["mixed"])
|
|
|
|
# Digitales PDF mit einer reinen Fotoseite (Rauschen) am Ende
|
|
photo = pymupdf.open()
|
|
photo.insert_pdf(digital)
|
|
rng = random.Random(7)
|
|
noise = pymupdf.Pixmap(
|
|
pymupdf.csRGB, 200, 280, bytes(rng.randrange(256) for _ in range(200 * 280 * 3)), 0
|
|
)
|
|
page = photo.new_page(width=420, height=595)
|
|
page.insert_image(page.rect, pixmap=noise)
|
|
paths["photo"] = folder / "photo.pdf"
|
|
photo.save(paths["photo"])
|
|
|
|
blank = pymupdf.open()
|
|
blank.new_page()
|
|
paths["blank"] = folder / "blank.pdf"
|
|
blank.save(paths["blank"])
|
|
|
|
if pymupdf.get_tessdata():
|
|
scanned_ocr = pymupdf.open()
|
|
for page in digital:
|
|
_ocr_page(scanned_ocr, page)
|
|
paths["scanned_ocr"] = folder / "scanned_ocr.pdf"
|
|
scanned_ocr.save(paths["scanned_ocr"])
|
|
|
|
return paths
|