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.
- **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. Links mit eigenem Text („hier“, „Bericht“) werden über den Linktext
und die Wörter davor im Markdown wiedergefunden und als `[hier](https://…)` gesetzt.
und im EPUB anklickbar. Beim Umbruch zerbrochene URLs (`deutsche-|sehen`, `…/ youtube-…`,
auch über eine Blockgrenze oder als Listenpunkt fortgesetzt) werden mit den Linkzielen der
PDF abgeglichen und repariert; Tracking-Parameter (`utm_…`) entfallen. Ohne Linkziel wird
die URL aus dem gedruckten Text zusammengesetzt; Wortpaare wie „und/oder“ danach gehören
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
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
@ -128,8 +130,9 @@ synthetisch erzeugt.
- **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
(alle Kapitel auf einer Ebene).
- Links mit eigenem Text: Findet sich der Linktext nicht wieder (z. B. Trennung mitten in
einem langen Titel), bleibt er ohne Link (Zensur-Report: 75 von 76 gesetzt).
- Links mit eigenem Text: Weicht der Linktext stark vom gedruckten Text ab, bleibt er ohne
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
Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen. Listings mit eingedruckten
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.
- Listings mit eingedruckten Zeilennummern (Markdown Guide: „4 <html>“, „5 <head>“) werden
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.
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

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():
md = (
"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) == (
"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"
)