diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index c7496a6..b7847fc 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -200,10 +200,13 @@ band1/ part: "Teil I: Das Vermächtnis" chapter: 3 order: 5 + source_pages: [40, 43] # Seiten der PDF-Datei (nicht die gedruckten Seitenzahlen) book: "Operation Ljutsch" 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. 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` diff --git a/README.md b/README.md index 2fef8f2..065952d 100644 --- a/README.md +++ b/README.md @@ -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`, `…/ youtube-…`) werden mit den Linkzielen der PDF abgeglichen und repariert; 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. - **YAML-Kopf:** Jede Markdown-Datei beginnt mit `title`, `author`, `lang`, `cover` - (Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `book`, `author`). - `--from-md` liest daraus die EPUB-Angaben. + (Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `source_pages`, + `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 @@ -116,7 +119,8 @@ synthetisch erzeugt. - **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 (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 statt zu Zeilen; einzelne Konfigurations- oder Ausgabezeilen neben Code (`[dependencies]`) stehen als Zeilen statt im Codeblock. diff --git a/TODO.md b/TODO.md index b8cbdbe..4ae89e2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,13 +1,6 @@ # TODO -## 1. Ideen - -- `--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) +## Bekannte Schwächen (aus Probeläufen mit fremden PDFs) - Reine Zahlen als Überschrift: pymupdf4llm macht Fußnotenzeichen zu Überschriften („## 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. - 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-…“), - bleibt sie abgeschnitten. Links mit eigenem Text („hier“) gehen verloren; Abhilfe wäre, - die Link-Rechtecke der PDF dem Text zuzuordnen (`[hier](…)`). Die Heuristik ohne Linkziel - hängt Wörter mit „/“ an („https://a.de/ und/oder“ → eine URL). + bleibt sie abgeschnitten. Die Heuristik ohne Linkziel hängt Wörter mit „/“ an + („https://a.de/ und/oder“ → eine URL). Linktexte mit Trennung mitten in langen Titeln + werden nicht wiedergefunden. diff --git a/pdf2md.py b/pdf2md.py index 46880bf..15dd1c8 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -744,6 +744,94 @@ def link_urls(markdown: str, uris: list[str] | None = None) -> str: 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" + _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"(? 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 # =========================================================================== @@ -1580,6 +1668,34 @@ def iter_headings(lines: list[str]): 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]: """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) levels = sorted(level for level, n in counts.items() if n >= 2 and level <= 3) if not levels: - return [{"title": "Vorspann", "body": markdown}] + return split_by_length(markdown) part_level, chapter_level = levels[0], levels[-1] 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 +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]: """Abschnitte als nummerierte Dateien plus index.md (Reihenfolge) schreiben.""" 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"), "chapter": section.get("chapter"), "order": order, + "source_pages": section.get("source_pages"), "book": meta.get("title"), "author": meta.get("author"), } @@ -1847,6 +2008,7 @@ def main() -> int: kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi) outline = load_outline(doc) uris = load_link_uris(doc) + anchors = load_link_anchors(doc) page_count = doc.page_count file_title, file_author = title_author_from_filename(source.stem) title = ( @@ -1904,6 +2066,7 @@ def main() -> int: running_head_min=max(3, page_count // 10), ) markdown = link_urls(markdown, uris) + markdown = link_anchors(markdown, anchors) base = folder or target.parent meta = { @@ -1913,7 +2076,12 @@ def main() -> int: "cover": cover.relative_to(base).as_posix() if cover else None, } 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: target.write_text(make_frontmatter(meta) + markdown, encoding="utf-8") diff --git a/tests/test_split.py b/tests/test_split.py index 1eb298c..d5c7109 100644 --- a/tests/test_split.py +++ b/tests/test_split.py @@ -2,6 +2,8 @@ import zipfile +import pymupdf + import pytest 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")) 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 "" in md or "[www.example.org/info]" in md