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

112
pdf2md.py
View file

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