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:
parent
fa90050535
commit
d322ee3523
6 changed files with 545 additions and 17 deletions
|
|
@ -100,6 +100,9 @@ Wenn du kein Cover und kein Probe-EPUB brauchst, reicht `--images` statt `--epub
|
||||||
| Kapitel | `### 1 Der Notar` | eigenes Kapitel (eigene Datei im EPUB), unter dem Teil eingeordnet |
|
| Kapitel | `### 1 Der Notar` | eigenes Kapitel (eigene Datei im EPUB), unter dem Teil eingeordnet |
|
||||||
| Absatz | durch **eine Leerzeile** vom nächsten getrennt | neuer Absatz |
|
| Absatz | durch **eine Leerzeile** vom nächsten getrennt | neuer Absatz |
|
||||||
| Zeilenumbruch innerhalb eines Absatzes | einfacher Umbruch | wird zu einem Leerzeichen |
|
| Zeilenumbruch innerhalb eines Absatzes | einfacher Umbruch | wird zu einem Leerzeichen |
|
||||||
|
| Zeile erzwingen (Register, Vers, Adresse) | `\` am Zeilenende | neue Zeile im selben Absatz |
|
||||||
|
| Einrückung außerhalb von Code | geschützte Leerzeichen (U+00A0) am Zeilenanfang | bleibt erhalten (normale Leerzeichen verschwinden) |
|
||||||
|
| 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 |
|
||||||
| 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 |
|
||||||
|
|
@ -246,6 +249,8 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
|
||||||
| Buchstaben fehlen („Offzielle“, „treff ch“) | Ligatur (fi, fl) ohne Textzuordnung in der PDF; von Hand korrigieren |
|
| Buchstaben fehlen („Offzielle“, „treff ch“) | Ligatur (fi, fl) ohne Textzuordnung in der PDF; von Hand korrigieren |
|
||||||
| Überschrift gesperrt („M e i n k l e i nes Stück“) | Buchstaben einzeln gesetzt in der PDF; von Hand korrigieren |
|
| Überschrift gesperrt („M e i n k l e i nes Stück“) | Buchstaben einzeln gesetzt in der PDF; von Hand korrigieren |
|
||||||
| Link führt ins Leere oder ist abgeschnitten | URL in der PDF nachsehen und in `<…>` korrigieren; manche PDFs enthalten selbst abgeschnittene Linkziele |
|
| Link führt ins Leere oder ist abgeschnitten | URL in der PDF nachsehen und in `<…>` korrigieren; manche PDFs enthalten selbst abgeschnittene Linkziele |
|
||||||
|
| Register, Verse oder Code zu einem Absatz verklebt | Zeilen in Weg B mit `\` am Ende trennen bzw. Code in ```` ``` ```` einschließen; das Programm erkennt nur Umbrüche, bei denen das nächste Wort noch in die Zeile gepasst hätte |
|
||||||
|
| Fließtext zerfällt in einzelne Zeilen mit `\` | in Weg B die `\` am Zeilenende entfernen |
|
||||||
| Formeln erscheinen als Bilder | bei Mathebüchern normal; dafür ist das Programm nicht gebaut |
|
| Formeln erscheinen als Bilder | bei Mathebüchern normal; dafür ist das Programm nicht gebaut |
|
||||||
| Alter Scan: falsche Überschriften, viele Lesefehler | `--ocr always` (vorhandene OCR-Ebene ignorieren) |
|
| Alter Scan: falsche Überschriften, viele Lesefehler | `--ocr always` (vorhandene OCR-Ebene ignorieren) |
|
||||||
| Auf Scans fehlen Leerzeichen („deutetin“) | OCR-Fehler; höhere Auflösung versuchen (`--ocr-dpi 400`) oder von Hand korrigieren |
|
| Auf Scans fehlen Leerzeichen („deutetin“) | OCR-Fehler; höhere Auflösung versuchen (`--ocr-dpi 400`) oder von Hand korrigieren |
|
||||||
|
|
|
||||||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -14,6 +14,13 @@ Aktuelles Programm: `pdf2md.py` (Einzeldatei; Name bleibt fest, keine Versionsnu
|
||||||
- Hilfetexte, Meldungen, Kommentare und Docstrings auf Deutsch (wie v4); Bezeichner Englisch.
|
- Hilfetexte, Meldungen, Kommentare und Docstrings auf Deutsch (wie v4); Bezeichner Englisch.
|
||||||
- Echttests nie nach `Fertig/` schreiben (dort liegen die Ergebnisse des Nutzers).
|
- Echttests nie nach `Fertig/` schreiben (dort liegen die Ergebnisse des Nutzers).
|
||||||
- PDFs/EPUBs/Bilder sind per `.gitignore` ausgeschlossen – keine Binärdateien committen.
|
- PDFs/EPUBs/Bilder sind per `.gitignore` ausgeschlossen – keine Binärdateien committen.
|
||||||
- `pdf2md.py` patcht zwei Funktionen in `pymupdf4llm.helpers.document_layout`
|
- `pdf2md.py` patcht drei Funktionen in `pymupdf4llm.helpers.document_layout`
|
||||||
(`get_styled_text`, `text_to_md`). Nach einem pymupdf4llm-Update prüfen, ob
|
(`get_styled_text`, `text_to_md`, `create_list_item_levels` – letztere nur, um pro Seite
|
||||||
`tests/test_normalize.py::test_pymupdf4llm_patch_keeps_compound_hyphen` noch grün ist.
|
die Zeilengeometrie zu merken). Nach einem pymupdf4llm-Update prüfen, ob
|
||||||
|
`tests/test_normalize.py::test_pymupdf4llm_patch_keeps_compound_hyphen` und
|
||||||
|
`tests/test_verbatim.py` noch grün sind.
|
||||||
|
- Zwischen dem Patch (`lines_to_md`) und `normalize_markdown` laufen interne Markierungen
|
||||||
|
aus dem Private-Use-Bereich (`HARD_BREAK`, `BLOCK_END`, `PARAGRAPH_END`, `STANZA_BREAK`,
|
||||||
|
U+E002–U+E005); sie dürfen nie in der Ausgabe landen.
|
||||||
|
- Regressionsprüfung bei Änderungen am Umbruch: Band 1/2 vorher/nachher diffen; Fließtext
|
||||||
|
darf sich nur an echten Absatz- oder Zeilenwechseln der PDF ändern.
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -52,9 +52,15 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
|
||||||
|
|
||||||
## Was die Nachbearbeitung macht
|
## Was die Nachbearbeitung macht
|
||||||
|
|
||||||
- **Absätze:** Zeilen werden verbunden; eine Absatzgrenze gilt nur nach Satzende
|
- **Fließtext oder zeilengetreu:** Ein Zeilenumbruch gilt als *weich* (vom Satzprogramm),
|
||||||
(`.` `!` `?` `…`, auch mit `«` `“` `»` usw. dahinter). Auf Scans werden Absätze am
|
wenn das erste Wort der nächsten Zeile nicht mehr in die Zeile gepasst hätte – dann
|
||||||
Einzug erkannt.
|
werden die Zeilen zu Fließtext verbunden. Sonst hat ihn der Autor gesetzt (*hart*), und
|
||||||
|
die Zeile bleibt samt Einrückung erhalten. So bleiben **Code** (auch in Proportionalschrift,
|
||||||
|
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.
|
||||||
|
- **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
|
- **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit
|
||||||
Bindestrich, `Arbeits- und` bleibt. Weiche Trennstriche (U+00AD) und `¬` am Zeilenende
|
Bindestrich, `Arbeits- und` bleibt. Weiche Trennstriche (U+00AD) und `¬` am Zeilenende
|
||||||
werden ebenfalls aufgelöst (`wach|senden` → `wachsenden`).
|
werden ebenfalls aufgelöst (`wach|senden` → `wachsenden`).
|
||||||
|
|
@ -95,6 +101,9 @@ synthetisch erzeugt.
|
||||||
gesperrt gesetzte Überschriften („M e i n k l e i nes …“), flache PDF-Gliederungen
|
gesperrt gesetzte Überschriften („M e i n k l e i nes …“), flache PDF-Gliederungen
|
||||||
(alle Kapitel auf einer Ebene).
|
(alle Kapitel auf einer Ebene).
|
||||||
- Links mit anderem Text als der URL (z. B. „hier“) werden nicht übernommen.
|
- Links mit anderem Text als der URL (z. B. „hier“) werden nicht übernommen.
|
||||||
|
- 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.
|
||||||
- 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 95) gespeichert; EPUB Band 1 ca. 5 MB.
|
- Bilder werden als JPEG (Qualität 95) gespeichert; EPUB Band 1 ca. 5 MB.
|
||||||
|
|
|
||||||
4
TODO.md
4
TODO.md
|
|
@ -72,6 +72,10 @@ wieder zusammengesetzt = Gesamt-Markdown.
|
||||||
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
|
||||||
|
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.
|
||||||
- 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. Links mit eigenem Text („hier“) gehen verloren; Abhilfe wäre,
|
bleibt sie abgeschnitten. Links mit eigenem Text („hier“) gehen verloren; Abhilfe wäre,
|
||||||
die Link-Rechtecke der PDF dem Text zuzuordnen (`[hier](…)`). Die Heuristik ohne Linkziel
|
die Link-Rechtecke der PDF dem Text zuzuordnen (`[hier](…)`). Die Heuristik ohne Linkziel
|
||||||
|
|
|
||||||
382
pdf2md.py
382
pdf2md.py
|
|
@ -21,6 +21,7 @@ import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
|
@ -319,6 +320,8 @@ def normalize_markdown(
|
||||||
text = paragraph[0]
|
text = paragraph[0]
|
||||||
for part in paragraph[1:]:
|
for part in paragraph[1:]:
|
||||||
text = join_parts(text, part)
|
text = join_parts(text, part)
|
||||||
|
if result and result[-1].endswith(HARD_BREAK):
|
||||||
|
result.append("") # Fließtext nach zeilengetreuen Zeilen
|
||||||
result.append(text)
|
result.append(text)
|
||||||
paragraph.clear()
|
paragraph.clear()
|
||||||
|
|
||||||
|
|
@ -332,9 +335,13 @@ def normalize_markdown(
|
||||||
headings_seen.add(norm_title(text))
|
headings_seen.add(norm_title(text))
|
||||||
standalone("#" * min(max(level, 1), 6) + " " + text)
|
standalone("#" * min(max(level, 1), 6) + " " + text)
|
||||||
|
|
||||||
for original in lines:
|
for index, original in enumerate(lines):
|
||||||
line = original.rstrip()
|
line = original.rstrip()
|
||||||
marker = FENCE.match(line)
|
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:
|
if in_fence:
|
||||||
result.append(line)
|
result.append(line)
|
||||||
|
|
@ -346,14 +353,81 @@ def normalize_markdown(
|
||||||
|
|
||||||
if marker:
|
if marker:
|
||||||
flush_paragraph()
|
flush_paragraph()
|
||||||
blank_line()
|
|
||||||
result.append(line)
|
|
||||||
in_fence = True
|
in_fence = True
|
||||||
fence_char = marker.group(1)[0]
|
fence_char = marker.group(1)[0]
|
||||||
fence_length = len(marker.group(1))
|
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
|
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,
|
# Leerzeile nur dann als Absatzgrenze behandeln,
|
||||||
# wenn der bisherige Fließtext mit einem Satzende endet.
|
# wenn der bisherige Fließtext mit einem Satzende endet.
|
||||||
if paragraph and ends_sentence(paragraph[-1]):
|
if paragraph and ends_sentence(paragraph[-1]):
|
||||||
|
|
@ -461,7 +535,44 @@ def normalize_markdown(
|
||||||
paragraph.append(stripped)
|
paragraph.append(stripped)
|
||||||
|
|
||||||
flush_paragraph()
|
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(
|
URL_START = re.compile(
|
||||||
r"(?<![<\]/A-Za-z0-9])(?<!\]\()(?:https?://|www\.)", re.IGNORECASE
|
r"(?<![<\]/A-Za-z0-9])(?<!\]\()(?:https?://|www\.)", re.IGNORECASE
|
||||||
) # nicht in <…>-Links und [Text](…)-Zielen
|
) # 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
|
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
|
# Ohne Linkziel: Folgewort gehört zur URL, wenn diese an einer typischen
|
||||||
# Umbruchstelle endet und das Wort wie ein URL-Teil aussieht.
|
# 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
|
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:
|
def patch_pymupdf4llm_paragraphs() -> None:
|
||||||
original = getattr(document_layout, "text_to_md", None)
|
original = getattr(document_layout, "text_to_md", None)
|
||||||
if original is None or getattr(original, "pdf2md_patched", False):
|
if original is None or getattr(original, "pdf2md_patched", False):
|
||||||
return
|
return
|
||||||
|
|
||||||
def text_to_md(textlines, ignore_code: bool = False):
|
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:
|
try:
|
||||||
groups = split_by_indent(textlines)
|
return lines_to_md(textlines, original, ignore_code)
|
||||||
except (KeyError, IndexError, TypeError):
|
except (KeyError, IndexError, TypeError, ValueError, ZeroDivisionError):
|
||||||
groups = [textlines] # unerwartete Struktur: Originalverhalten
|
return original(textlines, ignore_code) # unerwartete Struktur
|
||||||
return "".join(original(group, ignore_code) for group in groups)
|
|
||||||
|
|
||||||
text_to_md.pdf2md_patched = True
|
text_to_md.pdf2md_patched = True
|
||||||
document_layout.text_to_md = text_to_md
|
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):
|
class _DiscardMessages(io.StringIO):
|
||||||
"""Ersatz für pymupdf4llms Meldungspuffer ("OCR on page …")."""
|
"""Ersatz für pymupdf4llms Meldungspuffer ("OCR on page …")."""
|
||||||
|
|
@ -1090,6 +1431,22 @@ def title_author_from_filename(stem: str) -> tuple[str, str]:
|
||||||
return name, ""
|
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(
|
def write_epub(
|
||||||
markdown_path: Path,
|
markdown_path: Path,
|
||||||
epub_path: Path,
|
epub_path: Path,
|
||||||
|
|
@ -1121,7 +1478,10 @@ def write_epub(
|
||||||
if cover:
|
if cover:
|
||||||
cmd += ["--epub-cover-image", str(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:
|
if done.returncode != 0:
|
||||||
raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}")
|
raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}")
|
||||||
|
|
||||||
|
|
|
||||||
143
tests/test_verbatim.py
Normal file
143
tests/test_verbatim.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"""Zeilengetreue Abschnitte: Register, Code, Verse – Fließtext bleibt Fließtext."""
|
||||||
|
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pymupdf
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import pdf2md as p
|
||||||
|
from conftest import needs_pandoc
|
||||||
|
from test_e2e import run
|
||||||
|
|
||||||
|
NBSP = " "
|
||||||
|
|
||||||
|
PROSE = (
|
||||||
|
"<p>Dieser Absatz ist ganz normaler Fließtext im Flattersatz. Er wird vom "
|
||||||
|
"Satzprogramm umbrochen, weil die Zeilen voll sind, und muss deshalb in der "
|
||||||
|
"Ausgabe wieder zu einer einzigen Zeile zusammengefügt werden, ohne harte "
|
||||||
|
"Umbrüche und ohne Leerzeilen dazwischen.</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_pdf(path, lines, html=PROSE):
|
||||||
|
"""Fließtext oben (legt die Spaltenbreite fest), darunter Zeilen an festen Positionen."""
|
||||||
|
doc = pymupdf.open()
|
||||||
|
page = doc.new_page(width=420, height=595)
|
||||||
|
page.insert_htmlbox(pymupdf.Rect(50, 40, 370, 200), html)
|
||||||
|
y = 240
|
||||||
|
for x, text in lines:
|
||||||
|
page.insert_text((x, y), text, fontname="tiro", fontsize=11)
|
||||||
|
y += 14
|
||||||
|
doc.save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def convert(tmp_path, lines, html=PROSE) -> str:
|
||||||
|
source = make_pdf(tmp_path / "probe.pdf", lines, html)
|
||||||
|
out = tmp_path / "probe.md"
|
||||||
|
run(source, out)
|
||||||
|
return out.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prose_stays_one_paragraph(tmp_path):
|
||||||
|
md = convert(tmp_path, [])
|
||||||
|
assert "Fließtext im Flattersatz. Er wird vom Satzprogramm umbrochen" in md
|
||||||
|
assert "\\" not in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_keeps_lines_and_indentation(tmp_path):
|
||||||
|
md = convert(tmp_path, [
|
||||||
|
(50, "Backslash"),
|
||||||
|
(62, "Maskierung, 79"),
|
||||||
|
(50, "BaseException, 227"),
|
||||||
|
(50, "Basisklasse, 206"),
|
||||||
|
(62, "abstrakte, 212"),
|
||||||
|
])
|
||||||
|
# "Backslash" hält das Layout-Modell hier für eine Überschrift.
|
||||||
|
assert (
|
||||||
|
f"{NBSP * 4}Maskierung, 79\\\n"
|
||||||
|
"BaseException, 227\\\n"
|
||||||
|
"Basisklasse, 206\\\n"
|
||||||
|
f"{NBSP * 4}abstrakte, 212"
|
||||||
|
) in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_in_proportional_font_becomes_code_block(tmp_path):
|
||||||
|
md = convert(tmp_path, [
|
||||||
|
(50, "def quadrat(x):"),
|
||||||
|
(62, "y = x * x"),
|
||||||
|
(62, "return y"),
|
||||||
|
(50, "print(quadrat(3))"),
|
||||||
|
])
|
||||||
|
assert "```\ndef quadrat(x):\n y = x * x\n return y\nprint(quadrat(3))\n```" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_poem_keeps_line_breaks(tmp_path):
|
||||||
|
html = PROSE + (
|
||||||
|
"<p>Der Mond ist aufgegangen,<br>die goldnen Sternlein prangen<br>"
|
||||||
|
"am Himmel hell und klar.</p>"
|
||||||
|
)
|
||||||
|
md = convert(tmp_path, [], html)
|
||||||
|
assert "Der Mond ist aufgegangen,\\\ndie goldnen Sternlein prangen\\\nam Himmel hell und klar." in md
|
||||||
|
|
||||||
|
|
||||||
|
# --- normalize_markdown mit Markierungen -------------------------------------------
|
||||||
|
|
||||||
|
H, P, B = p.HARD_BREAK, p.PARAGRAPH_END, p.BLOCK_END
|
||||||
|
|
||||||
|
|
||||||
|
def test_hard_breaks_become_backslashes_except_last():
|
||||||
|
md = f"Erste{H}\n\nZweite{H}\n\nDritte{H}\n\nDanach Fließtext.\n"
|
||||||
|
assert p.normalize_markdown(md) == "Erste\\\nZweite\\\nDritte\n\nDanach Fließtext.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_end_continues_when_word_did_not_fit():
|
||||||
|
md = f"Er sprach mit dem{B}3{B}\n\nGeheimdienstchef über alles.\n"
|
||||||
|
assert p.normalize_markdown(md) == "Er sprach mit dem Geheimdienstchef über alles.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_end_is_hard_when_word_would_have_fit():
|
||||||
|
md = f"Moskau, im Mai{B}40{B}\n\nDer Zug fuhr ab.\n"
|
||||||
|
assert p.normalize_markdown(md) == "Moskau, im Mai\n\nDer Zug fuhr ab.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiline_block_without_sentence_end_ends_paragraph():
|
||||||
|
md = f"Ein Absatz mit Fußnote.[8 ]{B}30P{B}\n\nDer nächste Absatz.\n"
|
||||||
|
assert p.normalize_markdown(md) == "Ein Absatz mit Fußnote.[8 ]\n\nDer nächste Absatz.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_paragraph_end_closes_verbatim_run_without_blank():
|
||||||
|
md = f"Die Wahrheit erzählen,{H}\ndas ist der größte Witz.{P}\n\nWeiter.\n"
|
||||||
|
assert p.normalize_markdown(md) == (
|
||||||
|
"Die Wahrheit erzählen,\\\ndas ist der größte Witz.\n\nWeiter.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_blocks_across_page_break_are_merged():
|
||||||
|
md = "```\nfn main() {\n```\n\n17\n\n```\n}\n```\n"
|
||||||
|
assert p.normalize_markdown(md) == "```\nfn main() {\n}\n```\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", ["fn main() {", "use std::io;", "def f(x):", "print(x)"])
|
||||||
|
def test_code_hints(text):
|
||||||
|
assert p.CODE_HINT.search(text)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", ["Maskierung, 79", "Der Mond ist aufgegangen,", "Kapitel (1)"])
|
||||||
|
def test_no_code_hints(text):
|
||||||
|
assert not p.CODE_HINT.search(text)
|
||||||
|
|
||||||
|
|
||||||
|
@needs_pandoc
|
||||||
|
def test_epub_wraps_long_code_lines(pdfs, tmp_path):
|
||||||
|
run(pdfs["digital"], tmp_path / "buch.md", "--epub")
|
||||||
|
with zipfile.ZipFile(tmp_path / "buch.epub") as z:
|
||||||
|
css = "".join(z.read(n).decode() for n in z.namelist() if n.endswith(".css"))
|
||||||
|
assert "white-space: pre-wrap" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_literal_fence_text_is_escaped_and_markers_never_leak():
|
||||||
|
md = f"```{B}66{B}\n\nText danach.\n\nRest{H}\n"
|
||||||
|
out = p.normalize_markdown(md)
|
||||||
|
assert out.startswith("\\```")
|
||||||
|
assert not p.MARKERS.search(out)
|
||||||
Loading…
Reference in a new issue