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 | | 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 | | Bild | `![](band1_images/…jpg)` auf eigener Zeile | Bild an dieser Stelle |
| kursiv / fett | `_Wort_` / `**Wort**` | kursiv / fett | | 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 | | Link | `<https://beispiel.de/seite>` oder `[Text](https://beispiel.de/seite)` | anklickbarer Link |
Wichtig: 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 als ```-Block mit Einrückung), **Register, Glossare, Verse, Adressen, Lebensläufe** zeilengetreu
(Markdown: `\` am Zeilenende, Einrückung als geschütztes Leerzeichen). Codeblöcke über (Markdown: `\` am Zeilenende, Einrückung als geschütztes Leerzeichen). Codeblöcke über
eine Seitengrenze werden zusammengeführt; lange Codezeilen bricht der Reader um. 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 `«` `“` `»` - **Absätze:** eine Absatzgrenze gilt nach Satzende (`.` `!` `?` `…`, auch mit `«` `“` `»`
usw. dahinter) oder an einem harten Umbruch; auf Scans werden Absätze am Einzug erkannt. usw. dahinter) oder an einem harten Umbruch; auf Scans werden Absätze am Einzug erkannt.
- **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit - **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 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 die Wörter davor im Markdown wiedergefunden und als `[hier](https://…)` gesetzt.
- **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt. - **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` - **YAML-Kopf:** Jede Markdown-Datei beginnt mit `title`, `author`, `lang`, `cover`
(Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `source_pages`, (Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `source_pages`,
`book`, `author`; `source_pages` = Seiten der PDF-Datei, nicht die gedruckten). `book`, `author`; `source_pages` = Seiten der PDF-Datei, nicht die gedruckten).
@ -121,9 +126,9 @@ synthetisch erzeugt.
(alle Kapitel auf einer Ebene). (alle Kapitel auf einer Ebene).
- Links mit eigenem Text: Findet sich der Linktext nicht wieder (z. B. Trennung mitten in - 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). 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 - Zeilengetreue Erkennung: Ist ein Gedicht genauso eingerückt wie Absatzanfänge, werden
statt zu Zeilen; einzelne Konfigurations- oder Ausgabezeilen neben Code (`[dependencies]`) Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen; einzelne Konfigurations- oder
stehen als Zeilen statt im Codeblock. Ausgabezeilen neben Code (`[dependencies]`) stehen als Zeilen statt im Codeblock.
- Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen). - Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen).
Weitere bekannte Schwächen: [TODO.md](TODO.md). Weitere bekannte Schwächen: [TODO.md](TODO.md).
- Bilder werden als JPEG (Qualität 85) gespeichert; EPUB Band 1 ca. 3 MB. - 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.“. wegen echter Kapitel „I.“, „II.“.
- Gesperrte Überschriften („M e i n k l e i nes Stück D D R“) bleiben zerrissen. - 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. - 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 - Zeilengetreu: Ein Gedicht, das genauso eingerückt ist wie Absatzanfänge (Band 2, „Blinde
wertet kurze Zeile + Satzende als Absatzende); Codefragmente ohne Code-Merkmale Flecken“: alle Verse bei x = 94 wie Absatzanfänge), ist geometrisch nicht von mehreren
(`[dependencies]`, Programmausgaben) landen als `\`-Zeilen neben dem Codeblock statt darin. einzeiligen Dialogabsätzen zu unterscheiden; Verszeilen mit Satzende werden dort Absätze.
- Markdown-Sonderzeichen werden nicht maskiert: `__add__` im Register wird fett gesetzt. - 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-…“), - 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 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 („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 # 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: def ends_sentence(text: str) -> bool:
return bool(SENTENCE_END.search(text.rstrip())) return bool(SENTENCE_END.search(text.rstrip()))
def clean_inline(text: str) -> str: def clean_inline(text: str) -> str:
"""Markdown-Hervorhebungen entfernen und Leerraum vereinheitlichen.""" """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() return re.sub(r"\s+", " ", text).strip()
def norm_title(text: str) -> str: def norm_title(text: str) -> str:
"""Vergleichsschlüssel für Überschriften und Gliederungseinträge.""" """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: 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 return False
elif LIST_ITEM.match(text) and not ORDERED_ITEM.match(text): elif LIST_ITEM.match(text) and not ORDERED_ITEM.match(text):
return False # "14. Jahrhundert" ist dagegen oft keine Liste 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. # Breite aus der vorigen Zeile geschätzt: ein Zeichen Spielraum.
return bool(word) and len(word[0]) + 2 > free return bool(word) and len(word[0]) + 2 > free
return False return False
@ -715,16 +730,32 @@ def _match_printed(tokens: list[str]) -> tuple[str, int]:
return url, consumed - (len(joined) - len(url)) 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: 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 = [] out = []
pos = 0 pos = 0
while match := URL_START.search(line, pos): while match := URL_START.search(plain, pos):
out.append(line[pos:match.start()]) out.append(line[index[pos]:index[match.start()]])
tokens = re.findall(r"\S+\s*", line[match.start():]) tokens = re.findall(r"\S+\s*", plain[match.start():])
url, consumed = _match_known(tokens, known) or _match_printed(tokens) url, consumed = _match_known(tokens, known) or _match_printed(tokens)
out.append(_autolink(url) if "." in url else line[match.start():][:consumed]) end = match.start() + consumed
pos = match.start() + consumed out.append(_autolink(url) if "." in url else line[index[match.start()]:index[end]])
out.append(line[pos:]) pos = end
out.append(line[index[pos]:])
return "".join(out) return "".join(out)
@ -780,12 +811,19 @@ def load_link_anchors(doc: pymupdf.Document) -> list[tuple[str, list[str], list[
return anchors 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: def _words_pattern(words: list[str]) -> str:
"""Wörter mit beliebigem Leerraum, Hervorhebung oder Trennung dazwischen.""" """Wörter mit beliebigem Leerraum, Hervorhebung oder Trennung dazwischen."""
parts = [] parts = []
for word in words: for word in words:
core = word.rstrip("-\u00ad") # Silbentrennung am Zeilenende 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) return r"[\s*_]*".join(parts)
@ -816,8 +854,7 @@ def link_anchors(markdown: str, anchors: list[tuple[str, list[str], list[str]]])
) )
if match: if match:
start, end = match.span("anchor") start, end = match.span("anchor")
label = markdown[start:end].replace("[", "\\[").replace("]", "\\]") markdown = f"{markdown[:start]}[{markdown[start:end]}]({uri}){markdown[end:]}"
markdown = f"{markdown[:start]}[{label}]({uri}){markdown[end:]}"
pos = start pos = start
break break
return markdown return markdown
@ -827,7 +864,11 @@ def _inside_link(markdown: str, index: int) -> bool:
"""Liegt die Stelle schon in einem Link (<…>, [..](…))?""" """Liegt die Stelle schon in einem Link (<…>, [..](…))?"""
line_start = markdown.rfind("\n", 0, index) + 1 line_start = markdown.rfind("\n", 0, index) + 1
before = markdown[line_start:index] 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(")") 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 nxt = spans[i + 1] if i + 1 < len(spans) else None
if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT: if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT:
span = {**span, "flags": span.get("flags", 0) & ~MONOSPACE_FLAG} 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() text = span["text"].rstrip()
line_end = nxt is not None and (span.get("block"), span.get("line")) != ( line_end = nxt is not None and (span.get("block"), span.get("line")) != (
nxt.get("block"), nxt.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() return "".join(span["text"] for span in line["spans"]).strip()
def split_by_indent(textlines: list[dict]) -> list[list[dict]]: def split_by_indent(textlines: list[dict], column_left: float | None = None) -> list[list[dict]]:
"""Zeilen eines Textblocks in Absätze aufteilen.""" """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: if len(textlines) < 2:
return [textlines] 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 em = sizes[len(sizes) // 2] if sizes else 10.0
lefts = sorted(line["bbox"][0] for line in textlines) lefts = sorted(line["bbox"][0] for line in textlines)
left = lefts[len(lefts) // 4] # Blockrand, robust gegen Einzüge 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] widths = [line["bbox"][2] - line["bbox"][0] for line in textlines]
longest = max(widths) 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)) 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: 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?""" """Hätte das erste Wort der Zeile nicht mehr in die vorige gepasst?"""
prev_text = _raw_text(prev).rstrip() 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) tol = _fit_tolerance(lines, right, em)
end = _block_end(lines, right, em, tol) 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:])] 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( hard = sum(
1 for is_soft, line in zip(soft, lines) 1 for is_soft, a, b in zip(soft, lines, lines[1:])
if not is_soft and not ends_sentence(_raw_text(line).strip()) if not is_soft and not new_paragraph(a, b)
) )
verbatim = bool(soft) and hard / len(soft) >= HARD_BLOCK_RATIO 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): if end and len(lines) == 1 and _is_code(lines, ignore_code):
return _code_block([lines], [0]) return _code_block([lines], [0])
# Fließtext: Absätze am Einzug trennen, wie bisher ausgeben. # 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) md = "".join(original(group, ignore_code) for group in groups)
return md.rstrip("\n").rstrip() + end + "\n\n" if end else md 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 = "" part = ""
for n, (index, level, text) in enumerate(cuts): for n, (index, level, text) in enumerate(cuts):
end = cuts[n + 1][0] if n + 1 < len(cuts) else len(lines) 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: if level == part_level:
part = title part = title
number = re.match(r"(\d+)\b", title) if level == chapter_level != part_level else None 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) files.append(folder / name)
entries = "\n".join( 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 = ( index = (
make_frontmatter(meta) 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 " "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/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" "## Vorschau Band Zwei\n\n32 Oberst a.D.\n\n33 Wolga-Attacke\n\n"
"Teil VI: Polen\n\nOperation Morgenlicht\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