diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 733b205..bb7d24b 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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) | | OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` (alte Ebene wird verworfen) | | 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`) | | 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. - **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 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 -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 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 - „Hinweis“. + +**Fraktur:** Das Modell `frk` aus dem Ubuntu-Paket ist alt und verwechselt oft ſ/f/k/l +(„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 („Geheim-|dienst“) zusammengefügt. diff --git a/README.md b/README.md index 12b3019..1605393 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ sudo apt install tesseract-ocr tesseract-ocr-deu pandoc 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 -zusätzlich `sudo apt install tesseract-ocr-frk` und `--ocr-language frk+deu`. +`tesseract-ocr` wird nur für Scans gebraucht, `pandoc` nur für `--epub`. Für Fraktur-Scans das +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 @@ -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 | | `--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`) | -| `--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 | ## Was die Nachbearbeitung macht @@ -131,9 +133,10 @@ synthetisch erzeugt. 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 als „|“, Buchstabenfetzen neben dem Textbereich) wird entfernt. -- Fraktur (`--ocr-language frk+deu`): Das lange s bleibt als „ſ“ erhalten; bei manchen Scans - legt die Zeilenbildung von pymupdf4llm zwei eng stehende Verszeilen zusammen - („UndDon tauchteweißen …“). +- Fraktur: Das lange s bleibt als „ſ“ erhalten; auch `deu_latf` liest alte Scans nicht fehlerfrei. +- Gedicht-Scans ohne `--keep-lines`: Die Layout-Analyse von pymupdf4llm kann eng stehende Verse + 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 Werkzeug besser geeignet (z. B. Marker, Nougat). - **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“), diff --git a/TODO.md b/TODO.md index b83a313..6ffd8be 100644 --- a/TODO.md +++ b/TODO.md @@ -8,8 +8,9 @@ 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. Für reine Gedichtbände: `--keep-lines`. -- Fraktur-Scans (Löwenberg 1895): Zwei eng stehende OCR-Zeilen werden von der Zeilenbildung - in pymupdf4llm/pymupdf-layout gelegentlich zu einer verschränkt („UndDon tauchteweißen“); - im OCR-PDF selbst sind sie getrennt. Idee: Option, „ſ“ in „s“ umzuwandeln. +- Gedicht-Scans ohne `--keep-lines`: pymupdf-layout verschränkt eng stehende OCR-Zeilen + („UndDon tauchteweißen“). Mit `--keep-lines` umgangen (Zeilen direkt aus Tesseract). +- `--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 “, „5 “) werden nicht immer als ein Codeblock erkannt; Programmausgaben ohne typische Merkmale bleiben Text. diff --git a/pdf2md.py b/pdf2md.py index bd5e066..ad483e5 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -14,6 +14,7 @@ Ablauf: import argparse import contextlib +import difflib import io import json import multiprocessing @@ -75,9 +76,10 @@ OMITTED_IMAGE = re.compile( ) # Text, den pymupdf4llm innerhalb von Bildern findet (Fotos, OCR-Reste). -PICTURE_TEXT = re.compile( - r"\*\*----- Start of picture text -----\*\*
.*?" - r"\*\*----- End of picture text -----\*\*
\n?", +PICTURE_TEXT = re.compile( # Format bis pymupdf4llm 1.27 bzw. ab 1.28 + r"(?:\*\*----- Start of picture text -----\*\*
|)" + r".*?" + r"(?:\*\*----- End of picture text -----\*\*
|)\n?", re.DOTALL, ) @@ -103,7 +105,8 @@ HTML_BLOCK = re.compile( # Seitenzahl allein in einer Zeile: "12", "- 12 -", "Seite 12" 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, ) @@ -409,6 +412,11 @@ def normalize_markdown( standalone(clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END))) 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): content = line.removesuffix(PARAGRAPH_END) 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,}") +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: + _dedupe_box_lines(boxes) _page_lines.clear() real: list[tuple[float, float]] = [] 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()): if run != RUN_OCR: 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 # alter und neuer Text doppelt da. Nur die geöffnete Kopie wird geändert. 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) +# --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: """Unsichtbaren Text (vorhandene OCR-Ebene) entfernen; Bild und Grafik bleiben.""" page.add_redact_annot(page.rect, fill=False) @@ -2392,6 +2530,10 @@ def main() -> int: with pymupdf.open(source) as doc: kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi) 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) anchors = load_link_anchors(doc) page_count = doc.page_count @@ -2438,6 +2580,7 @@ def main() -> int: ocr_dpi=args.ocr_dpi, jpeg_quality=args.jpeg_quality, keep_lines=args.keep_lines, + titles=titles, ) markdown = extract_markdown(source, plan, options, args.jobs) diff --git a/tests/test_verbatim.py b/tests/test_verbatim.py index a2139d7..f803e16 100644 --- a/tests/test_verbatim.py +++ b/tests/test_verbatim.py @@ -6,7 +6,7 @@ import pymupdf import pytest import pdf2md as p -from conftest import needs_pandoc +from conftest import needs_pandoc, needs_tesseract from test_e2e import run NBSP = " " @@ -285,3 +285,48 @@ def test_clean_ocr_lines_removes_edge_noise_and_splits_baselines(): def test_heading_without_letters_is_dropped(): 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]