From 1ba5b5005f69f009cd31b93917305fe588b1c91c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 2 Oct 2026 20:35:01 +0200 Subject: [PATCH] =?UTF-8?q?Links:=20URLs=20=C3=BCber=20Blockgrenzen,=20Wor?= =?UTF-8?q?tpaare,=20abweichende=20Linktexte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _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 --- README.md | 17 +++-- TODO.md | 4 -- pdf2md.py | 107 +++++++++++++++++++++++++++-- tests/test_links.py | 147 ++++++++++++++++++++++++++++++++++++++++ tests/test_normalize.py | 4 +- 5 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 tests/test_links.py diff --git a/README.md b/README.md index 3278ab2..8d13c48 100644 --- a/README.md +++ b/README.md @@ -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 `` 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. diff --git a/TODO.md b/TODO.md index ea74164..4070b01 100644 --- a/TODO.md +++ b/TODO.md @@ -9,7 +9,3 @@ einzeiligen Dialogabsätzen zu unterscheiden; Verszeilen mit Satzende werden dort Absätze. - Listings mit eingedruckten Zeilennummern (Markdown Guide: „4 “, „5 “) 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. diff --git a/pdf2md.py b/pdf2md.py index 72f124b..46ce5a7 100644 --- a/pdf2md.py +++ b/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" + _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 diff --git a/tests/test_links.py b/tests/test_links.py new file mode 100644 index 0000000..ba286c3 --- /dev/null +++ b/tests/test_links.py @@ -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 "." in md + assert "utm_source" not in md + + +def test_url_broken_after_slash_is_joined(tmp_path): + assert " 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 " 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 \n\nDer nächste Absatz.\n\nSiehe \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 . B 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" + ) diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 4609ab8..8b56c10 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -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 . und " - ". Oder und mehr.\n" + ". Oder und mehr.\n" )