Zeilengetreue Übernahme von Code, Registern, Versen und Adressen

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-02 16:39:06 +02:00
commit d322ee3523
6 changed files with 545 additions and 17 deletions

View file

@ -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 | `![](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 |
| 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 |

View file

@ -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.

View file

@ -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.

View file

@ -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
View file

@ -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
View 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)