Gedicht-Scans: Zeilen direkt aus Tesseract, Fehler aus pymupdf4llm 1.28
- --keep-lines auf Scanseiten: Zeilen direkt aus Tesseract statt über die
Layout-Analyse (die verschränkte eng stehende Verse und gab Zeilen
überlappender Blöcke doppelt aus); Stücke auf gleicher Höhe verbinden,
Rauschen außerhalb der Textspalte verwerfen, Strophen über den
Grundlinienabstand, Einrückung aus der x-Position, Gedichttitel aus der
PDF-Gliederung (richtige Schreibweise statt OCR-Lesart)
- Bildtext im neuen Format von pymupdf4llm 1.28 ("<!-- Start of picture
text -->") wieder entfernen
- Zeilen überlappender Layout-Blöcke nur einmal ausgeben
- OCR-Seitenzahlen ("= 58 —", "— VIII —") erkennen, auch am Strophenende
- Fraktur: deu_latf aus tessdata_best empfohlen (besser als frk)
- Tests, README, Bedienungsanleitung (Gedicht-/Fraktur-Abschnitt), TODO
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3e5195efc2
commit
180d98ae04
5 changed files with 225 additions and 19 deletions
|
|
@ -230,7 +230,7 @@ python3 pdf2md.py --epub scan.pdf Output/scan.md
|
||||||
| mehr oder weniger parallele OCR-Prozesse | `--jobs N` (Standard: halbe Kernzahl, höchstens 8) |
|
| mehr oder weniger parallele OCR-Prozesse | `--jobs N` (Standard: halbe Kernzahl, höchstens 8) |
|
||||||
| OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` (alte Ebene wird verworfen) |
|
| OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` (alte Ebene wird verworfen) |
|
||||||
| Gedichtband, Liederbuch | zusätzlich `--keep-lines` (siehe unten) |
|
| Gedichtband, Liederbuch | zusätzlich `--keep-lines` (siehe unten) |
|
||||||
| Fraktur | `--ocr-language frk+deu` (Paket `tesseract-ocr-frk` nötig; „ſ“ bleibt erhalten) |
|
| Fraktur | `--ocr-language deu_latf` (Modell nachladen, siehe unten) oder `frk+deu` (Paket `tesseract-ocr-frk`, schwächer) |
|
||||||
| englisches Buch | `--ocr-language eng` (Sprachpaket `tesseract-ocr-eng` nötig; EPUB-Sprache wird automatisch `en`) |
|
| englisches Buch | `--ocr-language eng` (Sprachpaket `tesseract-ocr-eng` nötig; EPUB-Sprache wird automatisch `en`) |
|
||||||
| keine OCR | `--ocr never` oder `--no-ocr` |
|
| keine OCR | `--ocr never` oder `--no-ocr` |
|
||||||
|
|
||||||
|
|
@ -245,17 +245,31 @@ So entscheidet das Programm von selbst (`--ocr auto`):
|
||||||
erkannt. Dann mit `--ocr always` neu erkennen lassen; die alte Ebene wird dabei verworfen.
|
erkannt. Dann mit `--ocr always` neu erkennen lassen; die alte Ebene wird dabei verworfen.
|
||||||
- **Rauschen vom Scanrand** (Buchstabenfetzen neben dem Textbereich) wird entfernt.
|
- **Rauschen vom Scanrand** (Buchstabenfetzen neben dem Textbereich) wird entfernt.
|
||||||
|
|
||||||
|
- **Reine Bildseiten in einem sonst digitalen PDF** (Fotos, Cover) werden nur übernommen, wenn eine
|
||||||
|
Probe-OCR echten Fließtext liefert. Sonst bleiben sie Bild, und das Programm meldet einen
|
||||||
|
„Hinweis“.
|
||||||
|
|
||||||
**Gedichtbände und Liederbücher:** Füllen die Verse die Zeile fast, kann das Programm sie nicht
|
**Gedichtbände und Liederbücher:** Füllen die Verse die Zeile fast, kann das Programm sie nicht
|
||||||
von Fließtext unterscheiden und fügt sie zusammen. Mit `--keep-lines` bleibt jede Zeile eine
|
von Fließtext unterscheiden und fügt sie zusammen. Mit `--keep-lines` bleibt jede Zeile eine
|
||||||
Zeile, Strophen werden durch Leerzeilen getrennt, Einrückungen bleiben; nichts wird
|
Zeile, Strophen werden durch Leerzeilen getrennt, Einrückungen bleiben; nichts wird
|
||||||
zusammengefügt, auch keine Silbentrennung:
|
zusammengefügt, auch keine Silbentrennung. Gescannte Seiten liest das Programm dann direkt aus
|
||||||
|
Tesseract (ohne Layout-Analyse, die bei Gedichten Zeilen verdoppelt oder verschränkt);
|
||||||
|
Gedichttitel kommen aus der PDF-Gliederung – in richtiger Schreibweise statt OCR-Lesart.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 pdf2md.py --epub --ocr always --keep-lines Neue_Gedichte_-_Rainer_Maria_Rilke.pdf Output/Neue_Gedichte_-_Rainer_Maria_Rilke.md
|
python3 pdf2md.py --epub --ocr always --keep-lines Neue_Gedichte_-_Rainer_Maria_Rilke.pdf Output/Neue_Gedichte_-_Rainer_Maria_Rilke.md
|
||||||
```
|
```
|
||||||
- **Reine Bildseiten in einem sonst digitalen PDF** (Fotos, Cover) werden nur übernommen, wenn eine
|
|
||||||
Probe-OCR echten Fließtext liefert. Sonst bleiben sie Bild, und das Programm meldet einen
|
**Fraktur:** Das Modell `frk` aus dem Ubuntu-Paket ist alt und verwechselt oft ſ/f/k/l
|
||||||
„Hinweis“.
|
(„ftarf“ statt „ſtark“). Deutlich besser liest das Modell `deu_latf` aus tessdata_best:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo wget -P /usr/share/tesseract-ocr/5/tessdata https://github.com/tesseract-ocr/tessdata_best/raw/main/deu_latf.traineddata
|
||||||
|
python3 pdf2md.py --epub --ocr-language deu_latf --keep-lines gedichte.pdf Output/gedichte.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Das lange s bleibt als „ſ“ erhalten. Auch mit `deu_latf` bleiben Lesefehler; alte Scans muss
|
||||||
|
man nacharbeiten (Weg B).
|
||||||
|
|
||||||
Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, getrennte Wörter
|
Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, getrennte Wörter
|
||||||
(„Geheim-|dienst“) zusammengefügt.
|
(„Geheim-|dienst“) zusammengefügt.
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -16,8 +16,10 @@ sudo apt install tesseract-ocr tesseract-ocr-deu pandoc
|
||||||
pip install -r requirements.txt # pymupdf4llm, pymupdf-layout, pytest
|
pip install -r requirements.txt # pymupdf4llm, pymupdf-layout, pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
`tesseract-ocr` wird nur für Scans gebraucht, `pandoc` nur für `--epub`. Für Fraktur-Scans
|
`tesseract-ocr` wird nur für Scans gebraucht, `pandoc` nur für `--epub`. Für Fraktur-Scans das
|
||||||
zusätzlich `sudo apt install tesseract-ocr-frk` und `--ocr-language frk+deu`.
|
Modell `deu_latf` aus [tessdata_best](https://github.com/tesseract-ocr/tessdata_best) nach
|
||||||
|
`/usr/share/tesseract-ocr/5/tessdata/` laden und `--ocr-language deu_latf` angeben (besser als
|
||||||
|
das Ubuntu-Paket `tesseract-ocr-frk`).
|
||||||
|
|
||||||
## Verwendung
|
## Verwendung
|
||||||
|
|
||||||
|
|
@ -60,7 +62,7 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
|
||||||
| `--from-md PFAD` | nur EPUB erzeugen – aus einer (bearbeiteten) `.md` oder einem `--split`-Ordner |
|
| `--from-md PFAD` | nur EPUB erzeugen – aus einer (bearbeiteten) `.md` oder einem `--split`-Ordner |
|
||||||
| `--title`, `--author` | Titel/Autor im EPUB vorgeben (statt PDF-Metadaten bzw. YAML-Kopf) |
|
| `--title`, `--author` | Titel/Autor im EPUB vorgeben (statt PDF-Metadaten bzw. YAML-Kopf) |
|
||||||
| `--lang` | Sprache im EPUB (Standard: aus `--ocr-language`, `deu`→`de`, `eng`→`en`) |
|
| `--lang` | Sprache im EPUB (Standard: aus `--ocr-language`, `deu`→`de`, `eng`→`en`) |
|
||||||
| `--keep-lines` | jede Zeile der PDF bleibt eine Zeile (Gedichtbände, Liedtexte): Strophen als Absätze, Einrückungen bleiben; nichts wird zusammengefügt, auch keine Silbentrennung |
|
| `--keep-lines` | jede Zeile der PDF bleibt eine Zeile (Gedichtbände, Liedtexte): Strophen als Absätze, Einrückungen bleiben; nichts wird zusammengefügt, auch keine Silbentrennung. Scans werden direkt aus Tesseract gelesen, Titel aus der PDF-Gliederung übernommen |
|
||||||
| `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen |
|
| `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen |
|
||||||
|
|
||||||
## Was die Nachbearbeitung macht
|
## Was die Nachbearbeitung macht
|
||||||
|
|
@ -131,9 +133,10 @@ synthetisch erzeugt.
|
||||||
mitgelieferter OCR-Ebene liefert `--ocr always` meist deutlich besseren Text (die alte
|
mitgelieferter OCR-Ebene liefert `--ocr always` meist deutlich besseren Text (die alte
|
||||||
Ebene wird dabei verworfen, z. B. „bliht“ → „blüht“). OCR-Rauschen vom Scanrand (Buchkante
|
Ebene wird dabei verworfen, z. B. „bliht“ → „blüht“). OCR-Rauschen vom Scanrand (Buchkante
|
||||||
als „|“, Buchstabenfetzen neben dem Textbereich) wird entfernt.
|
als „|“, Buchstabenfetzen neben dem Textbereich) wird entfernt.
|
||||||
- Fraktur (`--ocr-language frk+deu`): Das lange s bleibt als „ſ“ erhalten; bei manchen Scans
|
- Fraktur: Das lange s bleibt als „ſ“ erhalten; auch `deu_latf` liest alte Scans nicht fehlerfrei.
|
||||||
legt die Zeilenbildung von pymupdf4llm zwei eng stehende Verszeilen zusammen
|
- Gedicht-Scans ohne `--keep-lines`: Die Layout-Analyse von pymupdf4llm kann eng stehende Verse
|
||||||
(„UndDon tauchteweißen …“).
|
verschränken („UndDon tauchteweißen …“) – mit `--keep-lines` werden Scans direkt aus
|
||||||
|
Tesseract gelesen.
|
||||||
- **Mathematische Formeln** werden als Bilder übernommen; für Mathebücher ist ein anderes
|
- **Mathematische Formeln** werden als Bilder übernommen; für Mathebücher ist ein anderes
|
||||||
Werkzeug besser geeignet (z. B. Marker, Nougat).
|
Werkzeug besser geeignet (z. B. Marker, Nougat).
|
||||||
- **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“),
|
- **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“),
|
||||||
|
|
|
||||||
7
TODO.md
7
TODO.md
|
|
@ -8,8 +8,9 @@
|
||||||
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.
|
||||||
Für reine Gedichtbände: `--keep-lines`.
|
Für reine Gedichtbände: `--keep-lines`.
|
||||||
- Fraktur-Scans (Löwenberg 1895): Zwei eng stehende OCR-Zeilen werden von der Zeilenbildung
|
- Gedicht-Scans ohne `--keep-lines`: pymupdf-layout verschränkt eng stehende OCR-Zeilen
|
||||||
in pymupdf4llm/pymupdf-layout gelegentlich zu einer verschränkt („UndDon tauchteweißen“);
|
(„UndDon tauchteweißen“). Mit `--keep-lines` umgangen (Zeilen direkt aus Tesseract).
|
||||||
im OCR-PDF selbst sind sie getrennt. Idee: Option, „ſ“ in „s“ umzuwandeln.
|
- `--keep-lines` auf Scans: Zierleisten/Ornamente werden manchmal als Wortsalat gelesen
|
||||||
|
(„amm Cem wen rn“); keine Bilder aus Scanseiten. Idee: Option „ſ“ → „s“.
|
||||||
- Listings mit eingedruckten Zeilennummern (Markdown Guide: „4 <html>“, „5 <head>“) werden
|
- Listings mit eingedruckten Zeilennummern (Markdown Guide: „4 <html>“, „5 <head>“) werden
|
||||||
nicht immer als ein Codeblock erkannt; Programmausgaben ohne typische Merkmale bleiben Text.
|
nicht immer als ein Codeblock erkannt; Programmausgaben ohne typische Merkmale bleiben Text.
|
||||||
|
|
|
||||||
151
pdf2md.py
151
pdf2md.py
|
|
@ -14,6 +14,7 @@ Ablauf:
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import difflib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
|
@ -75,9 +76,10 @@ OMITTED_IMAGE = re.compile(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Text, den pymupdf4llm innerhalb von Bildern findet (Fotos, OCR-Reste).
|
# Text, den pymupdf4llm innerhalb von Bildern findet (Fotos, OCR-Reste).
|
||||||
PICTURE_TEXT = re.compile(
|
PICTURE_TEXT = re.compile( # Format bis pymupdf4llm 1.27 bzw. ab 1.28
|
||||||
r"\*\*----- Start of picture text -----\*\*<br>.*?"
|
r"(?:\*\*----- Start of picture text -----\*\*<br>|<!-- Start of picture text -->)"
|
||||||
r"\*\*----- End of picture text -----\*\*<br>\n?",
|
r".*?"
|
||||||
|
r"(?:\*\*----- End of picture text -----\*\*<br>|<!-- End of picture text -->)\n?",
|
||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -103,7 +105,8 @@ HTML_BLOCK = re.compile(
|
||||||
|
|
||||||
# Seitenzahl allein in einer Zeile: "12", "- 12 -", "Seite 12"
|
# Seitenzahl allein in einer Zeile: "12", "- 12 -", "Seite 12"
|
||||||
PAGE_NUMBER = re.compile(
|
PAGE_NUMBER = re.compile(
|
||||||
r"^(?:[-–—]\s*)?(?:(?:seite|page|s\.)\s*)?\d{1,4}(?:\s*[-–—])?$",
|
r"^(?:[-–—=]\s*)?(?:(?:seite|page|s\.)\s*)?\d{1,4}(?:\s*[-–—=])?$"
|
||||||
|
r"|^[-–—=]\s*[ivxlc]{1,7}\s*[-–—=]$", # "— VIII —" (OCR: "= 58 —")
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -409,6 +412,11 @@ def normalize_markdown(
|
||||||
standalone(clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END)))
|
standalone(clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END)))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if line.endswith(PARAGRAPH_END) and drop_page_numbers and PAGE_NUMBER.match(
|
||||||
|
clean_inline(line.removesuffix(PARAGRAPH_END))
|
||||||
|
):
|
||||||
|
continue # Seitenzahl am Strophen-/Seitenende
|
||||||
|
|
||||||
if line.endswith(PARAGRAPH_END):
|
if line.endswith(PARAGRAPH_END):
|
||||||
content = line.removesuffix(PARAGRAPH_END)
|
content = line.removesuffix(PARAGRAPH_END)
|
||||||
if not paragraph and result and result[-1].endswith(HARD_BREAK):
|
if not paragraph and result and result[-1].endswith(HARD_BREAK):
|
||||||
|
|
@ -1171,7 +1179,26 @@ _keep_lines = [False] # --keep-lines: jede PDF-Zeile bleibt eine Zeile
|
||||||
REAL_WORD = re.compile(r"[^\W\d_]{3,}")
|
REAL_WORD = re.compile(r"[^\W\d_]{3,}")
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_box_lines(boxes) -> None:
|
||||||
|
"""Überlappende Layout-Blöcke enthalten dieselben Zeilen doppelt
|
||||||
|
(Gedichtseiten in Scans) – jede Zeile nur im ersten Block behalten."""
|
||||||
|
seen: set = set()
|
||||||
|
for box in boxes:
|
||||||
|
lines = getattr(box, "textlines", None)
|
||||||
|
if not lines or getattr(box, "boxclass", None) not in TEXT_BOXES:
|
||||||
|
continue
|
||||||
|
kept = []
|
||||||
|
for line in lines:
|
||||||
|
key = (tuple(round(v) for v in line["bbox"]),
|
||||||
|
"".join(span["text"] for span in line["spans"]))
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
kept.append(line)
|
||||||
|
box.textlines = kept
|
||||||
|
|
||||||
|
|
||||||
def _remember_page_lines(boxes) -> None:
|
def _remember_page_lines(boxes) -> None:
|
||||||
|
_dedupe_box_lines(boxes)
|
||||||
_page_lines.clear()
|
_page_lines.clear()
|
||||||
real: list[tuple[float, float]] = []
|
real: list[tuple[float, float]] = []
|
||||||
for box in boxes:
|
for box in boxes:
|
||||||
|
|
@ -1805,6 +1832,15 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str
|
||||||
with contextlib.redirect_stdout(io.StringIO()):
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
if run != RUN_OCR:
|
if run != RUN_OCR:
|
||||||
return pymupdf4llm.to_markdown(source, **kwargs)
|
return pymupdf4llm.to_markdown(source, **kwargs)
|
||||||
|
if options.get("keep_lines"):
|
||||||
|
with pymupdf.open(source) as doc:
|
||||||
|
return "".join(
|
||||||
|
ocr_lines_md(
|
||||||
|
doc[pno], options["ocr_language"], options["ocr_dpi"],
|
||||||
|
options.get("titles", {}).get(pno, []),
|
||||||
|
)
|
||||||
|
for pno in pages
|
||||||
|
)
|
||||||
# Fremde OCR-Ebenen entfernt pymupdf4llm nicht (nur eigene); sonst stünde
|
# Fremde OCR-Ebenen entfernt pymupdf4llm nicht (nur eigene); sonst stünde
|
||||||
# alter und neuer Text doppelt da. Nur die geöffnete Kopie wird geändert.
|
# alter und neuer Text doppelt da. Nur die geöffnete Kopie wird geändert.
|
||||||
with pymupdf.open(source) as doc:
|
with pymupdf.open(source) as doc:
|
||||||
|
|
@ -1813,6 +1849,108 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str
|
||||||
return pymupdf4llm.to_markdown(doc, **kwargs)
|
return pymupdf4llm.to_markdown(doc, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# --keep-lines auf Scanseiten: Zeilen direkt aus Tesseract, ohne Layout-Modell.
|
||||||
|
# Dieses legt bei Gedicht-Scans eng stehende Verse zusammen ("UndDon tauchte…")
|
||||||
|
# und gibt Zeilen sich überlappender Blöcke doppelt aus.
|
||||||
|
|
||||||
|
OCR_TITLE_LINES = 8 # Titel suchen wir unter den ersten Zeilen der Seite
|
||||||
|
OCR_STANZA_GAP = 1.4 # Grundlinienabstand (× üblichem) für eine Strophengrenze
|
||||||
|
PAGE_LABEL = re.compile(r"^seite\s+\d+$", re.IGNORECASE) # Gliederungseintrag "Seite 4"
|
||||||
|
|
||||||
|
|
||||||
|
def _ocr_segments(page: pymupdf.Page, language: str, dpi: int) -> list[list]:
|
||||||
|
"""Textzeilen-Stücke [x0, y0, x1, y1, text] aus Tesseract, ohne Rauschen."""
|
||||||
|
pix = page.get_pixmap(dpi=dpi) # RGB: MuPDFs OCR liest Graustufen nicht
|
||||||
|
segments = []
|
||||||
|
with pymupdf.open("pdf", pix.pdfocr_tobytes(language=language)) as ocr:
|
||||||
|
for block in ocr[0].get_text("dict")["blocks"]:
|
||||||
|
for line in block.get("lines", []):
|
||||||
|
text = OCR_NOISE.sub("", "".join(s["text"] for s in line["spans"]))
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
if HAS_ALNUM.search(text): # Grundlinie für Zeilen- und Strophenabstand
|
||||||
|
segments.append([*line["bbox"], text, line["spans"][0]["origin"][1]])
|
||||||
|
real = [seg for seg in segments if len(REAL_WORD.findall(seg[4])) >= 2]
|
||||||
|
if real: # Spalte aus echten Zeilen; Fetzen am Scanrand liegen außerhalb
|
||||||
|
left = min(seg[0] for seg in real)
|
||||||
|
right = max(seg[2] for seg in real)
|
||||||
|
height = sorted(seg[3] - seg[1] for seg in real)[len(real) // 2]
|
||||||
|
segments = [
|
||||||
|
seg for seg in segments
|
||||||
|
if seg[2] > left - height and seg[0] < right + height
|
||||||
|
and (len(REAL_WORD.findall(seg[4])) >= 1 or PAGE_NUMBER.match(seg[4]))
|
||||||
|
]
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def _ocr_rows(segments: list[list]) -> list[list]:
|
||||||
|
"""Stücke auf gleicher Höhe zu Zeilen verbinden: [x0, y0, x1, y1, text].
|
||||||
|
|
||||||
|
Verglichen wird mit dem Mittelpunkt des ersten Stücks einer Zeile – ein
|
||||||
|
wachsender Bereich würde sonst die Nachbarzeilen mit einsammeln."""
|
||||||
|
if not segments:
|
||||||
|
return []
|
||||||
|
heights = sorted(seg[3] - seg[1] for seg in segments)
|
||||||
|
height = heights[len(heights) // 2]
|
||||||
|
rows: list[list] = [] # [x0, y0, x1, y1, stücke, mittelpunkt]
|
||||||
|
for seg in sorted(segments, key=lambda s: (s[1] + s[3]) / 2):
|
||||||
|
center = (seg[1] + seg[3]) / 2
|
||||||
|
row = next((r for r in rows if abs(r[5] - center) < 0.5 * height
|
||||||
|
and all(seg[0] >= o[2] or seg[2] <= o[0] for o in r[4])), None)
|
||||||
|
if row is None:
|
||||||
|
rows.append([*seg[:4], [seg], center])
|
||||||
|
else:
|
||||||
|
row[0], row[1] = min(row[0], seg[0]), min(row[1], seg[1])
|
||||||
|
row[2], row[3] = max(row[2], seg[2]), max(row[3], seg[3])
|
||||||
|
row[4].append(seg)
|
||||||
|
rows.sort(key=lambda r: r[5])
|
||||||
|
return [
|
||||||
|
[*r[:4], " ".join(seg[4] for seg in sorted(r[4], key=lambda s: s[0])),
|
||||||
|
max(seg[5] for seg in r[4])]
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _title_key(text: str) -> str:
|
||||||
|
return re.sub(r"[\W\d_]+", "", text.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def ocr_lines_md(page: pymupdf.Page, language: str, dpi: int, titles: list) -> str:
|
||||||
|
"""Scanseite zeilengetreu: Verse, Strophen (Abstand), Einrückung, Titel aus
|
||||||
|
der PDF-Gliederung (richtige Schreibweise statt OCR-Lesart)."""
|
||||||
|
rows = _ocr_rows(_ocr_segments(page, language, dpi))
|
||||||
|
if not rows:
|
||||||
|
return ""
|
||||||
|
heights = sorted(r[3] - r[1] for r in rows)
|
||||||
|
height = heights[len(heights) // 2]
|
||||||
|
left = min(r[0] for r in rows if not PAGE_NUMBER.match(r[4]))
|
||||||
|
pitches = sorted(b[5] - a[5] for a, b in zip(rows, rows[1:])) or [height]
|
||||||
|
pitch = pitches[len(pitches) // 2] # Abstand der Grundlinien
|
||||||
|
|
||||||
|
headings: dict[int, str] = {}
|
||||||
|
for level, title in titles: # Gliederungstitel dieser Seite wiederfinden
|
||||||
|
key = _title_key(title)
|
||||||
|
scored = [
|
||||||
|
(difflib.SequenceMatcher(None, key, _title_key(r[4])).ratio(), i)
|
||||||
|
for i, r in enumerate(rows[:OCR_TITLE_LINES]) if i not in headings
|
||||||
|
]
|
||||||
|
if scored and (best := max(scored))[0] >= 0.6:
|
||||||
|
headings[best[1]] = "#" * min(level, 6) + " " + md_escape(title)
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
if i in headings:
|
||||||
|
out += ["", headings[i], ""]
|
||||||
|
continue
|
||||||
|
indent = int(max(0.0, row[0] - left) / (1.2 * height))
|
||||||
|
text = TEXT_INDENT * indent + md_escape(row[4])
|
||||||
|
gap = i + 1 < len(rows) and rows[i + 1][5] - row[5] > OCR_STANZA_GAP * pitch
|
||||||
|
last = i + 1 == len(rows) or (i + 1) in headings
|
||||||
|
out.append(text + (PARAGRAPH_END if gap or (last and i + 1 < len(rows)) else HARD_BREAK))
|
||||||
|
if gap:
|
||||||
|
out.append("")
|
||||||
|
return "\n".join(out).strip("\n") + "\n\n"
|
||||||
|
|
||||||
|
|
||||||
def drop_invisible_text(page: pymupdf.Page) -> None:
|
def drop_invisible_text(page: pymupdf.Page) -> None:
|
||||||
"""Unsichtbaren Text (vorhandene OCR-Ebene) entfernen; Bild und Grafik bleiben."""
|
"""Unsichtbaren Text (vorhandene OCR-Ebene) entfernen; Bild und Grafik bleiben."""
|
||||||
page.add_redact_annot(page.rect, fill=False)
|
page.add_redact_annot(page.rect, fill=False)
|
||||||
|
|
@ -2392,6 +2530,10 @@ def main() -> int:
|
||||||
with pymupdf.open(source) as doc:
|
with pymupdf.open(source) as doc:
|
||||||
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)
|
||||||
|
titles: dict[int, list[tuple[int, str]]] = {} # Seite → Gliederungstitel
|
||||||
|
for level, title, page_no in doc.get_toc():
|
||||||
|
if page_no > 0 and title.strip() and not PAGE_LABEL.match(title.strip()):
|
||||||
|
titles.setdefault(page_no - 1, []).append((level, title.strip()))
|
||||||
uris = load_link_uris(doc)
|
uris = load_link_uris(doc)
|
||||||
anchors = load_link_anchors(doc)
|
anchors = load_link_anchors(doc)
|
||||||
page_count = doc.page_count
|
page_count = doc.page_count
|
||||||
|
|
@ -2438,6 +2580,7 @@ def main() -> int:
|
||||||
ocr_dpi=args.ocr_dpi,
|
ocr_dpi=args.ocr_dpi,
|
||||||
jpeg_quality=args.jpeg_quality,
|
jpeg_quality=args.jpeg_quality,
|
||||||
keep_lines=args.keep_lines,
|
keep_lines=args.keep_lines,
|
||||||
|
titles=titles,
|
||||||
)
|
)
|
||||||
markdown = extract_markdown(source, plan, options, args.jobs)
|
markdown = extract_markdown(source, plan, options, args.jobs)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import pymupdf
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import pdf2md as p
|
import pdf2md as p
|
||||||
from conftest import needs_pandoc
|
from conftest import needs_pandoc, needs_tesseract
|
||||||
from test_e2e import run
|
from test_e2e import run
|
||||||
|
|
||||||
NBSP = " "
|
NBSP = " "
|
||||||
|
|
@ -285,3 +285,48 @@ def test_clean_ocr_lines_removes_edge_noise_and_splits_baselines():
|
||||||
|
|
||||||
def test_heading_without_letters_is_dropped():
|
def test_heading_without_letters_is_dropped():
|
||||||
assert p.normalize_markdown("# ||\n\nText.\n") == "Text.\n"
|
assert p.normalize_markdown("# ||\n\nText.\n") == "Text.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ocr_rows_join_pieces_but_not_neighbour_lines():
|
||||||
|
segments = [ # [x0, y0, x1, y1, text, grundlinie]
|
||||||
|
[120, 227, 256, 239, "Sie kam vom Markte, und", 237],
|
||||||
|
[264, 227, 364, 239, "sie ging zum Markt,", 237],
|
||||||
|
[123, 245, 320, 256, "Ein bildhübsch, übermütig junges Ding.", 254],
|
||||||
|
[208, 414, 366, 436, "Gieb uns ein Lied zum besten", 430], # höherer Kasten
|
||||||
|
[123, 424, 198, 436, "Bis einer ruft:", 433],
|
||||||
|
]
|
||||||
|
rows = [row[4] for row in p._ocr_rows(segments)]
|
||||||
|
assert rows == [
|
||||||
|
"Sie kam vom Markte, und sie ging zum Markt,",
|
||||||
|
"Ein bildhübsch, übermütig junges Ding.",
|
||||||
|
"Bis einer ruft: Gieb uns ein Lied zum besten",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@needs_tesseract
|
||||||
|
def test_keep_lines_on_scan_uses_ocr_lines_and_outline_title(tmp_path):
|
||||||
|
doc = pymupdf.open()
|
||||||
|
page = doc.new_page(width=420, height=595)
|
||||||
|
y = 90
|
||||||
|
page.insert_text((150, y), "Unterwegs.", fontname="tiro", fontsize=16)
|
||||||
|
for line in ["Sie kam vom Markte, und sie ging zum Markt,",
|
||||||
|
"Ein bildhübsches, übermütig junges Ding.", None,
|
||||||
|
"Die Laute ihr von der Schulter hing,",
|
||||||
|
"Ein rotes Tuch sie überm Arme trug."]:
|
||||||
|
y += 30 if line is None else 16
|
||||||
|
if line:
|
||||||
|
page.insert_text((80, y), line, fontname="tiro", fontsize=12)
|
||||||
|
page.insert_text((10, 300), "|", fontsize=14) # Buchkante
|
||||||
|
scan = pymupdf.open()
|
||||||
|
scan.new_page(width=420, height=595).insert_image(
|
||||||
|
scan[0].rect, pixmap=page.get_pixmap(dpi=300))
|
||||||
|
scan.set_toc([[1, "Unterwegs", 1]])
|
||||||
|
scan.save(tmp_path / "gedicht.pdf")
|
||||||
|
|
||||||
|
out = tmp_path / "gedicht.md"
|
||||||
|
run(tmp_path / "gedicht.pdf", out, "--keep-lines")
|
||||||
|
md = out.read_text(encoding="utf-8")
|
||||||
|
assert "# Unterwegs\n\n" in md # Titel aus der Gliederung
|
||||||
|
assert "Markt,\\\nEin bildh" in md # Verse als Zeilen
|
||||||
|
assert "Ding.\n\nDie Laute" in md # Strophengrenze
|
||||||
|
assert "|" not in md.split("---", 2)[2]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue