Aufräumen: alte Skriptstände und Info/ entfernt
- History/pdf2md_v1.py … v4.py entfernt (bleiben in der Git-Historie) - Info/ entfernt - README.md und CLAUDE.md angepasst Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b4b911d37f
commit
a21d900e6b
7 changed files with 1 additions and 2318 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
PDF → Markdown → EPUB für den PocketBook-Reader (digitale und gescannte PDFs).
|
PDF → Markdown → EPUB für den PocketBook-Reader (digitale und gescannte PDFs).
|
||||||
Aktuelles Programm: `pdf2md.py` (Einzeldatei; Name bleibt fest, keine Versionsnummer im Dateinamen). v1–v4 liegen in `History/` (Historie, nicht ändern).
|
Aktuelles Programm: `pdf2md.py` (Einzeldatei; Name bleibt fest, keine Versionsnummer im Dateinamen). Frühere Stände (v1–v4) nur noch in der Git-Historie.
|
||||||
|
|
||||||
## Befehle
|
## Befehle
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""PDF nach Markdown konvertieren und weiche Zeilenumbrüche entfernen."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pymupdf4llm
|
|
||||||
|
|
||||||
|
|
||||||
HEADING = re.compile(r"^\s{0,3}#{1,6}\s+")
|
|
||||||
LIST_ITEM = re.compile(r"^\s{0,3}(?:[-+*]|\d+[.)])\s+")
|
|
||||||
BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?")
|
|
||||||
FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
|
|
||||||
RULE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$")
|
|
||||||
IMAGE = re.compile(r"^\s*!\[.*?\]\(.*\)\s*$")
|
|
||||||
HTML_BLOCK = re.compile(r"^\s*</?(?:table|tr|td|th|div|pre|p|h[1-6])\b", re.I)
|
|
||||||
|
|
||||||
|
|
||||||
def is_structural(line: str) -> bool:
|
|
||||||
"""Zeilen, die nicht mit dem vorherigen Fließtext verbunden werden."""
|
|
||||||
return bool(
|
|
||||||
HEADING.match(line)
|
|
||||||
or 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 normalize_markdown(markdown: str) -> str:
|
|
||||||
result = []
|
|
||||||
paragraph = []
|
|
||||||
in_fence = False
|
|
||||||
fence_char = ""
|
|
||||||
fence_length = 0
|
|
||||||
|
|
||||||
def flush_paragraph():
|
|
||||||
if paragraph:
|
|
||||||
result.append(" ".join(paragraph))
|
|
||||||
paragraph.clear()
|
|
||||||
|
|
||||||
for original in markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
|
||||||
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()
|
|
||||||
result.append(line)
|
|
||||||
in_fence = True
|
|
||||||
fence_char = marker.group(1)[0]
|
|
||||||
fence_length = len(marker.group(1))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not line.strip():
|
|
||||||
flush_paragraph()
|
|
||||||
if result and result[-1] != "":
|
|
||||||
result.append("")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if is_structural(line):
|
|
||||||
flush_paragraph()
|
|
||||||
result.append(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
paragraph.append(line.strip())
|
|
||||||
|
|
||||||
flush_paragraph()
|
|
||||||
|
|
||||||
return "\n".join(result).strip() + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="PDF in Markdown umwandeln; Zeilen innerhalb von Absätzen verbinden."
|
|
||||||
)
|
|
||||||
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-language",
|
|
||||||
default="deu",
|
|
||||||
help="Tesseract-Sprachcode bei erforderlicher OCR (Standard: deu)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-ocr",
|
|
||||||
action="store_true",
|
|
||||||
help="OCR abschalten, wenn die PDF sicher durchsuchbaren Text enthält",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--keep-headers",
|
|
||||||
action="store_true",
|
|
||||||
help="Wiederkehrende Seitenköpfe in die Ausgabe übernehmen",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--keep-footers",
|
|
||||||
action="store_true",
|
|
||||||
help="Wiederkehrende Seitenfüße in die Ausgabe ü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.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
markdown = pymupdf4llm.to_markdown(
|
|
||||||
str(source),
|
|
||||||
header=args.keep_headers,
|
|
||||||
footer=args.keep_footers,
|
|
||||||
use_ocr=not args.no_ocr,
|
|
||||||
ocr_language=args.ocr_language,
|
|
||||||
)
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
target.write_text(normalize_markdown(markdown), encoding="utf-8")
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
print(f"Markdown geschrieben: {target}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""PDF nach Markdown konvertieren und Absätze anhand von Satzenden verbinden."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pymupdf4llm
|
|
||||||
from pymupdf4llm.ocr import tesseract_api
|
|
||||||
|
|
||||||
|
|
||||||
HEADING = re.compile(r"^\s{0,3}#{1,6}\s+")
|
|
||||||
LIST_ITEM = re.compile(r"^\s{0,3}(?:[-+*]|\d+[.)])\s+")
|
|
||||||
BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?")
|
|
||||||
FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
|
|
||||||
RULE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$")
|
|
||||||
IMAGE = re.compile(r"^\s*!\[.*?\]\(.*\)\s*$")
|
|
||||||
HTML_BLOCK = re.compile(
|
|
||||||
r"^\s*</?(?:table|tr|td|th|div|pre|p|h[1-6])\b",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Satzzeichen, optional gefolgt von schließenden Anführungszeichen/Klammern.
|
|
||||||
SENTENCE_END = re.compile(r"""[.!?…]+[”’"'»›)\]]*$""")
|
|
||||||
|
|
||||||
|
|
||||||
def ends_sentence(text: str) -> bool:
|
|
||||||
return bool(SENTENCE_END.search(text.rstrip()))
|
|
||||||
|
|
||||||
|
|
||||||
def is_structural(line: str) -> bool:
|
|
||||||
"""Markdown-Zeilen, die nicht in Fließtext aufgehen sollen."""
|
|
||||||
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 normalize_markdown(markdown: str) -> str:
|
|
||||||
result: list[str] = []
|
|
||||||
paragraph: list[str] = []
|
|
||||||
|
|
||||||
in_fence = False
|
|
||||||
fence_char = ""
|
|
||||||
fence_length = 0
|
|
||||||
|
|
||||||
def add_blank_line() -> None:
|
|
||||||
if result and result[-1] != "":
|
|
||||||
result.append("")
|
|
||||||
|
|
||||||
def flush_paragraph() -> None:
|
|
||||||
if paragraph:
|
|
||||||
result.append(" ".join(paragraph))
|
|
||||||
paragraph.clear()
|
|
||||||
|
|
||||||
for original in markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
|
||||||
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()
|
|
||||||
add_blank_line()
|
|
||||||
result.append(line)
|
|
||||||
in_fence = True
|
|
||||||
fence_char = marker.group(1)[0]
|
|
||||||
fence_length = len(marker.group(1))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if HEADING.match(line):
|
|
||||||
flush_paragraph()
|
|
||||||
add_blank_line()
|
|
||||||
result.append(line)
|
|
||||||
result.append("")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if is_structural(line):
|
|
||||||
flush_paragraph()
|
|
||||||
result.append(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not line.strip():
|
|
||||||
# Eine Leerzeile ist nur nach einem Satzende eine Absatzgrenze.
|
|
||||||
# Andernfalls bleibt paragraph bestehen und die nächste
|
|
||||||
# Textzeile wird mit einem Leerzeichen angehängt.
|
|
||||||
if paragraph and ends_sentence(paragraph[-1]):
|
|
||||||
flush_paragraph()
|
|
||||||
add_blank_line()
|
|
||||||
elif not paragraph:
|
|
||||||
add_blank_line()
|
|
||||||
continue
|
|
||||||
|
|
||||||
paragraph.append(line.strip())
|
|
||||||
|
|
||||||
flush_paragraph()
|
|
||||||
return "\n".join(result).strip() + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description=(
|
|
||||||
"PDF in Markdown umwandeln; Absätze nur nach Satzende trennen."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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-language",
|
|
||||||
default="deu",
|
|
||||||
help="Tesseract-Sprachcode bei OCR (Standard: deu)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-ocr",
|
|
||||||
action="store_true",
|
|
||||||
help="OCR deaktivieren",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--keep-headers",
|
|
||||||
action="store_true",
|
|
||||||
help="Wiederkehrende Seitenköpfe übernehmen",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--keep-footers",
|
|
||||||
action="store_true",
|
|
||||||
help="Wiederkehrende Seitenfüße ü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.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
markdown = pymupdf4llm.to_markdown(
|
|
||||||
str(source),
|
|
||||||
header=args.keep_headers,
|
|
||||||
footer=args.keep_footers,
|
|
||||||
use_ocr=not args.no_ocr,
|
|
||||||
ocr_language=args.ocr_language,
|
|
||||||
ocr_function=(
|
|
||||||
tesseract_api.exec_ocr if not args.no_ocr else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
target.write_text(normalize_markdown(markdown), encoding="utf-8")
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
print(f"Markdown geschrieben: {target}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""PDF nach Markdown konvertieren und Fließtext nach Satzenden gliedern."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pymupdf4llm
|
|
||||||
from pymupdf4llm.ocr import tesseract_api
|
|
||||||
|
|
||||||
|
|
||||||
HEADING = re.compile(r"^\s{0,3}#{1,6}\s+\S")
|
|
||||||
|
|
||||||
# Punkt, Fragezeichen, Ausrufezeichen oder Auslassungspunkte;
|
|
||||||
# danach dürfen schließende Anführungszeichen/Klammern folgen.
|
|
||||||
SENTENCE_END = re.compile(r"""[.!?…]+[”’"'»›)\]]*$""")
|
|
||||||
|
|
||||||
FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
|
|
||||||
LIST_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,
|
|
||||||
)
|
|
||||||
|
|
||||||
CONTENTS_TITLE = re.compile(
|
|
||||||
r"^\s*(?:#{1,6}\s+)?"
|
|
||||||
r"(?:inhaltsverzeichnis|inhalt|contents|table of contents)"
|
|
||||||
r"\s*$",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Inhaltsverzeichnis mit Füllpunkten oder Abstand vor einer Seitenzahl:
|
|
||||||
# Kapitel 1 .......... 12
|
|
||||||
# Kapitel 1 12
|
|
||||||
# Kapitel 1 xiv
|
|
||||||
TOC_WITH_PAGE = re.compile(
|
|
||||||
r"^\s*(?:[-*]\s+)?\S.+?"
|
|
||||||
r"(?:\.{2,}|\s{2,})"
|
|
||||||
r"(?:\d+|[ivxlcdm]+)\s*$",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Einträge ohne Seitenzahl, sofern bereits ein Inhaltsverzeichnis erkannt wurde:
|
|
||||||
# 1 Einleitung
|
|
||||||
# 1.2 Vorgeschichte
|
|
||||||
# Kapitel 3 – Die Reise
|
|
||||||
TOC_NUMBERED = re.compile(
|
|
||||||
r"^\s*(?:[-*]\s+)?"
|
|
||||||
r"(?:\d+(?:\.\d+)*\.?\s+|Kapitel\s+\d+\b|Chapter\s+\d+\b)"
|
|
||||||
r".+",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
HTML_BLOCK = re.compile(
|
|
||||||
r"^\s*</?(?:table|tr|td|th|div|pre|p|h[1-6])\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ends_sentence(text: str) -> bool:
|
|
||||||
return bool(SENTENCE_END.search(text.rstrip()))
|
|
||||||
|
|
||||||
|
|
||||||
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 normalize_markdown(markdown: str) -> str:
|
|
||||||
result: list[str] = []
|
|
||||||
paragraph: list[str] = []
|
|
||||||
|
|
||||||
in_fence = False
|
|
||||||
fence_char = ""
|
|
||||||
fence_length = 0
|
|
||||||
in_contents = False
|
|
||||||
|
|
||||||
def blank_line() -> None:
|
|
||||||
if result and result[-1] != "":
|
|
||||||
result.append("")
|
|
||||||
|
|
||||||
def flush_paragraph() -> None:
|
|
||||||
if paragraph:
|
|
||||||
result.append(" ".join(paragraph))
|
|
||||||
paragraph.clear()
|
|
||||||
|
|
||||||
def standalone(line: str) -> None:
|
|
||||||
flush_paragraph()
|
|
||||||
blank_line()
|
|
||||||
result.append(line)
|
|
||||||
result.append("")
|
|
||||||
|
|
||||||
lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if CONTENTS_TITLE.match(line):
|
|
||||||
standalone(line)
|
|
||||||
in_contents = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
if HEADING.match(line):
|
|
||||||
standalone(line)
|
|
||||||
in_contents = False
|
|
||||||
continue
|
|
||||||
|
|
||||||
if OMITTED_IMAGE.match(line):
|
|
||||||
standalone(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if TOC_WITH_PAGE.match(line):
|
|
||||||
standalone(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if in_contents and TOC_NUMBERED.match(line):
|
|
||||||
standalone(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if in_contents and ends_sentence(line):
|
|
||||||
# Erster normaler Satz nach einem erkannten Inhaltsverzeichnis.
|
|
||||||
in_contents = False
|
|
||||||
|
|
||||||
if is_other_structure(line):
|
|
||||||
flush_paragraph()
|
|
||||||
result.append(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Einfache Zeilenumbrüche und nicht bestätigte Absatzgrenzen
|
|
||||||
# werden beim späteren Flush durch Leerzeichen ersetzt.
|
|
||||||
paragraph.append(line.strip())
|
|
||||||
|
|
||||||
flush_paragraph()
|
|
||||||
return "\n".join(result).strip() + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="PDF in Markdown umwandeln; Fließtext nur nach Satzende trennen."
|
|
||||||
)
|
|
||||||
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-language",
|
|
||||||
default="deu",
|
|
||||||
help="Tesseract-Sprachcode bei OCR (Standard: deu)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-ocr",
|
|
||||||
action="store_true",
|
|
||||||
help="OCR deaktivieren",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--keep-headers",
|
|
||||||
action="store_true",
|
|
||||||
help="Wiederkehrende Seitenköpfe übernehmen",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--keep-footers",
|
|
||||||
action="store_true",
|
|
||||||
help="Wiederkehrende Seitenfüße ü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.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
markdown = pymupdf4llm.to_markdown(
|
|
||||||
str(source),
|
|
||||||
header=args.keep_headers,
|
|
||||||
footer=args.keep_footers,
|
|
||||||
use_ocr=not args.no_ocr,
|
|
||||||
ocr_language=args.ocr_language,
|
|
||||||
ocr_function=(
|
|
||||||
tesseract_api.exec_ocr if not args.no_ocr else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
target.write_text(normalize_markdown(markdown), encoding="utf-8")
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
print(f"Markdown geschrieben: {target}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
||||||
|
|
@ -1,269 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""PDF nach Markdown konvertieren, Absätze gliedern und optional Bilder speichern."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pymupdf4llm
|
|
||||||
from pymupdf4llm.ocr import tesseract_api
|
|
||||||
|
|
||||||
|
|
||||||
HEADING = re.compile(r"^\s{0,3}#{1,6}\s+\S")
|
|
||||||
|
|
||||||
# Satzzeichen, optional gefolgt von schließenden Anführungszeichen/Klammern.
|
|
||||||
SENTENCE_END = re.compile(r"""[.!?…]+[”’"'»›)\]]*$""")
|
|
||||||
|
|
||||||
FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
|
|
||||||
LIST_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,
|
|
||||||
)
|
|
||||||
|
|
||||||
CONTENTS_TITLE = re.compile(
|
|
||||||
r"^\s*(?:#{1,6}\s+)?"
|
|
||||||
r"(?:inhaltsverzeichnis|inhalt|contents|table of contents)"
|
|
||||||
r"\s*$",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
TOC_WITH_PAGE = re.compile(
|
|
||||||
r"^\s*(?:[-*]\s+)?\S.+?"
|
|
||||||
r"(?:\.{2,}|\s{2,})"
|
|
||||||
r"(?:\d+|[ivxlcdm]+)\s*$",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
TOC_NUMBERED = re.compile(
|
|
||||||
r"^\s*(?:[-*]\s+)?"
|
|
||||||
r"(?:\d+(?:\.\d+)*\.?\s+|Kapitel\s+\d+\b|Chapter\s+\d+\b)"
|
|
||||||
r".+",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
HTML_BLOCK = re.compile(
|
|
||||||
r"^\s*</?(?:table|tr|td|th|div|pre|p|h[1-6])\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ends_sentence(text: str) -> bool:
|
|
||||||
return bool(SENTENCE_END.search(text.rstrip()))
|
|
||||||
|
|
||||||
|
|
||||||
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 normalize_markdown(markdown: str) -> str:
|
|
||||||
result: list[str] = []
|
|
||||||
paragraph: list[str] = []
|
|
||||||
|
|
||||||
in_fence = False
|
|
||||||
fence_char = ""
|
|
||||||
fence_length = 0
|
|
||||||
in_contents = False
|
|
||||||
|
|
||||||
def blank_line() -> None:
|
|
||||||
if result and result[-1] != "":
|
|
||||||
result.append("")
|
|
||||||
|
|
||||||
def flush_paragraph() -> None:
|
|
||||||
if paragraph:
|
|
||||||
result.append(" ".join(paragraph))
|
|
||||||
paragraph.clear()
|
|
||||||
|
|
||||||
def standalone(line: str) -> None:
|
|
||||||
flush_paragraph()
|
|
||||||
blank_line()
|
|
||||||
result.append(line)
|
|
||||||
result.append("")
|
|
||||||
|
|
||||||
lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
||||||
|
|
||||||
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():
|
|
||||||
if paragraph and ends_sentence(paragraph[-1]):
|
|
||||||
flush_paragraph()
|
|
||||||
blank_line()
|
|
||||||
elif not paragraph:
|
|
||||||
blank_line()
|
|
||||||
continue
|
|
||||||
|
|
||||||
if CONTENTS_TITLE.match(line):
|
|
||||||
standalone(line)
|
|
||||||
in_contents = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
if HEADING.match(line):
|
|
||||||
standalone(line)
|
|
||||||
in_contents = False
|
|
||||||
continue
|
|
||||||
|
|
||||||
if OMITTED_IMAGE.match(line):
|
|
||||||
standalone(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if TOC_WITH_PAGE.match(line):
|
|
||||||
standalone(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if in_contents and TOC_NUMBERED.match(line):
|
|
||||||
standalone(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if in_contents and ends_sentence(line):
|
|
||||||
in_contents = False
|
|
||||||
|
|
||||||
if is_other_structure(line):
|
|
||||||
flush_paragraph()
|
|
||||||
result.append(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
paragraph.append(line.strip())
|
|
||||||
|
|
||||||
flush_paragraph()
|
|
||||||
return "\n".join(result).strip() + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
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 + "/",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description=(
|
|
||||||
"PDF in Markdown umwandeln; Fließtext nach Satzenden gliedern "
|
|
||||||
"und optional Grafiken speichern."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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-language",
|
|
||||||
default="deu",
|
|
||||||
help="Tesseract-Sprachcode bei OCR (Standard: deu)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-ocr",
|
|
||||||
action="store_true",
|
|
||||||
help="OCR deaktivieren",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--images",
|
|
||||||
action="store_true",
|
|
||||||
help="Grafiken speichern und in Markdown verlinken",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--keep-headers",
|
|
||||||
action="store_true",
|
|
||||||
help="Wiederkehrende Seitenköpfe übernehmen",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--keep-footers",
|
|
||||||
action="store_true",
|
|
||||||
help="Wiederkehrende Seitenfüße ü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.")
|
|
||||||
|
|
||||||
image_dir = target.parent / f"{target.stem}_images"
|
|
||||||
|
|
||||||
try:
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
if args.images:
|
|
||||||
image_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
markdown = pymupdf4llm.to_markdown(
|
|
||||||
str(source),
|
|
||||||
header=args.keep_headers,
|
|
||||||
footer=args.keep_footers,
|
|
||||||
use_ocr=not args.no_ocr,
|
|
||||||
ocr_language=args.ocr_language,
|
|
||||||
ocr_function=(
|
|
||||||
tesseract_api.exec_ocr if not args.no_ocr else None
|
|
||||||
),
|
|
||||||
write_images=args.images,
|
|
||||||
image_path=str(image_dir.resolve()) if args.images else "",
|
|
||||||
image_format="png",
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.images:
|
|
||||||
markdown = make_image_links_relative(markdown, image_dir)
|
|
||||||
|
|
||||||
target.write_text(normalize_markdown(markdown), encoding="utf-8")
|
|
||||||
|
|
||||||
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}")
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -77,6 +77,4 @@ synthetisch erzeugt.
|
||||||
## Dateien
|
## Dateien
|
||||||
|
|
||||||
- `pdf2md.py` – aktuelle Version
|
- `pdf2md.py` – aktuelle Version
|
||||||
- `History/pdf2md_v1.py` … `History/pdf2md_v4.py` – frühere Entwicklungsstände (siehe `Info/`)
|
|
||||||
- `Info/` – Recherche und Entstehungsgeschichte der Skripte
|
|
||||||
- `tests/` – pytest-Suite
|
- `tests/` – pytest-Suite
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue