--split seiten, --epub-per-file, Nachspann, --from-md --split, --split-chars
- --split seiten mit --pages-per-file N (Standard 20, Ausrichtung an Überschriften bis eine Seite neben der Grenze) oder --page-ranges (streng; Seiten außerhalb werden eigene Dateien) - Schnitt nur im fertigen Gesamt-Markdown (lückenlos): vor dem ersten Absatz, der auf der Grenzseite beginnt; Überschriften vor dem Schnitt wandern in die neue Datei; source_pages aus Anfang/Ende jedes Absatzes - Seitenzuordnung über die Rohtexte je Seite (pymupdf4llm page_chunks=True, aneinandergehängt byte-gleich; Band 1/2 unverändert), auch für OCR-Seiten - --epub-per-file: ein EPUB je Einzeldatei in <name>/epub/ - Nachspann: Anhang/Autor/Impressum nach dem letzten Kapitel als eigene Datei - --from-md DATEI --split teile|kapitel: bearbeitetes Markdown nachträglich teilen, Bild- und Cover-Pfade angepasst - --split-chars N; Längen-Abschnitte tragen die Überschrift am Anfang als Titel - Tests (tests/test_split_pages.py), README, Bedienungsanleitung, CLAUDE.md, TODO Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b05e2d9cf5
commit
5cd5e5d69b
1 changed files with 568 additions and 39 deletions
369
pdf2md.py
369
pdf2md.py
|
|
@ -13,6 +13,7 @@ Ablauf:
|
|||
"""
|
||||
|
||||
import argparse
|
||||
import bisect
|
||||
import contextlib
|
||||
import difflib
|
||||
import io
|
||||
|
|
@ -1804,7 +1805,8 @@ def keep_existing_ocr(page, **kwargs) -> None:
|
|||
"""
|
||||
|
||||
|
||||
def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str:
|
||||
def convert_pages(source: str, run: str, pages: list[int], options: dict) -> list[str]:
|
||||
"""Markdown je Seite (in der Reihenfolge von pages)."""
|
||||
_jpeg_quality[0] = options.get("jpeg_quality", DEFAULT_JPEG_QUALITY) # auch im Worker
|
||||
_keep_lines[0] = options.get("keep_lines", False)
|
||||
kwargs = dict(
|
||||
|
|
@ -1814,6 +1816,8 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str
|
|||
write_images=options["images"],
|
||||
image_path=options["image_path"],
|
||||
image_format=options["image_format"],
|
||||
# Text je Seite; aneinandergehängt identisch mit page_chunks=False
|
||||
page_chunks=True,
|
||||
)
|
||||
if run == RUN_OCR:
|
||||
# FORCE_DROP_OLD: vorhandene (schlechte) OCR-Ebene verwerfen und neu
|
||||
|
|
@ -1832,22 +1836,22 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str
|
|||
# pymupdf4llm schreibt Statusmeldungen auf stdout; wir melden selbst.
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
if run != RUN_OCR:
|
||||
return pymupdf4llm.to_markdown(source, **kwargs)
|
||||
return [chunk["text"] for chunk in pymupdf4llm.to_markdown(source, **kwargs)]
|
||||
if options.get("keep_lines"):
|
||||
with pymupdf.open(source) as doc:
|
||||
return "".join(
|
||||
return [
|
||||
ocr_lines_md(
|
||||
doc[pno], options["ocr_language"], options["ocr_dpi"],
|
||||
options.get("titles", {}).get(pno, []),
|
||||
)
|
||||
for pno in pages
|
||||
)
|
||||
]
|
||||
# Fremde OCR-Ebenen entfernt pymupdf4llm nicht (nur eigene); sonst stünde
|
||||
# alter und neuer Text doppelt da. Nur die geöffnete Kopie wird geändert.
|
||||
with pymupdf.open(source) as doc:
|
||||
for pno in pages:
|
||||
drop_invisible_text(doc[pno])
|
||||
return pymupdf4llm.to_markdown(doc, **kwargs)
|
||||
return [chunk["text"] for chunk in pymupdf4llm.to_markdown(doc, **kwargs)]
|
||||
|
||||
|
||||
# --keep-lines auf Scanseiten: Zeilen direkt aus Tesseract, ohne Layout-Modell.
|
||||
|
|
@ -1968,7 +1972,10 @@ def _init_worker() -> None:
|
|||
os.environ["OMP_THREAD_LIMIT"] = "1"
|
||||
|
||||
|
||||
def extract_markdown(source: Path, plan: list[str], options: dict, jobs: int) -> str:
|
||||
def extract_markdown(
|
||||
source: Path, plan: list[str], options: dict, jobs: int
|
||||
) -> tuple[str, dict[int, str]]:
|
||||
"""Gesamt-Markdown und Rohtext je Seite (für --split seiten)."""
|
||||
tasks = make_tasks(plan)
|
||||
total = len(tasks)
|
||||
labels = {RUN_PLAIN: "Text", RUN_OCR: "OCR", RUN_REUSE: "OCR-Ebene"}
|
||||
|
|
@ -1981,11 +1988,17 @@ def extract_markdown(source: Path, plan: list[str], options: dict, jobs: int) ->
|
|||
)
|
||||
|
||||
parts = []
|
||||
page_texts: dict[int, str] = {}
|
||||
|
||||
def collect(pages: list[int], texts: list[str]) -> None:
|
||||
page_texts.update(zip(pages, texts))
|
||||
parts.append("".join(texts))
|
||||
|
||||
# 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))
|
||||
collect(pages, convert_pages(str(source), run, pages, options))
|
||||
report(i, run, pages)
|
||||
else:
|
||||
context = multiprocessing.get_context("spawn")
|
||||
|
|
@ -1999,10 +2012,10 @@ def extract_markdown(source: Path, plan: list[str], options: dict, jobs: int) ->
|
|||
for run, pages in tasks
|
||||
]
|
||||
for i, (future, (run, pages)) in enumerate(zip(futures, tasks), 1):
|
||||
parts.append(future.result())
|
||||
collect(pages, future.result())
|
||||
report(i, run, pages)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
return "\n\n".join(parts), page_texts
|
||||
|
||||
|
||||
IMAGE_LINK = re.compile(r"(!\[[^\]]*\]\()([^)\s]+)\)")
|
||||
|
|
@ -2187,10 +2200,27 @@ def iter_headings(lines: list[str]):
|
|||
|
||||
|
||||
SPLIT_CHARS = 30_000 # ohne Gliederung: Abschnitte von etwa dieser Länge
|
||||
PAGES_PER_FILE = 20 # --split seiten: Standard für --pages-per-file
|
||||
HEADING_BLOCK = re.compile(r"^(#{1,6}) (.+)$")
|
||||
|
||||
# Nachspann: erste dieser Überschriften nach dem letzten Kapitel/Teil.
|
||||
BACKMATTER_TITLE = re.compile(
|
||||
r"^(?:nachwort|nachbemerkung|anhang|anhänge|der autor|die autorin|"
|
||||
r"über den autor|über die autorin|zum autor|zur autorin|impressum|danksagung|dank|"
|
||||
r"quellen|quellenverzeichnis|quellennachweis|literatur|literaturverzeichnis|"
|
||||
r"bibliographie|bibliografie|register|personenregister|sachregister|"
|
||||
r"stichwortverzeichnis|glossar|anmerkungen|endnoten|bildnachweis|"
|
||||
r"about the author|acknowledgements|acknowledgments|appendix|bibliography|"
|
||||
r"glossary|index|notes)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def split_by_length(markdown: str, size: int = SPLIT_CHARS) -> list[dict]:
|
||||
"""Ohne verwertbare Überschriften: an Absatzgrenzen in etwa gleich lange Stücke."""
|
||||
def md_blocks(markdown: str) -> list[str]:
|
||||
"""Absätze samt folgendem Leerraum; Codeblöcke bleiben ganz.
|
||||
|
||||
Aneinandergehängt ergeben die Blöcke wieder genau den Text.
|
||||
"""
|
||||
blocks, fence = [], False
|
||||
for part in re.split(r"(\n\n+)", markdown):
|
||||
if blocks and fence:
|
||||
|
|
@ -2200,31 +2230,47 @@ def split_by_length(markdown: str, size: int = SPLIT_CHARS) -> list[dict]:
|
|||
else:
|
||||
blocks[-1] += part
|
||||
fence ^= sum(1 for line in part.split("\n") if FENCE.match(line)) % 2 == 1
|
||||
sections, current = [], ""
|
||||
for block in blocks:
|
||||
if current and len(current) + len(block) > size:
|
||||
return blocks
|
||||
|
||||
|
||||
def block_heading(block: str) -> str | None:
|
||||
"""Text der Überschrift, wenn der Block nur aus einer Überschrift besteht."""
|
||||
lines = block.strip().split("\n")
|
||||
match = HEADING_BLOCK.match(lines[0]) if len(lines) == 1 else None
|
||||
return md_unescape(clean_inline(match.group(2))) if match else None
|
||||
|
||||
|
||||
def split_by_length(markdown: str, size: int = SPLIT_CHARS) -> list[dict]:
|
||||
"""Ohne verwertbare Überschriften: an Absatzgrenzen in etwa gleich lange Stücke."""
|
||||
sections, current = [], []
|
||||
for block in md_blocks(markdown):
|
||||
if current and sum(map(len, current)) + len(block) > size:
|
||||
sections.append(current)
|
||||
current = ""
|
||||
current += block
|
||||
if current.strip():
|
||||
current = []
|
||||
current.append(block)
|
||||
if "".join(current).strip():
|
||||
sections.append(current)
|
||||
return [
|
||||
{"title": f"Abschnitt {n}", "body": body.strip() + "\n"}
|
||||
for n, body in enumerate(sections, start=1)
|
||||
{
|
||||
"title": block_heading(blocks[0]) or f"Abschnitt {n}",
|
||||
"body": "".join(blocks).strip() + "\n",
|
||||
}
|
||||
for n, blocks in enumerate(sections, start=1)
|
||||
]
|
||||
|
||||
|
||||
def split_markdown(markdown: str, mode: str) -> list[dict]:
|
||||
def split_markdown(markdown: str, mode: str, size: int = SPLIT_CHARS) -> list[dict]:
|
||||
"""Buch in Abschnitte teilen: "teile" (oberste Ebene) oder "kapitel".
|
||||
|
||||
Titelei und Inhaltsverzeichnis bilden den Vorspann.
|
||||
Titelei und Inhaltsverzeichnis bilden den Vorspann, Anhang/Autor/Impressum
|
||||
nach dem letzten Kapitel den Nachspann. Ohne Gliederung: nach Länge (size).
|
||||
"""
|
||||
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 split_by_length(markdown)
|
||||
return split_by_length(markdown, size)
|
||||
part_level, chapter_level = levels[0], levels[-1]
|
||||
cut_levels = {part_level} if mode == "teile" else {part_level, chapter_level}
|
||||
|
||||
|
|
@ -2236,6 +2282,20 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
|
|||
break
|
||||
cuts = [(i, lvl, t) for i, lvl, t in headings if lvl in cut_levels and i >= start]
|
||||
|
||||
# Nachspann (Ebene 0): Anhang & Co. unterhalb der Schnittebenen, sonst hinge
|
||||
# er am letzten Kapitel.
|
||||
after = cuts[-1][0] if cuts else start - 1
|
||||
back = next(
|
||||
(
|
||||
(i, 0, t) for i, lvl, t in headings
|
||||
if i > after and lvl <= 3 and lvl not in cut_levels
|
||||
and BACKMATTER_TITLE.match(md_unescape(clean_inline(t)))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if back:
|
||||
cuts.append(back)
|
||||
|
||||
sections = []
|
||||
first = cuts[0][0] if cuts else len(lines)
|
||||
front = "\n".join(lines[:first]).strip()
|
||||
|
|
@ -2244,6 +2304,10 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
|
|||
part = ""
|
||||
for n, (index, level, text) in enumerate(cuts):
|
||||
end = cuts[n + 1][0] if n + 1 < len(cuts) else len(lines)
|
||||
body = "\n".join(lines[index:end]).strip() + "\n"
|
||||
if level == 0:
|
||||
sections.append({"title": "Nachspann", "body": body})
|
||||
continue
|
||||
title = md_unescape(clean_inline(text))
|
||||
if level == part_level:
|
||||
part = title
|
||||
|
|
@ -2252,11 +2316,135 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
|
|||
"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",
|
||||
"body": body,
|
||||
})
|
||||
return sections
|
||||
|
||||
|
||||
def parse_page_ranges(spec: str, page_count: int) -> list[int]:
|
||||
"""--page-ranges "1-12,13-40,41-" -> Startseiten (0-basiert), immer mit 0.
|
||||
|
||||
Seiten vor dem ersten, zwischen und nach den Bereichen werden eigene
|
||||
Abschnitte; so geht kein Text verloren.
|
||||
"""
|
||||
ranges = []
|
||||
for item in spec.replace(" ", "").split(","):
|
||||
match = re.fullmatch(r"(\d+)(?:-(\d*))?", item)
|
||||
if not match:
|
||||
raise ValueError(f"Seitenbereich nicht lesbar: {item!r} (Beispiel: 1-12,13-40,41-)")
|
||||
first = int(match.group(1))
|
||||
last = page_count if match.group(2) == "" else int(match.group(2) or first)
|
||||
if not 1 <= first <= last <= page_count:
|
||||
raise ValueError(f"Seitenbereich {item!r} passt nicht zu {page_count} Seiten")
|
||||
ranges.append((first - 1, last - 1))
|
||||
ranges.sort()
|
||||
for (_, last), (first, _) in zip(ranges, ranges[1:]):
|
||||
if first <= last:
|
||||
raise ValueError(f"Seitenbereiche überlappen sich: Seite {first + 1}")
|
||||
starts = {0}
|
||||
for first, last in ranges:
|
||||
starts.add(first)
|
||||
if last + 1 < page_count:
|
||||
starts.add(last + 1)
|
||||
return sorted(starts)
|
||||
|
||||
|
||||
PAGE_KEY_MIN = 6 # kürzere Absätze ("Ja.") erben die Seite des Vorgängers
|
||||
PAGE_SEARCH = 50_000 # so weit (Zeichen ohne Leerraum) wird vorausgesucht
|
||||
|
||||
|
||||
def block_pages(blocks: list[str], page_texts: dict[int, str]) -> list[tuple[int, int]]:
|
||||
"""Start- und Endseite (0-basiert) jedes Blocks.
|
||||
|
||||
Die Rohtexte aller Seiten bilden einen fortlaufenden Vergleichstext; Anfang und
|
||||
Ende jedes Blocks werden darin nur vorwärts gesucht. Nicht gefundene Blöcke
|
||||
(Bilder, stark veränderter Text) erben die Seite des Vorgängers.
|
||||
"""
|
||||
stream, offsets = [], []
|
||||
length = 0
|
||||
for pno in range(max(page_texts, default=-1) + 1):
|
||||
offsets.append(length)
|
||||
key = _page_key(page_texts.get(pno, ""))
|
||||
stream.append(key)
|
||||
length += len(key)
|
||||
text = "".join(stream)
|
||||
|
||||
def page_of(position: int) -> int:
|
||||
return bisect.bisect_right(offsets, position) - 1
|
||||
|
||||
result, pos, page = [], 0, 0
|
||||
for block in blocks:
|
||||
key = _page_key(block)
|
||||
end = page
|
||||
if len(key) >= PAGE_KEY_MIN:
|
||||
found = text.find(key[:30], pos, pos + PAGE_SEARCH + 30)
|
||||
if found >= 0:
|
||||
pos, page = found, page_of(found)
|
||||
tail = text.find(key[-30:], pos, pos + 2 * len(key) + 1000)
|
||||
end = page_of(tail + len(key[-30:]) - 1) if tail >= 0 else page
|
||||
result.append((page, max(page, end)))
|
||||
return result
|
||||
|
||||
|
||||
def split_by_pages(
|
||||
markdown: str,
|
||||
starts: list[int],
|
||||
page_texts: dict[int, str],
|
||||
align: bool = True,
|
||||
) -> list[dict]:
|
||||
"""Nach Seiten der PDF teilen; starts = Startseiten (0-basiert).
|
||||
|
||||
Geschnitten wird vor dem ersten Block, der auf der Startseite beginnt; ein
|
||||
Absatz über die Seitengrenze bleibt ganz in der Datei davor. Überschriften
|
||||
direkt vor dem Schnitt gehören zur neuen Datei. Mit align wird an einer
|
||||
Überschrift höchstens eine Seite vor oder nach der Grenze geschnitten.
|
||||
"""
|
||||
blocks = md_blocks(markdown)
|
||||
if not blocks:
|
||||
return []
|
||||
spans = block_pages(blocks, page_texts)
|
||||
pages = [first for first, _ in spans]
|
||||
heading = [block_heading(b) is not None for b in blocks]
|
||||
cuts: list[int] = []
|
||||
for start in starts:
|
||||
if start == 0:
|
||||
continue
|
||||
cut = next((i for i, page in enumerate(pages) if page >= start), None)
|
||||
if cut is None:
|
||||
break
|
||||
if align:
|
||||
near = [
|
||||
i for i in range(1, len(blocks))
|
||||
if heading[i] and not heading[i - 1] and abs(pages[i] - start) <= 1
|
||||
and (not cuts or i > cuts[-1])
|
||||
]
|
||||
if near:
|
||||
cut = min(near, key=lambda i: (abs(pages[i] - start), abs(i - cut)))
|
||||
while cut > 0 and heading[cut - 1]:
|
||||
cut -= 1
|
||||
if cut > (cuts[-1] if cuts else 0):
|
||||
cuts.append(cut)
|
||||
|
||||
last_page = max(page_texts, default=0)
|
||||
bounds = [0, *cuts, len(blocks)]
|
||||
sections = []
|
||||
for a, b in zip(bounds, bounds[1:]):
|
||||
first = pages[a] if a else 0 # Titelei ab Seite 1, auch ohne Text (Cover)
|
||||
# Ende: bis vor die nächste Startseite, mindestens bis zum Ende des letzten
|
||||
# Absatzes (der über die Seitengrenze reichen kann).
|
||||
end = max(
|
||||
max(last for _, last in spans[a:b]),
|
||||
pages[b] - 1 if b < len(blocks) else last_page,
|
||||
)
|
||||
name = f"Seite {first + 1}" if end == first else f"Seiten {first + 1}-{end + 1}"
|
||||
sections.append({
|
||||
"title": block_heading(blocks[a]) or name,
|
||||
"body": "".join(blocks[a:b]).strip() + "\n",
|
||||
"source_pages": [first + 1, end + 1],
|
||||
})
|
||||
return [s for s in sections if s["body"].strip()]
|
||||
|
||||
|
||||
def _page_key(text: str) -> str:
|
||||
return re.sub(r"[\W_]+", "", clean_inline(text).lower())
|
||||
|
||||
|
|
@ -2359,10 +2547,12 @@ def markdown_to_epub(
|
|||
title: str | None = None,
|
||||
author: str | None = None,
|
||||
language: str | None = None,
|
||||
cover: Path | 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
|
||||
if cover is None and meta.get("cover"):
|
||||
cover = base / meta["cover"]
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", suffix=".md", dir=base, delete=False
|
||||
) as tmp:
|
||||
|
|
@ -2381,6 +2571,38 @@ def markdown_to_epub(
|
|||
Path(tmp.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def epubs_per_file(folder: Path, files: list[Path], meta: dict) -> Path:
|
||||
"""--epub-per-file: ein EPUB je Einzeldatei im Unterordner "epub/"."""
|
||||
target = folder / "epub"
|
||||
target.mkdir(exist_ok=True)
|
||||
cover = folder / meta["cover"] if meta.get("cover") else None
|
||||
for file in files:
|
||||
head, _ = split_frontmatter(file.read_text(encoding="utf-8"))
|
||||
book = meta.get("title") or folder.name
|
||||
markdown_to_epub(
|
||||
file,
|
||||
target / f"{file.stem}.epub",
|
||||
title=f"{book} - {head.get('title') or file.stem}",
|
||||
author=meta.get("author"),
|
||||
language=meta.get("lang"),
|
||||
cover=cover if cover and cover.is_file() else None,
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def rebase_image_links(markdown: str, old_base: Path, new_base: Path) -> str:
|
||||
"""Relative Bildpfade von old_base auf new_base umrechnen."""
|
||||
|
||||
def fix(match: re.Match) -> str:
|
||||
link = match.group(2)
|
||||
if re.match(r"^[a-z][a-z0-9+.-]*:", link, re.IGNORECASE) or Path(link).is_absolute():
|
||||
return match.group(0) # URL, data: oder absoluter Pfad
|
||||
new = os.path.relpath(old_base / link, new_base)
|
||||
return f"{match.group(1)}{Path(new).as_posix()})"
|
||||
|
||||
return IMAGE_LINK.sub(fix, markdown)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Kommandozeile
|
||||
# ===========================================================================
|
||||
|
|
@ -2465,9 +2687,35 @@ def main() -> int:
|
|||
)
|
||||
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",
|
||||
choices=["teile", "kapitel", "seiten"],
|
||||
help="Teile, Kapitel oder Seitenbereiche der PDF als einzelne Markdown-Dateien "
|
||||
"in einem Ordner (mit index.md für die Reihenfolge) statt einer Datei",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pages-per-file",
|
||||
type=int,
|
||||
metavar="N",
|
||||
help=f"mit --split seiten: etwa N PDF-Seiten je Datei, Schnitt an Absatzgrenzen, "
|
||||
f"bevorzugt an einer Überschrift (Standard: {PAGES_PER_FILE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--page-ranges",
|
||||
metavar="BEREICHE",
|
||||
help="mit --split seiten: feste Bereiche der PDF-Seiten, z. B. 1-12,13-40,41- "
|
||||
"(Seiten außerhalb werden eigene Dateien)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--split-chars",
|
||||
type=int,
|
||||
default=SPLIT_CHARS,
|
||||
metavar="N",
|
||||
help="--split teile|kapitel ohne Gliederung: Abschnitte von etwa N Zeichen "
|
||||
"(Standard: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--epub-per-file",
|
||||
action="store_true",
|
||||
help="mit --split: zusätzlich ein EPUB je Einzeldatei im Unterordner epub/",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--from-md",
|
||||
|
|
@ -2502,6 +2750,16 @@ def main() -> int:
|
|||
args = parser.parse_args()
|
||||
if not 1 <= args.jpeg_quality <= 100:
|
||||
parser.error("--jpeg-quality muss zwischen 1 und 100 liegen.")
|
||||
if (args.pages_per_file is not None or args.page_ranges) and args.split != "seiten":
|
||||
parser.error("--pages-per-file und --page-ranges nur mit --split seiten.")
|
||||
if args.pages_per_file is not None and args.page_ranges:
|
||||
parser.error("--pages-per-file und --page-ranges schließen sich aus.")
|
||||
if args.pages_per_file is not None and args.pages_per_file < 1:
|
||||
parser.error("--pages-per-file muss mindestens 1 sein.")
|
||||
if args.split_chars < 1000:
|
||||
parser.error("--split-chars muss mindestens 1000 sein.")
|
||||
if args.epub_per_file and not args.split:
|
||||
parser.error("--epub-per-file nur mit --split.")
|
||||
|
||||
if args.from_md:
|
||||
return epub_from_markdown(args, parser)
|
||||
|
|
@ -2520,8 +2778,9 @@ def main() -> int:
|
|||
|
||||
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()
|
||||
if epub_path or args.epub_per_file:
|
||||
args.images = True # EPUB ohne Bilder wäre unvollständig
|
||||
|
||||
# --split: Ordner "<name>/" mit Einzeldateien und "images/" statt einer Datei
|
||||
folder = target.with_suffix("") if args.split else None
|
||||
|
|
@ -2583,7 +2842,7 @@ def main() -> int:
|
|||
keep_lines=args.keep_lines,
|
||||
titles=titles,
|
||||
)
|
||||
markdown = extract_markdown(source, plan, options, args.jobs)
|
||||
markdown, page_texts = extract_markdown(source, plan, options, args.jobs)
|
||||
|
||||
if args.images:
|
||||
markdown = make_image_links_relative(markdown, image_dir)
|
||||
|
|
@ -2605,8 +2864,17 @@ def main() -> int:
|
|||
"lang": args.lang,
|
||||
"cover": cover.relative_to(base).as_posix() if cover else None,
|
||||
}
|
||||
if folder:
|
||||
sections = split_markdown(markdown, args.split)
|
||||
if args.split == "seiten":
|
||||
if args.page_ranges:
|
||||
starts = parse_page_ranges(args.page_ranges, page_count)
|
||||
else:
|
||||
starts = list(range(0, page_count, args.pages_per_file or PAGES_PER_FILE))
|
||||
sections = split_by_pages(
|
||||
markdown, starts, page_texts, align=not args.page_ranges
|
||||
)
|
||||
files = write_split(folder, sections, meta)
|
||||
elif folder:
|
||||
sections = split_markdown(markdown, args.split, args.split_chars)
|
||||
if sections and sections[0]["title"] == "Abschnitt 1":
|
||||
print("Hinweis: keine Gliederung erkannt - nach Länge geteilt.")
|
||||
with pymupdf.open(source) as doc:
|
||||
|
|
@ -2617,6 +2885,8 @@ def main() -> int:
|
|||
|
||||
if epub_path:
|
||||
markdown_to_epub(folder or target, epub_path)
|
||||
if args.epub_per_file:
|
||||
epub_dir = epubs_per_file(folder, files, meta)
|
||||
|
||||
except Exception as exc:
|
||||
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
|
||||
|
|
@ -2630,6 +2900,8 @@ def main() -> int:
|
|||
print(f"Bilder gespeichert in: {image_dir}")
|
||||
if epub_path:
|
||||
print(f"EPUB geschrieben: {epub_path}")
|
||||
if args.epub_per_file:
|
||||
print(f"Einzel-EPUBs geschrieben: {len(files)} in {epub_dir}")
|
||||
|
||||
return 0
|
||||
|
||||
|
|
@ -2639,6 +2911,8 @@ def epub_from_markdown(args, parser) -> int:
|
|||
source = args.from_md.expanduser()
|
||||
if not source.exists():
|
||||
parser.error(f"Nicht gefunden: {source}")
|
||||
if args.split:
|
||||
return split_from_markdown(args, parser, source)
|
||||
base = source if source.is_dir() else source.with_suffix("")
|
||||
epub_path = (args.epub_file or base.with_suffix(".epub")).expanduser()
|
||||
try:
|
||||
|
|
@ -2652,5 +2926,38 @@ def epub_from_markdown(args, parser) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def split_from_markdown(args, parser, source: Path) -> int:
|
||||
"""--from-md DATEI --split teile|kapitel: Gesamt-Markdown in einen Ordner teilen."""
|
||||
if args.split == "seiten":
|
||||
parser.error("--split seiten braucht die PDF (Seitenzuordnung), nicht --from-md.")
|
||||
if not source.is_file():
|
||||
parser.error(f"--split mit --from-md braucht eine Markdown-Datei: {source}")
|
||||
folder = source.with_suffix("")
|
||||
try:
|
||||
meta, body = split_frontmatter(source.read_text(encoding="utf-8"))
|
||||
body = rebase_image_links(body, source.parent, folder)
|
||||
if meta.get("cover"):
|
||||
meta["cover"] = Path(os.path.relpath(source.parent / meta["cover"], folder)).as_posix()
|
||||
meta["title"] = args.title or meta.get("title") or source.stem
|
||||
meta["author"] = args.author or meta.get("author")
|
||||
meta["lang"] = args.lang or meta.get("lang")
|
||||
sections = split_markdown(body, args.split, args.split_chars)
|
||||
if sections and sections[0]["title"] == "Abschnitt 1":
|
||||
print("Hinweis: keine Gliederung erkannt - nach Länge geteilt.")
|
||||
files = write_split(folder, sections, meta)
|
||||
print(f"Markdown geschrieben: {len(files)} Dateien in {folder} (Reihenfolge: index.md)")
|
||||
if args.epub or args.epub_file:
|
||||
epub_path = (args.epub_file or folder.with_suffix(".epub")).expanduser()
|
||||
markdown_to_epub(folder, epub_path)
|
||||
print(f"EPUB geschrieben: {epub_path}")
|
||||
if args.epub_per_file:
|
||||
epub_dir = epubs_per_file(folder, files, meta)
|
||||
print(f"Einzel-EPUBs geschrieben: {len(files)} in {epub_dir}")
|
||||
except Exception as exc:
|
||||
print(f"Fehler beim Aufteilen: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
|
|||
Loading…
Reference in a new issue