Seitenzahlen bei --split, Teilen nach Länge, Links mit eigenem Text

- add_source_pages: source_pages [von, bis] im Frontmatter; Startseite aus
  der PDF-Gliederung, sonst Textsuche ohne Verzeichnisseiten; Ende davor,
  wenn der nächste Abschnitt oben auf der Seite beginnt
- split_by_length: ohne verwertbare Gliederung an Absatzgrenzen nach Länge
  (ca. 30 000 Zeichen), Codeblöcke bleiben ganz
- load_link_anchors/link_anchors: Links mit eigenem Text ("hier") über
  Linktext und Wörter davor wiederfinden und als [Text](Ziel) setzen
  (Zensur-Report: 75 von 76)
- Tests, README, Bedienungsanleitung, TODO

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-02 19:03:51 +02:00
commit 3d418e3987
5 changed files with 273 additions and 17 deletions

View file

@ -200,10 +200,13 @@ band1/
part: "Teil I: Das Vermächtnis" part: "Teil I: Das Vermächtnis"
chapter: 3 chapter: 3
order: 5 order: 5
source_pages: [40, 43] # Seiten der PDF-Datei (nicht die gedruckten Seitenzahlen)
book: "Operation Ljutsch" book: "Operation Ljutsch"
author: "Reinhard Otto Kranz" author: "Reinhard Otto Kranz"
--- ---
``` ```
- Erkennt das Programm keine Gliederung, teilt es nach Länge (ca. 30 000 Zeichen, an
Absatzgrenzen) in `Abschnitt 1`, `Abschnitt 2` … und meldet das.
- **Umsortieren:** in `index.md` die Zeilen verschieben. **Weglassen:** Zeile löschen. - **Umsortieren:** in `index.md` die Zeilen verschieben. **Weglassen:** Zeile löschen.
Dateien dürfen umbenannt werden, wenn der Link in `index.md` mitgeändert wird. Dateien dürfen umbenannt werden, wenn der Link in `index.md` mitgeändert wird.
- **EPUB neu bauen:** `python3 pdf2md.py --from-md Output/band1/` → `Output/band1.epub` - **EPUB neu bauen:** `python3 pdf2md.py --from-md Output/band1/` → `Output/band1.epub`

View file

@ -86,11 +86,14 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
und im EPUB anklickbar. Beim Zeilenumbruch zerbrochene URLs (`deutsche-|sehen`, und im EPUB anklickbar. Beim Zeilenumbruch zerbrochene URLs (`deutsche-|sehen`,
`…/ youtube-…`) werden mit den Linkzielen der PDF abgeglichen und repariert; `…/ youtube-…`) werden mit den Linkzielen der PDF abgeglichen und repariert;
Tracking-Parameter (`utm_…`) entfallen. Ohne Linkziel wird die URL aus dem gedruckten Tracking-Parameter (`utm_…`) entfallen. Ohne Linkziel wird die URL aus dem gedruckten
Text zusammengesetzt. Text zusammengesetzt. Links mit eigenem Text („hier“, „Bericht“) werden über den Linktext
und die Wörter davor im Markdown wiedergefunden und als `[hier](https://…)` gesetzt.
- **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt. - **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt.
- **YAML-Kopf:** Jede Markdown-Datei beginnt mit `title`, `author`, `lang`, `cover` - **YAML-Kopf:** Jede Markdown-Datei beginnt mit `title`, `author`, `lang`, `cover`
(Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `book`, `author`). (Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `source_pages`,
`--from-md` liest daraus die EPUB-Angaben. `book`, `author`; `source_pages` = Seiten der PDF-Datei, nicht die gedruckten).
`--from-md` liest daraus die EPUB-Angaben. Ohne verwertbare Gliederung teilt `--split`
nach Länge (ca. 30 000 Zeichen, an Absatzgrenzen) in „Abschnitt 1“, „Abschnitt 2“ …
## Tests ## Tests
@ -116,7 +119,8 @@ synthetisch erzeugt.
- **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“), - **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“),
gesperrt gesetzte Überschriften („M e i n k l e i nes …“), flache PDF-Gliederungen gesperrt gesetzte Überschriften („M e i n k l e i nes …“), flache PDF-Gliederungen
(alle Kapitel auf einer Ebene). (alle Kapitel auf einer Ebene).
- Links mit anderem Text als der URL (z. B. „hier“) werden nicht übernommen. - Links mit eigenem Text: Findet sich der Linktext nicht wieder (z. B. Trennung mitten in
einem langen Titel), bleibt er ohne Link (Zensur-Report: 75 von 76 gesetzt).
- Zeilengetreue Erkennung: Verszeilen mit Satzzeichen am Ende werden zu eigenen Absätzen - Zeilengetreue Erkennung: Verszeilen mit Satzzeichen am Ende werden zu eigenen Absätzen
statt zu Zeilen; einzelne Konfigurations- oder Ausgabezeilen neben Code (`[dependencies]`) statt zu Zeilen; einzelne Konfigurations- oder Ausgabezeilen neben Code (`[dependencies]`)
stehen als Zeilen statt im Codeblock. stehen als Zeilen statt im Codeblock.

