--split seiten, --epub-per-file, Nachspann, --from-md --split, --split-chars
- --split seiten mit --pages-per-file N (Standard 20, Ausrichtung an Überschriften bis eine Seite neben der Grenze) oder --page-ranges (streng; Seiten außerhalb werden eigene Dateien) - Schnitt nur im fertigen Gesamt-Markdown (lückenlos): vor dem ersten Absatz, der auf der Grenzseite beginnt; Überschriften vor dem Schnitt wandern in die neue Datei; source_pages aus Anfang/Ende jedes Absatzes - Seitenzuordnung über die Rohtexte je Seite (pymupdf4llm page_chunks=True, aneinandergehängt byte-gleich; Band 1/2 unverändert), auch für OCR-Seiten - --epub-per-file: ein EPUB je Einzeldatei in <name>/epub/ - Nachspann: Anhang/Autor/Impressum nach dem letzten Kapitel als eigene Datei - --from-md DATEI --split teile|kapitel: bearbeitetes Markdown nachträglich teilen, Bild- und Cover-Pfade angepasst - --split-chars N; Längen-Abschnitte tragen die Überschrift am Anfang als Titel - Tests (tests/test_split_pages.py), README, Bedienungsanleitung, CLAUDE.md, TODO Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b05e2d9cf5
commit
5cd5e5d69b
1 changed files with 568 additions and 39 deletions
180
tests/test_split_pages.py
Normal file
180
tests/test_split_pages.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""--split seiten, --page-ranges, Nachspann, --split-chars, --from-md --split, --epub-per-file."""
|
||||
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
import pdf2md as p
|
||||
from conftest import needs_pandoc, needs_tesseract
|
||||
from test_e2e import run
|
||||
from test_split import BOOK
|
||||
|
||||
# Rohtext je Seite (wie von pymupdf4llm) und das daraus normalisierte Markdown:
|
||||
# Absatz B läuft von Seite 2 auf Seite 3 (mit Silbentrennung), auf Seite 3
|
||||
# steht vor Absatz D eine Überschrift.
|
||||
PAGE_TEXTS = {
|
||||
0: "Absatz A steht ganz auf der ersten Seite.\n",
|
||||
1: "Absatz B beginnt auf der zweiten Seite und reicht mit einem Geheim-\n",
|
||||
2: "dienst bis auf die dritte Seite.\n\n## Neues Kapitel\n\nAbsatz D folgt danach.\n",
|
||||
3: "Absatz E auf der vierten Seite.\n",
|
||||
}
|
||||
MARKDOWN = (
|
||||
"Absatz A steht ganz auf der ersten Seite.\n\n"
|
||||
"Absatz B beginnt auf der zweiten Seite und reicht mit einem Geheimdienst bis auf "
|
||||
"die dritte Seite.\n\n"
|
||||
"## Neues Kapitel\n\n"
|
||||
"Absatz D folgt danach.\n\n"
|
||||
"Absatz E auf der vierten Seite.\n"
|
||||
)
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
return text.replace("\n", "")
|
||||
|
||||
|
||||
def test_md_blocks_keep_text():
|
||||
assert "".join(p.md_blocks(BOOK)) == BOOK
|
||||
assert "".join(p.md_blocks(MARKDOWN)) == MARKDOWN
|
||||
|
||||
|
||||
def test_block_pages_start_and_end():
|
||||
spans = p.block_pages(p.md_blocks(MARKDOWN), PAGE_TEXTS)
|
||||
assert spans == [(0, 0), (1, 2), (2, 2), (2, 2), (3, 3)]
|
||||
|
||||
|
||||
def test_block_pages_inherit_unknown_blocks():
|
||||
blocks = ["Absatz A steht ganz auf der ersten Seite.\n\n", "\n\n", "Ja.\n\n",
|
||||
"Absatz E auf der vierten Seite.\n"]
|
||||
assert [s for s, _ in p.block_pages(blocks, PAGE_TEXTS)] == [0, 0, 0, 3]
|
||||
|
||||
|
||||
def test_split_by_pages_keeps_paragraph_over_page_break():
|
||||
sections = p.split_by_pages(MARKDOWN, [0, 2], PAGE_TEXTS, align=False)
|
||||
# Absatz B beginnt auf Seite 2 -> bleibt vorn; geschnitten wird vor der
|
||||
# Überschrift, die auf Seite 3 beginnt.
|
||||
assert sections[0]["body"].endswith("die dritte Seite.\n")
|
||||
assert sections[0]["source_pages"] == [1, 3]
|
||||
assert sections[1]["body"].startswith("## Neues Kapitel")
|
||||
assert sections[1]["title"] == "Neues Kapitel" and sections[1]["source_pages"] == [3, 4]
|
||||
assert _flat("".join(s["body"] for s in sections)) == _flat(MARKDOWN)
|
||||
|
||||
|
||||
def test_split_by_pages_heading_moves_to_next_file():
|
||||
# Grenze vor Seite 3: der erste dort beginnende Block ist Absatz D; die
|
||||
# Überschrift davor gehört zur neuen Datei.
|
||||
texts = PAGE_TEXTS | {2: "dienst bis auf die dritte Seite.\n\n## Neues Kapitel\n",
|
||||
3: "Absatz D folgt danach.\n\nAbsatz E auf der vierten Seite.\n"}
|
||||
sections = p.split_by_pages(MARKDOWN, [0, 3], texts, align=False)
|
||||
assert sections[1]["body"].startswith("## Neues Kapitel\n\nAbsatz D")
|
||||
assert not sections[0]["body"].rstrip().endswith("Kapitel")
|
||||
|
||||
|
||||
def test_split_by_pages_aligns_to_nearby_heading():
|
||||
# Grenze vor Seite 4 (Absatz E); die Überschrift auf Seite 3 liegt eine Seite davor.
|
||||
aligned = p.split_by_pages(MARKDOWN, [0, 3], PAGE_TEXTS, align=True)
|
||||
strict = p.split_by_pages(MARKDOWN, [0, 3], PAGE_TEXTS, align=False)
|
||||
assert aligned[1]["body"].startswith("## Neues Kapitel")
|
||||
assert strict[1]["body"].startswith("Absatz E") and strict[1]["title"] == "Seite 4"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("starts", [[0, 1], [0, 1, 2, 3], [0, 2], [0]])
|
||||
def test_split_by_pages_loses_nothing(starts):
|
||||
for align in (True, False):
|
||||
sections = p.split_by_pages(MARKDOWN, starts, PAGE_TEXTS, align=align)
|
||||
assert _flat("".join(s["body"] for s in sections)) == _flat(MARKDOWN)
|
||||
|
||||
|
||||
def test_parse_page_ranges():
|
||||
assert p.parse_page_ranges("1-12,13-40,41-", 100) == [0, 12, 40]
|
||||
assert p.parse_page_ranges("5-10, 20-", 30) == [0, 4, 10, 19] # Lücken = eigene Dateien
|
||||
assert p.parse_page_ranges("3", 10) == [0, 2, 3]
|
||||
for bad in ("1-12,10-20", "0-5", "5-200", "a-b", "8-3"):
|
||||
with pytest.raises(ValueError):
|
||||
p.parse_page_ranges(bad, 100)
|
||||
|
||||
|
||||
def test_backmatter_becomes_own_file():
|
||||
book = BOOK.replace("## Impressum\n\nAlle Rechte vorbehalten.\n",
|
||||
"### Der Autor\n\nLebt in Berlin.\n\n### Impressum\n\nRechte.\n")
|
||||
sections = p.split_markdown(book, "teile")
|
||||
assert [s["title"] for s in sections] == ["Vorspann", "Teil I: Der Anfang", "Nachspann"]
|
||||
assert sections[-1]["body"].startswith("### Der Autor") and "Impressum" in sections[-1]["body"]
|
||||
assert _flat("".join(s["body"] for s in sections)) == _flat(book)
|
||||
|
||||
|
||||
def test_split_chars_and_heading_titles():
|
||||
paragraphs = [f"Absatz {n} " + " ".join(["Text"] * 40) for n in range(20)]
|
||||
markdown = "## Erster Teil\n\n" + "\n\n".join(paragraphs) + "\n"
|
||||
sections = p.split_markdown(markdown, "kapitel", size=1500)
|
||||
assert sections[0]["title"] == "Erster Teil" and sections[1]["title"] == "Abschnitt 2"
|
||||
assert len(p.split_markdown(markdown, "kapitel", size=100_000)) == 1
|
||||
|
||||
|
||||
def test_rebase_image_links(tmp_path):
|
||||
md = "  "
|
||||
out = p.rebase_image_links(md, tmp_path, tmp_path / "buch")
|
||||
assert out == "  "
|
||||
|
||||
|
||||
# --- Ende zu Ende ------------------------------------------------------------------
|
||||
|
||||
def _heads(folder):
|
||||
return [p.split_frontmatter(f.read_text())[0] for f in sorted(folder.glob("0*.md"))]
|
||||
|
||||
|
||||
def test_cli_split_pages(pdfs, tmp_path):
|
||||
run(pdfs["digital"], tmp_path / "buch.md", "--split", "seiten", "--pages-per-file", "1")
|
||||
heads = _heads(tmp_path / "buch")
|
||||
assert [h["source_pages"] for h in heads] == [[1, 1], [2, 3], [3, 3], [4, 4]]
|
||||
assert [h["title"] for h in heads] == ["Das Testbuch", "Teil I: Der Anfang", "2 Ein Brief", "Seite 4"]
|
||||
whole = tmp_path / "ganz.md"
|
||||
run(pdfs["digital"], whole)
|
||||
body = "\n\n".join(p.split_frontmatter(f.read_text())[1].strip()
|
||||
for f in sorted((tmp_path / "buch").glob("0*.md")))
|
||||
assert _flat(body) == _flat(p.split_frontmatter(whole.read_text())[1].strip())
|
||||
|
||||
|
||||
def test_cli_page_ranges(pdfs, tmp_path):
|
||||
run(pdfs["digital"], tmp_path / "buch.md", "--split", "seiten", "--page-ranges", "2-3")
|
||||
assert [h["source_pages"][0] for h in _heads(tmp_path / "buch")] == [1, 2, 4]
|
||||
|
||||
|
||||
@needs_tesseract
|
||||
def test_cli_split_pages_scanned(pdfs, tmp_path):
|
||||
# Seitenzuordnung über den OCR-Text (die Scanseiten haben selbst keinen Text)
|
||||
run(pdfs["scanned"], tmp_path / "buch.md", "--split", "seiten", "--pages-per-file", "2",
|
||||
"--jobs", "1")
|
||||
heads = _heads(tmp_path / "buch")
|
||||
assert len(heads) == 2 and heads[1]["source_pages"][0] == 3
|
||||
|
||||
|
||||
def test_cli_split_options_are_checked(pdfs, tmp_path):
|
||||
import subprocess
|
||||
import sys
|
||||
for extra in (["--pages-per-file", "3"], ["--split", "kapitel", "--page-ranges", "1-2"],
|
||||
["--epub-per-file"]):
|
||||
done = subprocess.run([sys.executable, str(p.__file__), str(pdfs["digital"]),
|
||||
str(tmp_path / "x.md"), *extra], capture_output=True, text=True)
|
||||
assert done.returncode == 2 and "nur mit --split" in done.stderr
|
||||
|
||||
|
||||
@needs_pandoc
|
||||
def test_from_md_split_and_epub_per_file(pdfs, tmp_path):
|
||||
md = tmp_path / "buch.md"
|
||||
run(pdfs["digital"], md, "--images")
|
||||
run("--from-md", md, "--split", "kapitel", "--epub-per-file")
|
||||
folder = tmp_path / "buch"
|
||||
names = sorted(f.name for f in folder.glob("*.md"))
|
||||
assert "index.md" in names and any(n.endswith("_1-der-notar.md") for n in names)
|
||||
text = "".join(f.read_text() for f in folder.glob("0*.md"))
|
||||
assert "](../buch_images/" in text
|
||||
meta, _ = p.split_frontmatter((folder / "index.md").read_text())
|
||||
assert meta["cover"] == "../buch_images/cover.jpg"
|
||||
epubs = sorted((folder / "epub").glob("*.epub"))
|
||||
assert len(epubs) == len(names) - 1
|
||||
notar = next(e for e in epubs if "1-der-notar" in e.name)
|
||||
with zipfile.ZipFile(notar) as z:
|
||||
opf = z.read("EPUB/content.opf").decode()
|
||||
images = [n for n in z.namelist() if n.endswith(".jpg")]
|
||||
assert "Das Testbuch - 1 Der Notar" in opf and "cover" in opf and images
|
||||
assert not (tmp_path / "buch.epub").exists() # Gesamt-EPUB nur mit --epub
|
||||
Loading…
Reference in a new issue