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
8 changed files with 1885 additions and 2 deletions
19
CLAUDE.md
Normal file
19
CLAUDE.md
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
PDF → Markdown → EPUB für den PocketBook-Reader (digitale und gescannte PDFs).
|
||||||
|
Aktuelles Programm: `pdf2md_v5.py` (Einzeldatei). v1–v4 sind Historie, nicht ändern.
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
- Tests: `python3 -m pytest` (Fixtures werden synthetisch erzeugt; OCR braucht `tesseract-ocr-deu`)
|
||||||
|
- Lint: `python3 -m ruff check pdf2md_v5.py tests/`
|
||||||
|
- Echttest: `python3 pdf2md_v5.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf <scratch>/band1.md`
|
||||||
|
|
||||||
|
## Konventionen
|
||||||
|
|
||||||
|
- Hilfetexte, Meldungen, Kommentare und Docstrings auf Deutsch (wie v4); Bezeichner Englisch.
|
||||||
|
- Echttests nie nach `Fertig/` schreiben (dort liegen die Ergebnisse des Nutzers).
|
||||||
|
- PDFs/EPUBs/Bilder sind per `.gitignore` ausgeschlossen – keine Binärdateien committen.
|
||||||
|
- `pdf2md_v5.py` patcht zwei Funktionen in `pymupdf4llm.helpers.document_layout`
|
||||||
|
(`get_styled_text`, `text_to_md`). Nach einem pymupdf4llm-Update prüfen, ob
|
||||||
|
`tests/test_normalize.py::test_pymupdf4llm_patch_keeps_compound_hyphen` noch grün ist.
|
||||||
74
README.md
74
README.md
|
|
@ -2,7 +2,77 @@
|
||||||
|
|
||||||
Werkzeug, um PDF-Bücher (hier: *Operation Ljutsch*, Band 1 und 2) in sauberes Markdown
|
Werkzeug, um PDF-Bücher (hier: *Operation Ljutsch*, Band 1 und 2) in sauberes Markdown
|
||||||
und anschließend in ein EPUB für den PocketBook-Reader umzuwandeln – mit Überschriften,
|
und anschließend in ein EPUB für den PocketBook-Reader umzuwandeln – mit Überschriften,
|
||||||
Bildern und echten Absätzen.
|
Bildern und echten Absätzen. Funktioniert für **digitale** PDFs (Textebene vorhanden)
|
||||||
|
ebenso wie für **gescannte** PDFs (nur Bilder, per OCR) und für Scans, die bereits eine
|
||||||
|
OCR-Ebene haben (z. B. von `ocrmypdf`).
|
||||||
|
|
||||||
- `pdf2md_v1.py` … `pdf2md_v4.py` – bisherige Entwicklungsstände (siehe `Info/`)
|
## Installation (Ubuntu 24.04)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install tesseract-ocr tesseract-ocr-deu pandoc
|
||||||
|
pip install -r requirements.txt # pymupdf4llm, pymupdf-layout, pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
`tesseract-ocr` wird nur für Scans gebraucht, `pandoc` nur für `--epub`.
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# digitales oder gescanntes PDF -> Markdown (OCR wird automatisch entschieden)
|
||||||
|
python3 pdf2md_v5.py buch.pdf buch.md
|
||||||
|
|
||||||
|
# zusätzlich Bilder und EPUB (Titel/Autor aus den PDF-Metadaten, Seite 1 als Cover)
|
||||||
|
python3 pdf2md_v5.py --epub buch.pdf buch.md
|
||||||
|
|
||||||
|
# Scan in Deutsch und Englisch, 8 parallele OCR-Prozesse
|
||||||
|
python3 pdf2md_v5.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Bedeutung |
|
||||||
|
| :-- | :-- |
|
||||||
|
| `--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-language`, `--ocr-dpi` | Tesseract-Sprache(n) (`deu`), Auflösung (300) |
|
||||||
|
| `--jobs N` | parallele OCR-Prozesse (Standard 4); reine Textdokumente laufen immer sequenziell |
|
||||||
|
| `--images` | Grafiken als PNG in `<name>_images/` speichern und verlinken |
|
||||||
|
| `--epub`, `--epub-file PFAD` | EPUB 3 mit pandoc erzeugen (schaltet `--images` ein) |
|
||||||
|
| `--lang` | Sprache im EPUB (`de`) |
|
||||||
|
| `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen |
|
||||||
|
|
||||||
|
## Was die Nachbearbeitung macht
|
||||||
|
|
||||||
|
- **Absätze:** Zeilen werden verbunden; eine Absatzgrenze gilt nur nach Satzende
|
||||||
|
(`.` `!` `?` `…`, auch mit `«` `“` `»` usw. dahinter). Auf Scans werden Absätze am
|
||||||
|
Einzug erkannt.
|
||||||
|
- **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit
|
||||||
|
Bindestrich, `Arbeits- und` bleibt.
|
||||||
|
- **Überschriften:** Ebenen aus den PDF-Lesezeichen (Teile `##`, Kapitel `###`);
|
||||||
|
Satzreste, die fälschlich als Überschrift erkannt wurden, kommen zurück in den Text.
|
||||||
|
- **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt.
|
||||||
|
- **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pytest # ca. 30 s; OCR-/EPUB-Tests werden ohne Tesseract/pandoc übersprungen
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Test-PDFs (digital, gescannt, mit OCR-Ebene, gemischt, Fotoseite) werden zur Laufzeit
|
||||||
|
synthetisch erzeugt.
|
||||||
|
|
||||||
|
## Grenzen
|
||||||
|
|
||||||
|
- Die Ergebnisqualität hängt von pymupdf4llm/pymupdf-layout ab. Zwei Schwächen der
|
||||||
|
Bibliothek gleicht `pdf2md_v5.py` per Patch aus (Bindestriche am Zeilenende, Absätze in
|
||||||
|
Scan-Blöcken). Eine weitere ist bekannt: Bei Schriften mit Ligaturen (ff, fi) mit
|
||||||
|
übergroßer Glyphenbox kann pymupdf4llm einzelne Zeilen verlieren. Bei den Ljutsch-Bänden
|
||||||
|
tritt das nicht auf (geprüft per Wortabgleich PDF ↔ Markdown).
|
||||||
|
- OCR-Fehler (z. B. fehlende Leerzeichen) werden nicht korrigiert.
|
||||||
|
- Bilder werden als PNG gespeichert; das EPUB wird dadurch relativ groß (Band 1 ca. 13 MB).
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
- `pdf2md_v5.py` – aktuelle Version
|
||||||
|
- `pdf2md_v1.py` … `pdf2md_v4.py` – frühere Entwicklungsstände (siehe `Info/`)
|
||||||
- `Info/` – Recherche und Entstehungsgeschichte der Skripte
|
- `Info/` – Recherche und Entstehungsgeschichte der Skripte
|
||||||
|
- `tests/` – pytest-Suite
|
||||||
|
|
|
||||||
1110
pdf2md_v5.py
Normal file
1110
pdf2md_v5.py
Normal file
File diff suppressed because it is too large
Load diff
3
pytest.ini
Normal file
3
pytest.ini
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -p no:asyncio
|
||||||
151
tests/conftest.py
Normal file
151
tests/conftest.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""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
|
||||||
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),
|
||||||
|
]
|
||||||
121
tests/test_e2e.py
Normal file
121
tests/test_e2e.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
SCRIPT = ROOT / "pdf2md_v5.py"
|
||||||
|
|
||||||
|
|
||||||
|
def run(*args) -> subprocess.CompletedProcess:
|
||||||
|
done = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), *map(str, args)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
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"
|
||||||
|
assert (tmp_path / "buch_images" / "cover.png").is_file()
|
||||||
|
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
|
||||||
265
tests/test_normalize.py
Normal file
265
tests/test_normalize.py
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
"""Unit-Tests für die Markdown-Nachbearbeitung (ohne PDF, schnell)."""
|
||||||
|
|
||||||
|
import pymupdf
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import pdf2md_v5 as p
|
||||||
|
|
||||||
|
|
||||||
|
def norm(md: str, outline=None, **kwargs) -> str:
|
||||||
|
return p.normalize_markdown(md, outline, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Satzende-Regel ---------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", ["Satz.", "Frage?", "»Ja.«", "„Gut!“", "da?«", "(so.)", "Ende.“_"])
|
||||||
|
def test_ends_sentence_true(text):
|
||||||
|
assert p.ends_sentence(text)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", ["Teil I", "geht weiter", "»Nee«,", "Geheim-"])
|
||||||
|
def test_ends_sentence_false(text):
|
||||||
|
assert not p.ends_sentence(text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_soft_line_breaks_are_joined():
|
||||||
|
assert norm("Dies ist die erste Zeile\ndesselben Absatzes.\n") == (
|
||||||
|
"Dies ist die erste Zeile desselben Absatzes.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_line_without_sentence_end_continues_paragraph():
|
||||||
|
md = "Ein vermeintlicher Absatz\n\nder weitergeht.\n\nNeuer Absatz.\n"
|
||||||
|
assert norm(md) == "Ein vermeintlicher Absatz der weitergeht.\n\nNeuer Absatz.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dialogue_with_guillemets_ends_paragraph():
|
||||||
|
md = "»War kein Schrank da?«\n\n»Nee«, sagte Oie.\n"
|
||||||
|
assert norm(md) == "»War kein Schrank da?«\n\n»Nee«, sagte Oie.\n"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Silbentrennung -------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"prev, nxt, expected",
|
||||||
|
[
|
||||||
|
("mit dem Geheim-", "dienst sprach", "mit dem Geheimdienst sprach"),
|
||||||
|
("der Militär-", "Geheimdienst wusste", "der Militär-Geheimdienst wusste"),
|
||||||
|
("gehirnoptimierte Arbeits-", "und Krieger-Skills", "gehirnoptimierte Arbeits- und Krieger-Skills"),
|
||||||
|
("ein normaler", "Satz", "ein normaler Satz"),
|
||||||
|
("Gedanken –", "und mehr", "Gedanken – und mehr"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_join_parts(prev, nxt, expected):
|
||||||
|
assert p.join_parts(prev, nxt) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_hyphenation_across_page_break():
|
||||||
|
md = "Er sprach mit dem Geheim-\n\n12\n\ndienst über alles.\n"
|
||||||
|
assert norm(md) == "Er sprach mit dem Geheimdienst über alles.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pymupdf4llm_patch_keeps_compound_hyphen():
|
||||||
|
# Zwei Zeilen desselben Blocks: "Militär-" | "Geheimdienst"
|
||||||
|
spans = [
|
||||||
|
{"text": "Der Militär-", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 0},
|
||||||
|
{"text": "Geheimdienst wusste", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 1},
|
||||||
|
{"text": "es. Arbeits-", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 2},
|
||||||
|
{"text": "und Krieger-", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 3},
|
||||||
|
{"text": "geschrei.", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 4},
|
||||||
|
]
|
||||||
|
output, _ = p.document_layout.get_styled_text(spans)
|
||||||
|
assert output.strip() == "Der Militär-Geheimdienst wusste es. Arbeits- und Kriegergeschrei."
|
||||||
|
|
||||||
|
|
||||||
|
# --- Überschriften -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_heading_without_space_and_bold_is_cleaned():
|
||||||
|
assert norm("##**Inhaltsverzeichnis**\n") == "## Inhaltsverzeichnis\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_false_heading_inside_open_paragraph_is_demoted():
|
||||||
|
md = "Er wusste, dass du von der\n\n## Staatssicherheit beschädigt werden solltest.«\n\nNeu.\n"
|
||||||
|
assert norm(md) == (
|
||||||
|
"Er wusste, dass du von der Staatssicherheit beschädigt werden solltest.«\n\nNeu.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"heading",
|
||||||
|
[
|
||||||
|
"## Sie verabschiedeten sich.",
|
||||||
|
"## – ist dir das klar?",
|
||||||
|
"## aber dann kam alles anders",
|
||||||
|
"## Klappe in Augenhöhe der Tür,",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_sentence_like_headings_are_demoted(heading):
|
||||||
|
assert not norm(f"Vorher.\n\n{heading}\n").count("#")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("heading", ["## 3 Die Grauen nehmen die Spur auf", "## IV.", "## Wer war Antonow?"])
|
||||||
|
def test_real_headings_are_kept(heading):
|
||||||
|
assert norm(f"Vorher.\n\n{heading}\n\nText.\n").count("\n## ") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_trailing_article_marks_false_heading_only_in_open_paragraph():
|
||||||
|
md = "Er war Mitglied des Verbandes, damals\n\n## eine Ehre für ihn.\n"
|
||||||
|
assert "#" not in norm(md)
|
||||||
|
md = "Er war fertig\n\n## Mitglied des Künstlerverbandes - damals eine\n\nmächtige Organisation.\n"
|
||||||
|
assert "#" not in norm(md)
|
||||||
|
|
||||||
|
|
||||||
|
OUTLINE = {
|
||||||
|
"das testbuch": 1,
|
||||||
|
"teil i: der anfang": 2,
|
||||||
|
"1 der notar": 3,
|
||||||
|
"2 ein brief": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_outline_sets_heading_levels():
|
||||||
|
md = "## **Teil I: Der Anfang**\n\n## 1 Der Notar\n\nText.\n"
|
||||||
|
assert norm(md, OUTLINE) == "## Teil I: Der Anfang\n\n### 1 Der Notar\n\nText.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_outline_promotes_bold_line_to_heading():
|
||||||
|
md = "Text davor.\n\n**2 Ein Brief**\n\nDer Brief lag da.\n"
|
||||||
|
assert norm(md, OUTLINE) == "Text davor.\n\n### 2 Ein Brief\n\nDer Brief lag da.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_outline_demotes_unknown_sentence_heading():
|
||||||
|
md = "Text.\n\n## Sie verabschiedeten sich.\n\n## Wer war es?\n"
|
||||||
|
assert "#" not in norm(md, OUTLINE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_outline_puts_chapters_below_parts():
|
||||||
|
doc = pymupdf.open()
|
||||||
|
for _ in range(3):
|
||||||
|
doc.new_page()
|
||||||
|
doc.set_toc([
|
||||||
|
[1, "Titel", 1],
|
||||||
|
[2, "Vorwort: Berlin 2015", 1],
|
||||||
|
[2, "Teil I: Das Vermächtnis", 2],
|
||||||
|
[2, "1 Der Notar", 2],
|
||||||
|
[2, "Der Autor", 3],
|
||||||
|
])
|
||||||
|
assert p.load_outline(doc) == {
|
||||||
|
"titel": 1,
|
||||||
|
"vorwort: berlin 2015": 2,
|
||||||
|
"teil i: das vermächtnis": 2,
|
||||||
|
"1 der notar": 3,
|
||||||
|
"der autor": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Inhaltsverzeichnis ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_contents_lines_are_split_by_outline_titles():
|
||||||
|
titles = sorted(["3 die grauen nehmen die spur auf", "4 flucht aufs land", "teil ii: verbündete"], key=len, reverse=True)
|
||||||
|
line = "3 Die Grauen nehmen die Spur auf 4 Flucht aufs Land Teil II: Verbündete"
|
||||||
|
assert p.split_contents_line(line, titles) == [
|
||||||
|
"3 Die Grauen nehmen die Spur auf",
|
||||||
|
"4 Flucht aufs Land",
|
||||||
|
"Teil II: Verbündete",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_contents_lines_split_by_numbers_without_outline():
|
||||||
|
assert p.split_contents_line("9 Brand-Jagd 10 Samuel Nussbaum – Kryptologe", []) == [
|
||||||
|
"9 Brand-Jagd",
|
||||||
|
"10 Samuel Nussbaum – Kryptologe",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_contents_block_keeps_entries_and_headings_follow():
|
||||||
|
outline = {"1 der notar": 3, "2 ein brief": 3}
|
||||||
|
md = (
|
||||||
|
"## Inhaltsverzeichnis\n\n1 Der Notar 2 Ein Brief\n\n"
|
||||||
|
"## 1 Der Notar\n\nDer Text beginnt hier.\n"
|
||||||
|
)
|
||||||
|
assert norm(md, outline) == (
|
||||||
|
"## Inhaltsverzeichnis\n\n1 Der Notar\n\n2 Ein Brief\n\n"
|
||||||
|
"### 1 Der Notar\n\nDer Text beginnt hier.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_heading_formatted_contents_entries_stay_plain():
|
||||||
|
outline = {"1 der notar": 3}
|
||||||
|
md = "## Inhaltsverzeichnis\n\n## 1 Der Notar\n\n## 1 Der Notar\n\nText.\n"
|
||||||
|
assert norm(md, outline) == "## Inhaltsverzeichnis\n\n1 Der Notar\n\n### 1 Der Notar\n\nText.\n"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Seitenzahlen, Kolumnen, Bildtext ---------------------------------------------
|
||||||
|
|
||||||
|
def test_page_numbers_are_dropped():
|
||||||
|
md = "Ein Satz ohne\n\n- 12 -\n\nEnde, der weitergeht.\n\nSeite 13\n"
|
||||||
|
assert norm(md) == "Ein Satz ohne Ende, der weitergeht.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_numbers_kept_on_request():
|
||||||
|
assert "12" in norm("Text.\n\n12\n", drop_page_numbers=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_heads_are_dropped():
|
||||||
|
md = "".join(f"Operation Ljutsch {n}\n\nText Nummer {n}.\n\n" for n in range(1, 5))
|
||||||
|
out = norm(md)
|
||||||
|
assert "Operation Ljutsch" not in out
|
||||||
|
assert out.count("Text Nummer") == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_dialogue_is_not_a_running_head():
|
||||||
|
md = "»Ja«\n\nEr nickte.\n\n" * 4
|
||||||
|
assert norm(md).count("»Ja«") == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_picture_text_is_removed():
|
||||||
|
md = (
|
||||||
|
"Vorher.\n\n**----- Start of picture text -----**<br>\nKauder welsch<br>"
|
||||||
|
"**----- End of picture text -----**<br>\n\nNachher.\n"
|
||||||
|
)
|
||||||
|
assert norm(md) == "Vorher.\n\nNachher.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_numbered_line_continuing_paragraph_is_no_list():
|
||||||
|
md = "Die Mauer fiel am\n9. November 1989 in Berlin.\n"
|
||||||
|
assert norm(md) == "Die Mauer fiel am 9. November 1989 in Berlin.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_list_stays_list():
|
||||||
|
md = "Liste:\n\n1. Erstens\n2. Zweitens\n"
|
||||||
|
assert norm(md) == "Liste:\n\n1. Erstens\n2. Zweitens\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_omitted_image_placeholder_stands_alone():
|
||||||
|
md = "Ein Satz ohne\n**==> picture [426 x 480] intentionally omitted <==**\nEnde.\n"
|
||||||
|
assert "\n\n**==> picture [426 x 480] intentionally omitted <==**\n\n" in norm(md)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Absatztrennung per Einzug ----------------------------------------------------
|
||||||
|
|
||||||
|
def _line(x0, x1, y, text):
|
||||||
|
return {"bbox": (x0, y, x1, y + 12), "spans": [{"text": text, "size": 12.0}]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_by_indent():
|
||||||
|
lines = [
|
||||||
|
_line(62, 400, 0, "Erster Absatz beginnt eingerückt und"),
|
||||||
|
_line(50, 400, 14, "läuft über die volle Breite bis zum"),
|
||||||
|
_line(50, 380, 28, "Ende des Satzes."),
|
||||||
|
_line(62, 400, 42, "Zweiter Absatz, wieder eingerückt und"),
|
||||||
|
_line(50, 200, 56, "kurz."),
|
||||||
|
_line(50, 400, 70, "Dritter Absatz ohne Einzug nach kurzer Zeile."),
|
||||||
|
]
|
||||||
|
groups = p.split_by_indent(lines)
|
||||||
|
assert [len(g) for g in groups] == [3, 2, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_by_indent_needs_sentence_end():
|
||||||
|
lines = [_line(50, 400, 0, "Ein Satz ohne Ende und"), _line(62, 400, 14, "eingerückte Fortsetzung.")]
|
||||||
|
assert len(p.split_by_indent(lines)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_demoted_heading_after_closed_paragraph_starts_new_paragraph():
|
||||||
|
md = "Erster Absatz.\n## Sie verabschiedeten sich.\n"
|
||||||
|
assert norm(md) == "Erster Absatz.\n\nSie verabschiedeten sich.\n"
|
||||||
Loading…
Reference in a new issue