Markdown-Sonderzeichen maskieren, Verszeilen mit Satzende erhalten

- md_escape im Span-Patch: \ ` * [ ] < > $ ~ ^ und "_" an Wortgrenzen
  werden maskiert (__add__ nicht mehr fett, <p> nicht mehr HTML); Code
  bleibt wörtlich
- Folgeschritte maskierungsfest: norm_title, clean_inline, link_urls
  (Positionstabelle inkl. Backslash), link_anchors, --split-Titel
- Kurze Zeile mit Satzende ist nur bei eingerückter Folgezeile ein
  Absatzende, sonst eine Verszeile; Einrückung gegen den Spaltenrand der
  Seite gemessen (trennt auch verklebte einzeilige Dialogabsätze)
- Tests, README, Bedienungsanleitung, TODO

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-02 19:28:56 +02:00
commit d6a8c11b0a
6 changed files with 148 additions and 28 deletions

View file

@ -122,6 +122,7 @@ Hier kannst du Titel, Autor, Sprache oder Cover ändern. Werte immer in `"…"`
| Code | zwischen zwei Zeilen ```` ``` ````, Einrückung mit Leerzeichen | Schreibmaschinenschrift, Einrückung bleibt, lange Zeilen werden umbrochen |
| Bild | `![](band1_images/…jpg)` auf eigener Zeile | Bild an dieser Stelle |
| kursiv / fett | `_Wort_` / `**Wort**` | kursiv / fett |
| Sonderzeichen wörtlich | `\*`, `\_`, `\[`, `\<`, `\$` … (so erzeugt das Programm sie auch) | Zeichen erscheint wie geschrieben, ohne Formatierung |
| Link | `<https://beispiel.de/seite>` oder `[Text](https://beispiel.de/seite)` | anklickbarer Link |
Wichtig:

View file

