diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index b4d25c5..088c461 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -97,6 +97,7 @@ Wenn du kein Cover und kein Probe-EPUB brauchst, reicht `--images` statt `--epub | Zeilenumbruch innerhalb eines Absatzes | einfacher Umbruch | wird zu einem Leerzeichen | | Bild | `![](band1_images/…jpg)` auf eigener Zeile | Bild an dieser Stelle | | kursiv / fett | `_Wort_` / `**Wort**` | kursiv / fett | +| Link | `` oder `[Text](https://beispiel.de/seite)` | anklickbarer Link | Wichtig: @@ -134,7 +135,7 @@ Im Ordner der Markdown-Datei starten (wichtig für die Bilder): ```bash cd ~/Downloads/ljutsch/Output pandoc band1.md \ - --from markdown-yaml_metadata_block --to epub3 \ + --from markdown-yaml_metadata_block+autolink_bare_uris --to epub3 \ --output band1.epub \ --resource-path . \ --split-level=3 --toc-depth=3 \ @@ -239,6 +240,7 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g | Überschrift besteht nur aus einer Zahl (`## 90`, `## XX`) | meist Fußnotenzeichen oder Seitenzahl → in Weg B löschen | | Buchstaben fehlen („Offzielle“, „treff ch“) | Ligatur (fi, fl) ohne Textzuordnung in der PDF; von Hand korrigieren | | Überschrift gesperrt („M e i n k l e i nes Stück“) | Buchstaben einzeln gesetzt in der PDF; von Hand korrigieren | +| Link führt ins Leere oder ist abgeschnitten | URL in der PDF nachsehen und in `<…>` korrigieren; manche PDFs enthalten selbst abgeschnittene Linkziele | | Formeln erscheinen als Bilder | bei Mathebüchern normal; dafür ist das Programm nicht gebaut | | Alter Scan: falsche Überschriften, viele Lesefehler | `--ocr always` (vorhandene OCR-Ebene ignorieren) | | Auf Scans fehlen Leerzeichen („deutetin“) | OCR-Fehler; höhere Auflösung versuchen (`--ocr-dpi 400`) oder von Hand korrigieren | diff --git a/README.md b/README.md index 175584a..6a1005c 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,11 @@ python3 pdf2md.py --epub --lang en --ocr-language eng --ocr always scan.pdf scan - **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt; liegt das Verzeichnis als Tabelle vor, bleibt die Tabelle erhalten. - **OCR-Ebenen** in Courier werden nicht als Programmcode (`` `…` ``) gesetzt. +- **Links:** URLs werden als `` geschrieben und sind damit in Markdown-Viewern + 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. - **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt. ## Tests @@ -88,6 +93,7 @@ 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. - Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen). Weitere bekannte Schwächen: [TODO.md](TODO.md). - Bilder werden als JPEG (Qualität 95) gespeichert; EPUB Band 1 ca. 5 MB. diff --git a/TODO.md b/TODO.md index 81e8f2f..606bff7 100644 --- a/TODO.md +++ b/TODO.md @@ -72,3 +72,7 @@ wieder zusammengesetzt = Gesamt-Markdown. wegen echter Kapitel „I.“, „II.“. - Gesperrte Überschriften („M e i n k l e i nes Stück D D R“) bleiben zerrissen. - Alte Scans mit eigener OCR-Ebene: `--ocr always` liefert meist besseren Text. +- 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). diff --git a/pdf2md.py b/pdf2md.py index c60685a..a848de4 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -21,6 +21,7 @@ import re import shutil import subprocess import sys +import urllib.parse from collections import Counter from concurrent.futures import ProcessPoolExecutor from pathlib import Path @@ -487,6 +488,131 @@ def load_outline(doc: pymupdf.Document) -> dict[str, int]: return levels +# =========================================================================== +# Links +# =========================================================================== +# pymupdf4llm übernimmt keine Link-Annotationen, und gedruckte URLs zerbrechen +# beim Zeilenumbruch ("deutsche-|sehen" -> "deutschesehen", ".../ youtube-..."). +# Wir gleichen URLs im Text mit den Linkzielen der PDF ab, ersetzen sie durch +# das korrekte Ziel und schreiben sie als Autolink , den pandoc im +# EPUB als echten Link ausgibt. + +URL_START = re.compile( + r"(?-Links und [Text](…)-Zielen +URL_TRAILING = ".,;:!?)]}»«“”„\"'*_" +MAX_URL_PARTS = 4 # eine URL kann über so viele Zeilen gebrochen sein +# Ohne Linkziel: Folgewort gehört zur URL, wenn diese an einer typischen +# Umbruchstelle endet und das Wort wie ein URL-Teil aussieht. +URL_BREAK_AFTER = "/_-.=&?#" +URL_CONTINUATION = re.compile(r"^(?:\S*/\S*|(?:html?|php|pdf|aspx?)\W*)$", re.IGNORECASE) + + +def url_key(text: str) -> str: + """Vergleichsschlüssel: Bindestriche, Leerraum und "/" am Ende zählen nicht.""" + return re.sub(r"[-\s]", "", text).rstrip("/").lower() + + +def uri_keys(uri: str) -> set[str]: + """Schlüssel, unter denen ein Linkziel im gedruckten Text auftauchen kann. + + Gedruckt fehlen oft Abfrageteil ("?utm_source=…") oder Endung (".html"). + """ + base = uri.split("#")[0] + keys = set() + for variant in (uri, base, base.split("?")[0]): + keys.add(url_key(variant)) + keys.add(url_key(re.sub(r"\.(?:html?|php|aspx?)$", "", variant.rstrip("/")))) + return keys + + +def clean_uri(uri: str) -> str: + """Tracking-Parameter (utm_…) entfernen.""" + parts = urllib.parse.urlsplit(uri) + if "utm_" not in parts.query.lower(): + return uri + query = [ + (k, v) for k, v in urllib.parse.parse_qsl(parts.query, keep_blank_values=True) + if not k.lower().startswith("utm_") + ] + return urllib.parse.urlunsplit(parts._replace(query=urllib.parse.urlencode(query))) + + +def load_link_uris(doc: pymupdf.Document) -> list[str]: + """Web-Linkziele der PDF (Link-Annotationen), ohne Dubletten.""" + uris: dict[str, None] = {} + for page in doc: + for link in page.get_links(): + uri = (link.get("uri") or "").strip() + if re.match(r"https?://", uri, re.IGNORECASE): + uris.setdefault(uri.replace(" ", "%20")) + return list(uris) + + +def _autolink(url: str) -> str: + if url.lower().startswith("www."): + return f"[{url}](https://{url})" + return f"<{url}>" + + +def _match_known(tokens: list[str], known: dict[str, str]) -> tuple[str, int] | None: + """Längste Verbindung von Wörtern, die zu einem Linkziel der PDF passt.""" + for count in range(min(MAX_URL_PARTS, len(tokens)), 0, -1): + parts = [t.rstrip() for t in tokens[:count]] + joined = "".join(parts) + consumed = sum(map(len, tokens[: count - 1])) + len(parts[-1]) + for strip in range(len(joined)): + candidate = joined[: len(joined) - strip] + if uri := known.get(url_key(candidate)): + return uri, consumed - strip + if candidate[-1] not in URL_TRAILING: + break + return None + + +def _match_printed(tokens: list[str]) -> tuple[str, int]: + """URL ohne (passendes) Linkziel: aus dem gedruckten Text zusammensetzen.""" + parts = [tokens[0].rstrip()] + for token in tokens[1:MAX_URL_PARTS]: + if parts[-1][-1] in URL_BREAK_AFTER and URL_CONTINUATION.match(token.rstrip()): + parts.append(token.rstrip()) + else: + break + joined = "".join(parts) + url = joined.rstrip(URL_TRAILING) + consumed = sum(map(len, tokens[: len(parts) - 1])) + len(parts[-1]) + return url, consumed - (len(joined) - len(url)) + + +def _link_line(line: str, known: dict[str, str]) -> str: + out = [] + pos = 0 + while match := URL_START.search(line, pos): + out.append(line[pos:match.start()]) + tokens = re.findall(r"\S+\s*", line[match.start():]) + url, consumed = _match_known(tokens, known) or _match_printed(tokens) + out.append(_autolink(url) if "." in url else line[match.start():][:consumed]) + pos = match.start() + consumed + out.append(line[pos:]) + return "".join(out) + + +def link_urls(markdown: str, uris: list[str] | None = None) -> str: + """URLs im Fließtext anklickbar machen und mit PDF-Linkzielen reparieren.""" + known: dict[str, str] = {} + for uri in uris or []: + for key in uri_keys(uri): + known.setdefault(key, clean_uri(uri)) + lines = markdown.split("\n") + in_fence = False + for i, line in enumerate(lines): + if FENCE.match(line): + in_fence = not in_fence + elif not in_fence and not IMAGE.match(line): + lines[i] = _link_line(line, known) + return "\n".join(lines) + + # =========================================================================== # Korrektur für pymupdf4llm: Trennstriche am Zeilenende, OCR-Ebenen # =========================================================================== @@ -522,7 +648,10 @@ def _mark_line_end_hyphens(spans: list[dict]) -> list[dict]: span = {**span, "text": text[:-1] + "-"} elif line_end and text.endswith("-"): word = FIRST_WORD.match(nxt["text"].lstrip()) - if word and word.group(0).lower() in CONJUNCTIONS: + last = text.split()[-1] + if URL_START.search(last) or "/" in last: + span = {**span, "text": text + KEEP_HYPHEN} # URLs trennt niemand + elif word and word.group(0).lower() in CONJUNCTIONS: span = {**span, "text": text + KEEP_HYPHEN_SPACE} elif word and word.group(0)[0].isupper(): span = {**span, "text": text + KEEP_HYPHEN} @@ -945,7 +1074,7 @@ def write_epub( cmd = [ pandoc, str(markdown_path), - "--from", "markdown-yaml_metadata_block", + "--from", "markdown-yaml_metadata_block+autolink_bare_uris", "--to", "epub3", "--output", str(epub_path), "--resource-path", str(markdown_path.parent), @@ -1075,6 +1204,7 @@ def main() -> int: with pymupdf.open(source) as doc: kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi) outline = load_outline(doc) + uris = load_link_uris(doc) page_count = doc.page_count title = (doc.metadata or {}).get("title") or source.stem author = (doc.metadata or {}).get("author") or "" @@ -1124,6 +1254,7 @@ def main() -> int: drop_running_heads=not args.keep_headers, running_head_min=max(3, page_count // 10), ) + markdown = link_urls(markdown, uris) target.write_text(markdown, encoding="utf-8") if epub_path: diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 7097ad4..2f71234 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -326,3 +326,78 @@ def test_make_image_links_relative(tmp_path, monkeypatch): "![](buch_images/a.jpg)\n\n![](buch_images/b.jpg)\n\n" "![](https://example.org/c.jpg)\n" ) + + +# --- Links ------------------------------------------------------------------------- + +def test_link_urls_repairs_broken_urls_with_pdf_targets(): + uris = [ + "https://www.theeuropean.de/insa-umfrage-deutsche-sehen/", + "https://www.fsm.de/en/fsm/netzdg/", + ] + md = ( + "Quelle: https://www.theeuropean.de/insa-umfrage-deutschesehen/.\n" + "Siehe https://www.fsm.de/en/fsm/ netzdg/. Danach Text.\n" + ) + assert p.link_urls(md, uris) == ( + "Quelle: .\n" + "Siehe . Danach Text.\n" + ) + + +def test_link_urls_without_pdf_targets(): + md = "Mehr unter https://example.org/a_b, www.example.org und (https://x.de/y).\n" + assert p.link_urls(md) == ( + "Mehr unter , [www.example.org](https://www.example.org)" + " und ().\n" + ) + + +def test_link_urls_leaves_images_and_existing_links_alone(): + md = "![](bilder/x.jpg)\n\n und [b](https://b.de/)\n" + assert p.link_urls(md) == md + + +def test_pymupdf4llm_patch_keeps_hyphen_inside_url(): + spans = [ + {"text": "Siehe https://a.de/umfrage-deutsche-", "flags": 0, "char_flags": 16, + "font": "x", "block": 0, "line": 0}, + {"text": "sehen/ und mehr.", "flags": 0, "char_flags": 16, "font": "x", + "block": 0, "line": 1}, + ] + output, _ = p.document_layout.get_styled_text(spans) + assert output.strip() == "Siehe https://a.de/umfrage-deutsche-sehen/ und mehr." + + +def test_load_link_uris(): + doc = pymupdf.open() + page = doc.new_page() + for uri in ("https://a.de/x", "mailto:a@b.de", "https://a.de/x"): + page.insert_link({"kind": pymupdf.LINK_URI, "from": pymupdf.Rect(0, 0, 10, 10), + "uri": uri}) + doc = pymupdf.open("pdf", doc.tobytes()) + assert p.load_link_uris(doc) == ["https://a.de/x"] + + +def test_link_urls_tolerates_printed_variants(): + uris = [ + "https://a.de/artikel?utm_source=chatgpt.com", + "https://b.de/news/Gesetz-Kritik-3718401.html", + "https://c.de/seite", + ] + md = "Siehe https://a.de/artikel/, https://b.de/news/ Gesetz-Kritik-3718401 und https://c.de/seite/.\n" + assert p.link_urls(md, uris) == ( + "Siehe , und " + ".\n" + ) + + +def test_link_urls_joins_printed_continuation_without_pdf_target(): + md = ( + "Siehe https://a.org/digital_ dispatches/towards-x/. und https://b.de/ " + "Docs/artikel. html. Oder https://c.de/ und mehr.\n" + ) + assert p.link_urls(md) == ( + "Siehe . und " + ". Oder und mehr.\n" + )