diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index e93638a..edfb491 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -302,7 +302,7 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g | Bilder fehlen im EPUB (Weg B) | pandoc im Ordner der `.md` starten (`cd Output`) bzw. `--resource-path` auf diesen Ordner setzen; der `_images`-Ordner muss daneben liegen | | Gescannter Text fehlt, Meldung „Hinweis: … Bildseite(n)“ | die Seite wurde für ein Foto gehalten → `--ocr always` | | Satz als Überschrift oder Kapitel ohne Überschrift | in Weg B von Hand korrigieren (Abschnitt 3) | -| Überschrift besteht nur aus einer Zahl (`## 90`, `## XX`) | meist Fußnotenzeichen oder Seitenzahl → in Weg B löschen | +| Überschrift besteht nur aus einer Zahl (`## 90`, `## XX`) | wird normalerweise automatisch entfernt; bleibt sie, weil direkt eine Überschrift folgt, in Weg B löschen | | 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 | diff --git a/README.md b/README.md index 08a3b90..3278ab2 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP 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 + als ```-Block mit Einrückung; ebenso Konfigurationszeilen wie `[dependencies]`, Befehle wie + `cargo build` und Programmausgaben), **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 @@ -90,7 +91,10 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP Tracking-Parameter (`utm_…`) entfallen. Ohne Linkziel wird die URL aus dem gedruckten 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. +- **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt – auch Überschriften, die nur + aus einer Zahl bestehen (`## 90`, `## xxiv`: Fußnotenzeichen, römische Seitenzahlen), sofern + nicht direkt eine Titelzeile folgt (`## 1` + `## Kapiteltitel` bleibt) und sie nicht in der + PDF-Gliederung stehen; Kapitelnummern mit Punkt (`## IV.`) und Register-Buchstaben bleiben. - **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. @@ -127,9 +131,8 @@ synthetisch erzeugt. - 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: 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). + Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen. Listings mit eingedruckten + Zeilennummern werden nicht immer als ein Codeblock erkannt. 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 0cb0586..ea74164 100644 --- a/TODO.md +++ b/TODO.md @@ -2,19 +2,13 @@ ## Bekannte Schwächen (aus Probeläufen mit fremden PDFs) -- Reine Zahlen als Überschrift: pymupdf4llm macht Fußnotenzeichen zu Überschriften - („## 90“ im Zensur-Report). Nicht pauschal entfernen – im DDR-Buch sind „## 1“, „## 2“ - echte Kapitelnummern. Möglicher Ansatz: Zahl-Überschrift nur behalten, wenn direkt eine - Überschrift folgt. -- Römische Seitenzahlen („## XX“, „## XXIV“) werden nicht als Seitenzahl erkannt; Vorsicht - 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: 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. +- Listings mit eingedruckten Zeilennummern (Markdown Guide: „4 “, „5
“) werden + nicht immer als ein Codeblock erkannt; Programmausgaben ohne typische Merkmale bleiben Text. - 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 4e2cce4..72f124b 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -499,6 +499,16 @@ def normalize_markdown( standalone(text) continue + if level is None and NUMBER_HEADING.match(text) and not heading_follows( + lines, index + ): + # "## 90", "## XXIV": Fußnotenzeichen oder Seitenzahl, keine + # Kapitelnummer (die steht direkt vor einer Titel-Überschrift). + if drop_page_numbers: + continue + standalone(text) + continue + if level is None and is_false_heading( text, bool(outline), paragraph_open() ): @@ -573,6 +583,20 @@ def normalize_markdown( return finish_hard_breaks("\n".join(result)).strip() + "\n" +# Überschrift nur aus einer Zahl ohne Punkt: arabisch oder römisch ii–xcix +# (mindestens zwei Zeichen, damit Register-Buchstaben "C", "D", "M" bleiben). +NUMBER_HEADING = re.compile( + r"^(?:\d{1,4}|(?=[ivxlc]{2})(?:xc|xl|l?x{0,3})(?:ix|iv|v?i{0,3}))$", + re.IGNORECASE, +) + + +def heading_follows(lines: list[str], index: int) -> bool: + """Ist die nächste nicht leere Zeile eine Überschrift?""" + following = next((line for line in lines[index + 1:] if line.strip()), "") + return bool(HEADING.match(following)) + + 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: @@ -1023,6 +1047,20 @@ CODE_HINT = re.compile( r"|\w\([^()]*\)\s*$" # endet mit Aufruf f(x) ) +# Zeilen rund um Code: Konfiguration (zählt erst ab zwei Zeilen als Code) … +CONFIG_LINE = re.compile( + r"^\s*\[[\w.\-]+\]\s*$" # [dependencies] + r"|^\s*[\w.\-]+\s*=\s*\S" # name = "wert" +) +# … sowie Befehle und Programmausgaben (auch als einzelne Zeile Code). +COMMAND_LINE = re.compile( + r"^\s*(?:\$|>>>|C:\\>)\s" # Eingabeaufforderung + r"|^\s*(?:cargo|rustup|rustc|pip3?|python3?|pytest|npm|npx|git|sudo|" + r"apt(?:-get)?|docker|conda|javac)\s+\S" # Befehl + r"|^\s*(?:Compiling|Finished|Traceback|error(?:\[\w+\])?:|warning:)\s" + r"|^\s*running \d+ tests?\b|^\s*test result:|\.\.\. (?:ok|FAILED|ignored)\s*$" +) + _page_lines: list[tuple[float, float]] = [] # (x0, x1) der Textzeilen der Seite @@ -1052,7 +1090,16 @@ def _visual_lines(textlines: list[dict]) -> list[dict]: def _raw_text(line: dict) -> str: - return "".join(span["text"] for span in line["spans"]) + """Text einer Zeile; Leerzeichen dort ergänzen, wo die PDF Wörter nur durch + Abstand trennt (sonst "Openthefile." in Codeblöcken).""" + text, prev = "", None + for span in line["spans"]: + if prev is not None and text and not text[-1].isspace() and not span["text"][:1].isspace(): + if span["bbox"][0] - prev["bbox"][2] > 0.15 * span.get("size", 10): + text += " " + text += span["text"] + prev = span + return text def _char_width(line: dict, em: float) -> float: @@ -1112,7 +1159,9 @@ def _indent_levels(lines: list[dict], em: float) -> list[int]: return levels -def _is_code(lines: list[dict], ignore_code: bool) -> bool: +def _is_code(lines: list[dict], ignore_code: bool, short: bool = False) -> bool: + """Ganzer Block Code? short: kurzer Block – dann zählen auch Konfiguration, + Befehle und Programmausgaben (sonst nur zeilenweise, siehe _mixed_block).""" if ignore_code: return False texts = [_raw_text(line) for line in lines] @@ -1121,11 +1170,62 @@ def _is_code(lines: list[dict], ignore_code: bool) -> bool: 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, " "))) + hints = sum( + 1 for t in texts + if not REGISTER_ENTRY.search(t) and CODE_HINT.search(t.replace(NBSP, " ")) + or (short and (_code_like(t) or COMMAND_LINE.search(t))) + ) 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) +# Registereintrag ("match(), 247", "Closure, 128, 130–132"): nie Code. +REGISTER_ENTRY = re.compile(r",\s*\d+(?:\s*[-–]\s*\d+)?(?:\s*,\s*\d+(?:\s*[-–]\s*\d+)?)*\s*$") + + +def _code_like(text: str) -> bool: + text = text.replace(NBSP, " ") + if REGISTER_ENTRY.search(text): + return False + return bool(CODE_HINT.search(text) or CONFIG_LINE.search(text)) + + +def _mixed_block( + logical: list[list[dict]], levels: list[int], gaps: list[bool], end: str +) -> str | None: + """Zeilengetreuer Block aus Text und Code (Anleitung, Konfiguration, Befehl, + Ausgabe): Code-Abschnitte als ```-Block, den Rest als Zeilen.""" + texts = [" ".join(_raw_text(line).strip() for line in parts) for parts in logical] + strong = [bool(COMMAND_LINE.search(t)) and not REGISTER_ENTRY.search(t) for t in texts] + code = [s or _code_like(t) for s, t in zip(strong, texts)] + for i in range(1, len(code) - 1): # Zeile zwischen zwei Codezeilen gehört dazu, + prose = texts[i].endswith(":") or ends_sentence(texts[i]) # außer Anleitung + code[i] = code[i] or (code[i - 1] and code[i + 1] and not prose) + + segments: list[list] = [] # [start, ende, ist_code] + for i, is_code in enumerate(code): + if segments and segments[-1][2] == is_code: + segments[-1][1] = i + 1 + else: + segments.append([i, i + 1, is_code]) + for segment in segments: # einzelne Treffer ("@classmethod, 190") sind kein Code + a, b, is_code = segment + if is_code and b - a < 2 and not any(strong[a:b]): + segment[2] = False + if not any(is_code for _, _, is_code in segments): + return None + + out = [] + for n, (a, b, is_code) in enumerate(segments): + last = n == len(segments) - 1 + if is_code: + base = min(levels[a:b]) + out.append(_code_block(logical[a:b], [lvl - base for lvl in levels[a:b]])) + else: + out.append(_verbatim_block(logical[a:b], levels[a:b], gaps[a:b - 1], end if last else "")) + return "".join(out) + + def _code_block(logical: list[list[dict]], levels: list[int]) -> str: rows = [] for parts, level in zip(logical, levels): @@ -1194,8 +1294,8 @@ def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> s 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]) + if end and len(lines) <= 3 and _is_code(lines, ignore_code, short=True): + return _code_block([lines], [0]) # kurze (umbrochene) Codezeile # Fließtext: Absätze am Einzug trennen, wie bisher ausgeben. groups = split_by_indent(textlines, left) md = "".join(original(group, ignore_code) for group in groups) @@ -1217,6 +1317,8 @@ def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> s 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] + if not ignore_code and (mixed := _mixed_block(logical, levels, gaps, end)): + return mixed return _verbatim_block(logical, levels, gaps, end) diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 853020c..4609ab8 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -430,3 +430,24 @@ def test_escaped_text_survives_link_steps(): out = p.link_anchors(p.link_urls(md, ["https://a.de/Leitlinien_x"]), []) assert out.startswith("\\_\\_add\\_\\_, 180\\\n") assert "