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:
parent
f9dfa3a52e
commit
619421ef10
1 changed files with 221 additions and 3 deletions
135
pdf2md.py
135
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 <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
|
||||
# ===========================================================================
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue