Zeilengetreue Übernahme von Code, Registern, Versen und Adressen

Ein Zeilenumbruch gilt als weich (vom Satzprogramm), wenn das erste Wort
der nächsten Zeile nicht mehr gepasst hätte (Toleranz 5 % der Spalte);
sonst als hart, und die Zeile bleibt samt Einrückung erhalten.

- lines_to_md (Patch von text_to_md): harte/weiche Umbrüche aus der
  Geometrie; Blöcke mit überwiegend harten Umbrüchen zeilengetreu als
  Codeblock (auch Proportionalschrift, Einrückung rekonstruiert) oder als
  Zeilen mit "\" und geschützten Leerzeichen
- Blockgrenzen: freie Zeichen der letzten Zeile werden mitgegeben,
  normalize_markdown prüft damit das erste Wort des nächsten Blocks
- normalize_markdown: harte Umbrüche, Absatzenden ohne Satzzeichen,
  Codeblöcke über Seitengrenzen zusammenführen, wörtliches ``` maskieren
- EPUB-CSS: lange Codezeilen umbrechen (white-space: pre-wrap)
- Tests (tests/test_verbatim.py), README, Bedienungsanleitung, CLAUDE.md,
  TODO.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-02 16:39:06 +02:00
commit d322ee3523

382
pdf2md.py
View file

@ -21,6 +21,7 @@ import re
import shutil
import subprocess
import sys
import tempfile
import urllib.parse
from collections import Counter
from concurrent.futures import ProcessPoolExecutor
@ -319,6 +320,8 @@ def normalize_markdown(
text = paragraph[0]
for part in paragraph[1:]:
text = join_parts(text, part)
if result and result[-1].endswith(HARD_BREAK):
result.append("") # Fließtext nach zeilengetreuen Zeilen
result.append(text)
paragraph.clear()
@ -332,9 +335,13 @@ def normalize_markdown(
headings_seen.add(norm_title(text))
standalone("#" * min(max(level, 1), 6) + " " + text)
for original in lines:
for index, original in enumerate(lines):
line = original.rstrip()
marker = FENCE.match(line)
if marker and not in_fence and MARKERS.search(line):
# Text aus einem Absatz, der mit ``` beginnt: kein Codezaun.
line = "\\" + line
marker = None
if in_fence:
result.append(line)
@ -346,14 +353,81 @@ def normalize_markdown(
if marker:
flush_paragraph()
blank_line()
result.append(line)
in_fence = True
fence_char = marker.group(1)[0]
fence_length = len(marker.group(1))
# Listing über eine Seitengrenze: an den vorigen Codeblock anhängen.
while result and result[-1] == "":
result.pop()
if result and result[-1] == line.strip() and line.strip() == fence_char * fence_length:
result.pop()
continue
blank_line()
result.append(line)
continue
if not line.strip():
if end_mark := BLOCK_END_MARK.search(line):
# Blockende ohne Satzende: Hätte das erste Wort des nächsten Blocks
# noch in die Zeile gepasst, war der Umbruch hart.
line = line[: end_mark.start()]
if not continues_block(lines[index + 1:], int(end_mark.group(1)), running_heads):
# Mehrzeiliger Absatz: Absatzende; einzelne Zeile: harter Umbruch
line += PARAGRAPH_END if end_mark.group(2) else HARD_BREAK
if line.endswith(PARAGRAPH_END):
content = line.removesuffix(PARAGRAPH_END)
if not paragraph and result and result[-1].endswith(HARD_BREAK):
result.append(content) # letzte Zeile eines zeilengetreuen Blocks
else:
if not paragraph_open():
flush_paragraph()
blank_line()
paragraph.append(content.strip())
flush_paragraph()
blank_line()
continue
if line.endswith(HARD_BREAK):
# Gliederungstitel oder "Inhalt" bleiben Überschriften.
check = clean_inline(line.removesuffix(HARD_BREAK))
key = norm_title(check)
if CONTENTS_TITLE.match(check) or (
key in outline and key not in headings_seen and not in_contents
):
line = line.removesuffix(HARD_BREAK)
if line.endswith(HARD_BREAK) or line == STANZA_BREAK:
# Zeilengetreue Zeile: Einrückung (geschützte Leerzeichen) behalten.
content = line.removesuffix(HARD_BREAK)
check = clean_inline(content)
if in_contents:
contents_seen.add(norm_title(check))
if drop_page_numbers and PAGE_NUMBER.match(check):
continue
if running_heads and running_head_key(check) in running_heads:
continue
if paragraph_open() and line != STANZA_BREAK:
# Letzte Zeile eines Absatzes aus dem vorigen Block
paragraph.append(content.strip())
flush_paragraph()
result[-1] += HARD_BREAK
continue
flush_paragraph()
while result and result[-1] == "" and len(result) > 1 and result[-2].endswith(HARD_BREAK):
result.pop() # Zeilen aus getrennten Blöcken zusammenhalten
if line == STANZA_BREAK:
if result and result[-1].endswith(HARD_BREAK):
result[-1] = result[-1].removesuffix(HARD_BREAK)
blank_line()
continue
if not result or result[-1] == "" or result[-1].endswith(HARD_BREAK):
result.append(line)
else:
blank_line()
result.append(line)
continue
if not line.strip(" \t"):
# Leerzeile nur dann als Absatzgrenze behandeln,
# wenn der bisherige Fließtext mit einem Satzende endet.
if paragraph and ends_sentence(paragraph[-1]):
@ -461,7 +535,44 @@ def normalize_markdown(
paragraph.append(stripped)
flush_paragraph()
return "\n".join(result).strip() + "\n"
return finish_hard_breaks("\n".join(result)).strip() + "\n"
def continues_block(following: list[str], free: int, running_heads: set[str]) -> bool:
"""Geht der Satz im nächsten Block weiter? (erstes Wort passte nicht mehr)"""
for line in following:
text = line.strip(" \t")
if not text or PAGE_NUMBER.match(clean_inline(text)):
continue
if running_heads and running_head_key(text) in running_heads:
continue
if heading := HEADING.match(text):
text = heading.group(2) # oft falsch erkannt ("## trösten.")
elif text.startswith(("|", "!", ">", "```", "~~~", "**==>")):
return False
elif LIST_ITEM.match(text) and not ORDERED_ITEM.match(text):
return False # "14. Jahrhundert" ist dagegen oft keine Liste
word = clean_inline(text).replace(NBSP, " ").split()
# Breite aus der vorigen Zeile geschätzt: ein Zeichen Spielraum.
return bool(word) and len(word[0]) + 2 > free
return False
def finish_hard_breaks(markdown: str) -> str:
"""Markierungen harter Umbrüche in Markdown umsetzen.
Folgt eine weitere zeilengetreue Zeile, wird daraus "\" (harter Umbruch);
die letzte Zeile eines Abschnitts endet ohne.
"""
lines = markdown.split("\n")
for i, line in enumerate(lines):
if line.endswith(HARD_BREAK):
text = line.removesuffix(HARD_BREAK).rstrip()
following = lines[i + 1] if i + 1 < len(lines) else ""
if following.strip(" \t") and not FENCE.match(following):
text += "\\"
lines[i] = text
return MARKERS.sub("", "\n".join(lines)) # Sicherheitsnetz
# ===========================================================================
@ -500,7 +611,7 @@ def load_outline(doc: pymupdf.Document) -> dict[str, int]:
URL_START = re.compile(
r"(?<![<\]/A-Za-z0-9])(?<!\]\()(?:https?://|www\.)", re.IGNORECASE
) # nicht in <…>-Links und [Text](…)-Zielen
URL_TRAILING = ".,;:!?)]}»«“”„\"'*_"
URL_TRAILING = ".,;:!?)]}»«“”„\"'*_\\" # "\\": harter Zeilenumbruch
MAX_URL_PARTS = 4 # eine URL kann über so viele Zeilen gebrochen sein
# Ohne Linkziel: Folgewort gehört zur URL, wenn diese an einer typischen
# Umbruchstelle endet und das Wort wie ein URL-Teil aussieht.
@ -716,21 +827,251 @@ def split_by_indent(textlines: list[dict]) -> list[list[dict]]:
return groups
# ---------------------------------------------------------------------------
# Zeilengetreue Abschnitte: Code, Register, Verse, Adressen …
# ---------------------------------------------------------------------------
# Ein Zeilenumbruch stammt vom Satzprogramm (weich), wenn das erste Wort der
# nächsten Zeile nicht mehr in die Zeile gepasst hätte. Sonst hat ihn der Autor
# gesetzt (hart); dann bleibt die Zeile samt Einrückung erhalten. Überwiegen in
# einem Block die harten Umbrüche, wird er zeilengetreu ausgegeben: als
# Codeblock (```) oder als Zeilen mit hartem Umbruch ("\" am Zeilenende).
HARD_BREAK = "\ue002" # Zeile endet mit hartem Umbruch (wird zu "\")
BLOCK_END = "\ue004" # Block endet ohne Satzende: "\ue004<freie Zeichen>[P]\ue004"
BLOCK_END_MARK = re.compile("\ue004(\\d+)(P?)\ue004$") # P: mehrzeiliger Absatz
PARAGRAPH_END = "\ue005" # Absatz endet hier, auch ohne Satzende
MARKERS = re.compile("\ue004\\d*P?\ue004|[\ue002-\ue005]") # alle internen Markierungen
STANZA_BREAK = "\ue003" # Leerzeile innerhalb eines zeilengetreuen Blocks
NBSP = "\u00a0"
TEXT_BOXES = ("text", "list-item", "footnote")
# Spielraum beim "Hätte das Wort noch gepasst?": Satzprogramme brechen nicht
# exakt um (Band 1, Flattersatz: bis 3 % der Spalte), Register und Verse
# lassen fast immer weit mehr frei.
FIT_TOLERANCE = 0.05 # Anteil der Spaltenbreite
FIT_TOLERANCE_EM = 0.3 # mindestens
HARD_BLOCK_RATIO = 0.5 # ab diesem Anteil harter Umbrüche: zeilengetreu
STANZA_GAP = 1.6 # Zeilenabstand (× üblichem) für Leerzeile
INDENT_STEP_EM = 0.8 # Einrückungsstufen: Abstand mindestens so groß
CODE_INDENT = " " # Leerzeichen pro Einrückungsstufe im Code
TEXT_INDENT = NBSP * 4 # geschützte Leerzeichen pro Stufe sonst
CODE_HINT = re.compile(
r"[{};]\s*$" # Zeile endet mit { } ;
r"|^\s*[}\])]" # beginnt mit } ] )
r"|::|=>|->|==|!=|\+=|&&|\|\|" # Operatoren
r"|^\s*(?:def|class|fn|let|mut|use|import|from|return|pub|impl|struct|"
r"enum|match|const|var|function|elif|lambda|async|await|#include)\b"
r"|^\s*(?:if|for|while|else|try|except|with)\b.*:\s*$" # Python-Block
r"|^\s*(?:#\[|@\w+|//|/\*|\$ )" # Attribut, Dekorator, Kommentar
r"|\w\([^()]*\)\s*$" # endet mit Aufruf f(x)
)
_page_lines: list[tuple[float, float]] = [] # (x0, x1) der Textzeilen der Seite
def _remember_page_lines(boxes) -> None:
_page_lines.clear()
for box in boxes:
if getattr(box, "boxclass", None) in TEXT_BOXES:
for line in getattr(box, "textlines", None) or []:
_page_lines.append((line["bbox"][0], line["bbox"][2]))
def _visual_lines(textlines: list[dict]) -> list[dict]:
"""Textzeilen auf gleicher Höhe (z. B. Wechsel der Schrift) zusammenfassen."""
merged: list[dict] = []
for line in textlines:
x0, y0, x1, y1 = line["bbox"]
if merged:
last = merged[-1]
ly0, ly1 = last["bbox"][1], last["bbox"][3]
if (y0 + y1) / 2 < ly1 and (y0 + y1) / 2 > ly0 and x0 >= last["bbox"][0]:
bx0, by0, bx1, by1 = last["bbox"]
last["bbox"] = (bx0, min(by0, y0), max(bx1, x1), max(by1, y1))
last["spans"] = last["spans"] + line["spans"]
continue
merged.append({"bbox": tuple(line["bbox"]), "spans": list(line["spans"])})
return merged
def _raw_text(line: dict) -> str:
return "".join(span["text"] for span in line["spans"])
def _char_width(line: dict, em: float) -> float:
chars = sum(len(s["text"]) for s in line["spans"])
width = sum(s["bbox"][2] - s["bbox"][0] for s in line["spans"])
return width / chars if chars and width > 0 else 0.5 * em
def _column_right(lines: list[dict], em: float) -> float:
"""Rechter Rand der Textspalte, in der der Block steht."""
bx0 = min(line["bbox"][0] for line in lines)
bx1 = max(line["bbox"][2] for line in lines)
edges = [
x1 for x0, x1 in _page_lines
if x0 < bx1 and x1 > bx0 and x0 >= bx0 - 2 * em
]
return max(edges + [bx1])
def _fit_tolerance(lines: list[dict], right: float, em: float) -> float:
left = min(line["bbox"][0] for line in lines)
return max(FIT_TOLERANCE_EM * em, FIT_TOLERANCE * (right - left))
def _is_soft_break(prev: dict, line: dict, right: float, em: float, tol: float) -> bool:
"""Hätte das erste Wort der Zeile nicht mehr in die vorige gepasst?"""
prev_text = _raw_text(prev).rstrip()
text = _raw_text(line).strip()
if not text:
return True
if prev_text.endswith(("-",) + SOFT_HYPHENS) and text[0].islower():
return True # Silbentrennung setzt nur das Satzprogramm
word = text.split()[0]
needed = (len(word) + 1) * _char_width(line, em)
return prev["bbox"][2] + needed > right - tol
def _indent_levels(lines: list[dict], em: float) -> list[int]:
"""Einrückung jeder Zeile als Stufe 0, 1, 2 … (geometrisch)."""
lefts = sorted({round(line["bbox"][0], 1) for line in lines})
stops: list[float] = []
for x in lefts:
if not stops or x - stops[-1] >= INDENT_STEP_EM * em:
stops.append(x)
levels = []
for line in lines:
x = line["bbox"][0]
levels.append(max(i for i, stop in enumerate(stops) if stop <= x + 0.5))
return levels
def _is_code(lines: list[dict], ignore_code: bool) -> bool:
if ignore_code:
return False
texts = [_raw_text(line) for line in lines]
if all(
s["flags"] & 8 and s.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT
for line in lines for s in line["spans"] if s["text"].strip()
):
return True # Schreibmaschinenschrift
hints = sum(1 for t in texts if CODE_HINT.search(t.replace(NBSP, " ")))
indented = sum(1 for t in texts if t[:1] in (" ", NBSP, "\t"))
return hints >= max(1, (len(texts) + 1) // 2) or (hints and indented)
def _code_block(logical: list[list[dict]], levels: list[int]) -> str:
rows = []
for parts, level in zip(logical, levels):
text = " ".join(_raw_text(line).replace(NBSP, " ").rstrip() for line in parts)
rows.append(CODE_INDENT * level + text)
fence = "~~~" if any("```" in row for row in rows) else "```"
return fence + "\n" + "\n".join(rows) + "\n" + fence + "\n\n"
def _verbatim_block(
logical: list[list[dict]], levels: list[int], gaps: list[bool], end: str
) -> str:
rows = []
for i, (parts, level) in enumerate(zip(logical, levels)):
spans = [span for line in parts for span in line["spans"]]
text, _ = document_layout.get_styled_text(spans)
lead = len(_raw_text(parts[0])) - len(_raw_text(parts[0]).lstrip())
row = TEXT_INDENT * level + NBSP * lead + text.strip()
last = i == len(logical) - 1
row += (end or PARAGRAPH_END) if last else HARD_BREAK
rows.append(row)
if not last and gaps[i]:
rows.append(STANZA_BREAK)
return "\n".join(rows) + "\n\n"
def _block_end(lines: list[dict], right: float, em: float, tol: float) -> str:
"""Markierung für ein Blockende ohne Satzende: freie Zeichen in der Zeile.
Ob der Satz im nächsten Block weitergeht, entscheidet normalize_markdown
mit dem ersten Wort dieses Blocks ("Hätte es noch gepasst?").
"""
last = lines[-1]
text = _raw_text(last).strip()
if not text or ends_sentence(text) or text.endswith(("-",) + SOFT_HYPHENS):
return ""
free = (right - tol - last["bbox"][2]) / _char_width(last, em)
paragraph = "P" if len(lines) > 1 else ""
return f"{BLOCK_END}{max(0, int(free))}{paragraph}{BLOCK_END}"
def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> str:
"""Textblock ausgeben: Fließtext wie bisher, sonst zeilengetreu."""
lines = _visual_lines(textlines)
sizes = sorted(s["size"] for line in lines for s in line["spans"])
em = sizes[len(sizes) // 2] if sizes else 10.0
right = _column_right(lines, em)
tol = _fit_tolerance(lines, right, em)
end = _block_end(lines, right, em, tol)
# Harte Umbrüche ohne Satzende; nach Satzende ist es ein Absatzende.
soft = [_is_soft_break(a, b, right, em, tol) for a, b in zip(lines, lines[1:])]
hard = sum(
1 for is_soft, line in zip(soft, lines)
if not is_soft and not ends_sentence(_raw_text(line).strip())
)
verbatim = bool(soft) and hard / len(soft) >= HARD_BLOCK_RATIO
if not verbatim:
if end and len(lines) == 1 and _is_code(lines, ignore_code):
return _code_block([lines], [0])
# Fließtext: Absätze am Einzug trennen, wie bisher ausgeben.
groups = split_by_indent(textlines)
md = "".join(original(group, ignore_code) for group in groups)
return md.rstrip("\n").rstrip() + end + "\n\n" if end else md
# Zeilengetreu: weich umbrochene Zeilen zu logischen Zeilen verbinden.
logical: list[list[dict]] = [[lines[0]]]
for is_soft, line in zip(soft, lines[1:]):
if is_soft:
logical[-1].append(line)
else:
logical.append([line])
firsts = [parts[0] for parts in logical]
levels = _indent_levels(firsts, em)
if _is_code(lines, ignore_code):
return _code_block(logical, levels)
pitches = [b["bbox"][1] - a["bbox"][1] for a, b in zip(firsts, firsts[1:])]
usual = sorted(pitches)[len(pitches) // 2] if pitches else 0
gaps = [usual > 0 and pitch > STANZA_GAP * usual for pitch in pitches]
return _verbatim_block(logical, levels, gaps, end)
def patch_pymupdf4llm_paragraphs() -> None:
original = getattr(document_layout, "text_to_md", None)
if original is None or getattr(original, "pdf2md_patched", False):
return
def text_to_md(textlines, ignore_code: bool = False):
if not textlines or document_layout.is_superscripted(textlines[0]):
return original(textlines, ignore_code)
try:
groups = split_by_indent(textlines)
except (KeyError, IndexError, TypeError):
groups = [textlines] # unerwartete Struktur: Originalverhalten
return "".join(original(group, ignore_code) for group in groups)
return lines_to_md(textlines, original, ignore_code)
except (KeyError, IndexError, TypeError, ValueError, ZeroDivisionError):
return original(textlines, ignore_code) # unerwartete Struktur
text_to_md.pdf2md_patched = True
document_layout.text_to_md = text_to_md
levels = getattr(document_layout, "create_list_item_levels", None)
if levels is not None and not getattr(levels, "pdf2md_patched", False):
def create_list_item_levels(layout_info):
_remember_page_lines(layout_info) # einmal pro Seite aufgerufen
return levels(layout_info)
create_list_item_levels.pdf2md_patched = True
document_layout.create_list_item_levels = create_list_item_levels
class _DiscardMessages(io.StringIO):
"""Ersatz für pymupdf4llms Meldungspuffer ("OCR on page …")."""
@ -1090,6 +1431,22 @@ def title_author_from_filename(stem: str) -> tuple[str, str]:
return name, ""
# Ergänzung zum Standard-Stylesheet von pandoc: Code auf dem kleinen
# Bildschirm umbrechen statt abschneiden, Einrückung bleibt erhalten.
EPUB_CSS = """
pre, pre code { white-space: pre-wrap; overflow-wrap: anywhere; }
pre { font-size: 0.85em; line-height: 1.3; }
"""
def epub_stylesheet(pandoc: str) -> str:
done = subprocess.run(
[pandoc, "--print-default-data-file", "epub.css"],
capture_output=True, text=True,
)
return (done.stdout if done.returncode == 0 else "") + EPUB_CSS
def write_epub(
markdown_path: Path,
epub_path: Path,
@ -1121,7 +1478,10 @@ def write_epub(
if cover:
cmd += ["--epub-cover-image", str(cover)]
done = subprocess.run(cmd, capture_output=True, text=True)
with tempfile.TemporaryDirectory() as tmp:
css = Path(tmp) / "pdf2md.css"
css.write_text(epub_stylesheet(pandoc), encoding="utf-8")
done = subprocess.run(cmd + ["--css", str(css)], capture_output=True, text=True)
if done.returncode != 0:
raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}")