@ -70,6 +70,8 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
als ```-Block mit Einrückung), **Register, Glossare, Verse, Adressen, Lebensläufe** zeilengetreu
(Markdown: `\` am Zeilenende, Einrückung als geschütztes Leerzeichen). Codeblöcke über
eine Seitengrenze werden zusammengeführt; lange Codezeilen bricht der Reader um.
- **Verse:** Eine kurze Zeile mit Satzende gilt nur dann als Absatzende, wenn die nächste
Zeile eingerückt ist (Roman); sonst ist sie eine Verszeile und bleibt eine Zeile.
- **Absätze:** eine Absatzgrenze gilt nach Satzende (`.` `!` `?` `…`, auch mit `«` `“` `»`
usw. dahinter) oder an einem harten Umbruch; auf Scans werden Absätze am Einzug erkannt.
- **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit
@ -89,6 +91,9 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
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.
- **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt.
- **Markdown-Sonderzeichen** im Text (`` \ ` * _ [ ] < > $ ~ ^ ``) werden mit `\` maskiert, damit
z. B. `__add__` nicht fett und `<p>` nicht zu HTML wird; `_` mitten im Wort (`snake_case`)
und Code bleiben unverändert.
- **YAML-Kopf:** Jede Markdown-Datei beginnt mit `title`, `author`, `lang`, `cover`
(Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `source_pages`,
`book`, `author`; `source_pages` = Seiten der PDF-Datei, nicht die gedruckten).
@ -121,9 +126,9 @@ synthetisch erzeugt.
(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).
- Zeilengetreue Erkennung: Verszeilen mit Satzzeichen am Ende werden zu eigenen Absätzen
statt zu Zeilen; einzelne Konfigurations- oder Ausgabezeilen neben Code (`[dependencies]`)
stehen als Zeilen statt im Codeblock.
- Zeilengetreue Erkennung: Ist ein Gedicht genauso eingerückt wie Absatzanfänge, werden
Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen; einzelne Konfigurations- oder
Ausgabezeilen neben Code (`[dependencies]`) stehen als Zeilen statt im Codeblock.
- Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen).
Weitere bekannte Schwächen: [TODO.md](TODO.md).
- Bilder werden als JPEG (Qualität 85) gespeichert; EPUB Band 1 ca. 3 MB.

View file

@ -10,10 +10,11 @@
wegen echter Kapitel „I.“, „II.“.
- Gesperrte Überschriften („M e i n k l e i nes Stück D D R“) bleiben zerrissen.
- Alte Scans mit eigener OCR-Ebene: `--ocr always` liefert meist besseren Text.
- Zeilengetreu: Verszeilen mit Satzzeichen am Ende werden zu Absätzen (split_by_indent
wertet kurze Zeile + Satzende als Absatzende); Codefragmente ohne Code-Merkmale
(`[dependencies]`, Programmausgaben) landen als `\`-Zeilen neben dem Codeblock statt darin.
- Markdown-Sonderzeichen werden nicht maskiert: `__add__` im Register wird fett gesetzt.
- Zeilengetreu: Ein Gedicht, das genauso eingerückt ist wie Absatzanfänge (Band 2, „Blinde
Flecken“: alle Verse bei x = 94 wie Absatzanfänge), ist geometrisch nicht von mehreren
einzeiligen Dialogabsätzen zu unterscheiden; Verszeilen mit Satzende werden dort Absätze.
- Codefragmente ohne Code-Merkmale (`[dependencies]`, Programmausgaben) landen als
`\`-Zeilen neben dem Codeblock statt darin.
- 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

108
pdf2md.py
View file

@ -148,19 +148,34 @@ NUMBERED_TITLE = re.compile(
# Hilfsfunktionen für Text
# ===========================================================================
# Markdown-Sonderzeichen im PDF-Text maskieren, damit z. B. "__add__" nicht fett
# wird. "_" mitten im Wort ("snake_case") bleibt unmaskiert.
MD_SPECIAL = "\\`*_[]<>$~^"
MD_ESCAPE = re.compile(r"[\\`*\[\]<>$~^]|(?<![^\W_])_|_(?![^\W_])")
MD_UNESCAPE = re.compile(r"\\([\\`*_\[\]<>$~^])")
def md_escape(text: str) -> str:
return MD_ESCAPE.sub(lambda m: "\\" + m.group(0), text)
def md_unescape(text: str) -> str:
return MD_UNESCAPE.sub(r"\1", text)
def ends_sentence(text: str) -> bool:
return bool(SENTENCE_END.search(text.rstrip()))
def clean_inline(text: str) -> str:
"""Markdown-Hervorhebungen entfernen und Leerraum vereinheitlichen."""
text = re.sub(r"\*\*|__|(?<!\w)_|_(?!\w)", "", text)
text = re.sub(r"(?<!\\)\*\*|(?<!\\)__|(?<![\w\\])_|(?<!\\)_(?!\w)", "", text)
return re.sub(r"\s+", " ", text).strip()
def norm_title(text: str) -> str:
"""Vergleichsschlüssel für Überschriften und Gliederungseinträge."""
return clean_inline(text).translate(DASHES).lower()
return md_unescape(clean_inline(text)).translate(DASHES).lower()
def is_other_structure(line: str) -> bool:
@ -572,7 +587,7 @@ def continues_block(following: list[str], free: int, running_heads: set[str]) ->
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()
word = md_unescape(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
@ -715,16 +730,32 @@ def _match_printed(tokens: list[str]) -> tuple[str, int]:
return url, consumed - (len(joined) - len(url))
def _unescape_map(text: str) -> tuple[str, list[int]]:
"""Text ohne Markdown-Maskierung und Position jedes Zeichens im Original."""
plain, index, i = [], [], 0
while i < len(text):
index.append(i) # Position samt Backslash, damit er beim Kopieren bleibt
if text[i] == "\\" and i + 1 < len(text) and text[i + 1] in MD_SPECIAL:
i += 1
plain.append(text[i])
i += 1
return "".join(plain), index + [len(text)]
def _link_line(line: str, known: dict[str, str]) -> str:
# URLs auf dem unmaskierten Text suchen ("Leitlinien\_" → "Leitlinien_"),
# den übrigen Text unverändert (maskiert) übernehmen.
plain, index = _unescape_map(line)
out = []
pos = 0
while match := URL_START.search(line, pos):
out.append(line[pos:match.start()])
tokens = re.findall(r"\S+\s*", line[match.start():])
while match := URL_START.search(plain, pos):
out.append(line[index[pos]:index[match.start()]])
tokens = re.findall(r"\S+\s*", plain[match.start():])
url, consumed = _match_known(tokens, known) or _match_printed(tokens)
out.append(_autolink(url) if "." in url else line[match.start():][:consumed])
pos = match.start() + consumed
out.append(line[pos:])
end = match.start() + consumed
out.append(_autolink(url) if "." in url else line[index[match.start()]:index[end]])
pos = end
out.append(line[index[pos]:])
return "".join(out)
@ -780,12 +811,19 @@ def load_link_anchors(doc: pymupdf.Document) -> list[tuple[str, list[str], list[
return anchors
def _md_literal(text: str) -> str:
"""Regex für Text, der im Markdown maskiert sein kann ("\\_", "\\*" …)."""
return "".join(
(r"\\?" if char in MD_SPECIAL else "") + re.escape(char) for char in text
)
def _words_pattern(words: list[str]) -> str:
"""Wörter mit beliebigem Leerraum, Hervorhebung oder Trennung dazwischen."""
parts = []
for word in words:
core = word.rstrip("-\u00ad") # Silbentrennung am Zeilenende
parts.append(re.escape(core) + (r"-?" if core != word else ""))
parts.append(_md_literal(core) + (r"-?" if core != word else ""))
return r"[\s*_]*".join(parts)
@ -816,8 +854,7 @@ def link_anchors(markdown: str, anchors: list[tuple[str, list[str], list[str]]])
)
if match:
start, end = match.span("anchor")
label = markdown[start:end].replace("[", "\\[").replace("]", "\\]")
markdown = f"{markdown[:start]}[{label}]({uri}){markdown[end:]}"
markdown = f"{markdown[:start]}[{markdown[start:end]}]({uri}){markdown[end:]}"
pos = start
break
return markdown
@ -827,7 +864,11 @@ def _inside_link(markdown: str, index: int) -> bool:
"""Liegt die Stelle schon in einem Link (<…>, [..](…))?"""
line_start = markdown.rfind("\n", 0, index) + 1
before = markdown[line_start:index]
return before.count("<") > before.count(">") or before.count("[") > before.count("]") or (
def count(char: str) -> int: # maskierte Zeichen ("\\[") zählen nicht
return len(re.findall(r"(?<!\\)" + re.escape(char), before))
return count("<") > count(">") or count("[") > count("]") or (
before.rfind("](") > before.rfind(")")
)
@ -859,6 +900,8 @@ def _mark_line_end_hyphens(spans: list[dict]) -> list[dict]:
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}
if not (span.get("flags", 0) & MONOSPACE_FLAG): # Code bleibt wörtlich
span = {**span, "text": md_escape(span["text"])}
text = span["text"].rstrip()
line_end = nxt is not None and (span.get("block"), span.get("line")) != (
nxt.get("block"), nxt.get("line")
@ -911,8 +954,12 @@ def _line_text(line: dict) -> str:
return "".join(span["text"] for span in line["spans"]).strip()
def split_by_indent(textlines: list[dict]) -> list[list[dict]]:
"""Zeilen eines Textblocks in Absätze aufteilen."""
def split_by_indent(textlines: list[dict], column_left: float | None = None) -> list[list[dict]]:
"""Zeilen eines Textblocks in Absätze aufteilen.
column_left: linker Rand der Textspalte, falls bekannt – sonst der Blockrand
(dann bleiben gleich eingerückte einzeilige Absätze unerkannt).
"""
if len(textlines) < 2:
return [textlines]
@ -920,6 +967,8 @@ def split_by_indent(textlines: list[dict]) -> list[list[dict]]:
em = sizes[len(sizes) // 2] if sizes else 10.0
lefts = sorted(line["bbox"][0] for line in textlines)
left = lefts[len(lefts) // 4] # Blockrand, robust gegen Einzüge
if column_left is not None:
left = min(left, column_left)
widths = [line["bbox"][2] - line["bbox"][0] for line in textlines]
longest = max(widths)
@ -1028,6 +1077,14 @@ def _fit_tolerance(lines: list[dict], right: float, em: float) -> float:
return max(FIT_TOLERANCE_EM * em, FIT_TOLERANCE * (right - left))
def _column_left(lines: list[dict], em: float) -> float:
"""Linker Rand der Textspalte (Einzüge einzeiliger Absätze erkennen)."""
bx0 = min(line["bbox"][0] for line in lines)
bx1 = max(line["bbox"][2] for line in lines)
edges = [x0 for x0, x1 in _page_lines if x0 < bx1 and x1 > bx0 and x0 >= bx0 - 10 * em]
return min(edges + [bx0])
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()
@ -1119,11 +1176,20 @@ def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> s
tol = _fit_tolerance(lines, right, em)
end = _block_end(lines, right, em, tol)
# Harte Umbrüche ohne Satzende; nach Satzende ist es ein Absatzende.
# Harte Umbrüche zählen; ein Satzende mit eingerückter Folgezeile ist dagegen
# ein Absatzende (Roman), ohne Einrückung eine Verszeile (Gedicht).
soft = [_is_soft_break(a, b, right, em, tol) for a, b in zip(lines, lines[1:])]
left = _column_left(lines, em)
def new_paragraph(prev: dict, line: dict) -> bool:
indent = line["bbox"][0] - left
return ends_sentence(_raw_text(prev).strip()) and (
INDENT_MIN_EM * em <= indent <= INDENT_MAX_EM * em
)
hard = sum(
1 for is_soft, line in zip(soft, lines)
if not is_soft and not ends_sentence(_raw_text(line).strip())
1 for is_soft, a, b in zip(soft, lines, lines[1:])
if not is_soft and not new_paragraph(a, b)
)
verbatim = bool(soft) and hard / len(soft) >= HARD_BLOCK_RATIO
@ -1131,7 +1197,7 @@ def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> s
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)
groups = split_by_indent(textlines, left)
md = "".join(original(group, ignore_code) for group in groups)
return md.rstrip("\n").rstrip() + end + "\n\n" if end else md
@ -1726,7 +1792,7 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
part = ""
for n, (index, level, text) in enumerate(cuts):
end = cuts[n + 1][0] if n + 1 < len(cuts) else len(lines)
title = clean_inline(text)
title = md_unescape(clean_inline(text))
if level == part_level:
part = title
number = re.match(r"(\d+)\b", title) if level == chapter_level != part_level else None
@ -1803,7 +1869,7 @@ def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]:
files.append(folder / name)
entries = "\n".join(
f"- [{s['title']}]({f.name})" for s, f in zip(sections, files)
f"- [{md_escape(s['title'])}]({f.name})" for s, f in zip(sections, files)
)
index = (
make_frontmatter(meta)

View file

@ -401,3 +401,32 @@ def test_link_urls_joins_printed_continuation_without_pdf_target():
"Siehe <https://a.org/digital_dispatches/towards-x/>. und "
"<https://b.de/Docs/artikel.html>. Oder <https://c.de/> und mehr.\n"
)
# --- Markdown-Sonderzeichen --------------------------------------------------------
@pytest.mark.parametrize("text, escaped", [
("__add__", "\\_\\_add\\_\\_"),
("snake_case", "snake_case"),
("a*b [1] <p> $5 x^2 ~x", "a\\*b \\[1\\] \\<p\\> \\$5 x\\^2 \\~x"),
])
def test_md_escape_round_trip(text, escaped):
assert p.md_escape(text) == escaped
assert p.md_unescape(escaped) == text
def test_pymupdf4llm_patch_escapes_text_but_not_code():
spans = [
{"text": "__add__ und *", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 0},
{"text": "a_b*c", "flags": 8, "char_flags": int(p.VISIBLE_TEXT), "font": "Courier",
"block": 0, "line": 0},
]
output, _ = p.document_layout.get_styled_text(spans)
assert output.strip() == "\\_\\_add\\_\\_ und \\* `a_b*c`"
def test_escaped_text_survives_link_steps():
md = "\\_\\_add\\_\\_, 180\\\nSiehe https://a.de/Leitlinien\\_ x.\n"
out = p.link_anchors(p.link_urls(md, ["https://a.de/Leitlinien_x"]), [])
assert out.startswith("\\_\\_add\\_\\_, 180\\\n")
assert "<https://a.de/Leitlinien_x>" in out

View file

@ -161,3 +161,21 @@ def test_outline_like_lines_outside_contents_become_paragraphs():
"## Vorschau Band Zwei\n\n32 Oberst a.D.\n\n33 Wolga-Attacke\n\n"
"Teil VI: Polen\n\nOperation Morgenlicht\n"
)
def test_poem_lines_with_sentence_ends_stay_lines(tmp_path):
md = convert(tmp_path, [
(50, "Der Mond ist aufgegangen."),
(50, "Die Sterne prangen hell."),
(50, "Der Wald steht schwarz."),
])
assert "Der Mond ist aufgegangen.\\\nDie Sterne prangen hell.\\\nDer Wald steht schwarz." in md
def test_indented_dialogue_lines_stay_paragraphs(tmp_path):
md = convert(tmp_path, [
(62, "»Und sind es heute noch«, lachte er."),
(62, "»Und sind es heute noch«, echote sie."),
])
assert "lachte er.\n\n»Und sind es heute noch«, echote sie." in md
assert "lachte er.\\" not in md