1110 lines
38 KiB
Python
1110 lines
38 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""PDF (digital oder gescannt) nach Markdown und optional EPUB konvertieren.
|
|||
|
|
|
|||
|
|
Ablauf:
|
|||
|
|
1. Jede Seite wird klassifiziert: Textseite, Scan ohne Text, Scan mit
|
|||
|
|
vorhandener OCR-Ebene oder leer.
|
|||
|
|
2. Seitengruppen gehen mit passender OCR-Einstellung an pymupdf4llm
|
|||
|
|
(optional parallel).
|
|||
|
|
3. normalize_markdown() gliedert Fließtext nach Satzenden, setzt
|
|||
|
|
Überschriften anhand der PDF-Gliederung, fügt Silbentrennungen
|
|||
|
|
zusammen und entfernt Seitenzahlen und Kolumnentitel.
|
|||
|
|
4. Optional erzeugt pandoc daraus ein EPUB.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import contextlib
|
|||
|
|
import io
|
|||
|
|
import multiprocessing
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import shutil
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
from collections import Counter
|
|||
|
|
from concurrent.futures import ProcessPoolExecutor
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
import pymupdf
|
|||
|
|
import pymupdf4llm
|
|||
|
|
from pymupdf4llm.helpers import document_layout
|
|||
|
|
from pymupdf4llm.ocr import tesseract_api
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ===========================================================================
|
|||
|
|
# Markdown-Muster
|
|||
|
|
# ===========================================================================
|
|||
|
|
|
|||
|
|
HEADING = re.compile(r"^\s{0,3}(#{1,6})\s*(\S.*?)\s*#*\s*$")
|
|||
|
|
|
|||
|
|
# Schließende Anführungszeichen (deutsch „…“ ‚…‘ »…«, englisch, Schweiz «…»),
|
|||
|
|
# Klammern und Markdown-Hervorhebungen.
|
|||
|
|
CLOSERS = r"""”“’‘"'»«›‹)\]*_"""
|
|||
|
|
|
|||
|
|
# Satzzeichen, optional gefolgt von schließenden Zeichen (z. B. "…da?«").
|
|||
|
|
SENTENCE_END = re.compile(rf"""[.!?…]+[{CLOSERS}]*$""")
|
|||
|
|
|
|||
|
|
# Typische Endungen von Sätzen, die fälschlich als Überschrift erkannt wurden:
|
|||
|
|
# Punkt/Auslassung oder Frage/Ausruf mit schließendem Anführungszeichen.
|
|||
|
|
SENTENCE_LIKE_END = re.compile(rf"""(?:[.…][{CLOSERS}]*|[!?]+[{CLOSERS}]+)$""")
|
|||
|
|
|
|||
|
|
# Überschriften, die legitim mit Punkt enden: "3.", "IV.", "Kapitel 3."
|
|||
|
|
NUMBERING_ONLY = re.compile(
|
|||
|
|
r"^(?:(?:kapitel|teil|chapter|part)\s+)?(?:\d+|[ivxlcdm]+)\.$",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
|
|||
|
|
LIST_ITEM = re.compile(r"^\s{0,3}(?:[-+*]|\d+[.)])\s+")
|
|||
|
|
ORDERED_ITEM = re.compile(r"^\s{0,3}\d+[.)]\s+")
|
|||
|
|
BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?")
|
|||
|
|
RULE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$")
|
|||
|
|
IMAGE = re.compile(r"^\s*!\[.*?\]\(.*\)\s*$")
|
|||
|
|
|
|||
|
|
# Beispiel: **==> picture [426 x 480] intentionally omitted <==**
|
|||
|
|
OMITTED_IMAGE = re.compile(
|
|||
|
|
r"^\s*(?:\*\*|__)?\s*==>\s*"
|
|||
|
|
r"(?:picture|image|bild)\b.*?"
|
|||
|
|
r"(?:intentionally omitted|omitted|ausgelassen)\s*"
|
|||
|
|
r"<==\s*(?:\*\*|__)?\s*$",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Text, den pymupdf4llm innerhalb von Bildern findet (Fotos, OCR-Reste).
|
|||
|
|
PICTURE_TEXT = re.compile(
|
|||
|
|
r"\*\*----- Start of picture text -----\*\*<br>.*?"
|
|||
|
|
r"\*\*----- End of picture text -----\*\*<br>\n?",
|
|||
|
|
re.DOTALL,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
CONTENTS_TITLE = re.compile(
|
|||
|
|
r"^(?:inhaltsverzeichnis|inhalt|contents|table of contents)$",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
TOC_WITH_PAGE = re.compile(
|
|||
|
|
r"^\s*(?:[-*]\s+)?\S.+?"
|
|||
|
|
r"(?:\.{2,}|\s{2,})"
|
|||
|
|
r"(?:\d+|[ivxlcdm]+)\s*$",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Beginn eines nummerierten Eintrags mitten in einer Inhaltsverzeichniszeile.
|
|||
|
|
NEXT_TOC_NUMBER = re.compile(r"\s(?=\d{1,3}\s+[^\W\d_])")
|
|||
|
|
|
|||
|
|
HTML_BLOCK = re.compile(
|
|||
|
|
r"^\s*</?(?:table|tr|td|th|div|pre|p|h[1-6])\b",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Seitenzahl allein in einer Zeile: "12", "- 12 -", "Seite 12"
|
|||
|
|
PAGE_NUMBER = re.compile(
|
|||
|
|
r"^(?:[-–—]\s*)?(?:(?:seite|page|s\.)\s*)?\d{1,4}(?:\s*[-–—])?$",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
QUOTE_CHARS = set("«»„“”\"‚‘’›‹")
|
|||
|
|
DASHES = str.maketrans({"–": "-", "—": "-", "‐": "-", "‑": "-"})
|
|||
|
|
|
|||
|
|
# Silbentrennung am Zeilen-/Blockende (Bindestrich, weicher Trennstrich, ¬).
|
|||
|
|
HYPHEN_END = re.compile(r"[^\W\d_]{2,}[-\u00ad¬]$")
|
|||
|
|
FIRST_WORD = re.compile(r"[^\W\d_]+")
|
|||
|
|
|
|||
|
|
# Nach "Arbeits-" folgt "und Krieger-Skills": Bindestrich + Leerzeichen bleiben.
|
|||
|
|
CONJUNCTIONS = {
|
|||
|
|
"und", "oder", "bis", "sowie", "als", "wie", "bzw", "noch",
|
|||
|
|
"beziehungsweise", "respektive", "and", "or", "to",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Wörter, mit denen eine echte Überschrift praktisch nie endet (Artikel,
|
|||
|
|
# Konjunktionen). Präpositionen fehlen bewusst: "Die Spur nehmen sie auf".
|
|||
|
|
TRAILING_STOPWORDS = {
|
|||
|
|
"der", "die", "das", "den", "dem", "des", "ein", "eine", "einen", "einem",
|
|||
|
|
"einer", "eines", "und", "oder", "aber", "dass", "daß", "sowie",
|
|||
|
|
"the", "a", "an", "and", "or", "of",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Gliederungseinträge für Teile bzw. nummerierte Kapitel.
|
|||
|
|
PART_TITLE = re.compile(
|
|||
|
|
r"^(?:teil|buch|band|part|book)\s+(?:[ivxlcdm]+|\d+)\b",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
NUMBERED_TITLE = re.compile(
|
|||
|
|
r"^(?:\d+\.?|kapitel\s+\d+|chapter\s+\d+)\s",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ===========================================================================
|
|||
|
|
# Hilfsfunktionen für Text
|
|||
|
|
# ===========================================================================
|
|||
|
|
|
|||
|
|
def ends_sentence(text: str) -> bool:
|
|||
|
|
return bool(SENTENCE_END.search(text.rstrip()))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def clean_inline(text: str) -> str:
|
|||
|
|
"""Markdown-Hervorhebungen entfernen und Leerraum vereinheitlichen."""
|
|||
|
|
text = re.sub(r"\*\*|__|(?<!\w)_|_(?!\w)", "", text)
|
|||
|
|
return re.sub(r"\s+", " ", text).strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def norm_title(text: str) -> str:
|
|||
|
|
"""Vergleichsschlüssel für Überschriften und Gliederungseinträge."""
|
|||
|
|
return clean_inline(text).translate(DASHES).lower()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_other_structure(line: str) -> bool:
|
|||
|
|
"""Markdown-Strukturen, die nicht zu Fließtext verbunden werden."""
|
|||
|
|
return bool(
|
|||
|
|
LIST_ITEM.match(line)
|
|||
|
|
or BLOCKQUOTE.match(line)
|
|||
|
|
or RULE.match(line)
|
|||
|
|
or IMAGE.match(line)
|
|||
|
|
or HTML_BLOCK.match(line)
|
|||
|
|
or line.lstrip().startswith("|")
|
|||
|
|
or line.startswith((" ", "\t"))
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def join_parts(prev: str, nxt: str) -> str:
|
|||
|
|
"""Zwei Teile eines Absatzes verbinden und Silbentrennung auflösen.
|
|||
|
|
|
|||
|
|
"Geheim-" + "dienst" -> "Geheimdienst"
|
|||
|
|
"Militär-" + "Geheimdienst" -> "Militär-Geheimdienst"
|
|||
|
|
"Arbeits-" + "und …" -> "Arbeits- und …"
|
|||
|
|
"""
|
|||
|
|
if not HYPHEN_END.search(prev) or not nxt:
|
|||
|
|
return f"{prev} {nxt}"
|
|||
|
|
|
|||
|
|
stem = prev[:-1]
|
|||
|
|
word = FIRST_WORD.match(nxt)
|
|||
|
|
if word and word.group(0).lower() in CONJUNCTIONS:
|
|||
|
|
return f"{stem}- {nxt}"
|
|||
|
|
if nxt[0].islower():
|
|||
|
|
return stem + nxt
|
|||
|
|
return f"{stem}-{nxt}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_false_heading(text: str, has_outline: bool, paragraph_open: bool) -> bool:
|
|||
|
|
"""Erkennen, ob eine "Überschrift" in Wahrheit ein Satz(-rest) ist."""
|
|||
|
|
if not text:
|
|||
|
|
return True
|
|||
|
|
if text[0].islower() or text[0] in "–—-…,;" or text[-1] in ",;":
|
|||
|
|
return True
|
|||
|
|
if NUMBERING_ONLY.match(text):
|
|||
|
|
return False
|
|||
|
|
if paragraph_open and ends_sentence(text):
|
|||
|
|
return True
|
|||
|
|
if paragraph_open and text.split()[-1].lower() in TRAILING_STOPWORDS:
|
|||
|
|
return True # "Mitglied des Künstlerverbandes – damals eine"
|
|||
|
|
if SENTENCE_LIKE_END.search(text):
|
|||
|
|
return True
|
|||
|
|
# Mit Gliederung stehen echte Überschriften im Outline; was dort fehlt
|
|||
|
|
# und wie ein Satz endet, ist Fließtext.
|
|||
|
|
return has_outline and ends_sentence(text)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def running_head_key(line: str) -> str | None:
|
|||
|
|
"""Vergleichsschlüssel für mögliche Kolumnentitel (sonst None)."""
|
|||
|
|
if (
|
|||
|
|
HEADING.match(line)
|
|||
|
|
or OMITTED_IMAGE.match(line)
|
|||
|
|
or is_other_structure(line)
|
|||
|
|
or FENCE.match(line)
|
|||
|
|
):
|
|||
|
|
return None
|
|||
|
|
text = clean_inline(line)
|
|||
|
|
if not text or len(text) > 60 or ends_sentence(text):
|
|||
|
|
return None
|
|||
|
|
if QUOTE_CHARS & set(text):
|
|||
|
|
return None # wörtliche Rede ist kein Kolumnentitel
|
|||
|
|
key = re.sub(r"\d+", "", text)
|
|||
|
|
key = re.sub(r"\s+", " ", key).strip(" .-–—|·").lower()
|
|||
|
|
if sum(ch.isalpha() for ch in key) < 4:
|
|||
|
|
return None
|
|||
|
|
return key
|
|||
|
|
|
|||
|
|
|
|||
|
|
def find_running_heads(lines: list[str], min_repeats: int = 3) -> set[str]:
|
|||
|
|
"""Kurze Zeilen, die (ohne Ziffern) sehr oft vorkommen."""
|
|||
|
|
counts = Counter(k for k in map(running_head_key, lines) if k)
|
|||
|
|
return {key for key, count in counts.items() if count >= min_repeats}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def split_contents_line(line: str, titles: list[str]) -> list[str]:
|
|||
|
|
"""Zusammengeklebte Inhaltsverzeichniseinträge trennen.
|
|||
|
|
|
|||
|
|
titles: normalisierte Gliederungstitel, längste zuerst.
|
|||
|
|
"""
|
|||
|
|
text = clean_inline(line)
|
|||
|
|
entries = []
|
|||
|
|
|
|||
|
|
while text:
|
|||
|
|
low = text.translate(DASHES).lower()
|
|||
|
|
|
|||
|
|
match = next(
|
|||
|
|
(
|
|||
|
|
t for t in titles
|
|||
|
|
if low.startswith(t) and (len(low) == len(t) or low[len(t)] == " ")
|
|||
|
|
),
|
|||
|
|
None,
|
|||
|
|
)
|
|||
|
|
if match:
|
|||
|
|
entries.append(text[: len(match)])
|
|||
|
|
text = text[len(match):].strip()
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Nächsten bekannten Titel oder nächste Kapitelnummer suchen.
|
|||
|
|
cuts = [
|
|||
|
|
pos for t in titles
|
|||
|
|
if (pos := low.find(" " + t, 1)) > 0
|
|||
|
|
and (pos + 1 + len(t) == len(low) or low[pos + 1 + len(t)] == " ")
|
|||
|
|
]
|
|||
|
|
number = NEXT_TOC_NUMBER.search(text)
|
|||
|
|
if number:
|
|||
|
|
cuts.append(number.start())
|
|||
|
|
if not cuts:
|
|||
|
|
entries.append(text)
|
|||
|
|
break
|
|||
|
|
cut = min(cuts)
|
|||
|
|
entries.append(text[:cut].strip())
|
|||
|
|
text = text[cut:].strip()
|
|||
|
|
|
|||
|
|
return [e for e in entries if e]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ===========================================================================
|
|||
|
|
# Markdown normalisieren
|
|||
|
|
# ===========================================================================
|
|||
|
|
|
|||
|
|
def normalize_markdown(
|
|||
|
|
markdown: str,
|
|||
|
|
outline: dict[str, int] | None = None,
|
|||
|
|
*,
|
|||
|
|
drop_page_numbers: bool = True,
|
|||
|
|
drop_running_heads: bool = True,
|
|||
|
|
running_head_min: int = 3,
|
|||
|
|
) -> str:
|
|||
|
|
outline = outline or {}
|
|||
|
|
titles = sorted(outline, key=len, reverse=True)
|
|||
|
|
|
|||
|
|
markdown = PICTURE_TEXT.sub("", markdown)
|
|||
|
|
lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|||
|
|
running_heads = (
|
|||
|
|
find_running_heads(lines, running_head_min) if drop_running_heads else set()
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
result: list[str] = []
|
|||
|
|
paragraph: list[str] = []
|
|||
|
|
|
|||
|
|
in_fence = False
|
|||
|
|
fence_char = ""
|
|||
|
|
fence_length = 0
|
|||
|
|
in_contents = False
|
|||
|
|
contents_seen: set[str] = set() # Titel, die im Inhaltsverzeichnis standen
|
|||
|
|
headings_seen: set[str] = set()
|
|||
|
|
|
|||
|
|
def paragraph_open() -> bool:
|
|||
|
|
return bool(paragraph) and not ends_sentence(paragraph[-1])
|
|||
|
|
|
|||
|
|
def blank_line() -> None:
|
|||
|
|
if result and result[-1] != "":
|
|||
|
|
result.append("")
|
|||
|
|
|
|||
|
|
def flush_paragraph() -> None:
|
|||
|
|
if paragraph:
|
|||
|
|
text = paragraph[0]
|
|||
|
|
for part in paragraph[1:]:
|
|||
|
|
text = join_parts(text, part)
|
|||
|
|
result.append(text)
|
|||
|
|
paragraph.clear()
|
|||
|
|
|
|||
|
|
def standalone(line: str) -> None:
|
|||
|
|
flush_paragraph()
|
|||
|
|
blank_line()
|
|||
|
|
result.append(line)
|
|||
|
|
result.append("")
|
|||
|
|
|
|||
|
|
def heading(level: int, text: str) -> None:
|
|||
|
|
headings_seen.add(norm_title(text))
|
|||
|
|
standalone("#" * min(max(level, 1), 6) + " " + text)
|
|||
|
|
|
|||
|
|
for original in lines:
|
|||
|
|
line = original.rstrip()
|
|||
|
|
marker = FENCE.match(line)
|
|||
|
|
|
|||
|
|
if in_fence:
|
|||
|
|
result.append(line)
|
|||
|
|
if marker:
|
|||
|
|
token = marker.group(1)
|
|||
|
|
if token[0] == fence_char and len(token) >= fence_length:
|
|||
|
|
in_fence = False
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if marker:
|
|||
|
|
flush_paragraph()
|
|||
|
|
blank_line()
|
|||
|
|
result.append(line)
|
|||
|
|
in_fence = True
|
|||
|
|
fence_char = marker.group(1)[0]
|
|||
|
|
fence_length = len(marker.group(1))
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if not line.strip():
|
|||
|
|
# Leerzeile nur dann als Absatzgrenze behandeln,
|
|||
|
|
# wenn der bisherige Fließtext mit einem Satzende endet.
|
|||
|
|
if paragraph and ends_sentence(paragraph[-1]):
|
|||
|
|
flush_paragraph()
|
|||
|
|
blank_line()
|
|||
|
|
elif not paragraph:
|
|||
|
|
blank_line()
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
stripped = line.strip()
|
|||
|
|
|
|||
|
|
if not in_contents:
|
|||
|
|
if drop_page_numbers and PAGE_NUMBER.match(clean_inline(stripped)):
|
|||
|
|
continue
|
|||
|
|
if running_heads and running_head_key(stripped) in running_heads:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
match = HEADING.match(line)
|
|||
|
|
text = clean_inline(match.group(2)) if match else clean_inline(stripped)
|
|||
|
|
|
|||
|
|
if CONTENTS_TITLE.match(text):
|
|||
|
|
heading(len(match.group(1)) if match else 2, text)
|
|||
|
|
in_contents = True
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if match:
|
|||
|
|
key = norm_title(text)
|
|||
|
|
level = outline.get(key)
|
|||
|
|
|
|||
|
|
if in_contents and level is not None and key not in contents_seen:
|
|||
|
|
# Überschrift-formatierter Eintrag im Inhaltsverzeichnis
|
|||
|
|
contents_seen.add(key)
|
|||
|
|
standalone(text)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if level is None and is_false_heading(
|
|||
|
|
text, bool(outline), paragraph_open()
|
|||
|
|
):
|
|||
|
|
# Satz(-rest) zurück in den Fließtext: setzt einen offenen
|
|||
|
|
# Absatz fort, sonst beginnt er einen neuen.
|
|||
|
|
in_contents = False
|
|||
|
|
if paragraph and not paragraph_open():
|
|||
|
|
flush_paragraph()
|
|||
|
|
blank_line()
|
|||
|
|
paragraph.append(text)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
in_contents = False
|
|||
|
|
heading(level or len(match.group(1)), text)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if OMITTED_IMAGE.match(line):
|
|||
|
|
standalone(line)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if in_contents:
|
|||
|
|
if ends_sentence(text) and len(text) > 80:
|
|||
|
|
in_contents = False # erster Fließtext nach dem Verzeichnis
|
|||
|
|
else:
|
|||
|
|
for entry in split_contents_line(stripped, titles):
|
|||
|
|
contents_seen.add(norm_title(entry))
|
|||
|
|
standalone(entry)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if TOC_WITH_PAGE.match(line):
|
|||
|
|
standalone(line)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Eigenständige Zeile, die genau einem Gliederungstitel entspricht
|
|||
|
|
# (z. B. nur fett gesetzt): zur Überschrift machen.
|
|||
|
|
key = norm_title(stripped)
|
|||
|
|
if key in outline and key not in headings_seen and len(stripped) <= 150:
|
|||
|
|
heading(outline[key], text)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# "Die Mauer fiel am" + "9. November …" ist keine Liste,
|
|||
|
|
# "Liste:" + "1. Erstens" dagegen schon.
|
|||
|
|
continues_sentence = paragraph_open() and paragraph[-1][-1:].isalnum()
|
|||
|
|
if is_other_structure(line) and not (
|
|||
|
|
ORDERED_ITEM.match(line) and continues_sentence
|
|||
|
|
):
|
|||
|
|
if paragraph:
|
|||
|
|
flush_paragraph()
|
|||
|
|
blank_line() # pandoc braucht eine Leerzeile vor Listen/Tabellen
|
|||
|
|
result.append(line)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Einfache Zeilenumbrüche und nicht bestätigte Absatzgrenzen
|
|||
|
|
# werden beim Flush durch Leerzeichen ersetzt.
|
|||
|
|
paragraph.append(stripped)
|
|||
|
|
|
|||
|
|
flush_paragraph()
|
|||
|
|
return "\n".join(result).strip() + "\n"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ===========================================================================
|
|||
|
|
# PDF-Gliederung
|
|||
|
|
# ===========================================================================
|
|||
|
|
|
|||
|
|
def load_outline(doc: pymupdf.Document) -> dict[str, int]:
|
|||
|
|
"""PDF-Lesezeichen als {normalisierter Titel: Überschriftenebene}."""
|
|||
|
|
original: dict[str, int] = {}
|
|||
|
|
for level, title, _page in doc.get_toc(simple=True):
|
|||
|
|
key = norm_title(title)
|
|||
|
|
if key:
|
|||
|
|
original.setdefault(key, level)
|
|||
|
|
|
|||
|
|
# Teile und nummerierte Kapitel stehen oft auf derselben Ebene;
|
|||
|
|
# dann rücken die Kapitel eine Ebene tiefer.
|
|||
|
|
levels = dict(original)
|
|||
|
|
for level in set(original.values()):
|
|||
|
|
keys = [k for k, v in original.items() if v == level]
|
|||
|
|
if any(PART_TITLE.match(k) for k in keys):
|
|||
|
|
for key in keys:
|
|||
|
|
if NUMBERED_TITLE.match(key) and not PART_TITLE.match(key):
|
|||
|
|
levels[key] = level + 1
|
|||
|
|
return levels
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ===========================================================================
|
|||
|
|
# Korrektur für pymupdf4llm: Bindestrich-Komposita am Zeilenende
|
|||
|
|
# ===========================================================================
|
|||
|
|
# pymupdf4llm entfernt jeden Bindestrich am Zeilenende ("Militär-" + Zeile
|
|||
|
|
# "Geheimdienst" -> "MilitärGeheimdienst"). Für Großbuchstaben und
|
|||
|
|
# Konjunktionen ("Arbeits- und") markieren wir den Strich vorher, sodass er
|
|||
|
|
# erhalten bleibt. Echte Silbentrennung (Kleinbuchstabe) bleibt unverändert.
|
|||
|
|
|
|||
|
|
KEEP_HYPHEN = "\ue000" # Strich behalten, ohne Leerzeichen anschließen
|
|||
|
|
KEEP_HYPHEN_SPACE = "\ue001" # Strich und Leerzeichen behalten
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _mark_line_end_hyphens(spans: list[dict]) -> list[dict]:
|
|||
|
|
marked = []
|
|||
|
|
for i, span in enumerate(spans):
|
|||
|
|
nxt = spans[i + 1] if i + 1 < len(spans) else None
|
|||
|
|
text = span["text"].rstrip()
|
|||
|
|
if (
|
|||
|
|
nxt is not None
|
|||
|
|
and text.endswith("-")
|
|||
|
|
and (span.get("block"), span.get("line"))
|
|||
|
|
!= (nxt.get("block"), nxt.get("line"))
|
|||
|
|
):
|
|||
|
|
word = FIRST_WORD.match(nxt["text"].lstrip())
|
|||
|
|
if word and word.group(0).lower() in CONJUNCTIONS:
|
|||
|
|
span = {**span, "text": text + KEEP_HYPHEN_SPACE}
|
|||
|
|
elif word and word.group(0)[0].isupper():
|
|||
|
|
span = {**span, "text": text + KEEP_HYPHEN}
|
|||
|
|
marked.append(span)
|
|||
|
|
return marked
|
|||
|
|
|
|||
|
|
|
|||
|
|
def patch_pymupdf4llm_hyphens() -> None:
|
|||
|
|
original = getattr(document_layout, "get_styled_text", None)
|
|||
|
|
if original is None or getattr(original, "pdf2md_patched", False):
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
def get_styled_text(spans):
|
|||
|
|
output, suffix = original(_mark_line_end_hyphens(spans))
|
|||
|
|
output = (
|
|||
|
|
output.replace(KEEP_HYPHEN + " ", "")
|
|||
|
|
.replace(KEEP_HYPHEN, "")
|
|||
|
|
.replace(KEEP_HYPHEN_SPACE, "")
|
|||
|
|
)
|
|||
|
|
return output, suffix
|
|||
|
|
|
|||
|
|
get_styled_text.pdf2md_patched = True
|
|||
|
|
document_layout.get_styled_text = get_styled_text
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Gedruckte Bücher trennen Absätze durch Einzug statt Leerraum. Auf Scanseiten
|
|||
|
|
# fasst das Layout-Modell dann mehrere Absätze zu einem Block zusammen, und
|
|||
|
|
# pymupdf4llm verbindet alle Zeilen. Wir trennen den Block vorher dort, wo
|
|||
|
|
# eine Zeile mit Satzende schließt und die nächste eingerückt ist bzw. die
|
|||
|
|
# vorige Zeile deutlich kürzer war.
|
|||
|
|
|
|||
|
|
INDENT_MIN_EM = 0.8 # Einzug ab ca. 1 Geviert
|
|||
|
|
INDENT_MAX_EM = 8.0 # mehr ist eher zentrierter Text
|
|||
|
|
SHORT_LINE_RATIO = 0.7 # Absatzende: Zeile kürzer als 70 % der längsten
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _line_text(line: dict) -> str:
|
|||
|
|
return "".join(span["text"] for span in line["spans"]).strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def split_by_indent(textlines: list[dict]) -> list[list[dict]]:
|
|||
|
|
"""Zeilen eines Textblocks in Absätze aufteilen."""
|
|||
|
|
if len(textlines) < 2:
|
|||
|
|
return [textlines]
|
|||
|
|
|
|||
|
|
sizes = sorted(s["size"] for line in textlines for s in line["spans"])
|
|||
|
|
em = sizes[len(sizes) // 2] if sizes else 10.0
|
|||
|
|
lefts = sorted(line["bbox"][0] for line in textlines)
|
|||
|
|
left = lefts[len(lefts) // 4] # Blockrand, robust gegen Einzüge
|
|||
|
|
widths = [line["bbox"][2] - line["bbox"][0] for line in textlines]
|
|||
|
|
longest = max(widths)
|
|||
|
|
|
|||
|
|
groups = [[textlines[0]]]
|
|||
|
|
for prev, line, prev_width in zip(textlines, textlines[1:], widths):
|
|||
|
|
indent = line["bbox"][0] - left
|
|||
|
|
indented = INDENT_MIN_EM * em <= indent <= INDENT_MAX_EM * em
|
|||
|
|
short = prev_width < SHORT_LINE_RATIO * longest
|
|||
|
|
if (indented or short) and ends_sentence(_line_text(prev)):
|
|||
|
|
groups.append([line])
|
|||
|
|
else:
|
|||
|
|
groups[-1].append(line)
|
|||
|
|
return groups
|
|||
|
|
|
|||
|
|
|
|||
|
|
def patch_pymupdf4llm_paragraphs() -> None:
|
|||
|
|
original = getattr(document_layout, "text_to_md", None)
|
|||
|
|
if original is None or getattr(original, "pdf2md_patched", False):
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
def text_to_md(textlines, ignore_code: bool = False):
|
|||
|
|
try:
|
|||
|
|
groups = split_by_indent(textlines)
|
|||
|
|
except (KeyError, IndexError, TypeError):
|
|||
|
|
groups = [textlines] # unerwartete Struktur: Originalverhalten
|
|||
|
|
return "".join(original(group, ignore_code) for group in groups)
|
|||
|
|
|
|||
|
|
text_to_md.pdf2md_patched = True
|
|||
|
|
document_layout.text_to_md = text_to_md
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _DiscardMessages(io.StringIO):
|
|||
|
|
"""Ersatz für pymupdf4llms Meldungspuffer ("OCR on page …")."""
|
|||
|
|
|
|||
|
|
def write(self, text: str) -> int:
|
|||
|
|
return len(text)
|
|||
|
|
|
|||
|
|
|
|||
|
|
patch_pymupdf4llm_hyphens()
|
|||
|
|
patch_pymupdf4llm_paragraphs()
|
|||
|
|
document_layout.INFO_MESSAGES = _DiscardMessages()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ===========================================================================
|
|||
|
|
# Seiten klassifizieren und OCR planen
|
|||
|
|
# ===========================================================================
|
|||
|
|
|
|||
|
|
PAGE_TEXT = "text" # digitale Textseite
|
|||
|
|
PAGE_OCR_LAYER = "ocr-layer" # Scan mit vorhandener (unsichtbarer) OCR-Ebene
|
|||
|
|
PAGE_SCAN = "scan" # Bildseite ohne Text
|
|||
|
|
PAGE_EMPTY = "empty"
|
|||
|
|
|
|||
|
|
MIN_TEXT_CHARS = 50
|
|||
|
|
MAX_BAD_CHAR_RATIO = 0.1
|
|||
|
|
MIN_IMAGE_COVER = 0.3
|
|||
|
|
MIN_OCR_LAYER_CHARS = 20
|
|||
|
|
|
|||
|
|
PLAUSIBLE_MIN_CHARS = 40
|
|||
|
|
PLAUSIBLE_MIN_WORDS = 20 # Cover/Titelbilder haben weniger Wörter
|
|||
|
|
PLAUSIBLE_WORD_RATIO = 0.7
|
|||
|
|
PLAUSIBLE_FUNCTION_RATIO = 0.1
|
|||
|
|
WORDLIKE = re.compile(r"^\W*([^\W\d_]{2,}(?:[-'’][^\W\d_]+)*)\W*$")
|
|||
|
|
|
|||
|
|
# Häufige Funktionswörter: In echtem Fließtext machen sie gut 10 % aus,
|
|||
|
|
# im "Text", den die OCR aus Fotos oder Rauschen liest, praktisch nie.
|
|||
|
|
FUNCTION_WORDS = {
|
|||
|
|
"deu": set(
|
|||
|
|
"der die das den dem des ein eine einen einem einer und oder aber in im "
|
|||
|
|
"an am auf aus bei mit von zu zum zur für über unter vor nach ist war "
|
|||
|
|
"sind waren hat hatte wird wurde nicht er sie es ich wir ihr du man sich "
|
|||
|
|
"so wie als auch noch nur schon dass daß wenn da dann doch ja".split()
|
|||
|
|
),
|
|||
|
|
"eng": set(
|
|||
|
|
"the a an and or but in on at of to for with is was are were be been he "
|
|||
|
|
"she it i we you they not that this as by from his her had have".split()
|
|||
|
|
),
|
|||
|
|
"fra": set(
|
|||
|
|
"le la les un une et ou de du des en dans est sont pas que qui il elle "
|
|||
|
|
"au aux ce se sur pour par avec".split()
|
|||
|
|
),
|
|||
|
|
"spa": set(
|
|||
|
|
"el la los las un una y o de del en es son no que se al por con para "
|
|||
|
|
"su lo como".split()
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
SKEW_TOLERANCE = 0.002 # sin(Winkel), ca. 0,1°
|
|||
|
|
MAX_SKEWED_LINES = 0.2 # mehr schiefe Zeilen -> OCR-Ebene neu erkennen
|
|||
|
|
|
|||
|
|
VISIBLE_TEXT = pymupdf.mupdf.FZ_STEXT_FILLED | pymupdf.mupdf.FZ_STEXT_STROKED
|
|||
|
|
|
|||
|
|
# Verarbeitung pro Seite
|
|||
|
|
RUN_PLAIN = "plain" # vorhandenen Text verwenden, keine OCR
|
|||
|
|
RUN_OCR = "ocr" # Tesseract-OCR
|
|||
|
|
RUN_REUSE = "reuse" # vorhandene OCR-Ebene übernehmen
|
|||
|
|
|
|||
|
|
CHUNK_SIZE = {RUN_PLAIN: 50, RUN_OCR: 10, RUN_REUSE: 25}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def classify_page(page: pymupdf.Page) -> str:
|
|||
|
|
visible = invisible = bad = 0
|
|||
|
|
for block in page.get_text("dict")["blocks"]:
|
|||
|
|
for line in block.get("lines", []):
|
|||
|
|
for span in line["spans"]:
|
|||
|
|
text = span["text"].strip()
|
|||
|
|
if not text:
|
|||
|
|
continue
|
|||
|
|
if span["char_flags"] & VISIBLE_TEXT and span.get("alpha", 255):
|
|||
|
|
visible += len(text)
|
|||
|
|
bad += text.count("\ufffd")
|
|||
|
|
else:
|
|||
|
|
invisible += len(text)
|
|||
|
|
|
|||
|
|
image_rect = pymupdf.EMPTY_RECT()
|
|||
|
|
for kind, bbox in page.get_bboxlog():
|
|||
|
|
if kind in ("fill-image", "fill-imgmask"):
|
|||
|
|
image_rect |= pymupdf.Rect(bbox) & page.rect
|
|||
|
|
cover = abs(image_rect) / abs(page.rect) if abs(page.rect) else 0.0
|
|||
|
|
|
|||
|
|
if visible >= MIN_TEXT_CHARS:
|
|||
|
|
return PAGE_SCAN if bad / visible > MAX_BAD_CHAR_RATIO else PAGE_TEXT
|
|||
|
|
if cover >= MIN_IMAGE_COVER:
|
|||
|
|
return PAGE_OCR_LAYER if invisible >= MIN_OCR_LAYER_CHARS else PAGE_SCAN
|
|||
|
|
return PAGE_TEXT if visible else PAGE_EMPTY
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_wordlike(token: str) -> bool:
|
|||
|
|
"""Buchstabenwort mit üblicher Schreibweise (nicht "erNee", "eeBR")."""
|
|||
|
|
match = WORDLIKE.match(token)
|
|||
|
|
if not match:
|
|||
|
|
return False
|
|||
|
|
core = re.sub(r"[-'’]", "", match.group(1))
|
|||
|
|
return core.islower() or core.isupper() or core.istitle()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def skewed_line_ratio(page: pymupdf.Page) -> float:
|
|||
|
|
"""Anteil schräg liegender Textzeilen (z. B. OCR-Ebene eines schiefen Scans)."""
|
|||
|
|
total = skewed = 0
|
|||
|
|
for block in page.get_text("dict")["blocks"]:
|
|||
|
|
for line in block.get("lines", []):
|
|||
|
|
total += 1
|
|||
|
|
skewed += abs(line["dir"][1]) > SKEW_TOLERANCE
|
|||
|
|
return skewed / total if total else 0.0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def looks_like_text(text: str, language: str = "deu") -> bool:
|
|||
|
|
"""Plausibilitätsprüfung: Ergibt die OCR echten Text oder Kauderwelsch?"""
|
|||
|
|
words = text.split()
|
|||
|
|
if len(text.strip()) < PLAUSIBLE_MIN_CHARS or len(words) < PLAUSIBLE_MIN_WORDS:
|
|||
|
|
return False
|
|||
|
|
if sum(map(is_wordlike, words)) / len(words) < PLAUSIBLE_WORD_RATIO:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
function_words = set().union(
|
|||
|
|
*(FUNCTION_WORDS.get(lang, set()) for lang in language.split("+"))
|
|||
|
|
)
|
|||
|
|
if not function_words:
|
|||
|
|
return True # Sprache ohne Wortliste: nur die Wortform zählt
|
|||
|
|
lowered = [w.strip(".,;:!?»«„“”\"'‚‘’()").lower() for w in words]
|
|||
|
|
hits = sum(1 for w in lowered if w in function_words)
|
|||
|
|
return hits / len(words) >= PLAUSIBLE_FUNCTION_RATIO
|
|||
|
|
|
|||
|
|
|
|||
|
|
def check_ocr_language(language: str) -> None:
|
|||
|
|
try:
|
|||
|
|
tessdata = pymupdf.get_tessdata()
|
|||
|
|
except Exception:
|
|||
|
|
tessdata = None
|
|||
|
|
if not tessdata:
|
|||
|
|
raise RuntimeError(
|
|||
|
|
"Tesseract nicht gefunden – installieren mit: "
|
|||
|
|
"sudo apt install tesseract-ocr tesseract-ocr-deu"
|
|||
|
|
)
|
|||
|
|
missing = [
|
|||
|
|
lang for lang in language.split("+")
|
|||
|
|
if not (Path(tessdata) / f"{lang}.traineddata").is_file()
|
|||
|
|
]
|
|||
|
|
if missing:
|
|||
|
|
packages = " ".join(f"tesseract-ocr-{lang}" for lang in missing)
|
|||
|
|
raise RuntimeError(
|
|||
|
|
f"Tesseract-Sprachdaten fehlen: {', '.join(missing)} – "
|
|||
|
|
f"installieren mit: sudo apt install {packages}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def probe_ocr(page: pymupdf.Page, language: str, dpi: int) -> bool:
|
|||
|
|
textpage = page.get_textpage_ocr(language=language, dpi=dpi, full=True)
|
|||
|
|
return looks_like_text(page.get_text(textpage=textpage), language)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def plan_pages(
|
|||
|
|
doc: pymupdf.Document, mode: str, language: str, dpi: int
|
|||
|
|
) -> tuple[list[str], list[str]]:
|
|||
|
|
"""Seitentypen ermitteln und pro Seite die Verarbeitung festlegen."""
|
|||
|
|
kinds = [classify_page(page) for page in doc]
|
|||
|
|
counts = Counter(kinds)
|
|||
|
|
|
|||
|
|
if mode == "never":
|
|||
|
|
return kinds, [RUN_REUSE if k == PAGE_OCR_LAYER else RUN_PLAIN for k in kinds]
|
|||
|
|
|
|||
|
|
if mode == "always":
|
|||
|
|
check_ocr_language(language)
|
|||
|
|
return kinds, [RUN_PLAIN if k == PAGE_EMPTY else RUN_OCR for k in kinds]
|
|||
|
|
|
|||
|
|
# auto
|
|||
|
|
if counts[PAGE_SCAN] or counts[PAGE_OCR_LAYER]:
|
|||
|
|
check_ocr_language(language)
|
|||
|
|
scanned_doc = counts[PAGE_SCAN] + counts[PAGE_OCR_LAYER] > counts[PAGE_TEXT]
|
|||
|
|
|
|||
|
|
plan = []
|
|||
|
|
for page, kind in zip(doc, kinds):
|
|||
|
|
if kind == PAGE_OCR_LAYER:
|
|||
|
|
# Gute OCR-Ebenen übernehmen; schiefe (Scan nicht entzerrt)
|
|||
|
|
# liefern eine falsche Lesereihenfolge -> neu erkennen.
|
|||
|
|
skewed = skewed_line_ratio(page) > MAX_SKEWED_LINES
|
|||
|
|
plan.append(RUN_OCR if skewed else RUN_REUSE)
|
|||
|
|
elif kind == PAGE_SCAN and (scanned_doc or probe_ocr(page, language, dpi)):
|
|||
|
|
# In digitalen PDFs sind reine Bildseiten meist Fotos/Cover;
|
|||
|
|
# die werden nur übernommen, wenn die Probe-OCR echten Text liefert.
|
|||
|
|
plan.append(RUN_OCR)
|
|||
|
|
else:
|
|||
|
|
plan.append(RUN_PLAIN)
|
|||
|
|
return kinds, plan
|
|||
|
|
|
|||
|
|
|
|||
|
|
def make_tasks(plan: list[str]) -> list[tuple[str, list[int]]]:
|
|||
|
|
"""Aufeinanderfolgende Seiten gleicher Verarbeitung zu Paketen bündeln."""
|
|||
|
|
tasks: list[tuple[str, list[int]]] = []
|
|||
|
|
for pno, run in enumerate(plan):
|
|||
|
|
if (
|
|||
|
|
tasks
|
|||
|
|
and tasks[-1][0] == run
|
|||
|
|
and tasks[-1][1][-1] == pno - 1
|
|||
|
|
and len(tasks[-1][1]) < CHUNK_SIZE[run]
|
|||
|
|
):
|
|||
|
|
tasks[-1][1].append(pno)
|
|||
|
|
else:
|
|||
|
|
tasks.append((run, [pno]))
|
|||
|
|
return tasks
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ===========================================================================
|
|||
|
|
# Konvertierung mit pymupdf4llm
|
|||
|
|
# ===========================================================================
|
|||
|
|
|
|||
|
|
def keep_existing_ocr(page, **kwargs) -> None:
|
|||
|
|
"""OCR-Funktion, die nichts tut: Die vorhandene OCR-Ebene wird genutzt.
|
|||
|
|
|
|||
|
|
pymupdf4llm verwendet unsichtbaren Text nur auf Seiten, die als
|
|||
|
|
OCR-Seiten gelten; mit dieser Funktion entfällt eine zweite OCR.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str:
|
|||
|
|
kwargs = dict(
|
|||
|
|
pages=pages,
|
|||
|
|
header=options["keep_headers"],
|
|||
|
|
footer=options["keep_footers"],
|
|||
|
|
write_images=options["images"],
|
|||
|
|
image_path=options["image_path"],
|
|||
|
|
image_format="png",
|
|||
|
|
)
|
|||
|
|
if run == RUN_OCR:
|
|||
|
|
kwargs.update(
|
|||
|
|
force_ocr=True,
|
|||
|
|
ocr_function=tesseract_api.exec_ocr,
|
|||
|
|
ocr_language=options["ocr_language"],
|
|||
|
|
ocr_dpi=options["ocr_dpi"],
|
|||
|
|
)
|
|||
|
|
elif run == RUN_REUSE:
|
|||
|
|
kwargs.update(force_ocr=True, ocr_function=keep_existing_ocr)
|
|||
|
|
else:
|
|||
|
|
kwargs.update(use_ocr=False)
|
|||
|
|
|
|||
|
|
# pymupdf4llm schreibt Statusmeldungen auf stdout; wir melden selbst.
|
|||
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|||
|
|
return pymupdf4llm.to_markdown(source, **kwargs)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _init_worker() -> None:
|
|||
|
|
# Ein Thread pro Prozess, falls Tesseract mit OpenMP gebaut ist;
|
|||
|
|
# die Parallelität kommt von den Prozessen.
|
|||
|
|
os.environ["OMP_THREAD_LIMIT"] = "1"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def extract_markdown(source: Path, plan: list[str], options: dict, jobs: int) -> str:
|
|||
|
|
tasks = make_tasks(plan)
|
|||
|
|
total = len(tasks)
|
|||
|
|
labels = {RUN_PLAIN: "Text", RUN_OCR: "OCR", RUN_REUSE: "OCR-Ebene"}
|
|||
|
|
|
|||
|
|
def report(i: int, run: str, pages: list[int]) -> None:
|
|||
|
|
print(
|
|||
|
|
f" [{i}/{total}] Seiten {pages[0] + 1}–{pages[-1] + 1} "
|
|||
|
|
f"({labels[run]}) fertig",
|
|||
|
|
flush=True,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
parts = []
|
|||
|
|
# Nur OCR profitiert von mehreren Prozessen; das Layout-Modell nutzt
|
|||
|
|
# ohnehin alle Kerne.
|
|||
|
|
if jobs <= 1 or total == 1 or RUN_OCR not in plan:
|
|||
|
|
for i, (run, pages) in enumerate(tasks, 1):
|
|||
|
|
parts.append(convert_pages(str(source), run, pages, options))
|
|||
|
|
report(i, run, pages)
|
|||
|
|
else:
|
|||
|
|
context = multiprocessing.get_context("spawn")
|
|||
|
|
with ProcessPoolExecutor(
|
|||
|
|
max_workers=min(jobs, total),
|
|||
|
|
mp_context=context,
|
|||
|
|
initializer=_init_worker,
|
|||
|
|
) as pool:
|
|||
|
|
futures = [
|
|||
|
|
pool.submit(convert_pages, str(source), run, pages, options)
|
|||
|
|
for run, pages in tasks
|
|||
|
|
]
|
|||
|
|
for i, (future, (run, pages)) in enumerate(zip(futures, tasks), 1):
|
|||
|
|
parts.append(future.result())
|
|||
|
|
report(i, run, pages)
|
|||
|
|
|
|||
|
|
return "\n\n".join(parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def make_image_links_relative(markdown: str, image_dir: Path) -> str:
|
|||
|
|
"""Absolute Bildpfade durch Pfade relativ zur Markdown-Datei ersetzen."""
|
|||
|
|
absolute_dir = image_dir.resolve().as_posix()
|
|||
|
|
relative_dir = image_dir.name
|
|||
|
|
|
|||
|
|
return markdown.replace(
|
|||
|
|
absolute_dir + "/",
|
|||
|
|
relative_dir + "/",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ===========================================================================
|
|||
|
|
# EPUB mit pandoc
|
|||
|
|
# ===========================================================================
|
|||
|
|
|
|||
|
|
def heading_depth(markdown: str) -> int:
|
|||
|
|
"""Tiefste verwendete Überschriftenebene (1–3) für die Kapitelaufteilung."""
|
|||
|
|
levels = [
|
|||
|
|
len(m.group(1))
|
|||
|
|
for m in re.finditer(r"^(#{1,6}) ", markdown, re.MULTILINE)
|
|||
|
|
]
|
|||
|
|
return min(max(levels, default=1), 3)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def write_epub(
|
|||
|
|
markdown_path: Path,
|
|||
|
|
epub_path: Path,
|
|||
|
|
*,
|
|||
|
|
title: str,
|
|||
|
|
author: str,
|
|||
|
|
language: str,
|
|||
|
|
cover: Path | None,
|
|||
|
|
split_level: int,
|
|||
|
|
) -> None:
|
|||
|
|
pandoc = shutil.which("pandoc")
|
|||
|
|
if not pandoc:
|
|||
|
|
raise RuntimeError("pandoc nicht gefunden – installieren mit: sudo apt install pandoc")
|
|||
|
|
|
|||
|
|
cmd = [
|
|||
|
|
pandoc,
|
|||
|
|
str(markdown_path),
|
|||
|
|
"--from", "markdown-yaml_metadata_block",
|
|||
|
|
"--to", "epub3",
|
|||
|
|
"--output", str(epub_path),
|
|||
|
|
"--resource-path", str(markdown_path.parent),
|
|||
|
|
f"--split-level={split_level}",
|
|||
|
|
f"--toc-depth={split_level}",
|
|||
|
|
"--metadata", f"title={title}",
|
|||
|
|
"--metadata", f"lang={language}",
|
|||
|
|
]
|
|||
|
|
if author:
|
|||
|
|
cmd += ["--metadata", f"author={author}"]
|
|||
|
|
if cover:
|
|||
|
|
cmd += ["--epub-cover-image", str(cover)]
|
|||
|
|
|
|||
|
|
done = subprocess.run(cmd, capture_output=True, text=True)
|
|||
|
|
if done.returncode != 0:
|
|||
|
|
raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ===========================================================================
|
|||
|
|
# Kommandozeile
|
|||
|
|
# ===========================================================================
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
parser = argparse.ArgumentParser(
|
|||
|
|
description=(
|
|||
|
|
"PDF (digital oder gescannt) in Markdown umwandeln; Fließtext nach "
|
|||
|
|
"Satzenden gliedern, optional Grafiken speichern und EPUB erzeugen."
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
parser.add_argument("pdf", type=Path, help="Eingabe-PDF")
|
|||
|
|
parser.add_argument(
|
|||
|
|
"markdown",
|
|||
|
|
type=Path,
|
|||
|
|
nargs="?",
|
|||
|
|
help="Ausgabe-Datei; Standard: gleicher Name mit .md",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--ocr",
|
|||
|
|
choices=["auto", "always", "never"],
|
|||
|
|
default="auto",
|
|||
|
|
help=(
|
|||
|
|
"auto: nur Scanseiten per OCR lesen, gerade vorhandene OCR-Ebenen "
|
|||
|
|
"übernehmen, schiefe neu erkennen (Standard); always: alle Seiten "
|
|||
|
|
"neu erkennen; never: keine OCR"
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--no-ocr",
|
|||
|
|
dest="ocr",
|
|||
|
|
action="store_const",
|
|||
|
|
const="never",
|
|||
|
|
help="Kurzform für --ocr never",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--ocr-language",
|
|||
|
|
default="deu",
|
|||
|
|
help="Tesseract-Sprachcode bei OCR, z. B. deu+eng (Standard: deu)",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--ocr-dpi",
|
|||
|
|
type=int,
|
|||
|
|
default=300,
|
|||
|
|
help="Auflösung für die OCR (Standard: 300)",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--jobs",
|
|||
|
|
type=int,
|
|||
|
|
default=min(4, os.cpu_count() or 1),
|
|||
|
|
help="Anzahl paralleler Prozesse (Standard: %(default)s)",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--images",
|
|||
|
|
action="store_true",
|
|||
|
|
help="Grafiken speichern und in Markdown verlinken",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--epub",
|
|||
|
|
action="store_true",
|
|||
|
|
help="zusätzlich EPUB mit pandoc erzeugen (schaltet --images ein)",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--epub-file",
|
|||
|
|
type=Path,
|
|||
|
|
metavar="PFAD",
|
|||
|
|
help="Pfad des EPUB; Standard: gleicher Name wie Markdown mit .epub",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--lang",
|
|||
|
|
default="de",
|
|||
|
|
help="Sprache im EPUB (Standard: de)",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--keep-headers",
|
|||
|
|
action="store_true",
|
|||
|
|
help="Seitenköpfe/Kolumnentitel übernehmen",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--keep-footers",
|
|||
|
|
action="store_true",
|
|||
|
|
help="Seitenfüße/Seitenzahlen übernehmen",
|
|||
|
|
)
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
|
|||
|
|
source = args.pdf.expanduser()
|
|||
|
|
target = (args.markdown or source.with_suffix(".md")).expanduser()
|
|||
|
|
|
|||
|
|
if not source.is_file():
|
|||
|
|
parser.error(f"Eingabedatei nicht gefunden: {source}")
|
|||
|
|
|
|||
|
|
if source.resolve() == target.resolve():
|
|||
|
|
parser.error("Eingabe- und Ausgabedatei müssen verschieden sein.")
|
|||
|
|
|
|||
|
|
epub_path = None
|
|||
|
|
if args.epub or args.epub_file:
|
|||
|
|
args.images = True # EPUB ohne Bilder wäre unvollständig
|
|||
|
|
epub_path = (args.epub_file or target.with_suffix(".epub")).expanduser()
|
|||
|
|
|
|||
|
|
image_dir = target.parent / f"{target.stem}_images"
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
with pymupdf.open(source) as doc:
|
|||
|
|
kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi)
|
|||
|
|
outline = load_outline(doc)
|
|||
|
|
page_count = doc.page_count
|
|||
|
|
title = (doc.metadata or {}).get("title") or source.stem
|
|||
|
|
author = (doc.metadata or {}).get("author") or ""
|
|||
|
|
|
|||
|
|
counts = Counter(kinds)
|
|||
|
|
print(
|
|||
|
|
f"{page_count} Seiten: {counts[PAGE_TEXT]} Text, "
|
|||
|
|
f"{counts[PAGE_SCAN]} Scan/Bild, {counts[PAGE_OCR_LAYER]} mit "
|
|||
|
|
f"OCR-Ebene, {counts[PAGE_EMPTY]} leer → "
|
|||
|
|
f"{plan.count(RUN_OCR)} Seiten werden per OCR gelesen"
|
|||
|
|
)
|
|||
|
|
unread = sum(
|
|||
|
|
1 for k, run in zip(kinds, plan) if k == PAGE_SCAN and run != RUN_OCR
|
|||
|
|
)
|
|||
|
|
if unread:
|
|||
|
|
print(
|
|||
|
|
f"Hinweis: {unread} Bildseite(n) ohne Text bleiben ohne OCR "
|
|||
|
|
"(Fotos/Cover oder --ocr never)."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
cover = None
|
|||
|
|
if args.images:
|
|||
|
|
image_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
if epub_path:
|
|||
|
|
cover = image_dir / "cover.png"
|
|||
|
|
doc[0].get_pixmap(dpi=150).save(cover)
|
|||
|
|
|
|||
|
|
options = dict(
|
|||
|
|
keep_headers=args.keep_headers,
|
|||
|
|
keep_footers=args.keep_footers,
|
|||
|
|
images=args.images,
|
|||
|
|
image_path=str(image_dir.resolve()) if args.images else "",
|
|||
|
|
ocr_language=args.ocr_language,
|
|||
|
|
ocr_dpi=args.ocr_dpi,
|
|||
|
|
)
|
|||
|
|
markdown = extract_markdown(source, plan, options, args.jobs)
|
|||
|
|
|
|||
|
|
if args.images:
|
|||
|
|
markdown = make_image_links_relative(markdown, image_dir)
|
|||
|
|
|
|||
|
|
markdown = normalize_markdown(
|
|||
|
|
markdown,
|
|||
|
|
outline,
|
|||
|
|
drop_page_numbers=not args.keep_footers,
|
|||
|
|
drop_running_heads=not args.keep_headers,
|
|||
|
|
running_head_min=max(3, page_count // 10),
|
|||
|
|
)
|
|||
|
|
target.write_text(markdown, encoding="utf-8")
|
|||
|
|
|
|||
|
|
if epub_path:
|
|||
|
|
write_epub(
|
|||
|
|
target,
|
|||
|
|
epub_path,
|
|||
|
|
title=title,
|
|||
|
|
author=author,
|
|||
|
|
language=args.lang,
|
|||
|
|
cover=cover,
|
|||
|
|
split_level=heading_depth(markdown),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
print(f"Markdown geschrieben: {target}")
|
|||
|
|
if args.images:
|
|||
|
|
print(f"Bilder gespeichert in: {image_dir}")
|
|||
|
|
if epub_path:
|
|||
|
|
print(f"EPUB geschrieben: {epub_path}")
|
|||
|
|
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
raise SystemExit(main())
|