15
TODO.md
View file

@ -1,13 +1,6 @@
# TODO # TODO
## 1. Ideen ## Bekannte Schwächen (aus Probeläufen mit fremden PDFs)
- `--split`: Seitenzahlen der PDF ins Frontmatter (`source_pages`); pymupdf4llm liefert sie
mit `page_chunks=True`, müsste aber durch die ganze Nachbearbeitung getragen werden.
- `--split`: Bücher ohne erkennbare Gliederung werden nicht geteilt (nur `00_vorspann.md`);
denkbar wäre ein Schnitt nach Seitenzahl oder Länge.
## 2. Bekannte Schwächen (aus Probeläufen mit fremden PDFs)
- Reine Zahlen als Überschrift: pymupdf4llm macht Fußnotenzeichen zu Überschriften - Reine Zahlen als Überschrift: pymupdf4llm macht Fußnotenzeichen zu Überschriften
(„## 90“ im Zensur-Report). Nicht pauschal entfernen – im DDR-Buch sind „## 1“, „## 2“ („## 90“ im Zensur-Report). Nicht pauschal entfernen – im DDR-Buch sind „## 1“, „## 2“
@ -22,6 +15,6 @@
(`[dependencies]`, Programmausgaben) landen als `\`-Zeilen neben dem Codeblock statt darin. (`[dependencies]`, Programmausgaben) landen als `\`-Zeilen neben dem Codeblock statt darin.
- Markdown-Sonderzeichen werden nicht maskiert: `__add__` im Register wird fett gesetzt. - Markdown-Sonderzeichen werden nicht maskiert: `__add__` im Register wird fett gesetzt.
- Links: Setzt sich eine URL in einem neuen Block fort (z. B. als Listenpunkt „- juuuport-…“), - Links: Setzt sich eine URL in einem neuen Block fort (z. B. als Listenpunkt „- juuuport-…“),
bleibt sie abgeschnitten. Links mit eigenem Text („hier“) gehen verloren; Abhilfe wäre, bleibt sie abgeschnitten. Die Heuristik ohne Linkziel hängt Wörter mit „/“ an
die Link-Rechtecke der PDF dem Text zuzuordnen (`[hier](…)`). Die Heuristik ohne Linkziel („https://a.de/ und/oder“ → eine URL). Linktexte mit Trennung mitten in langen Titeln
hängt Wörter mit „/“ an („https://a.de/ und/oder“ → eine URL). werden nicht wiedergefunden.

172
pdf2md.py
View file

@ -744,6 +744,94 @@ def link_urls(markdown: str, uris: list[str] | None = None) -> str:
return "\n".join(lines) return "\n".join(lines)
# Links mit eigenem Text ("hier", "Bericht"): Linktext und die Wörter davor aus
# der PDF lesen und die Stelle im Markdown als [Text](Ziel) markieren.
ANCHOR_CONTEXT_WORDS = 3
URL_LIKE_WORD = re.compile(r"://|^www\.|/|\w\.(?:[a-z]{2,4})\.?$", re.IGNORECASE)
def load_link_anchors(doc: pymupdf.Document) -> list[tuple[str, list[str], list[str]]]:
"""(Ziel, Wörter davor, Linkwörter) für Links, deren Text keine URL ist."""
anchors: list[tuple[str, list[str], list[str]]] = []
for page in doc:
words = page.get_text("words", sort=True)
links = [link for link in page.get_links() if (link.get("uri") or "").startswith("http")]
merged: list[tuple[str, list[int]]] = [] # (Ziel, Indizes der Wörter)
for link in links:
rect = link["from"]
hits = [
i for i, w in enumerate(words)
if rect.contains(pymupdf.Point((w[0] + w[2]) / 2, (w[1] + w[3]) / 2))
]
if not hits:
continue
if merged and merged[-1][0] == link["uri"] and hits[0] <= merged[-1][1][-1] + 1:
merged[-1][1].extend(i for i in hits if i not in merged[-1][1])
else:
merged.append((link["uri"], hits))
for uri, hits in merged:
text = [words[i][4] for i in sorted(hits)]
if any(URL_LIKE_WORD.search(word) for word in text):
continue # URL als Linktext: erledigt link_urls
first = min(hits)
before = [w[4] for w in words[max(0, first - ANCHOR_CONTEXT_WORDS):first]]
anchors.append((clean_uri(uri), before, text))
return anchors
def _words_pattern(words: list[str]) -> str:
"""Wörter mit beliebigem Leerraum, Hervorhebung oder Trennung dazwischen."""
parts = []
for word in words:
core = word.rstrip("-\u00ad") # Silbentrennung am Zeilenende
parts.append(re.escape(core) + (r"-?" if core != word else ""))
return r"[\s*_]*".join(parts)
# Zwischen Kontextwörtern: Satzzeichen, Fußnotenziffern, Linkziele "](…)".
CONTEXT_GAP = r"(?:[\W_\d]|\]\([^)]*\))*"
def _context_pattern(words: list[str]) -> str:
cores = [core for word in words for core in re.findall(r"[^\W\d_]+", word)]
return CONTEXT_GAP.join(re.escape(core) for core in cores)
def link_anchors(markdown: str, anchors: list[tuple[str, list[str], list[str]]]) -> str:
"""Linktexte im Markdown als [Text](Ziel) setzen, in Lesereihenfolge."""
pos = 0
for uri, before, text in anchors:
anchor = r"(?P<anchor>" + _words_pattern(text) + r")"
patterns = []
if context := _context_pattern(before):
patterns.append(re.compile(context + CONTEXT_GAP + anchor))
if len(" ".join(text)) >= 6:
patterns.append(re.compile(r"(?<![\w\[])" + anchor)) # ohne Kontext
for pattern in patterns:
match = next(
(m for m in pattern.finditer(markdown, pos)
if not _inside_link(markdown, m.start("anchor"))),
None,
)
if match:
start, end = match.span("anchor")
label = markdown[start:end].replace("[", "\\[").replace("]", "\\]")
markdown = f"{markdown[:start]}[{label}]({uri}){markdown[end:]}"
pos = start
break
return markdown
def _inside_link(markdown: str, index: int) -> bool:
"""Liegt die Stelle schon in einem Link (<…>, [..](…))?"""
line_start = markdown.rfind("\n", 0, index) + 1
before = markdown[line_start:index]
return before.count("<") > before.count(">") or before.count("[") > before.count("]") or (
before.rfind("](") > before.rfind(")")
)
# =========================================================================== # ===========================================================================
# Korrektur für pymupdf4llm: Trennstriche am Zeilenende, OCR-Ebenen # Korrektur für pymupdf4llm: Trennstriche am Zeilenende, OCR-Ebenen
# =========================================================================== # ===========================================================================
@ -1580,6 +1668,34 @@ def iter_headings(lines: list[str]):
yield index, len(match.group(1)), match.group(2).strip() yield index, len(match.group(1)), match.group(2).strip()
SPLIT_CHARS = 30_000 # ohne Gliederung: Abschnitte von etwa dieser Länge
def split_by_length(markdown: str, size: int = SPLIT_CHARS) -> list[dict]:
"""Ohne verwertbare Überschriften: an Absatzgrenzen in etwa gleich lange Stücke."""
blocks, fence = [], False
for part in re.split(r"(\n\n+)", markdown):
if blocks and fence:
blocks[-1] += part # Codeblöcke nicht zerteilen
elif part.strip() or not blocks:
blocks.append(part)
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:
sections.append(current)
current = ""
current += block
if current.strip():
sections.append(current)
return [
{"title": f"Abschnitt {n}", "body": body.strip() + "\n"}
for n, body in enumerate(sections, start=1)
]
def split_markdown(markdown: str, mode: str) -> list[dict]: def split_markdown(markdown: str, mode: str) -> list[dict]:
"""Buch in Abschnitte teilen: "teile" (oberste Ebene) oder "kapitel". """Buch in Abschnitte teilen: "teile" (oberste Ebene) oder "kapitel".
@ -1590,7 +1706,7 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
counts = Counter(level for _, level, _ in headings) counts = Counter(level for _, level, _ in headings)
levels = sorted(level for level, n in counts.items() if n >= 2 and level <= 3) levels = sorted(level for level, n in counts.items() if n >= 2 and level <= 3)
if not levels: if not levels:
return [{"title": "Vorspann", "body": markdown}] return split_by_length(markdown)
part_level, chapter_level = levels[0], levels[-1] part_level, chapter_level = levels[0], levels[-1]
cut_levels = {part_level} if mode == "teile" else {part_level, chapter_level} cut_levels = {part_level} if mode == "teile" else {part_level, chapter_level}
@ -1623,6 +1739,50 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
return sections return sections
def _page_key(text: str) -> str:
return re.sub(r"[\W_]+", "", clean_inline(text).lower())
def add_source_pages(doc: pymupdf.Document, sections: list[dict]) -> None:
"""Seiten der PDF (1-basiert) je Abschnitt.
Startseite aus der PDF-Gliederung, sonst die erste Seite (nach der vorigen),
auf der die Überschrift steht – Verzeichnisseiten ausgenommen.
"""
outline = {norm_title(title): page - 1 for _, title, page in doc.get_toc() if page > 0}
pages = [_page_key(page.get_text()) for page in doc]
keys = [_page_key(section["title"])[:40] for section in sections]
listing = { # Verzeichnisse: viele Abschnittstitel auf einer Seite
i for i, text in enumerate(pages) if sum(1 for k in keys if k and k in text) >= 3
}
starts, page = [], 0
for section, key in zip(sections, keys):
found = outline.get(norm_title(section["title"]))
if found is None and section["title"] != "Vorspann" and key:
found = next(
(i for i in range(page, len(pages)) if i not in listing and key in pages[i]),
None,
)
page = max(page, found) if found is not None else page
starts.append(page)
for n, section in enumerate(sections):
end = starts[n + 1] if n + 1 < len(sections) else len(pages) - 1
# Beginnt der nächste Abschnitt oben auf einer Seite, endet dieser davor.
if n + 1 < len(sections) and end > starts[n] and _starts_page(doc[end], keys[n + 1]):
end -= 1
section["source_pages"] = [starts[n] + 1, max(starts[n], end) + 1]
def _starts_page(page: pymupdf.Page, key: str) -> bool:
"""Steht über der Überschrift nur Kolumnentitel/Seitenzahl (≤ 3 Wörter je Block)?"""
blocks = [b for b in page.get_text("blocks") if b[6] == 0 and b[4].strip()]
heading = next((b for b in blocks if key and key in _page_key(b[4])), None)
if heading is None or not _page_key(heading[4]).startswith(key):
return False # nicht gefunden oder Text davor im selben Block
above = [b for b in blocks if b[3] <= heading[1] + 1 and b is not heading]
return all(len(b[4].split()) <= 3 for b in above)
def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]: def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]:
"""Abschnitte als nummerierte Dateien plus index.md (Reihenfolge) schreiben.""" """Abschnitte als nummerierte Dateien plus index.md (Reihenfolge) schreiben."""
folder.mkdir(parents=True, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
@ -1635,6 +1795,7 @@ def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]:
"part": section.get("part"), "part": section.get("part"),
"chapter": section.get("chapter"), "chapter": section.get("chapter"),
"order": order, "order": order,
"source_pages": section.get("source_pages"),
"book": meta.get("title"), "book": meta.get("title"),
"author": meta.get("author"), "author": meta.get("author"),
} }
@ -1847,6 +2008,7 @@ def main() -> int:
kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi) kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi)
outline = load_outline(doc) outline = load_outline(doc)
uris = load_link_uris(doc) uris = load_link_uris(doc)
anchors = load_link_anchors(doc)
page_count = doc.page_count page_count = doc.page_count
file_title, file_author = title_author_from_filename(source.stem) file_title, file_author = title_author_from_filename(source.stem)
title = ( title = (
@ -1904,6 +2066,7 @@ def main() -> int:
running_head_min=max(3, page_count // 10), running_head_min=max(3, page_count // 10),
) )
markdown = link_urls(markdown, uris) markdown = link_urls(markdown, uris)
markdown = link_anchors(markdown, anchors)
base = folder or target.parent base = folder or target.parent
meta = { meta = {
@ -1913,7 +2076,12 @@ def main() -> int:
"cover": cover.relative_to(base).as_posix() if cover else None, "cover": cover.relative_to(base).as_posix() if cover else None,
} }
if folder: if folder:
files = write_split(folder, split_markdown(markdown, args.split), meta) sections = split_markdown(markdown, args.split)
if sections and sections[0]["title"] == "Abschnitt 1":
print("Hinweis: keine Gliederung erkannt – nach Länge geteilt.")
with pymupdf.open(source) as doc:
add_source_pages(doc, sections)
files = write_split(folder, sections, meta)
else: else:
target.write_text(make_frontmatter(meta) + markdown, encoding="utf-8") target.write_text(make_frontmatter(meta) + markdown, encoding="utf-8")

View file

@ -2,6 +2,8 @@
import zipfile import zipfile
import pymupdf
import pytest import pytest
import pdf2md as p import pdf2md as p
@ -144,3 +146,89 @@ def test_jpeg_quality_changes_image_size(pdfs, tmp_path):
return sum(f.stat().st_size for f in (out.parent / "buch_images").glob("*.jpg")) return sum(f.stat().st_size for f in (out.parent / "buch_images").glob("*.jpg"))
assert size(30) < size(95) assert size(30) < size(95)
# --- Seitenzahlen, Teilen nach Länge -----------------------------------------------
def _book_pdf(outline: bool):
doc = pymupdf.open()
texts = [
"Inhalt\n1 Anfang\n2 Weiter\n3 Schluss",
"1 Anfang\nText eins.",
"Noch mehr Text\nzum Anfang,\nüber mehrere\nZeilen.\n2 Weiter\nText zwei.",
"3 Schluss\nText drei.",
]
for text in texts:
doc.new_page().insert_text((72, 72), text, fontsize=11)
if outline:
doc.set_toc([[1, "1 Anfang", 2], [1, "2 Weiter", 3], [1, "3 Schluss", 4]])
return doc
@pytest.mark.parametrize("outline", [True, False])
def test_source_pages(outline):
sections = [{"title": t} for t in ("Vorspann", "1 Anfang", "2 Weiter", "3 Schluss")]
p.add_source_pages(_book_pdf(outline), sections)
assert [s["source_pages"] for s in sections] == [[1, 1], [2, 3], [3, 3], [4, 4]]
def test_split_without_headings_by_length():
paragraphs = [f"Absatz {n} " + " ".join(["Text"] * 40) for n in range(30)]
code = "```\n" + "\n\n".join(f"zeile_{n} = {n}" for n in range(20)) + "\n```"
markdown = "\n\n".join(paragraphs[:15] + [code] + paragraphs[15:]) + "\n"
assert p.split_markdown(markdown, "kapitel")[0]["title"] == "Abschnitt 1"
sections = p.split_by_length(markdown, size=1500)
assert len(sections) > 3
assert [s["title"] for s in sections[:2]] == ["Abschnitt 1", "Abschnitt 2"]
assert all(s["body"].count("```") in (0, 2) for s in sections) # Code bleibt ganz
assert "".join(s["body"] for s in sections).replace("\n", "") == markdown.replace("\n", "")
def test_cli_split_writes_source_pages(pdfs, tmp_path):
run(pdfs["digital"], tmp_path / "buch.md", "--split", "kapitel")
files = {f.name: p.split_frontmatter(f.read_text())[0] for f in (tmp_path / "buch").glob("0*.md")}
notar = next(meta for name, meta in files.items() if name.endswith("_1-der-notar.md"))
assert notar["source_pages"] == [2, 3]
# --- Links mit eigenem Text --------------------------------------------------------
def _link_pdf(path):
doc = pymupdf.open()
page = doc.new_page()
page.insert_text((72, 72), "Den ganzen Bericht finden Sie hier im Netz.", fontsize=11)
page.insert_text((72, 90), "Siehe auch www.example.org/info zum Thema.", fontsize=11)
for word, uri in (("hier", "https://beispiel.de/bericht?utm_source=x"),
("www.example.org/info", "https://www.example.org/info")):
rect = page.search_for(word)[0]
page.insert_link({"kind": pymupdf.LINK_URI, "from": rect, "uri": uri})
doc.save(path)
return path
def test_load_link_anchors_skips_url_texts(tmp_path):
doc = pymupdf.open(_link_pdf(tmp_path / "links.pdf"))
assert p.load_link_anchors(doc) == [
("https://beispiel.de/bericht", ["Bericht", "finden", "Sie"], ["hier"])
]
def test_link_anchors_uses_context_and_skips_existing_links():
anchors = [("https://a.de/x", ["finden", "Sie"], ["hier"])]
md = "Hier steht hier. Den Bericht finden Sie hier im Netz.\n"
assert p.link_anchors(md, anchors) == (
"Hier steht hier. Den Bericht finden Sie [hier](https://a.de/x) im Netz.\n"
)
# Fußnotenziffer und Trennung zwischen Kontext und Linktext stören nicht
anchors = [("https://b.de/", ["überprüfen.81"], ["Landes-", "medienanstalten"])]
md = "Die Landesmedienanstalten.\n\nzu überprüfen.[81 ] Die Landesmedienanstalten prüfen.\n"
anchors = [("https://b.de/", ["überprüfen.81", "Die"], ["Landes-", "medienanstalten"])]
assert "Die [Landesmedienanstalten](https://b.de/) prüfen" in p.link_anchors(md, anchors)
def test_cli_sets_anchor_links(tmp_path):
out = tmp_path / "links.md"
run(_link_pdf(tmp_path / "links.pdf"), out)
md = out.read_text()
assert "[hier](https://beispiel.de/bericht)" in md
assert "<https://www.example.org/info>" in md or "[www.example.org/info]" in md