Weiche Trennstriche auflösen, Courier-OCR-Ebenen nicht als Code setzen

- Zeilenende mit U+00AD oder "¬" wird wie Silbentrennung behandelt
  ("wach­ senden" -> "wachsenden")
- Unsichtbarer Text (OCR-Ebene) verliert das Monospace-Flag, sonst setzt
  pymupdf4llm jedes Wort in Backticks
- Tests für beide Fälle und für sichtbaren Monospace-Text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-02 13:51:50 +02:00
commit 217d1ee09b
2 changed files with 49 additions and 7 deletions

View file

@ -472,28 +472,39 @@ def load_outline(doc: pymupdf.Document) -> dict[str, int]:
# ===========================================================================
# Korrektur für pymupdf4llm: Bindestrich-Komposita am Zeilenende
# Korrektur für pymupdf4llm: Trennstriche am Zeilenende, OCR-Ebenen
# ===========================================================================
# pymupdf4llm entfernt jeden Bindestrich am Zeilenende ("Militär-" + Zeile
# "Geheimdienst" -> "MilitärGeheimdienst"). Für Großbuchstaben und
# Konjunktionen ("Arbeits- und") markieren wir den Strich vorher, sodass er
# erhalten bleibt. Echte Silbentrennung (Kleinbuchstabe) bleibt unverändert.
# Weiche Trennstriche (U+00AD) und "¬" kennt pymupdf4llm nicht ("wach­ senden");
# sie trennen immer innerhalb eines Wortes und werden zu "-", das dann entfällt.
#
# Unsichtbarer Text (OCR-Ebene eines Scans) ist oft in Courier gesetzt;
# pymupdf4llm hielte ihn sonst für Programmcode und setzte jedes Wort in `…`.
KEEP_HYPHEN = "\ue000" # Strich behalten, ohne Leerzeichen anschließen
KEEP_HYPHEN_SPACE = "\ue001" # Strich und Leerzeichen behalten
SOFT_HYPHENS = ("\u00ad", "¬")
MONOSPACE_FLAG = 8
def _mark_line_end_hyphens(spans: list[dict]) -> list[dict]:
marked = []
for i, span in enumerate(spans):
nxt = spans[i + 1] if i + 1 < len(spans) else None
if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT:
span = {**span, "flags": span.get("flags", 0) & ~MONOSPACE_FLAG}
text = span["text"].rstrip()
if (
nxt is not None
and text.endswith("-")
and (span.get("block"), span.get("line"))
!= (nxt.get("block"), nxt.get("line"))
):
line_end = nxt is not None and (span.get("block"), span.get("line")) != (
nxt.get("block"), nxt.get("line")
)
if line_end and text.endswith(SOFT_HYPHENS):
span = {**span, "text": text[:-1] + "-"}
elif line_end and text.endswith("-"):
word = FIRST_WORD.match(nxt["text"].lstrip())
if word and word.group(0).lower() in CONJUNCTIONS:
span = {**span, "text": text + KEEP_HYPHEN_SPACE}

View file

@ -74,6 +74,37 @@ def test_pymupdf4llm_patch_keeps_compound_hyphen():
assert output.strip() == "Der Militär-Geheimdienst wusste es. Arbeits- und Kriegergeschrei."
def test_pymupdf4llm_patch_resolves_soft_hyphens():
# Weicher Trennstrich (U+00AD) bzw. "¬" am Zeilenende, auch vor Großbuchstaben
spans = [
{"text": "einer wach­", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 0},
{"text": "senden Demo¬", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 1},
{"text": "kratie und Lebens­ ", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 2},
{"text": "Läufe.", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 3},
]
output, _ = p.document_layout.get_styled_text(spans)
assert output.strip() == "einer wachsenden Demokratie und LebensLäufe."
def test_pymupdf4llm_patch_ignores_monospace_of_invisible_text():
# OCR-Ebene in Courier: unsichtbar, aber mit Monospace-Flag
spans = [
{"text": "Nature reioicej", "flags": 8, "char_flags": 0, "alpha": 0, "font": "Courier",
"block": 0, "line": 0},
{"text": "of many frendes", "flags": 8, "char_flags": 0, "alpha": 0, "font": "Courier",
"block": 0, "line": 1},
]
output, _ = p.document_layout.get_styled_text(spans)
assert output.strip() == "Nature reioicej of many frendes"
def test_visible_monospace_stays_code():
span = {"text": "print()", "flags": 8, "char_flags": int(p.VISIBLE_TEXT), "font": "Courier",
"block": 0, "line": 0}
output, _ = p.document_layout.get_styled_text([span])
assert output.strip() == "`print()`"
# --- Überschriften -----------------------------------------------------------------
def test_heading_without_space_and_bold_is_cleaned():