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:
parent
3d418e3987
commit
d6a8c11b0a
6 changed files with 148 additions and 28 deletions
|
|
@ -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 | `` auf eigener Zeile | Bild an dieser Stelle |
|
| Bild | `` 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:
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
9
TODO.md
9
TODO.md
|
|
@ -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
108
pdf2md.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue