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:
parent
d6a8c11b0a
commit
7962999e8d
6 changed files with 164 additions and 19 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
13
README.md
13
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 `<p>` 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.
|
||||
|
||||
|
|
|
|||
10
TODO.md
10
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 <html>“, „5 <head>“) 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
|
||||
|
|
|
|||
112
pdf2md.py
112
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "<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"
|
||||
|
|
|
|||
|
|
@ -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.\\" 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue