diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index b7847fc..e93638a 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -122,6 +122,7 @@ Hier kannst du Titel, Autor, Sprache oder Cover ändern. Werte immer in `"…"` | Code | zwischen zwei Zeilen ```` ``` ````, Einrückung mit Leerzeichen | Schreibmaschinenschrift, Einrückung bleibt, lange Zeilen werden umbrochen | | Bild | `![](band1_images/…jpg)` auf eigener Zeile | Bild an dieser Stelle | | kursiv / fett | `_Wort_` / `**Wort**` | kursiv / fett | +| Sonderzeichen wörtlich | `\*`, `\_`, `\[`, `\<`, `\$` … (so erzeugt das Programm sie auch) | Zeichen erscheint wie geschrieben, ohne Formatierung | | Link | `` oder `[Text](https://beispiel.de/seite)` | anklickbarer Link | Wichtig: diff --git a/README.md b/README.md index 065952d..08a3b90 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP als ```-Block mit Einrückung), **Register, Glossare, Verse, Adressen, Lebensläufe** zeilengetreu (Markdown: `\` am Zeilenende, Einrückung als geschütztes Leerzeichen). Codeblöcke über eine Seitengrenze werden zusammengeführt; lange Codezeilen bricht der Reader um. +- **Verse:** Eine kurze Zeile mit Satzende gilt nur dann als Absatzende, wenn die nächste + Zeile eingerückt ist (Roman); sonst ist sie eine Verszeile und bleibt eine Zeile. - **Absätze:** eine Absatzgrenze gilt nach Satzende (`.` `!` `?` `…`, auch mit `«` `“` `»` usw. dahinter) oder an einem harten Umbruch; auf Scans werden Absätze am Einzug erkannt. - **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit @@ -89,6 +91,9 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP Text zusammengesetzt. Links mit eigenem Text („hier“, „Bericht“) werden über den Linktext und die Wörter davor im Markdown wiedergefunden und als `[hier](https://…)` gesetzt. - **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt. +- **Markdown-Sonderzeichen** im Text (`` \ ` * _ [ ] < > $ ~ ^ ``) werden mit `\` maskiert, damit + z. B. `__add__` nicht fett und `

` nicht zu HTML wird; `_` mitten im Wort (`snake_case`) + und Code bleiben unverändert. - **YAML-Kopf:** Jede Markdown-Datei beginnt mit `title`, `author`, `lang`, `cover` (Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `source_pages`, `book`, `author`; `source_pages` = Seiten der PDF-Datei, nicht die gedruckten). @@ -121,9 +126,9 @@ synthetisch erzeugt. (alle Kapitel auf einer Ebene). - Links mit eigenem Text: Findet sich der Linktext nicht wieder (z. B. Trennung mitten in einem langen Titel), bleibt er ohne Link (Zensur-Report: 75 von 76 gesetzt). -- 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. +- Zeilengetreue Erkennung: Ist ein Gedicht genauso eingerückt wie Absatzanfänge, werden + Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen; 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 85) gespeichert; EPUB Band 1 ca. 3 MB. diff --git a/TODO.md b/TODO.md index 4ae89e2..0cb0586 100644 --- a/TODO.md +++ b/TODO.md @@ -10,10 +10,11 @@ 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. +- Zeilengetreu: Ein Gedicht, das genauso eingerückt ist wie Absatzanfänge (Band 2, „Blinde + Flecken“: alle Verse bei x = 94 wie Absatzanfänge), ist geometrisch nicht von mehreren + einzeiligen Dialogabsätzen zu unterscheiden; Verszeilen mit Satzende werden dort Absätze. +- Codefragmente ohne Code-Merkmale (`[dependencies]`, Programmausgaben) landen als + `\`-Zeilen neben dem Codeblock statt darin. - Links: Setzt sich eine URL in einem neuen Block fort (z. B. als Listenpunkt „- juuuport-…“), bleibt sie abgeschnitten. Die Heuristik ohne Linkziel hängt Wörter mit „/“ an („https://a.de/ und/oder“ → eine URL). Linktexte mit Trennung mitten in langen Titeln diff --git a/pdf2md.py b/pdf2md.py index 15dd1c8..4e2cce4 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -148,19 +148,34 @@ NUMBERED_TITLE = re.compile( # Hilfsfunktionen für Text # =========================================================================== +# Markdown-Sonderzeichen im PDF-Text maskieren, damit z. B. "__add__" nicht fett +# wird. "_" mitten im Wort ("snake_case") bleibt unmaskiert. +MD_SPECIAL = "\\`*_[]<>$~^" +MD_ESCAPE = re.compile(r"[\\`*\[\]<>$~^]|(?$~^])") + + +def md_escape(text: str) -> str: + return MD_ESCAPE.sub(lambda m: "\\" + m.group(0), text) + + +def md_unescape(text: str) -> str: + return MD_UNESCAPE.sub(r"\1", text) + + def ends_sentence(text: str) -> bool: return bool(SENTENCE_END.search(text.rstrip())) def clean_inline(text: str) -> str: """Markdown-Hervorhebungen entfernen und Leerraum vereinheitlichen.""" - text = re.sub(r"\*\*|__|(? str: """Vergleichsschlüssel für Überschriften und Gliederungseinträge.""" - return clean_inline(text).translate(DASHES).lower() + return md_unescape(clean_inline(text)).translate(DASHES).lower() def is_other_structure(line: str) -> bool: @@ -572,7 +587,7 @@ def continues_block(following: list[str], free: int, running_heads: set[str]) -> 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() + word = md_unescape(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 @@ -715,16 +730,32 @@ def _match_printed(tokens: list[str]) -> tuple[str, int]: return url, consumed - (len(joined) - len(url)) +def _unescape_map(text: str) -> tuple[str, list[int]]: + """Text ohne Markdown-Maskierung und Position jedes Zeichens im Original.""" + plain, index, i = [], [], 0 + while i < len(text): + index.append(i) # Position samt Backslash, damit er beim Kopieren bleibt + if text[i] == "\\" and i + 1 < len(text) and text[i + 1] in MD_SPECIAL: + i += 1 + plain.append(text[i]) + i += 1 + return "".join(plain), index + [len(text)] + + def _link_line(line: str, known: dict[str, str]) -> str: + # URLs auf dem unmaskierten Text suchen ("Leitlinien\_" → "Leitlinien_"), + # den übrigen Text unverändert (maskiert) übernehmen. + plain, index = _unescape_map(line) out = [] pos = 0 - while match := URL_START.search(line, pos): - out.append(line[pos:match.start()]) - tokens = re.findall(r"\S+\s*", line[match.start():]) + while match := URL_START.search(plain, pos): + out.append(line[index[pos]:index[match.start()]]) + tokens = re.findall(r"\S+\s*", plain[match.start():]) url, consumed = _match_known(tokens, known) or _match_printed(tokens) - out.append(_autolink(url) if "." in url else line[match.start():][:consumed]) - pos = match.start() + consumed - out.append(line[pos:]) + end = match.start() + consumed + out.append(_autolink(url) if "." in url else line[index[match.start()]:index[end]]) + pos = end + out.append(line[index[pos]:]) return "".join(out) @@ -780,12 +811,19 @@ def load_link_anchors(doc: pymupdf.Document) -> list[tuple[str, list[str], list[ return anchors +def _md_literal(text: str) -> str: + """Regex für Text, der im Markdown maskiert sein kann ("\\_", "\\*" …).""" + return "".join( + (r"\\?" if char in MD_SPECIAL else "") + re.escape(char) for char in text + ) + + def _words_pattern(words: list[str]) -> str: """Wörter mit beliebigem Leerraum, Hervorhebung oder Trennung dazwischen.""" parts = [] for word in words: core = word.rstrip("-\u00ad") # Silbentrennung am Zeilenende - parts.append(re.escape(core) + (r"-?" if core != word else "")) + parts.append(_md_literal(core) + (r"-?" if core != word else "")) return r"[\s*_]*".join(parts) @@ -816,8 +854,7 @@ def link_anchors(markdown: str, anchors: list[tuple[str, list[str], list[str]]]) ) if match: start, end = match.span("anchor") - label = markdown[start:end].replace("[", "\\[").replace("]", "\\]") - markdown = f"{markdown[:start]}[{label}]({uri}){markdown[end:]}" + markdown = f"{markdown[:start]}[{markdown[start:end]}]({uri}){markdown[end:]}" pos = start break return markdown @@ -827,7 +864,11 @@ def _inside_link(markdown: str, index: int) -> bool: """Liegt die Stelle schon in einem Link (<…>, [..](…))?""" line_start = markdown.rfind("\n", 0, index) + 1 before = markdown[line_start:index] - return before.count("<") > before.count(">") or before.count("[") > before.count("]") or ( + + def count(char: str) -> int: # maskierte Zeichen ("\\[") zählen nicht + return len(re.findall(r"(? count(">") or count("[") > count("]") or ( before.rfind("](") > before.rfind(")") ) @@ -859,6 +900,8 @@ def _mark_line_end_hyphens(spans: list[dict]) -> list[dict]: nxt = spans[i + 1] if i + 1 < len(spans) else None if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT: span = {**span, "flags": span.get("flags", 0) & ~MONOSPACE_FLAG} + if not (span.get("flags", 0) & MONOSPACE_FLAG): # Code bleibt wörtlich + span = {**span, "text": md_escape(span["text"])} text = span["text"].rstrip() line_end = nxt is not None and (span.get("block"), span.get("line")) != ( nxt.get("block"), nxt.get("line") @@ -911,8 +954,12 @@ def _line_text(line: dict) -> str: return "".join(span["text"] for span in line["spans"]).strip() -def split_by_indent(textlines: list[dict]) -> list[list[dict]]: - """Zeilen eines Textblocks in Absätze aufteilen.""" +def split_by_indent(textlines: list[dict], column_left: float | None = None) -> list[list[dict]]: + """Zeilen eines Textblocks in Absätze aufteilen. + + column_left: linker Rand der Textspalte, falls bekannt – sonst der Blockrand + (dann bleiben gleich eingerückte einzeilige Absätze unerkannt). + """ if len(textlines) < 2: return [textlines] @@ -920,6 +967,8 @@ def split_by_indent(textlines: list[dict]) -> list[list[dict]]: em = sizes[len(sizes) // 2] if sizes else 10.0 lefts = sorted(line["bbox"][0] for line in textlines) left = lefts[len(lefts) // 4] # Blockrand, robust gegen Einzüge + if column_left is not None: + left = min(left, column_left) widths = [line["bbox"][2] - line["bbox"][0] for line in textlines] longest = max(widths) @@ -1028,6 +1077,14 @@ def _fit_tolerance(lines: list[dict], right: float, em: float) -> float: return max(FIT_TOLERANCE_EM * em, FIT_TOLERANCE * (right - left)) +def _column_left(lines: list[dict], em: float) -> float: + """Linker Rand der Textspalte (Einzüge einzeiliger Absätze erkennen).""" + bx0 = min(line["bbox"][0] for line in lines) + bx1 = max(line["bbox"][2] for line in lines) + edges = [x0 for x0, x1 in _page_lines if x0 < bx1 and x1 > bx0 and x0 >= bx0 - 10 * em] + return min(edges + [bx0]) + + def _is_soft_break(prev: dict, line: dict, right: float, em: float, tol: float) -> bool: """Hätte das erste Wort der Zeile nicht mehr in die vorige gepasst?""" prev_text = _raw_text(prev).rstrip() @@ -1119,11 +1176,20 @@ def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> s tol = _fit_tolerance(lines, right, em) end = _block_end(lines, right, em, tol) - # Harte Umbrüche ohne Satzende; nach Satzende ist es ein Absatzende. + # Harte Umbrüche zählen; ein Satzende mit eingerückter Folgezeile ist dagegen + # ein Absatzende (Roman), ohne Einrückung eine Verszeile (Gedicht). soft = [_is_soft_break(a, b, right, em, tol) for a, b in zip(lines, lines[1:])] + left = _column_left(lines, em) + + def new_paragraph(prev: dict, line: dict) -> bool: + indent = line["bbox"][0] - left + return ends_sentence(_raw_text(prev).strip()) and ( + INDENT_MIN_EM * em <= indent <= INDENT_MAX_EM * em + ) + hard = sum( - 1 for is_soft, line in zip(soft, lines) - if not is_soft and not ends_sentence(_raw_text(line).strip()) + 1 for is_soft, a, b in zip(soft, lines, lines[1:]) + if not is_soft and not new_paragraph(a, b) ) verbatim = bool(soft) and hard / len(soft) >= HARD_BLOCK_RATIO @@ -1131,7 +1197,7 @@ def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> s if end and len(lines) == 1 and _is_code(lines, ignore_code): return _code_block([lines], [0]) # Fließtext: Absätze am Einzug trennen, wie bisher ausgeben. - groups = split_by_indent(textlines) + groups = split_by_indent(textlines, left) md = "".join(original(group, ignore_code) for group in groups) return md.rstrip("\n").rstrip() + end + "\n\n" if end else md @@ -1726,7 +1792,7 @@ def split_markdown(markdown: str, mode: str) -> list[dict]: part = "" for n, (index, level, text) in enumerate(cuts): end = cuts[n + 1][0] if n + 1 < len(cuts) else len(lines) - title = clean_inline(text) + title = md_unescape(clean_inline(text)) if level == part_level: part = title number = re.match(r"(\d+)\b", title) if level == chapter_level != part_level else None @@ -1803,7 +1869,7 @@ def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]: files.append(folder / name) entries = "\n".join( - f"- [{s['title']}]({f.name})" for s, f in zip(sections, files) + f"- [{md_escape(s['title'])}]({f.name})" for s, f in zip(sections, files) ) index = ( make_frontmatter(meta) diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 2f71234..853020c 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -401,3 +401,32 @@ def test_link_urls_joins_printed_continuation_without_pdf_target(): "Siehe . und " ". Oder und mehr.\n" ) + + +# --- Markdown-Sonderzeichen -------------------------------------------------------- + +@pytest.mark.parametrize("text, escaped", [ + ("__add__", "\\_\\_add\\_\\_"), + ("snake_case", "snake_case"), + ("a*b [1]

$5 x^2 ~x", "a\\*b \\[1\\] \\ \\$5 x\\^2 \\~x"), +]) +def test_md_escape_round_trip(text, escaped): + assert p.md_escape(text) == escaped + assert p.md_unescape(escaped) == text + + +def test_pymupdf4llm_patch_escapes_text_but_not_code(): + spans = [ + {"text": "__add__ und *", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 0}, + {"text": "a_b*c", "flags": 8, "char_flags": int(p.VISIBLE_TEXT), "font": "Courier", + "block": 0, "line": 0}, + ] + output, _ = p.document_layout.get_styled_text(spans) + assert output.strip() == "\\_\\_add\\_\\_ und \\* `a_b*c`" + + +def test_escaped_text_survives_link_steps(): + md = "\\_\\_add\\_\\_, 180\\\nSiehe https://a.de/Leitlinien\\_ x.\n" + out = p.link_anchors(p.link_urls(md, ["https://a.de/Leitlinien_x"]), []) + assert out.startswith("\\_\\_add\\_\\_, 180\\\n") + assert "" in out diff --git a/tests/test_verbatim.py b/tests/test_verbatim.py index b8e7cd5..64a3240 100644 --- a/tests/test_verbatim.py +++ b/tests/test_verbatim.py @@ -161,3 +161,21 @@ def test_outline_like_lines_outside_contents_become_paragraphs(): "## Vorschau Band Zwei\n\n32 Oberst a.D.\n\n33 Wolga-Attacke\n\n" "Teil VI: Polen\n\nOperation Morgenlicht\n" ) + + +def test_poem_lines_with_sentence_ends_stay_lines(tmp_path): + md = convert(tmp_path, [ + (50, "Der Mond ist aufgegangen."), + (50, "Die Sterne prangen hell."), + (50, "Der Wald steht schwarz."), + ]) + assert "Der Mond ist aufgegangen.\\\nDie Sterne prangen hell.\\\nDer Wald steht schwarz." in md + + +def test_indented_dialogue_lines_stay_paragraphs(tmp_path): + md = convert(tmp_path, [ + (62, "»Und sind es heute noch«, lachte er."), + (62, "»Und sind es heute noch«, echote sie."), + ]) + assert "lachte er.\n\n»Und sind es heute noch«, echote sie." in md + assert "lachte er.\\" not in md