--split, --from-md, --jpeg-quality, YAML-Kopf, --title/--author
- --split teile|kapitel: Einzeldateien in einem Ordner mit index.md (Reihenfolge) und images/; Titelei und Inhaltsverzeichnis als Vorspann; YAML-Kopf je Datei (title, part, chapter, order, book, author) - --from-md DATEI|ORDNER: EPUB aus bearbeitetem Markdown bzw. Split-Ordner, Angaben aus dem YAML-Kopf; ersetzt den pandoc-Befehl aus Weg B - Markdown beginnt mit YAML-Kopf (title, author, lang, cover); Cover wird mit --images immer abgelegt - --jpeg-quality (Standard 85 statt fest 95): EPUB Band 1 3 MB statt 5 MB - --title/--author überschreiben PDF-Metadaten - Tests (tests/test_split.py), README, Bedienungsanleitung (Weg C), CLAUDE.md, TODO.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f0e0562451
commit
d52ac597fc
1 changed files with 527 additions and 103 deletions
295
pdf2md.py
295
pdf2md.py
|
|
@ -15,6 +15,7 @@ Ablauf:
|
|||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import re
|
||||
|
|
@ -22,6 +23,7 @@ import shutil
|
|||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
from collections import Counter
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
|
|
@ -1098,6 +1100,20 @@ class _DiscardMessages(io.StringIO):
|
|||
return len(text)
|
||||
|
||||
|
||||
# JPEG-Qualität: pymupdf4llm speichert Bilder mit Pixmap.save() ohne Angabe
|
||||
# der Qualität (Standard 95); wir setzen sie über --jpeg-quality.
|
||||
DEFAULT_JPEG_QUALITY = 85
|
||||
_jpeg_quality = [DEFAULT_JPEG_QUALITY]
|
||||
_pixmap_save = pymupdf.Pixmap.save
|
||||
|
||||
|
||||
def _save_pixmap(self, filename, output=None, jpg_quality=None):
|
||||
quality = _jpeg_quality[0] if jpg_quality is None else jpg_quality
|
||||
return _pixmap_save(self, filename, output, quality)
|
||||
|
||||
|
||||
pymupdf.Pixmap.save = _save_pixmap
|
||||
|
||||
patch_pymupdf4llm_hyphens()
|
||||
patch_pymupdf4llm_paragraphs()
|
||||
document_layout.INFO_MESSAGES = _DiscardMessages()
|
||||
|
|
@ -1314,6 +1330,7 @@ def keep_existing_ocr(page, **kwargs) -> None:
|
|||
|
||||
|
||||
def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str:
|
||||
_jpeg_quality[0] = options.get("jpeg_quality", DEFAULT_JPEG_QUALITY) # auch im Worker
|
||||
kwargs = dict(
|
||||
pages=pages,
|
||||
header=options["keep_headers"],
|
||||
|
|
@ -1504,6 +1521,187 @@ def write_epub(
|
|||
raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Metadaten-Kopf, Aufteilen in Einzeldateien, EPUB aus Markdown
|
||||
# ===========================================================================
|
||||
# Jede erzeugte Markdown-Datei beginnt mit einem YAML-Kopf (title, author, lang,
|
||||
# cover …). Wir schreiben und lesen ihn selbst; pandoc bekommt nur den Text,
|
||||
# weil "---"-Zeilen im Buchtext sonst als YAML missverstanden werden könnten.
|
||||
|
||||
FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n+", re.DOTALL)
|
||||
INDEX_LINK = re.compile(r"\]\(([^)\s]+\.md)\)")
|
||||
UMLAUTS = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
|
||||
|
||||
|
||||
def make_frontmatter(meta: dict) -> str:
|
||||
lines = ["---"]
|
||||
for key, value in meta.items():
|
||||
if value not in (None, ""):
|
||||
lines.append(f"{key}: {json.dumps(value, ensure_ascii=False)}")
|
||||
return "\n".join(lines + ["---"]) + "\n\n"
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[dict, str]:
|
||||
"""YAML-Kopf (nur "schlüssel: wert") abtrennen und auswerten."""
|
||||
match = FRONTMATTER.match(text)
|
||||
if not match:
|
||||
return {}, text
|
||||
meta = {}
|
||||
for line in match.group(1).splitlines():
|
||||
key, sep, value = line.partition(":")
|
||||
if sep:
|
||||
value = value.strip()
|
||||
try:
|
||||
meta[key.strip()] = json.loads(value)
|
||||
except ValueError:
|
||||
meta[key.strip()] = value.strip("'\"")
|
||||
return meta, text[match.end():]
|
||||
|
||||
|
||||
def slugify(text: str, limit: int = 50) -> str:
|
||||
text = clean_inline(text).lower().translate(UMLAUTS)
|
||||
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
||||
return slug[:limit].rstrip("-") or "abschnitt"
|
||||
|
||||
|
||||
def iter_headings(lines: list[str]):
|
||||
"""(Zeilennummer, Ebene, Text) aller Überschriften außerhalb von Codeblöcken."""
|
||||
fence = None
|
||||
for index, line in enumerate(lines):
|
||||
marker = FENCE.match(line)
|
||||
if fence:
|
||||
if marker and marker.group(1)[0] == fence:
|
||||
fence = None
|
||||
continue
|
||||
if marker:
|
||||
fence = marker.group(1)[0]
|
||||
elif match := re.match(r"^(#{1,6}) (.+)$", line):
|
||||
yield index, len(match.group(1)), match.group(2).strip()
|
||||
|
||||
|
||||
def split_markdown(markdown: str, mode: str) -> list[dict]:
|
||||
"""Buch in Abschnitte teilen: "teile" (oberste Ebene) oder "kapitel".
|
||||
|
||||
Titelei und Inhaltsverzeichnis bilden den Vorspann.
|
||||
"""
|
||||
lines = markdown.split("\n")
|
||||
headings = list(iter_headings(lines))
|
||||
counts = Counter(level for _, level, _ in headings)
|
||||
levels = sorted(level for level, n in counts.items() if n >= 2 and level <= 3)
|
||||
if not levels:
|
||||
return [{"title": "Vorspann", "body": markdown}]
|
||||
part_level, chapter_level = levels[0], levels[-1]
|
||||
cut_levels = {part_level} if mode == "teile" else {part_level, chapter_level}
|
||||
|
||||
# Bis einschließlich Inhaltsverzeichnis (falls am Anfang) alles Vorspann.
|
||||
start = 0
|
||||
for index, _level, text in headings:
|
||||
if CONTENTS_TITLE.match(clean_inline(text)) and index < len(lines) // 5:
|
||||
start = index + 1
|
||||
break
|
||||
cuts = [(i, lvl, t) for i, lvl, t in headings if lvl in cut_levels and i >= start]
|
||||
|
||||
sections = []
|
||||
first = cuts[0][0] if cuts else len(lines)
|
||||
front = "\n".join(lines[:first]).strip()
|
||||
if front:
|
||||
sections.append({"title": "Vorspann", "body": front + "\n"})
|
||||
part = ""
|
||||
for n, (index, level, text) in enumerate(cuts):
|
||||
end = cuts[n + 1][0] if n + 1 < len(cuts) else len(lines)
|
||||
title = clean_inline(text)
|
||||
if level == part_level:
|
||||
part = title
|
||||
number = re.match(r"(\d+)\b", title) if level == chapter_level != part_level else None
|
||||
sections.append({
|
||||
"title": title,
|
||||
"part": part if part != title else "",
|
||||
"chapter": int(number.group(1)) if number else None,
|
||||
"body": "\n".join(lines[index:end]).strip() + "\n",
|
||||
})
|
||||
return sections
|
||||
|
||||
|
||||
def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]:
|
||||
"""Abschnitte als nummerierte Dateien plus index.md (Reihenfolge) schreiben."""
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
width = max(2, len(str(len(sections))))
|
||||
files = []
|
||||
for order, section in enumerate(sections):
|
||||
name = f"{order:0{width}d}_{slugify(section['title'])}.md"
|
||||
head = {
|
||||
"title": section["title"],
|
||||
"part": section.get("part"),
|
||||
"chapter": section.get("chapter"),
|
||||
"order": order,
|
||||
"book": meta.get("title"),
|
||||
"author": meta.get("author"),
|
||||
}
|
||||
(folder / name).write_text(make_frontmatter(head) + section["body"], encoding="utf-8")
|
||||
files.append(folder / name)
|
||||
|
||||
entries = "\n".join(
|
||||
f"- [{s['title']}]({f.name})" for s, f in zip(sections, files)
|
||||
)
|
||||
index = (
|
||||
make_frontmatter(meta)
|
||||
+ "<!-- Reihenfolge der Dateien im Buch; zum Umsortieren Zeilen verschieben. -->\n\n"
|
||||
+ entries + "\n"
|
||||
)
|
||||
(folder / "index.md").write_text(index, encoding="utf-8")
|
||||
return files
|
||||
|
||||
|
||||
def read_markdown_source(path: Path) -> tuple[dict, str, Path]:
|
||||
"""Metadaten, Text und Basisordner aus einer .md-Datei oder einem Split-Ordner."""
|
||||
if path.is_dir():
|
||||
index = path / "index.md"
|
||||
if not index.is_file():
|
||||
raise RuntimeError(f"index.md fehlt in {path}")
|
||||
meta, listing = split_frontmatter(index.read_text(encoding="utf-8"))
|
||||
parts = []
|
||||
for name in INDEX_LINK.findall(listing):
|
||||
file = path / name
|
||||
if not file.is_file():
|
||||
raise RuntimeError(f"In index.md genannt, aber nicht vorhanden: {file}")
|
||||
parts.append(split_frontmatter(file.read_text(encoding="utf-8"))[1].strip())
|
||||
if not parts:
|
||||
raise RuntimeError(f"index.md nennt keine Dateien: {index}")
|
||||
return meta, "\n\n".join(parts) + "\n", path
|
||||
meta, body = split_frontmatter(path.read_text(encoding="utf-8"))
|
||||
return meta, body, path.parent
|
||||
|
||||
|
||||
def markdown_to_epub(
|
||||
source: Path,
|
||||
epub_path: Path,
|
||||
*,
|
||||
title: str | None = None,
|
||||
author: str | None = None,
|
||||
language: str | None = None,
|
||||
) -> None:
|
||||
"""EPUB aus Markdown-Datei oder Split-Ordner; Angaben aus dem YAML-Kopf."""
|
||||
meta, body, base = read_markdown_source(source)
|
||||
cover = base / meta["cover"] if meta.get("cover") else None
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", suffix=".md", dir=base, delete=False
|
||||
) as tmp:
|
||||
tmp.write(body)
|
||||
try:
|
||||
write_epub(
|
||||
Path(tmp.name),
|
||||
epub_path,
|
||||
title=title or meta.get("title") or source.stem,
|
||||
author=author or meta.get("author") or "",
|
||||
language=language or meta.get("lang") or "de",
|
||||
cover=cover if cover and cover.is_file() else None,
|
||||
split_level=heading_depth(body),
|
||||
)
|
||||
finally:
|
||||
Path(tmp.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Kommandozeile
|
||||
# ===========================================================================
|
||||
|
|
@ -1515,7 +1713,7 @@ def main() -> int:
|
|||
"Satzenden gliedern, optional Grafiken speichern und EPUB erzeugen."
|
||||
)
|
||||
)
|
||||
parser.add_argument("pdf", type=Path, help="Eingabe-PDF")
|
||||
parser.add_argument("pdf", type=Path, nargs="?", help="Eingabe-PDF")
|
||||
parser.add_argument(
|
||||
"markdown",
|
||||
type=Path,
|
||||
|
|
@ -1579,6 +1777,28 @@ def main() -> int:
|
|||
metavar="PFAD",
|
||||
help="Pfad des EPUB; Standard: gleicher Name wie Markdown mit .epub",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jpeg-quality",
|
||||
type=int,
|
||||
default=DEFAULT_JPEG_QUALITY,
|
||||
metavar="1-100",
|
||||
help="JPEG-Qualität der Bilder (Standard: %(default)s; 95 = fast verlustfrei, größer)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--split",
|
||||
choices=["teile", "kapitel"],
|
||||
help="Teile bzw. Kapitel als einzelne Markdown-Dateien in einem Ordner "
|
||||
"(mit index.md für die Reihenfolge) statt einer Datei",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--from-md",
|
||||
type=Path,
|
||||
metavar="PFAD",
|
||||
help="nur EPUB erzeugen: aus einer (bearbeiteten) Markdown-Datei oder einem "
|
||||
"--split-Ordner; Titel, Autor, Cover aus dem YAML-Kopf",
|
||||
)
|
||||
parser.add_argument("--title", help="Titel im EPUB (statt PDF-Metadaten/Dateiname)")
|
||||
parser.add_argument("--author", help="Autor im EPUB (statt PDF-Metadaten/Dateiname)")
|
||||
parser.add_argument(
|
||||
"--lang",
|
||||
help="Sprache im EPUB, z. B. en (Standard: aus --ocr-language, "
|
||||
|
|
@ -1595,6 +1815,13 @@ def main() -> int:
|
|||
help="Seitenfüße/Seitenzahlen übernehmen",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if not 1 <= args.jpeg_quality <= 100:
|
||||
parser.error("--jpeg-quality muss zwischen 1 und 100 liegen.")
|
||||
|
||||
if args.from_md:
|
||||
return epub_from_markdown(args, parser)
|
||||
if args.pdf is None:
|
||||
parser.error("Eingabe-PDF fehlt (oder --from-md PFAD angeben).")
|
||||
args.lang = args.lang or epub_language(args.ocr_language)
|
||||
|
||||
source = args.pdf.expanduser()
|
||||
|
|
@ -1611,7 +1838,9 @@ def main() -> int:
|
|||
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"
|
||||
# --split: Ordner "<name>/" mit Einzeldateien und "images/" statt einer Datei
|
||||
folder = target.with_suffix("") if args.split else None
|
||||
image_dir = folder / "images" if folder else target.parent / f"{target.stem}_images"
|
||||
|
||||
try:
|
||||
with pymupdf.open(source) as doc:
|
||||
|
|
@ -1620,8 +1849,12 @@ def main() -> int:
|
|||
uris = load_link_uris(doc)
|
||||
page_count = doc.page_count
|
||||
file_title, file_author = title_author_from_filename(source.stem)
|
||||
title = ((doc.metadata or {}).get("title") or "").strip() or file_title
|
||||
author = ((doc.metadata or {}).get("author") or "").strip() or file_author
|
||||
title = (
|
||||
args.title or ((doc.metadata or {}).get("title") or "").strip() or file_title
|
||||
)
|
||||
author = (
|
||||
args.author or ((doc.metadata or {}).get("author") or "").strip() or file_author
|
||||
)
|
||||
|
||||
counts = Counter(kinds)
|
||||
print(
|
||||
|
|
@ -1642,10 +1875,11 @@ def main() -> int:
|
|||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
cover = None
|
||||
if args.images:
|
||||
# Cover immer mit ablegen, damit --from-md es später findet.
|
||||
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)
|
||||
cover = image_dir / f"cover.{args.image_format}"
|
||||
_jpeg_quality[0] = args.jpeg_quality
|
||||
doc[0].get_pixmap(dpi=150).save(cover)
|
||||
|
||||
options = dict(
|
||||
keep_headers=args.keep_headers,
|
||||
|
|
@ -1655,6 +1889,7 @@ def main() -> int:
|
|||
image_format=args.image_format,
|
||||
ocr_language=args.ocr_language,
|
||||
ocr_dpi=args.ocr_dpi,
|
||||
jpeg_quality=args.jpeg_quality,
|
||||
)
|
||||
markdown = extract_markdown(source, plan, options, args.jobs)
|
||||
|
||||
|
|
@ -1669,24 +1904,30 @@ def main() -> int:
|
|||
running_head_min=max(3, page_count // 10),
|
||||
)
|
||||
markdown = link_urls(markdown, uris)
|
||||
target.write_text(markdown, encoding="utf-8")
|
||||
|
||||
base = folder or target.parent
|
||||
meta = {
|
||||
"title": title,
|
||||
"author": author,
|
||||
"lang": args.lang,
|
||||
"cover": cover.relative_to(base).as_posix() if cover else None,
|
||||
}
|
||||
if folder:
|
||||
files = write_split(folder, split_markdown(markdown, args.split), meta)
|
||||
else:
|
||||
target.write_text(make_frontmatter(meta) + 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),
|
||||
)
|
||||
markdown_to_epub(folder or target, epub_path)
|
||||
|
||||
except Exception as exc:
|
||||
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Markdown geschrieben: {target}")
|
||||
if folder:
|
||||
print(f"Markdown geschrieben: {len(files)} Dateien in {folder} (Reihenfolge: index.md)")
|
||||
else:
|
||||
print(f"Markdown geschrieben: {target}")
|
||||
if args.images:
|
||||
print(f"Bilder gespeichert in: {image_dir}")
|
||||
if epub_path:
|
||||
|
|
@ -1695,5 +1936,23 @@ def main() -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def epub_from_markdown(args, parser) -> int:
|
||||
"""--from-md: EPUB aus bearbeitetem Markdown bzw. Split-Ordner erzeugen."""
|
||||
source = args.from_md.expanduser()
|
||||
if not source.exists():
|
||||
parser.error(f"Nicht gefunden: {source}")
|
||||
base = source if source.is_dir() else source.with_suffix("")
|
||||
epub_path = (args.epub_file or base.with_suffix(".epub")).expanduser()
|
||||
try:
|
||||
markdown_to_epub(
|
||||
source, epub_path, title=args.title, author=args.author, language=args.lang
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Fehler beim EPUB-Erzeugen: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"EPUB geschrieben: {epub_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
|
|||
Loading…
Reference in a new issue