URLs als anklickbare Links, zerbrochene URLs reparieren

- load_link_uris: Linkziele aus den Link-Annotationen der PDF lesen
- link_urls: URLs als Autolink <https://…> schreiben; beim Zeilenumbruch
  zerbrochene URLs mit den Linkzielen abgleichen (tolerant gegenüber
  Bindestrichen, Leerraum, "/" am Ende, Abfrageteil, .html) und
  utm-Parameter entfernen; ohne Linkziel aus dem gedruckten Text
  zusammensetzen
- Bindestrich am Zeilenende innerhalb einer URL bleibt erhalten
- pandoc liest mit +autolink_bare_uris
- Tests, README, Bedienungsanleitung und TODO ergänzt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-02 15:07:47 +02:00
commit 619421ef10
5 changed files with 221 additions and 3 deletions

View file

@ -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 | | Zeilenumbruch innerhalb eines Absatzes | einfacher Umbruch | wird zu einem Leerzeichen |
| Bild | `![](band1_images/…jpg)` auf eigener Zeile | Bild an dieser Stelle | | Bild | `![](band1_images/…jpg)` auf eigener Zeile | Bild an dieser Stelle |
| kursiv / fett | `_Wort_` / `**Wort**` | kursiv / fett | | kursiv / fett | `_Wort_` / `**Wort**` | kursiv / fett |
| Link | `<https://beispiel.de/seite>` oder `[Text](https://beispiel.de/seite)` | anklickbarer Link |
Wichtig: Wichtig:
@ -134,7 +135,7 @@ Im Ordner der Markdown-Datei starten (wichtig für die Bilder):
```bash ```bash
cd ~/Downloads/ljutsch/Output cd ~/Downloads/ljutsch/Output
pandoc band1.md \ pandoc band1.md \
--from markdown-yaml_metadata_block --to epub3 \ --from markdown-yaml_metadata_block+autolink_bare_uris --to epub3 \
--output band1.epub \ --output band1.epub \
--resource-path . \ --resource-path . \
--split-level=3 --toc-depth=3 \ --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 | | Ü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 | | 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 | | Ü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 | | 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) | | 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 | | Auf Scans fehlen Leerzeichen („deutetin“) | OCR-Fehler; höhere Auflösung versuchen (`--ocr-dpi 400`) oder von Hand korrigieren |

View file

@ -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 - **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt; liegt das Verzeichnis
als Tabelle vor, bleibt die Tabelle erhalten. als Tabelle vor, bleibt die Tabelle erhalten.
- **OCR-Ebenen** in Courier werden nicht als Programmcode (`` `…` ``) gesetzt. - **OCR-Ebenen** in Courier werden nicht als Programmcode (`` `…` ``) gesetzt.
- **Links:** URLs werden als `<https://…>` 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. - **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt.
## Tests ## Tests
@ -88,6 +93,7 @@ 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.
- Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen). - Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen).
Weitere bekannte Schwächen: [TODO.md](TODO.md). Weitere bekannte Schwächen: [TODO.md](TODO.md).
- Bilder werden als JPEG (Qualität 95) gespeichert; EPUB Band 1 ca. 5 MB. - Bilder werden als JPEG (Qualität 95) gespeichert; EPUB Band 1 ca. 5 MB.

View file

@ -72,3 +72,7 @@ wieder zusammengesetzt = Gesamt-Markdown.
wegen echter Kapitel „I.“, „II.“. wegen echter Kapitel „I.“, „II.“.
- Gesperrte Überschriften („M e i n k l e i nes Stück D D R“) bleiben zerrissen. - 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. - 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).

135
pdf2md.py
View file

@ -21,6 +21,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import urllib.parse
from collections import Counter from collections import Counter
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
from pathlib import Path from pathlib import Path
@ -487,6 +488,131 @@ def load_outline(doc: pymupdf.Document) -> dict[str, int]:
return levels 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 <https://…>, den pandoc im
# EPUB als echten Link ausgibt.
URL_START = re.compile(
r"(?<![<\]/A-Za-z0-9])(?<!\]\()(?:https?://|www\.)", re.IGNORECASE
) # nicht in <…>-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 # 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] + "-"} span = {**span, "text": text[:-1] + "-"}
elif line_end and text.endswith("-"): elif line_end and text.endswith("-"):
word = FIRST_WORD.match(nxt["text"].lstrip()) 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} span = {**span, "text": text + KEEP_HYPHEN_SPACE}
elif word and word.group(0)[0].isupper(): elif word and word.group(0)[0].isupper():
span = {**span, "text": text + KEEP_HYPHEN} span = {**span, "text": text + KEEP_HYPHEN}
@ -945,7 +1074,7 @@ def write_epub(
cmd = [ cmd = [
pandoc, pandoc,
str(markdown_path), str(markdown_path),
"--from", "markdown-yaml_metadata_block", "--from", "markdown-yaml_metadata_block+autolink_bare_uris",
"--to", "epub3", "--to", "epub3",
"--output", str(epub_path), "--output", str(epub_path),
"--resource-path", str(markdown_path.parent), "--resource-path", str(markdown_path.parent),
@ -1075,6 +1204,7 @@ def main() -> int:
with pymupdf.open(source) as doc: with pymupdf.open(source) as doc:
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)
page_count = doc.page_count page_count = doc.page_count
title = (doc.metadata or {}).get("title") or source.stem title = (doc.metadata or {}).get("title") or source.stem
author = (doc.metadata or {}).get("author") or "" author = (doc.metadata or {}).get("author") or ""
@ -1124,6 +1254,7 @@ def main() -> int:
drop_running_heads=not args.keep_headers, drop_running_heads=not args.keep_headers,
running_head_min=max(3, page_count // 10), running_head_min=max(3, page_count // 10),
) )
markdown = link_urls(markdown, uris)
target.write_text(markdown, encoding="utf-8") target.write_text(markdown, encoding="utf-8")
if epub_path: if epub_path:

View file

@ -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" "![](buch_images/a.jpg)\n\n![](buch_images/b.jpg)\n\n"
"![](https://example.org/c.jpg)\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: <https://www.theeuropean.de/insa-umfrage-deutsche-sehen/>.\n"
"Siehe <https://www.fsm.de/en/fsm/netzdg/>. 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 <https://example.org/a_b>, [www.example.org](https://www.example.org)"
" und (<https://x.de/y>).\n"
)
def test_link_urls_leaves_images_and_existing_links_alone():
md = "![](bilder/x.jpg)\n\n<https://a.de/> 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 <https://a.de/artikel>, <https://b.de/news/Gesetz-Kritik-3718401.html> und "
"<https://c.de/seite>.\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 <https://a.org/digital_dispatches/towards-x/>. und "
"<https://b.de/Docs/artikel.html>. Oder <https://c.de/> und mehr.\n"
)