diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index af9695b..690c3c6 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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 | | Absatz | durch **eine Leerzeile** vom nächsten getrennt | neuer Absatz | | 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 | | kursiv / fett | `_Wort_` / `**Wort**` | kursiv / fett | | Link | `` 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 | | Ü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 | +| 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 | | 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 | diff --git a/CLAUDE.md b/CLAUDE.md index 53f8adc..bd80d49 100644 --- a/CLAUDE.md +++ b/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. - Echttests nie nach `Fertig/` schreiben (dort liegen die Ergebnisse des Nutzers). - PDFs/EPUBs/Bilder sind per `.gitignore` ausgeschlossen – keine Binärdateien committen. -- `pdf2md.py` patcht zwei Funktionen in `pymupdf4llm.helpers.document_layout` - (`get_styled_text`, `text_to_md`). Nach einem pymupdf4llm-Update prüfen, ob - `tests/test_normalize.py::test_pymupdf4llm_patch_keeps_compound_hyphen` noch grün ist. +- `pdf2md.py` patcht drei Funktionen in `pymupdf4llm.helpers.document_layout` + (`get_styled_text`, `text_to_md`, `create_list_item_levels` – letztere nur, um pro Seite + 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. diff --git a/README.md b/README.md index 93e43e1..5debf81 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,15 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP ## Was die Nachbearbeitung macht -- **Absätze:** Zeilen werden verbunden; eine Absatzgrenze gilt nur nach Satzende - (`.` `!` `?` `…`, auch mit `«` `“` `»` usw. dahinter). Auf Scans werden Absätze am - Einzug erkannt. +- **Fließtext oder zeilengetreu:** Ein Zeilenumbruch gilt als *weich* (vom Satzprogramm), + wenn das erste Wort der nächsten Zeile nicht mehr in die Zeile gepasst hätte – dann + 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 Bindestrich, `Arbeits- und` bleibt. Weiche Trennstriche (U+00AD) und `¬` am Zeilenende 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 (alle Kapitel auf einer Ebene). - 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). Weitere bekannte Schwächen: [TODO.md](TODO.md). - Bilder werden als JPEG (Qualität 95) gespeichert; EPUB Band 1 ca. 5 MB. diff --git a/TODO.md b/TODO.md index 606bff7..e397e31 100644 --- a/TODO.md +++ b/TODO.md @@ -72,6 +72,10 @@ wieder zusammengesetzt = Gesamt-Markdown. wegen echter Kapitel „I.“, „II.“. - Gesperrte Überschriften („M e i n k l e i nes Stück D D R“) bleiben zerrissen. - Alte Scans mit eigener OCR-Ebene: `--ocr always` liefert meist besseren Text. +- Zeilengetreu: Verszeilen mit Satzzeichen am Ende werden zu Absätzen (split_by_indent + wertet kurze Zeile + Satzende als Absatzende); Codefragmente ohne Code-Merkmale + (`[dependencies]`, Programmausgaben) landen als `\`-Zeilen neben dem Codeblock statt darin. +- Markdown-Sonderzeichen werden nicht maskiert: `__add__` im Register wird fett gesetzt. - 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, die Link-Rechtecke der PDF dem Text zuzuordnen (`[hier](…)`). Die Heuristik ohne Linkziel diff --git a/pdf2md.py b/pdf2md.py index 7f81ff0..a9feea7 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -21,6 +21,7 @@ import re import shutil import subprocess import sys +import tempfile import urllib.parse from collections import Counter from concurrent.futures import ProcessPoolExecutor @@ -319,6 +320,8 @@ def normalize_markdown( text = paragraph[0] for part in paragraph[1:]: text = join_parts(text, part) + if result and result[-1].endswith(HARD_BREAK): + result.append("") # Fließtext nach zeilengetreuen Zeilen result.append(text) paragraph.clear() @@ -332,9 +335,13 @@ def normalize_markdown( headings_seen.add(norm_title(text)) standalone("#" * min(max(level, 1), 6) + " " + text) - for original in lines: + for index, original in enumerate(lines): line = original.rstrip() 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: result.append(line) @@ -346,14 +353,81 @@ def normalize_markdown( if marker: flush_paragraph() - blank_line() - result.append(line) in_fence = True fence_char = marker.group(1)[0] 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 - 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, # wenn der bisherige Fließtext mit einem Satzende endet. if paragraph and ends_sentence(paragraph[-1]): @@ -461,7 +535,44 @@ def normalize_markdown( paragraph.append(stripped) 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( r"(?-Links und [Text](…)-Zielen -URL_TRAILING = ".,;:!?)]}»«“”„\"'*_" +URL_TRAILING = ".,;:!?)]}»«“”„\"'*_\\" # "\\": harter Zeilenumbruch 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 # 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 +# --------------------------------------------------------------------------- +# 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[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: original = getattr(document_layout, "text_to_md", None) if original is None or getattr(original, "pdf2md_patched", False): return 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: - groups = split_by_indent(textlines) - except (KeyError, IndexError, TypeError): - groups = [textlines] # unerwartete Struktur: Originalverhalten - return "".join(original(group, ignore_code) for group in groups) + return lines_to_md(textlines, original, ignore_code) + except (KeyError, IndexError, TypeError, ValueError, ZeroDivisionError): + return original(textlines, ignore_code) # unerwartete Struktur text_to_md.pdf2md_patched = True 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): """Ersatz für pymupdf4llms Meldungspuffer ("OCR on page …").""" @@ -1090,6 +1431,22 @@ def title_author_from_filename(stem: str) -> tuple[str, str]: 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( markdown_path: Path, epub_path: Path, @@ -1121,7 +1478,10 @@ def write_epub( if 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: raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}") diff --git a/tests/test_verbatim.py b/tests/test_verbatim.py new file mode 100644 index 0000000..bc39827 --- /dev/null +++ b/tests/test_verbatim.py @@ -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 = ( + "

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.

" +) + + +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 + ( + "

Der Mond ist aufgegangen,
die goldnen Sternlein prangen
" + "am Himmel hell und klar.

" + ) + 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)