#!/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 -----\*\*
.*?" r"\*\*----- End of picture text -----\*\*
\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* bool: return bool(SENTENCE_END.search(text.rstrip())) def clean_inline(text: str) -> str: """Markdown-Hervorhebungen entfernen und Leerraum vereinheitlichen.""" text = re.sub(r"\*\*|__|(? 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=options["image_format"], ) 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) IMAGE_LINK = re.compile(r"(!\[[^\]]*\]\()([^)\s]+)\)") def make_image_links_relative(markdown: str, image_dir: Path) -> str: """Bildpfade relativ zur Markdown-Datei machen ("_images/datei.jpg"). pymupdf4llm schreibt den Pfad relativ zum Arbeitsverzeichnis, wenn der Bilderordner darunter liegt (z. B. "Output/x_images/…"), sonst absolut. """ image_dir = image_dir.resolve() def fix(match: re.Match) -> str: path = Path(match.group(2)) full = path if path.is_absolute() else Path.cwd() / path if full.resolve().parent != image_dir: return match.group(0) return f"{match.group(1)}{image_dir.name}/{path.name})" return IMAGE_LINK.sub(fix, markdown) # =========================================================================== # 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( "--image-format", choices=["jpg", "png"], default="jpg", help="Bildformat: jpg (klein, Standard) oder png (verlustfrei, groß)", ) 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 / f"cover.{args.image_format}" 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 "", image_format=args.image_format, 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())