Inhaltsverzeichnis einheitlich: Einträge als Absätze statt "\"-Zeilen

- Im Inhaltsverzeichnis wird jeder Eintrag ein eigener Absatz, auch wenn
  er als zeilengetreu erkannt wurde (vorher gemischte Abstände im EPUB)
- Zeilen, die wie Gliederungseinträge aussehen ("Teil V: …", "32 …"),
  werden auch ohne Überschrift "Inhalt" zu Absätzen (Vorschau Band Zwei)
- Tests, README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-02 17:32:20 +02:00
commit f0e0562451
3 changed files with 42 additions and 2 deletions

View file

@ -66,8 +66,10 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
werden ebenfalls aufgelöst (`wach­|senden` → `wachsenden`).
- **Ü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; liegt das Verzeichnis
als Tabelle vor, bleibt die Tabelle erhalten.
- **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt; jeder Eintrag wird ein
eigener Absatz (auch bei Verzeichnissen ohne Überschrift „Inhalt“, z. B. „Vorschau Band
Zwei“), nie eine Zeile mit `\`. Liegt das Verzeichnis als Tabelle vor, bleibt die Tabelle
erhalten.
- **OCR-Ebenen** in Courier werden nicht als Programmcode (`` `…` ``) gesetzt.
- **Links:** URLs werden als `<https://…>` geschrieben und sind damit in Markdown-Viewern
und im EPUB anklickbar. Beim Zeilenumbruch zerbrochene URLs (`deutsche-|sehen`,

View file

@ -131,6 +131,11 @@ PART_TITLE = re.compile(
r"^(?:teil|buch|band|part|book)\s+(?:[ivxlcdm]+|\d+)\b",
re.IGNORECASE,
)
# Zeile, die wie ein Gliederungseintrag aussieht ("Teil V: …", "32 Oberst …").
OUTLINE_ENTRY = re.compile(
r"^(?:\d{1,3}\.?\s+\S|(?:teil|buch|band|part|book|kapitel|chapter)\s+(?:[ivxlcdm]+|\d+)\b)",
re.IGNORECASE,
)
NUMBERED_TITLE = re.compile(
r"^(?:\d+\.?|kapitel\s+\d+|chapter\s+\d+)\s",
re.IGNORECASE,
@ -374,6 +379,19 @@ def normalize_markdown(
# Mehrzeiliger Absatz: Absatzende; einzelne Zeile: harter Umbruch
line += PARAGRAPH_END if end_mark.group(2) else HARD_BREAK
if in_contents and line.endswith((HARD_BREAK, PARAGRAPH_END)):
# Inhaltsverzeichnis: jeder Eintrag ein eigener Absatz (einheitlich,
# auch wenn das Verzeichnis in der PDF zeilengetreu erscheint).
line = line.rstrip(HARD_BREAK + PARAGRAPH_END)
elif line.endswith((HARD_BREAK, PARAGRAPH_END)) and OUTLINE_ENTRY.match(
clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END))
):
# Verzeichnis ohne Überschrift "Inhalt" (z. B. "Vorschau Band Zwei"):
# Einträge als eigene Absätze statt als Zeilen mit "\".
flush_paragraph()
standalone(clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END)))
continue
if line.endswith(PARAGRAPH_END):
content = line.removesuffix(PARAGRAPH_END)
if not paragraph and result and result[-1].endswith(HARD_BREAK):

View file

@ -141,3 +141,23 @@ def test_literal_fence_text_is_escaped_and_markers_never_leak():
out = p.normalize_markdown(md)
assert out.startswith("\\```")
assert not p.MARKERS.search(out)
def test_contents_entries_are_uniform_paragraphs():
outline = {"vorwort": 2, "1 der notar": 3, "2 ein brief": 3}
md = (
f"## Inhaltsverzeichnis\n\nTitel{H}\nVorwort{H}\n1 Der Notar{P}\n\n"
f"2 Ein Brief{B}40{B}\n\n## Vorwort\n\nText.\n"
)
assert p.normalize_markdown(md, outline) == (
"## Inhaltsverzeichnis\n\nTitel\n\nVorwort\n\n1 Der Notar\n\n2 Ein Brief\n\n"
"## Vorwort\n\nText.\n"
)
def test_outline_like_lines_outside_contents_become_paragraphs():
md = f"## Vorschau Band Zwei\n\n32 Oberst a.D.{H}\n33 Wolga-Attacke{P}\n\nTeil VI: Polen{H}\nOperation Morgenlicht{P}\n"
assert p.normalize_markdown(md) == (
"## Vorschau Band Zwei\n\n32 Oberst a.D.\n\n33 Wolga-Attacke\n\n"
"Teil VI: Polen\n\nOperation Morgenlicht\n"
)