Seitenzahlen bei --split, Teilen nach Länge, Links mit eigenem Text
- add_source_pages: source_pages [von, bis] im Frontmatter; Startseite aus
der PDF-Gliederung, sonst Textsuche ohne Verzeichnisseiten; Ende davor,
wenn der nächste Abschnitt oben auf der Seite beginnt
- split_by_length: ohne verwertbare Gliederung an Absatzgrenzen nach Länge
(ca. 30 000 Zeichen), Codeblöcke bleiben ganz
- load_link_anchors/link_anchors: Links mit eigenem Text ("hier") über
Linktext und Wörter davor wiederfinden und als [Text](Ziel) setzen
(Zensur-Report: 75 von 76)
- Tests, README, Bedienungsanleitung, TODO
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b8ad0310d0
commit
3d418e3987
5 changed files with 273 additions and 17 deletions
|
|
@ -200,10 +200,13 @@ band1/
|
||||||
part: "Teil I: Das Vermächtnis"
|
part: "Teil I: Das Vermächtnis"
|
||||||
chapter: 3
|
chapter: 3
|
||||||
order: 5
|
order: 5
|
||||||
|
source_pages: [40, 43] # Seiten der PDF-Datei (nicht die gedruckten Seitenzahlen)
|
||||||
book: "Operation Ljutsch"
|
book: "Operation Ljutsch"
|
||||||
author: "Reinhard Otto Kranz"
|
author: "Reinhard Otto Kranz"
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
- Erkennt das Programm keine Gliederung, teilt es nach Länge (ca. 30 000 Zeichen, an
|
||||||
|
Absatzgrenzen) in `Abschnitt 1`, `Abschnitt 2` … und meldet das.
|
||||||
- **Umsortieren:** in `index.md` die Zeilen verschieben. **Weglassen:** Zeile löschen.
|
- **Umsortieren:** in `index.md` die Zeilen verschieben. **Weglassen:** Zeile löschen.
|
||||||
Dateien dürfen umbenannt werden, wenn der Link in `index.md` mitgeändert wird.
|
Dateien dürfen umbenannt werden, wenn der Link in `index.md` mitgeändert wird.
|
||||||
- **EPUB neu bauen:** `python3 pdf2md.py --from-md Output/band1/` → `Output/band1.epub`
|
- **EPUB neu bauen:** `python3 pdf2md.py --from-md Output/band1/` → `Output/band1.epub`
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -86,11 +86,14 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
|
||||||
und im EPUB anklickbar. Beim Zeilenumbruch zerbrochene URLs (`deutsche-|sehen`,
|
und im EPUB anklickbar. Beim Zeilenumbruch zerbrochene URLs (`deutsche-|sehen`,
|
||||||
`…/ youtube-…`) werden mit den Linkzielen der PDF abgeglichen und repariert;
|
`…/ youtube-…`) werden mit den Linkzielen der PDF abgeglichen und repariert;
|
||||||
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.
|
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.
|
||||||
- **YAML-Kopf:** Jede Markdown-Datei beginnt mit `title`, `author`, `lang`, `cover`
|
- **YAML-Kopf:** Jede Markdown-Datei beginnt mit `title`, `author`, `lang`, `cover`
|
||||||
(Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `book`, `author`).
|
(Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `source_pages`,
|
||||||
`--from-md` liest daraus die EPUB-Angaben.
|
`book`, `author`; `source_pages` = Seiten der PDF-Datei, nicht die gedruckten).
|
||||||
|
`--from-md` liest daraus die EPUB-Angaben. Ohne verwertbare Gliederung teilt `--split`
|
||||||
|
nach Länge (ca. 30 000 Zeichen, an Absatzgrenzen) in „Abschnitt 1“, „Abschnitt 2“ …
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|
@ -116,7 +119,8 @@ synthetisch erzeugt.
|
||||||
- **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“),
|
- **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“),
|
||||||
gesperrt gesetzte Überschriften („M e i n k l e i nes …“), flache PDF-Gliederungen
|
gesperrt gesetzte Überschriften („M e i n k l e i nes …“), flache PDF-Gliederungen
|
||||||
(alle Kapitel auf einer Ebene).
|
(alle Kapitel auf einer Ebene).
|
||||||
- Links mit anderem Text als der URL (z. B. „hier“) werden nicht übernommen.
|
- 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: Verszeilen mit Satzzeichen am Ende werden zu eigenen Absätzen
|
- Zeilengetreue Erkennung: Verszeilen mit Satzzeichen am Ende werden zu eigenen Absätzen
|
||||||
statt zu Zeilen; einzelne Konfigurations- oder Ausgabezeilen neben Code (`[dependencies]`)
|
statt zu Zeilen; einzelne Konfigurations- oder Ausgabezeilen neben Code (`[dependencies]`)
|
||||||
stehen als Zeilen statt im Codeblock.
|
stehen als Zeilen statt im Codeblock.
|
||||||
|
|
|
||||||
15
TODO.md
15
TODO.md
|
|
@ -1,13 +1,6 @@
|
||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## 1. Ideen
|
## Bekannte Schwächen (aus Probeläufen mit fremden PDFs)
|
||||||
|
|
||||||
- `--split`: Seitenzahlen der PDF ins Frontmatter (`source_pages`); pymupdf4llm liefert sie
|
|
||||||
mit `page_chunks=True`, müsste aber durch die ganze Nachbearbeitung getragen werden.
|
|
||||||
- `--split`: Bücher ohne erkennbare Gliederung werden nicht geteilt (nur `00_vorspann.md`);
|
|
||||||
denkbar wäre ein Schnitt nach Seitenzahl oder Länge.
|
|
||||||
|
|
||||||
## 2. Bekannte Schwächen (aus Probeläufen mit fremden PDFs)
|
|
||||||
|
|
||||||
- Reine Zahlen als Überschrift: pymupdf4llm macht Fußnotenzeichen zu Überschriften
|
- Reine Zahlen als Überschrift: pymupdf4llm macht Fußnotenzeichen zu Überschriften
|
||||||
(„## 90“ im Zensur-Report). Nicht pauschal entfernen – im DDR-Buch sind „## 1“, „## 2“
|
(„## 90“ im Zensur-Report). Nicht pauschal entfernen – im DDR-Buch sind „## 1“, „## 2“
|
||||||
|
|
@ -22,6 +15,6 @@
|
||||||
(`[dependencies]`, Programmausgaben) landen als `\`-Zeilen neben dem Codeblock statt darin.
|
(`[dependencies]`, Programmausgaben) landen als `\`-Zeilen neben dem Codeblock statt darin.
|
||||||
- Markdown-Sonderzeichen werden nicht maskiert: `__add__` im Register wird fett gesetzt.
|
- Markdown-Sonderzeichen werden nicht maskiert: `__add__` im Register wird fett gesetzt.
|
||||||
- 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. Links mit eigenem Text („hier“) gehen verloren; Abhilfe wäre,
|
bleibt sie abgeschnitten. Die Heuristik ohne Linkziel hängt Wörter mit „/“ an
|
||||||
die Link-Rechtecke der PDF dem Text zuzuordnen (`[hier](…)`). Die Heuristik ohne Linkziel
|
(„https://a.de/ und/oder“ → eine URL). Linktexte mit Trennung mitten in langen Titeln
|
||||||
hängt Wörter mit „/“ an („https://a.de/ und/oder“ → eine URL).
|
werden nicht wiedergefunden.
|
||||||
|
|
|
||||||
172
pdf2md.py
172
pdf2md.py
|
|
@ -744,6 +744,94 @@ def link_urls(markdown: str, uris: list[str] | None = None) -> str:
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# Links mit eigenem Text ("hier", "Bericht"): Linktext und die Wörter davor aus
|
||||||
|
# der PDF lesen und die Stelle im Markdown als [Text](Ziel) markieren.
|
||||||
|
|
||||||
|
ANCHOR_CONTEXT_WORDS = 3
|
||||||
|
URL_LIKE_WORD = re.compile(r"://|^www\.|/|\w\.(?:[a-z]{2,4})\.?$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def load_link_anchors(doc: pymupdf.Document) -> list[tuple[str, list[str], list[str]]]:
|
||||||
|
"""(Ziel, Wörter davor, Linkwörter) für Links, deren Text keine URL ist."""
|
||||||
|
anchors: list[tuple[str, list[str], list[str]]] = []
|
||||||
|
for page in doc:
|
||||||
|
words = page.get_text("words", sort=True)
|
||||||
|
links = [link for link in page.get_links() if (link.get("uri") or "").startswith("http")]
|
||||||
|
merged: list[tuple[str, list[int]]] = [] # (Ziel, Indizes der Wörter)
|
||||||
|
for link in links:
|
||||||
|
rect = link["from"]
|
||||||
|
hits = [
|
||||||
|
i for i, w in enumerate(words)
|
||||||
|
if rect.contains(pymupdf.Point((w[0] + w[2]) / 2, (w[1] + w[3]) / 2))
|
||||||
|
]
|
||||||
|
if not hits:
|
||||||
|
continue
|
||||||
|
if merged and merged[-1][0] == link["uri"] and hits[0] <= merged[-1][1][-1] + 1:
|
||||||
|
merged[-1][1].extend(i for i in hits if i not in merged[-1][1])
|
||||||
|
else:
|
||||||
|
merged.append((link["uri"], hits))
|
||||||
|
for uri, hits in merged:
|
||||||
|
text = [words[i][4] for i in sorted(hits)]
|
||||||
|
if any(URL_LIKE_WORD.search(word) for word in text):
|
||||||
|
continue # URL als Linktext: erledigt link_urls
|
||||||
|
first = min(hits)
|
||||||
|
before = [w[4] for w in words[max(0, first - ANCHOR_CONTEXT_WORDS):first]]
|
||||||
|
anchors.append((clean_uri(uri), before, text))
|
||||||
|
return anchors
|
||||||
|
|
||||||
|
|
||||||
|
def _words_pattern(words: list[str]) -> str:
|
||||||
|
"""Wörter mit beliebigem Leerraum, Hervorhebung oder Trennung dazwischen."""
|
||||||
|
parts = []
|
||||||
|
for word in words:
|
||||||
|
core = word.rstrip("-\u00ad") # Silbentrennung am Zeilenende
|
||||||
|
parts.append(re.escape(core) + (r"-?" if core != word else ""))
|
||||||
|
return r"[\s*_]*".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# Zwischen Kontextwörtern: Satzzeichen, Fußnotenziffern, Linkziele "](…)".
|
||||||
|
CONTEXT_GAP = r"(?:[\W_\d]|\]\([^)]*\))*"
|
||||||
|
|
||||||
|
|
||||||
|
def _context_pattern(words: list[str]) -> str:
|
||||||
|
cores = [core for word in words for core in re.findall(r"[^\W\d_]+", word)]
|
||||||
|
return CONTEXT_GAP.join(re.escape(core) for core in cores)
|
||||||
|
|
||||||
|
|
||||||
|
def link_anchors(markdown: str, anchors: list[tuple[str, list[str], list[str]]]) -> str:
|
||||||
|
"""Linktexte im Markdown als [Text](Ziel) setzen, in Lesereihenfolge."""
|
||||||
|
pos = 0
|
||||||
|
for uri, before, text in anchors:
|
||||||
|
anchor = r"(?P<anchor>" + _words_pattern(text) + r")"
|
||||||
|
patterns = []
|
||||||
|
if context := _context_pattern(before):
|
||||||
|
patterns.append(re.compile(context + CONTEXT_GAP + anchor))
|
||||||
|
if len(" ".join(text)) >= 6:
|
||||||
|
patterns.append(re.compile(r"(?<![\w\[])" + anchor)) # ohne Kontext
|
||||||
|
for pattern in patterns:
|
||||||
|
match = next(
|
||||||
|
(m for m in pattern.finditer(markdown, pos)
|
||||||
|
if not _inside_link(markdown, m.start("anchor"))),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
start, end = match.span("anchor")
|
||||||
|
label = markdown[start:end].replace("[", "\\[").replace("]", "\\]")
|
||||||
|
markdown = f"{markdown[:start]}[{label}]({uri}){markdown[end:]}"
|
||||||
|
pos = start
|
||||||
|
break
|
||||||
|
return markdown
|
||||||
|
|
||||||
|
|
||||||
|
def _inside_link(markdown: str, index: int) -> bool:
|
||||||
|
"""Liegt die Stelle schon in einem Link (<…>, [..](…))?"""
|
||||||
|
line_start = markdown.rfind("\n", 0, index) + 1
|
||||||
|
before = markdown[line_start:index]
|
||||||
|
return before.count("<") > before.count(">") or before.count("[") > before.count("]") or (
|
||||||
|
before.rfind("](") > before.rfind(")")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Korrektur für pymupdf4llm: Trennstriche am Zeilenende, OCR-Ebenen
|
# Korrektur für pymupdf4llm: Trennstriche am Zeilenende, OCR-Ebenen
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
@ -1580,6 +1668,34 @@ def iter_headings(lines: list[str]):
|
||||||
yield index, len(match.group(1)), match.group(2).strip()
|
yield index, len(match.group(1)), match.group(2).strip()
|
||||||
|
|
||||||
|
|
||||||
|
SPLIT_CHARS = 30_000 # ohne Gliederung: Abschnitte von etwa dieser Länge
|
||||||
|
|
||||||
|
|
||||||
|
def split_by_length(markdown: str, size: int = SPLIT_CHARS) -> list[dict]:
|
||||||
|
"""Ohne verwertbare Überschriften: an Absatzgrenzen in etwa gleich lange Stücke."""
|
||||||
|
blocks, fence = [], False
|
||||||
|
for part in re.split(r"(\n\n+)", markdown):
|
||||||
|
if blocks and fence:
|
||||||
|
blocks[-1] += part # Codeblöcke nicht zerteilen
|
||||||
|
elif part.strip() or not blocks:
|
||||||
|
blocks.append(part)
|
||||||
|
else:
|
||||||
|
blocks[-1] += part
|
||||||
|
fence ^= sum(1 for line in part.split("\n") if FENCE.match(line)) % 2 == 1
|
||||||
|
sections, current = [], ""
|
||||||
|
for block in blocks:
|
||||||
|
if current and len(current) + len(block) > size:
|
||||||
|
sections.append(current)
|
||||||
|
current = ""
|
||||||
|
current += block
|
||||||
|
if current.strip():
|
||||||
|
sections.append(current)
|
||||||
|
return [
|
||||||
|
{"title": f"Abschnitt {n}", "body": body.strip() + "\n"}
|
||||||
|
for n, body in enumerate(sections, start=1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def split_markdown(markdown: str, mode: str) -> list[dict]:
|
def split_markdown(markdown: str, mode: str) -> list[dict]:
|
||||||
"""Buch in Abschnitte teilen: "teile" (oberste Ebene) oder "kapitel".
|
"""Buch in Abschnitte teilen: "teile" (oberste Ebene) oder "kapitel".
|
||||||
|
|
||||||
|
|
@ -1590,7 +1706,7 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
|
||||||
counts = Counter(level for _, level, _ in headings)
|
counts = Counter(level for _, level, _ in headings)
|
||||||
levels = sorted(level for level, n in counts.items() if n >= 2 and level <= 3)
|
levels = sorted(level for level, n in counts.items() if n >= 2 and level <= 3)
|
||||||
if not levels:
|
if not levels:
|
||||||
return [{"title": "Vorspann", "body": markdown}]
|
return split_by_length(markdown)
|
||||||
part_level, chapter_level = levels[0], levels[-1]
|
part_level, chapter_level = levels[0], levels[-1]
|
||||||
cut_levels = {part_level} if mode == "teile" else {part_level, chapter_level}
|
cut_levels = {part_level} if mode == "teile" else {part_level, chapter_level}
|
||||||
|
|
||||||
|
|
@ -1623,6 +1739,50 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
|
||||||
return sections
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def _page_key(text: str) -> str:
|
||||||
|
return re.sub(r"[\W_]+", "", clean_inline(text).lower())
|
||||||
|
|
||||||
|
|
||||||
|
def add_source_pages(doc: pymupdf.Document, sections: list[dict]) -> None:
|
||||||
|
"""Seiten der PDF (1-basiert) je Abschnitt.
|
||||||
|
|
||||||
|
Startseite aus der PDF-Gliederung, sonst die erste Seite (nach der vorigen),
|
||||||
|
auf der die Überschrift steht – Verzeichnisseiten ausgenommen.
|
||||||
|
"""
|
||||||
|
outline = {norm_title(title): page - 1 for _, title, page in doc.get_toc() if page > 0}
|
||||||
|
pages = [_page_key(page.get_text()) for page in doc]
|
||||||
|
keys = [_page_key(section["title"])[:40] for section in sections]
|
||||||
|
listing = { # Verzeichnisse: viele Abschnittstitel auf einer Seite
|
||||||
|
i for i, text in enumerate(pages) if sum(1 for k in keys if k and k in text) >= 3
|
||||||
|
}
|
||||||
|
starts, page = [], 0
|
||||||
|
for section, key in zip(sections, keys):
|
||||||
|
found = outline.get(norm_title(section["title"]))
|
||||||
|
if found is None and section["title"] != "Vorspann" and key:
|
||||||
|
found = next(
|
||||||
|
(i for i in range(page, len(pages)) if i not in listing and key in pages[i]),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
page = max(page, found) if found is not None else page
|
||||||
|
starts.append(page)
|
||||||
|
for n, section in enumerate(sections):
|
||||||
|
end = starts[n + 1] if n + 1 < len(sections) else len(pages) - 1
|
||||||
|
# Beginnt der nächste Abschnitt oben auf einer Seite, endet dieser davor.
|
||||||
|
if n + 1 < len(sections) and end > starts[n] and _starts_page(doc[end], keys[n + 1]):
|
||||||
|
end -= 1
|
||||||
|
section["source_pages"] = [starts[n] + 1, max(starts[n], end) + 1]
|
||||||
|
|
||||||
|
|
||||||
|
def _starts_page(page: pymupdf.Page, key: str) -> bool:
|
||||||
|
"""Steht über der Überschrift nur Kolumnentitel/Seitenzahl (≤ 3 Wörter je Block)?"""
|
||||||
|
blocks = [b for b in page.get_text("blocks") if b[6] == 0 and b[4].strip()]
|
||||||
|
heading = next((b for b in blocks if key and key in _page_key(b[4])), None)
|
||||||
|
if heading is None or not _page_key(heading[4]).startswith(key):
|
||||||
|
return False # nicht gefunden oder Text davor im selben Block
|
||||||
|
above = [b for b in blocks if b[3] <= heading[1] + 1 and b is not heading]
|
||||||
|
return all(len(b[4].split()) <= 3 for b in above)
|
||||||
|
|
||||||
|
|
||||||
def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]:
|
def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]:
|
||||||
"""Abschnitte als nummerierte Dateien plus index.md (Reihenfolge) schreiben."""
|
"""Abschnitte als nummerierte Dateien plus index.md (Reihenfolge) schreiben."""
|
||||||
folder.mkdir(parents=True, exist_ok=True)
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -1635,6 +1795,7 @@ def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]:
|
||||||
"part": section.get("part"),
|
"part": section.get("part"),
|
||||||
"chapter": section.get("chapter"),
|
"chapter": section.get("chapter"),
|
||||||
"order": order,
|
"order": order,
|
||||||
|
"source_pages": section.get("source_pages"),
|
||||||
"book": meta.get("title"),
|
"book": meta.get("title"),
|
||||||
"author": meta.get("author"),
|
"author": meta.get("author"),
|
||||||
}
|
}
|
||||||
|
|
@ -1847,6 +2008,7 @@ def main() -> int:
|
||||||
kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi)
|
kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi)
|
||||||
outline = load_outline(doc)
|
outline = load_outline(doc)
|
||||||
uris = load_link_uris(doc)
|
uris = load_link_uris(doc)
|
||||||
|
anchors = load_link_anchors(doc)
|
||||||
page_count = doc.page_count
|
page_count = doc.page_count
|
||||||
file_title, file_author = title_author_from_filename(source.stem)
|
file_title, file_author = title_author_from_filename(source.stem)
|
||||||
title = (
|
title = (
|
||||||
|
|
@ -1904,6 +2066,7 @@ def main() -> int:
|
||||||
running_head_min=max(3, page_count // 10),
|
running_head_min=max(3, page_count // 10),
|
||||||
)
|
)
|
||||||
markdown = link_urls(markdown, uris)
|
markdown = link_urls(markdown, uris)
|
||||||
|
markdown = link_anchors(markdown, anchors)
|
||||||
|
|
||||||
base = folder or target.parent
|
base = folder or target.parent
|
||||||
meta = {
|
meta = {
|
||||||
|
|
@ -1913,7 +2076,12 @@ def main() -> int:
|
||||||
"cover": cover.relative_to(base).as_posix() if cover else None,
|
"cover": cover.relative_to(base).as_posix() if cover else None,
|
||||||
}
|
}
|
||||||
if folder:
|
if folder:
|
||||||
files = write_split(folder, split_markdown(markdown, args.split), meta)
|
sections = split_markdown(markdown, args.split)
|
||||||
|
if sections and sections[0]["title"] == "Abschnitt 1":
|
||||||
|
print("Hinweis: keine Gliederung erkannt – nach Länge geteilt.")
|
||||||
|
with pymupdf.open(source) as doc:
|
||||||
|
add_source_pages(doc, sections)
|
||||||
|
files = write_split(folder, sections, meta)
|
||||||
else:
|
else:
|
||||||
target.write_text(make_frontmatter(meta) + markdown, encoding="utf-8")
|
target.write_text(make_frontmatter(meta) + markdown, encoding="utf-8")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
|
import pymupdf
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import pdf2md as p
|
import pdf2md as p
|
||||||
|
|
@ -144,3 +146,89 @@ def test_jpeg_quality_changes_image_size(pdfs, tmp_path):
|
||||||
return sum(f.stat().st_size for f in (out.parent / "buch_images").glob("*.jpg"))
|
return sum(f.stat().st_size for f in (out.parent / "buch_images").glob("*.jpg"))
|
||||||
|
|
||||||
assert size(30) < size(95)
|
assert size(30) < size(95)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Seitenzahlen, Teilen nach Länge -----------------------------------------------
|
||||||
|
|
||||||
|
def _book_pdf(outline: bool):
|
||||||
|
doc = pymupdf.open()
|
||||||
|
texts = [
|
||||||
|
"Inhalt\n1 Anfang\n2 Weiter\n3 Schluss",
|
||||||
|
"1 Anfang\nText eins.",
|
||||||
|
"Noch mehr Text\nzum Anfang,\nüber mehrere\nZeilen.\n2 Weiter\nText zwei.",
|
||||||
|
"3 Schluss\nText drei.",
|
||||||
|
]
|
||||||
|
for text in texts:
|
||||||
|
doc.new_page().insert_text((72, 72), text, fontsize=11)
|
||||||
|
if outline:
|
||||||
|
doc.set_toc([[1, "1 Anfang", 2], [1, "2 Weiter", 3], [1, "3 Schluss", 4]])
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("outline", [True, False])
|
||||||
|
def test_source_pages(outline):
|
||||||
|
sections = [{"title": t} for t in ("Vorspann", "1 Anfang", "2 Weiter", "3 Schluss")]
|
||||||
|
p.add_source_pages(_book_pdf(outline), sections)
|
||||||
|
assert [s["source_pages"] for s in sections] == [[1, 1], [2, 3], [3, 3], [4, 4]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_without_headings_by_length():
|
||||||
|
paragraphs = [f"Absatz {n} " + " ".join(["Text"] * 40) for n in range(30)]
|
||||||
|
code = "```\n" + "\n\n".join(f"zeile_{n} = {n}" for n in range(20)) + "\n```"
|
||||||
|
markdown = "\n\n".join(paragraphs[:15] + [code] + paragraphs[15:]) + "\n"
|
||||||
|
assert p.split_markdown(markdown, "kapitel")[0]["title"] == "Abschnitt 1"
|
||||||
|
sections = p.split_by_length(markdown, size=1500)
|
||||||
|
assert len(sections) > 3
|
||||||
|
assert [s["title"] for s in sections[:2]] == ["Abschnitt 1", "Abschnitt 2"]
|
||||||
|
assert all(s["body"].count("```") in (0, 2) for s in sections) # Code bleibt ganz
|
||||||
|
assert "".join(s["body"] for s in sections).replace("\n", "") == markdown.replace("\n", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_split_writes_source_pages(pdfs, tmp_path):
|
||||||
|
run(pdfs["digital"], tmp_path / "buch.md", "--split", "kapitel")
|
||||||
|
files = {f.name: p.split_frontmatter(f.read_text())[0] for f in (tmp_path / "buch").glob("0*.md")}
|
||||||
|
notar = next(meta for name, meta in files.items() if name.endswith("_1-der-notar.md"))
|
||||||
|
assert notar["source_pages"] == [2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Links mit eigenem Text --------------------------------------------------------
|
||||||
|
|
||||||
|
def _link_pdf(path):
|
||||||
|
doc = pymupdf.open()
|
||||||
|
page = doc.new_page()
|
||||||
|
page.insert_text((72, 72), "Den ganzen Bericht finden Sie hier im Netz.", fontsize=11)
|
||||||
|
page.insert_text((72, 90), "Siehe auch www.example.org/info zum Thema.", fontsize=11)
|
||||||
|
for word, uri in (("hier", "https://beispiel.de/bericht?utm_source=x"),
|
||||||
|
("www.example.org/info", "https://www.example.org/info")):
|
||||||
|
rect = page.search_for(word)[0]
|
||||||
|
page.insert_link({"kind": pymupdf.LINK_URI, "from": rect, "uri": uri})
|
||||||
|
doc.save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_link_anchors_skips_url_texts(tmp_path):
|
||||||
|
doc = pymupdf.open(_link_pdf(tmp_path / "links.pdf"))
|
||||||
|
assert p.load_link_anchors(doc) == [
|
||||||
|
("https://beispiel.de/bericht", ["Bericht", "finden", "Sie"], ["hier"])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_anchors_uses_context_and_skips_existing_links():
|
||||||
|
anchors = [("https://a.de/x", ["finden", "Sie"], ["hier"])]
|
||||||
|
md = "Hier steht hier. Den Bericht finden Sie hier im Netz.\n"
|
||||||
|
assert p.link_anchors(md, anchors) == (
|
||||||
|
"Hier steht hier. Den Bericht finden Sie [hier](https://a.de/x) im Netz.\n"
|
||||||
|
)
|
||||||
|
# Fußnotenziffer und Trennung zwischen Kontext und Linktext stören nicht
|
||||||
|
anchors = [("https://b.de/", ["überprüfen.81"], ["Landes-", "medienanstalten"])]
|
||||||
|
md = "Die Landesmedienanstalten.\n\nzu überprüfen.[81 ] Die Landesmedienanstalten prüfen.\n"
|
||||||
|
anchors = [("https://b.de/", ["überprüfen.81", "Die"], ["Landes-", "medienanstalten"])]
|
||||||
|
assert "Die [Landesmedienanstalten](https://b.de/) prüfen" in p.link_anchors(md, anchors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_sets_anchor_links(tmp_path):
|
||||||
|
out = tmp_path / "links.md"
|
||||||
|
run(_link_pdf(tmp_path / "links.pdf"), out)
|
||||||
|
md = out.read_text()
|
||||||
|
assert "[hier](https://beispiel.de/bericht)" in md
|
||||||
|
assert "<https://www.example.org/info>" in md or "[www.example.org/info]" in md
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue