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 |
|
| 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 |
|
||||||
|
|
|
||||||
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
|
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
10
TODO.md
|
|
@ -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
112
pdf2md.py
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue