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
5 changed files with 262 additions and 17 deletions

View file

@ -86,11 +86,13 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
erhalten. 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 - **Links:** URLs werden als `<https://…>` geschrieben und sind damit in Markdown-Viewern
und im EPUB anklickbar. Beim Zeilenumbruch zerbrochene URLs (`deutsche-|sehen`, und im EPUB anklickbar. Beim Umbruch zerbrochene URLs (`deutsche-|sehen`, `…/ youtube-…`,
`…/ youtube-…`) werden mit den Linkzielen der PDF abgeglichen und repariert; auch über eine Blockgrenze oder als Listenpunkt fortgesetzt) werden mit den Linkzielen der
Tracking-Parameter (`utm_…`) entfallen. Ohne Linkziel wird die URL aus dem gedruckten PDF abgeglichen und repariert; Tracking-Parameter (`utm_…`) entfallen. Ohne Linkziel wird
Text zusammengesetzt. Links mit eigenem Text („hier“, „Bericht“) werden über den Linktext die URL aus dem gedruckten Text zusammengesetzt; Wortpaare wie „und/oder“ danach gehören
und die Wörter davor im Markdown wiedergefunden und als `[hier](https://…)` gesetzt. nicht dazu. Links mit eigenem Text („hier“, „Bericht“) werden über den Linktext und die
Wörter davor im Markdown wiedergefunden – auch bei verlorener Ligatur („Verifcation“) –
und als `[hier](https://…)` gesetzt.
- **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt – auch Überschriften, die nur - **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt – auch Überschriften, die nur
aus einer Zahl bestehen (`## 90`, `## xxiv`: Fußnotenzeichen, römische Seitenzahlen), sofern aus einer Zahl bestehen (`## 90`, `## xxiv`: Fußnotenzeichen, römische Seitenzahlen), sofern
nicht direkt eine Titelzeile folgt (`## 1` + `## Kapiteltitel` bleibt) und sie nicht in der nicht direkt eine Titelzeile folgt (`## 1` + `## Kapiteltitel` bleibt) und sie nicht in der
@ -128,8 +130,9 @@ 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 eigenem Text: Findet sich der Linktext nicht wieder (z. B. Trennung mitten in - Links mit eigenem Text: Weicht der Linktext stark vom gedruckten Text ab, bleibt er ohne
einem langen Titel), bleibt er ohne Link (Zensur-Report: 75 von 76 gesetzt). Link (Zensur-Report: alle 76 gesetzt). Ohne Linkziel in der PDF wird eine URL nur über
typische Umbruchstellen (`-`, `/`, `_`) mit ihrer Fortsetzung verbunden.
- Zeilengetreue Erkennung: Ist ein Gedicht genauso eingerückt wie Absatzanfänge, werden - Zeilengetreue Erkennung: Ist ein Gedicht genauso eingerückt wie Absatzanfänge, werden
Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen. Listings mit eingedruckten Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen. Listings mit eingedruckten
Zeilennummern werden nicht immer als ein Codeblock erkannt. Zeilennummern werden nicht immer als ein Codeblock erkannt.

View file

@ -9,7 +9,3 @@
einzeiligen Dialogabsätzen zu unterscheiden; Verszeilen mit Satzende werden dort Absätze. einzeiligen Dialogabsätzen zu unterscheiden; Verszeilen mit Satzende werden dort Absätze.
- Listings mit eingedruckten Zeilennummern (Markdown Guide: „4 <html>“, „5 <head>“) werden - Listings mit eingedruckten Zeilennummern (Markdown Guide: „4 <html>“, „5 <head>“) werden
nicht immer als ein Codeblock erkannt; Programmausgaben ohne typische Merkmale bleiben Text. nicht immer als ein Codeblock erkannt; Programmausgaben ohne typische Merkmale bleiben Text.
- Links: Setzt sich eine URL in einem neuen Block fort (z. B. als Listenpunkt „- juuuport-…“),
bleibt sie abgeschnitten. Die Heuristik ohne Linkziel hängt Wörter mit „/“ an
(„https://a.de/ und/oder“ → eine URL). Linktexte mit Trennung mitten in langen Titeln
werden nicht wiedergefunden.

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. # Umbruchstelle endet und das Wort wie ein URL-Teil aussieht.
URL_BREAK_AFTER = "/_-.=&?#" URL_BREAK_AFTER = "/_-.=&?#"
URL_CONTINUATION = re.compile(r"^(?:\S*/\S*|(?:html?|php|pdf|aspx?)\W*)$", re.IGNORECASE) 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: 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.""" """URL ohne (passendes) Linkziel: aus dem gedruckten Text zusammensetzen."""
parts = [tokens[0].rstrip()] parts = [tokens[0].rstrip()]
for token in tokens[1:MAX_URL_PARTS]: 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()) parts.append(token.rstrip())
else: else:
break break
@ -783,13 +792,74 @@ def _link_line(line: str, known: dict[str, str]) -> str:
return "".join(out) 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: def link_urls(markdown: str, uris: list[str] | None = None) -> str:
"""URLs im Fließtext anklickbar machen und mit PDF-Linkzielen reparieren.""" """URLs im Fließtext anklickbar machen und mit PDF-Linkzielen reparieren."""
known: dict[str, str] = {} known: dict[str, str] = {}
for uri in uris or []: for uri in uris or []:
for key in uri_keys(uri): for key in uri_keys(uri):
known.setdefault(key, clean_uri(uri)) known.setdefault(key, clean_uri(uri))
lines = markdown.split("\n") lines = _join_split_urls(markdown.split("\n"), known)
in_fence = False in_fence = False
for i, line in enumerate(lines): for i, line in enumerate(lines):
if FENCE.match(line): if FENCE.match(line):
@ -878,12 +948,41 @@ def link_anchors(markdown: str, anchors: list[tuple[str, list[str], list[str]]])
) )
if match: if match:
start, end = match.span("anchor") start, end = match.span("anchor")
markdown = f"{markdown[:start]}[{markdown[start:end]}]({uri}){markdown[end:]}"
pos = start
break 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 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: def _inside_link(markdown: str, index: int) -> bool:
"""Liegt die Stelle schon in einem Link (<…>, [..](…))?""" """Liegt die Stelle schon in einem Link (<…>, [..](…))?"""
line_start = markdown.rfind("\n", 0, index) + 1 line_start = markdown.rfind("\n", 0, index) + 1

147
tests/test_links.py Normal file
View file

@ -0,0 +1,147 @@
"""Links: Test-PDF mit typischen Varianten gebrochener URLs und Linktexte.
Die PDF lässt sich auch zum Ansehen erzeugen:
python3 -c "import sys; sys.path[:0]=['.', 'tests']; import test_links; test_links.make_link_pdf('links.pdf')"
"""
import pymupdf
import pdf2md as p
from test_e2e import run
FONT, SIZE, LEFT, WIDTH = "tiro", 11, 50, 320
FILLER = "Wie schon im letzten Bericht dargestellt wurde die Lage danach genauer untersucht".split()
# (Zeilen, Linkziel, Text des Links in der PDF, Abstand vor dem Absatz)
PARAGRAPHS = [
# 1. URL innerhalb des Absatzes am Bindestrich umbrochen, Tracking-Parameter
(["Quelle: https://example.org/artikel/insa-umfrage-deutsche-",
"sehen-meinungsfreiheit/. Danach geht der Satz ganz normal weiter."],
"https://example.org/artikel/insa-umfrage-deutsche-sehen-meinungsfreiheit/?utm_source=chatgpt.com",
None, 0),
# 2. URL am "/" umbrochen (mit Leerzeichen danach), Endung in der nächsten Zeile
(["Mehr unter https://example.org/docs/",
"handbuch-2024.html und im Anhang des Berichts."],
"https://example.org/docs/handbuch-2024.html", None, 14),
# 3. URL geht in einem neuen Block weiter (großer Abstand)
(["Siehe https://example.org/presse/safer-internet-day-"],
None, None, 14),
(["juuuport-scouts-seminar/. Ende des Satzes."],
None, None, 40),
# 4. ohne Linkziel: "und/oder" gehört nicht zur URL
(["Wir prüfen https://kein-ziel.de/ und/oder andere Seiten im Netz."], None, None, 14),
# 5. Link mit eigenem Text "hier"
(["Den ganzen Bericht finden Sie hier im Netz der Organisation."],
"https://example.org/bericht", ["hier"], 14),
# 6. Linktext über zwei Zeilen mit Trennung mitten im Wort
(["Zu den Empfängern gehört RUBIN: News-Poly-",
"graph: Multimodal Orchestration for Media Verification. Danach folgt Text."],
"https://example.org/rubin", ["RUBIN:", "News-Poly-", "graph:", "Multimodal",
"Orchestration", "for", "Media", "Verification."], 14),
]
CROSS_BLOCK_URI = "https://example.org/presse/safer-internet-day-juuuport-scouts-seminar/"
def _fill(line: str) -> str:
"""Zeile vorn mit Füllwörtern bis an den rechten Rand bringen (Blocksatz-ähnlich)."""
for n in range(len(FILLER), 0, -1):
filled = " ".join(FILLER[:n]) + " " + line
if pymupdf.get_text_length(filled, FONT, SIZE) <= WIDTH:
return filled
return line
def make_link_pdf(path) -> str:
doc = pymupdf.open()
page = doc.new_page(width=420, height=595)
y = 60
cross_rects = []
for lines, uri, anchor, gap in PARAGRAPHS:
y += gap
rects = []
for n, line in enumerate(lines):
text = _fill(line) if n < len(lines) - 1 or len(lines) == 1 and line.endswith("-") else line
page.insert_text((LEFT, y), text, fontname=FONT, fontsize=SIZE)
words = [w for w in page.get_text("words") if abs(w[3] - y) < 4]
if uri and anchor is None: # URL-Teile dieser Zeile
rects += [pymupdf.Rect(w[:4]) for w in words if "/" in w[4] or "html" in w[4]
or w[4].startswith(("sehen", "handbuch"))]
elif uri:
rects += [pymupdf.Rect(w[:4]) for w in words if w[4] in anchor]
if "safer-internet" in line or "juuuport" in line:
cross_rects += [pymupdf.Rect(w[:4]) for w in words
if "safer" in w[4] or "juuuport" in w[4]]
y += 14
for rect in rects:
page.insert_link({"kind": pymupdf.LINK_URI, "from": rect, "uri": uri})
for rect in cross_rects:
page.insert_link({"kind": pymupdf.LINK_URI, "from": rect, "uri": CROSS_BLOCK_URI})
doc.save(str(path))
return str(path)
def convert(tmp_path) -> str:
out = tmp_path / "links.md"
run(make_link_pdf(tmp_path / "links.pdf"), out)
return out.read_text(encoding="utf-8")
# --- Ende zu Ende ------------------------------------------------------------------
def test_url_broken_at_hyphen_is_repaired_without_tracking(tmp_path):
md = convert(tmp_path)
assert "<https://example.org/artikel/insa-umfrage-deutsche-sehen-meinungsfreiheit/>." in md
assert "utm_source" not in md
def test_url_broken_after_slash_is_joined(tmp_path):
assert "<https://example.org/docs/handbuch-2024.html> und im Anhang" in convert(tmp_path)
def test_url_continued_in_next_block_is_joined(tmp_path):
md = convert(tmp_path)
assert f"<{CROSS_BLOCK_URI}>. Ende des Satzes." in md
assert "\njuuuport" not in md
def test_word_pair_after_url_is_not_part_of_it(tmp_path):
assert "<https://kein-ziel.de/> und/oder andere Seiten" in convert(tmp_path)
def test_anchor_links(tmp_path):
md = convert(tmp_path)
assert "finden Sie [hier](https://example.org/bericht) im Netz" in md
assert ("[RUBIN: News-Polygraph: Multimodal Orchestration for Media Verification.]"
"(https://example.org/rubin)") in md
# --- Einzelfälle auf Markdown-Ebene -------------------------------------------------
def test_url_continued_as_list_item_is_joined():
uri = "https://www.nlm.de/aktuell/safer-internet-day-juuuport-scouts-seminar-vor"
md = "Siehe https://www.nlm.de/aktuell/safer-internet-day-\n\n- juuuport-scouts-\n\nseminar-vor/. Weiter.\n"
assert p.link_urls(md, [uri]) == f"Siehe <{uri}>. Weiter.\n"
assert p.link_urls(md) == f"Siehe <{uri}/>. Weiter.\n" # ohne Linkziel: gedruckte Form
def test_url_at_line_end_does_not_swallow_next_paragraph():
md = "Normal https://a.de/x-\n\nDer nächste Absatz.\n\nSiehe https://b.de/\n\n- erster Punkt\n"
assert p.link_urls(md) == (
"Normal <https://a.de/x->\n\nDer nächste Absatz.\n\nSiehe <https://b.de/>\n\n- erster Punkt\n"
)
def test_extension_only_after_dot():
assert p.link_urls("A https://b.de/a. html. B https://c.de/ html.\n") == (
"A <https://b.de/a.html>. B <https://c.de/> html.\n"
)
def test_fuzzy_anchor_with_lost_ligature():
anchors = [("https://x.de/rubin", ["haben,", "gehört"],
["RUBIN", "–", "News-Poly-", "graph:", "Media-Content", "Verification", "(MOVERA)."])]
md = "erhalten haben, gehört RUBIN – News-Polygraph: Media-Content Verifcation (MOVERA). In diesem Programm\n"
assert p.link_anchors(md, anchors) == (
"erhalten haben, gehört [RUBIN – News-Polygraph: Media-Content Verifcation (MOVERA).]"
"(https://x.de/rubin) In diesem Programm\n"
)

View file

@ -395,11 +395,11 @@ def test_link_urls_tolerates_printed_variants():
def test_link_urls_joins_printed_continuation_without_pdf_target(): def test_link_urls_joins_printed_continuation_without_pdf_target():
md = ( md = (
"Siehe https://a.org/digital_ dispatches/towards-x/. und https://b.de/ " "Siehe https://a.org/digital_ dispatches/towards-x/. und https://b.de/ "
"Docs/artikel. html. Oder https://c.de/ und mehr.\n" "SharedDocs/schwerpunkte/artikel. html. Oder https://c.de/ und mehr.\n"
) )
assert p.link_urls(md) == ( assert p.link_urls(md) == (
"Siehe <https://a.org/digital_dispatches/towards-x/>. und " "Siehe <https://a.org/digital_dispatches/towards-x/>. und "
"<https://b.de/Docs/artikel.html>. Oder <https://c.de/> und mehr.\n" "<https://b.de/SharedDocs/schwerpunkte/artikel.html>. Oder <https://c.de/> und mehr.\n"
) )