Zahl-Überschriften entfernen, Code-Nachbarzeilen in Codeblöcke

- "## 90", "## xxiv" (Fußnotenzeichen, römische Seitenzahlen ii–xcix)
  wie Seitenzahlen entfernen, außer direkt vor einer Titel-Überschrift
  oder in der PDF-Gliederung; "## IV." und Register-Buchstaben bleiben
- _mixed_block: zeilengetreue Blöcke zeilenweise prüfen; Konfiguration
  ([dependencies], name = "wert"), Befehle (cargo build, $ …) und
  Programmausgaben (… ok, running n tests) als Codeblock, Anleitungen
  als Text; kurze umbrochene Codezeilen erkennen
- Registereinträge ("match(), 247") nie als Code
- _raw_text: Leerzeichen bei Wortabstand ohne Leerzeichen-Zeichen
- Tests, README, Bedienungsanleitung, TODO

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-02 20:13:42 +02:00
commit 7962999e8d
6 changed files with 164 additions and 19 deletions

View file

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

View file

@ -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 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 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, 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 (Markdown: `\` am Zeilenende, Einrückung als geschütztes Leerzeichen). Codeblöcke über
eine Seitengrenze werden zusammengeführt; lange Codezeilen bricht der Reader um. 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 - **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 Tracking-Parameter (`utm_…`) entfallen. Ohne Linkziel wird die URL aus dem gedruckten
Text zusammengesetzt. Links mit eigenem Text („hier“, „Bericht“) werden über den Linktext 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. 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 - **Markdown-Sonderzeichen** im Text (`` \ ` * _ [ ] < > $ ~ ^ ``) werden mit `\` maskiert, damit
z. B. `__add__` nicht fett und `<p>` nicht zu HTML wird; `_` mitten im Wort (`snake_case`) z. B. `__add__` nicht fett und `<p>` nicht zu HTML wird; `_` mitten im Wort (`snake_case`)
und Code bleiben unverändert. 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 - 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). 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 - Zeilengetreue Erkennung: Ist ein Gedicht genauso eingerückt wie Absatzanfänge, werden
Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen; einzelne Konfigurations- oder Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen. Listings mit eingedruckten
Ausgabezeilen neben Code (`[dependencies]`) stehen als Zeilen statt im Codeblock. Zeilennummern werden nicht immer als ein Codeblock erkannt.
- 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 85) gespeichert; EPUB Band 1 ca. 3 MB. - Bilder werden als JPEG (Qualität 85) gespeichert; EPUB Band 1 ca. 3 MB.

10
TODO.md
View file

@ -2,19 +2,13 @@
## Bekannte Schwächen (aus Probeläufen mit fremden PDFs) ## 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. - 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: Ein Gedicht, das genauso eingerückt ist wie Absatzanfänge (Band 2, „Blinde - 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 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. einzeiligen Dialogabsätzen zu unterscheiden; Verszeilen mit Satzende werden dort Absätze.
- Codefragmente ohne Code-Merkmale (`[dependencies]`, Programmausgaben) landen als - Listings mit eingedruckten Zeilennummern (Markdown Guide: „4 <html>“, „5 <head>“) werden
`\`-Zeilen neben dem Codeblock statt darin. 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-…“), - 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 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 („https://a.de/ und/oder“ → eine URL). Linktexte mit Trennung mitten in langen Titeln

112
pdf2md.py
View file

@ -499,6 +499,16 @@ def normalize_markdown(
standalone(text) standalone(text)
continue 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( if level is None and is_false_heading(
text, bool(outline), paragraph_open() text, bool(outline), paragraph_open()
): ):
@ -573,6 +583,20 @@ def normalize_markdown(
return finish_hard_breaks("\n".join(result)).strip() + "\n" 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: 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)""" """Geht der Satz im nächsten Block weiter? (erstes Wort passte nicht mehr)"""
for line in following: for line in following:
@ -1023,6 +1047,20 @@ CODE_HINT = re.compile(
r"|\w\([^()]*\)\s*$" # endet mit Aufruf f(x) 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 _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: 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: def _char_width(line: dict, em: float) -> float:
@ -1112,7 +1159,9 @@ def _indent_levels(lines: list[dict], em: float) -> list[int]:
return levels 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: if ignore_code:
return False return False
texts = [_raw_text(line) for line in lines] 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() for line in lines for s in line["spans"] if s["text"].strip()
): ):
return True # Schreibmaschinenschrift 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")) 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) 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: def _code_block(logical: list[list[dict]], levels: list[int]) -> str:
rows = [] rows = []
for parts, level in zip(logical, levels): 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 verbatim = bool(soft) and hard / len(soft) >= HARD_BLOCK_RATIO
if not verbatim: if not verbatim:
if end and len(lines) == 1 and _is_code(lines, ignore_code): if end and len(lines) <= 3 and _is_code(lines, ignore_code, short=True):
return _code_block([lines], [0]) return _code_block([lines], [0]) # kurze (umbrochene) Codezeile
# Fließtext: Absätze am Einzug trennen, wie bisher ausgeben. # Fließtext: Absätze am Einzug trennen, wie bisher ausgeben.
groups = split_by_indent(textlines, left) groups = split_by_indent(textlines, left)
md = "".join(original(group, ignore_code) for group in groups) 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:])] 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 usual = sorted(pitches)[len(pitches) // 2] if pitches else 0
gaps = [usual > 0 and pitch > STANZA_GAP * usual for pitch in pitches] 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) return _verbatim_block(logical, levels, gaps, end)

View file

@ -430,3 +430,24 @@ def test_escaped_text_survives_link_steps():
out = p.link_anchors(p.link_urls(md, ["https://a.de/Leitlinien_x"]), []) out = p.link_anchors(p.link_urls(md, ["https://a.de/Leitlinien_x"]), [])
assert out.startswith("\\_\\_add\\_\\_, 180\\\n") assert out.startswith("\\_\\_add\\_\\_, 180\\\n")
assert "<https://a.de/Leitlinien_x>" in out assert "<https://a.de/Leitlinien_x>" in out
# --- Zahlen als Überschrift --------------------------------------------------------
def test_number_heading_without_title_is_dropped():
md = "# Private Stiftungen\n\n## 90\n\nText.\n\n## XXIV\n\nMehr.\n"
assert norm(md) == "# Private Stiftungen\n\nText.\n\nMehr.\n"
def test_number_heading_kept_before_title_or_with_dot_or_in_outline():
md = "## 1\n\n## Wenn Ostdeutsche fragen\n\nText.\n\n## IV.\n\nMehr.\n\n## 7\n\nNoch.\n"
assert norm(md, {"7": 2}) == md
def test_register_letters_stay_headings():
md = "## C\n\ncalendar, 266\n\n## D\n\ndata stream, 249\n\n## I\n\nif, 105\n"
assert norm(md) == md
def test_number_heading_kept_as_text_when_page_numbers_kept():
assert norm("## 90\n\nText.\n", drop_page_numbers=False) == "90\n\nText.\n"

View file

@ -179,3 +179,28 @@ def test_indented_dialogue_lines_stay_paragraphs(tmp_path):
]) ])
assert "lachte er.\n\n»Und sind es heute noch«, echote sie." in md assert "lachte er.\n\n»Und sind es heute noch«, echote sie." in md
assert "lachte er.\\" not in md assert "lachte er.\\" not in md
def test_config_and_command_lines_become_code_between_text(tmp_path):
md = convert(tmp_path, [
(50, "Add to Cargo.toml:"),
(50, "[dependencies]"),
(50, 'serde = "1.0"'),
(50, "Then build it:"),
(50, "cargo build"),
])
assert "Add to Cargo.toml:\n\n```\n[dependencies]\nserde = \"1.0\"\n```\n\nThen build it:" in md
assert "```\ncargo build\n```" in md
def test_single_code_like_register_entry_stays_text(tmp_path):
md = convert(tmp_path, [
(50, "case-sensitive, 48"),
(50, "@classmethod, 190"),
(50, "match(), 247"),
(50, "match-case, 110"),
(50, "clear(), 154, 158"),
(50, "Closure, 128"),
])
assert "```" not in md
assert "@classmethod, 190\\" in md