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:
parent
7962999e8d
commit
1ba5b5005f
1 changed files with 262 additions and 17 deletions
107
pdf2md.py
107
pdf2md.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue