Links: URLs über Blockgrenzen, Wortpaare, abweichende Linktexte

- _join_split_urls: URL, die in einem neuen Block (auch als Listenpunkt)
  weitergeht, mit der Fortsetzung verbinden; mit Linkziel per Abgleich,
  ohne nur an typischen Umbruchstellen und bei pfadartiger Fortsetzung
- "und/oder" nach einer URL nicht anhängen; Dateiendung nur nach Punkt
- _fuzzy_anchor: Linktext mit Abweichungen (verlorene Ligatur) über
  Kontext/Anfang und letztes Wort finden (Zensur-Report: 76 von 76)
- tests/test_links.py: Test-PDF mit typischen Varianten
- README, TODO

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-02 20:35:01 +02:00
commit 1ba5b5005f

107
pdf2md.py
View file

@ -676,6 +676,8 @@ MAX_URL_PARTS = 4 # eine URL kann über so viele Zeilen gebrochen sein
# 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)
# "und/oder", "Ein/Aus": nur Buchstaben und ein "/" – ein Wortpaar, kein URL-Pfad.
WORD_PAIR = re.compile(r"^[^\W\d_]+/[^\W\d_]+$")
def url_key(text: str) -> str:
@ -744,7 +746,14 @@ 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()):
word = token.strip().rstrip(URL_TRAILING)
extension_only = "/" not in word # "html": nur nach "artikel."
if (
parts[-1][-1] in URL_BREAK_AFTER
and URL_CONTINUATION.match(token.rstrip())
and not WORD_PAIR.match(word)
and not (extension_only and not parts[-1].endswith("."))
):
parts.append(token.rstrip())
else:
break
@ -783,13 +792,74 @@ def _link_line(line: str, known: dict[str, str]) -> str:
return "".join(out)
LIST_MARKER = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+")
URL_PATH = re.compile(r"^[\w\-./%?=&#~+:]+$")
def _url_tail(line: str) -> str | None:
"""URL am Zeilenende (unmaskiert), sonst None."""
plain = _unescape_map(line)[0].rstrip()
tokens = plain.split()
if not tokens:
return None
for n in range(len(tokens) - 1, -1, -1):
if URL_START.search(tokens[n]):
if all(URL_CONTINUATION.match(t) for t in tokens[n + 1:]):
return "".join(tokens[n:])
return None
return None
def _join_split_urls(lines: list[str], known: dict[str, str]) -> list[str]:
"""URL, die in einem neuen Block weitergeht (oft als Listenpunkt "- …"),
mit ihrer Fortsetzung verbinden."""
i = 0
while i < len(lines):
tail = _url_tail(lines[i])
j = i + 1
while j < len(lines) and not lines[j].strip() and j - i <= 2:
j += 1
if tail is None or j >= len(lines):
i += 1
continue
nxt = lines[j]
if HEADING.match(nxt) or FENCE.match(nxt) or IMAGE.match(nxt) or nxt.lstrip().startswith(("|", ">")):
i += 1
continue
rest = LIST_MARKER.sub("", nxt, count=1).lstrip(" \t" + NBSP)
first = _unescape_map(rest)[0].split()
if not first:
i += 1
continue
piece = first[0].rstrip(URL_TRAILING)
joined = url_key(tail.rstrip(URL_TRAILING) + piece)
if known:
fits = url_key(tail.rstrip(URL_TRAILING)) not in known and any(
key.startswith(joined) for key in known
)
else:
fits = (
tail.endswith(("-", "/", "_"))
and URL_PATH.match(piece) is not None
and (piece[:1].islower() or piece[:1].isdigit())
and any(c in piece for c in "-/.")
and not WORD_PAIR.match(piece)
)
if not fits:
i += 1
continue
lines[i] = lines[i].rstrip() + rest # ohne Leerzeichen: dieselbe URL
del lines[i + 1:j + 1]
return lines
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")
lines = _join_split_urls(markdown.split("\n"), known)
in_fence = False
for i, line in enumerate(lines):
if FENCE.match(line):
@ -878,12 +948,41 @@ def link_anchors(markdown: str, anchors: list[tuple[str, list[str], list[str]]])
)
if match:
start, end = match.span("anchor")
markdown = f"{markdown[:start]}[{markdown[start:end]}]({uri}){markdown[end:]}"
pos = start
break
else:
span = _fuzzy_anchor(markdown, pos, before, text)
if span is None:
continue
start, end = span
markdown = f"{markdown[:start]}[{markdown[start:end]}]({uri}){markdown[end:]}"
pos = start
return markdown
def _fuzzy_anchor(
markdown: str, pos: int, before: list[str], text: list[str]
) -> tuple[int, int] | None:
"""Linktext mit Abweichungen (verlorene Ligatur: "Verifcation") finden:
Anfang über Kontext und erste Wörter, Ende über das letzte Wort."""
if len(text) < 3 or not (context := _context_pattern(before)):
return None
head = re.compile(context + CONTEXT_GAP + r"(?P<anchor>" + _words_pattern(text[:2]) + r")")
match = next(
(m for m in head.finditer(markdown, pos) if not _inside_link(markdown, m.start("anchor"))),
None,
)
if match is None:
return None
start = match.start("anchor")
window = markdown[start:start + int(len(" ".join(text)) * 1.2) + 10]
if "\n\n" in window:
window = window[:window.index("\n\n")] # nicht über den Absatz hinaus
last = None
for last in re.finditer(_words_pattern(text[-1:]), window):
pass
return (start, start + last.end()) if last else None
def _inside_link(markdown: str, index: int) -> bool:
"""Liegt die Stelle schon in einem Link (<…>, [..](…))?"""
line_start = markdown.rfind("\n", 0, index) + 1