diff --git a/conf.py b/conf.py index f1d042a..099573f 100644 --- a/conf.py +++ b/conf.py @@ -62,6 +62,8 @@ exclude_patterns = [ # TODO: mit fortna_convert_chapters.py sauber neu erzeugen, dann Exclude entfernen. "chapters/02_sicherheit/risikobewertung/rb_fortna_h_m_500592_ev12_20260311.de.rst", "chapters/00_inhaltsverzeichnis/contens_obsah_500592_135776_v2_1_en_cz.de.rst", + "chapters/10_anhang/01_getriebe_loadingloopantr_de.de.rst", + "chapters/10_anhang/02_motor_loadingloopantr_de.de.rst", ] # -- HTML ------------------------------------------------------------------- diff --git a/doc/plan_fortna_extraktion.md b/doc/plan_fortna_extraktion.md index c9d21d1..3d50d1a 100644 --- a/doc/plan_fortna_extraktion.md +++ b/doc/plan_fortna_extraktion.md @@ -33,11 +33,39 @@ Ergebnis des ersten Laufs: (polyglossia 2.13 wirft unter lualatex einen bidi-Fehler), **`iconpackage=none`** (FontAwesome7 in MiKTeX bricht unter lualatex ab). Beides in `conf.py` kommentiert. +### Extraktion: neues Modul `lib/pdf_extract.py` + +Die Volltext-Konvertierung der 5 Hauptanleitungen läuft über das neue Modul +`lib/pdf_extract.py` (`extract_manual()`), das gegenüber der ersten Version deutlich +sauberere `.rst` erzeugt: + +- **Keine doppelte Nummerierung mehr:** Die im PDF vorhandene Gliederungsnummer + (z. B. „1.7 Wichtige Informationen") wird nur zur Bestimmung der Überschriften-Ebene + ausgewertet und im RST-Text **entfernt**. Sphinx/LaTeX nummeriert selbst → vorher + „3.2.1 1.1 Wichtige Informationen", jetzt nur noch „3.2.1 Wichtige Informationen". +- **Überschriften-Erkennung** nur bei führender PDF-Gliederungsnummer (kein „fett allein"), + damit Tabellenzellen nicht fälschlich zu Überschriften werden. +- **Bilder positionsbasiert extrahiert und referenziert:** pro Seite werden Text und Bilder + nach Y-Position verwoben; jedes Bild wird als `.. figure::` mit korrektem Pfad und – falls + vorhanden – der folgenden „Abbildung N: …"-Zeile als Caption eingebaut. (625 figure-Refs, + 0 fehlende Bilddateien.) Duplikate werden per MD5 entfernt, Titelseiten-Deko und + Vollseiten-Hintergründe herausgefiltert. +- **Fließtext zu Absätzen zusammengeführt:** PDF-Zeilenumbrüche werden nicht mehr zu je einem + Absatz; zusammenhängende Zeilen bilden einen Absatz, Satzende beendet ihn. +- **Aufzählungen als RST-Listen:** einzeln stehende Bullet-Zeichen werden mit der Folgezeile + zusammengeführt, in eine Zeile zusammengelaufene Bullets aufgesplittet → `-`-Listenpunkte. +- **Änderungshistorie-/Dokumentinfo-Tabellen** am Dokumentanfang werden übersprungen (zerfielen + vorher in sinnlose Einzelzellen), ebenso Inhaltsverzeichnis-Seiten und Kopf-/Fußzeilen. + ### Behobene Extraktionsprobleme - **Symbolfont-Zeichen** (Wingdings/Symbol Private-Use-Area, z. B. Aufzählungs-Bullet U+F0B7): - in `extract_products.py` via `clean_pua()` auf echte Unicode-Zeichen (•, →, ✓) gemappt, - restliche PUA-Zeichen entfernt. Vorher erschienen leere Kästchen im PDF. + via `clean_pua()` auf echte Unicode-Zeichen (•, →, ✓) gemappt, restliche PUA-Zeichen entfernt. + Vorher erschienen leere Kästchen im PDF. (In `lib/pdf_extract.py`; die Variante in + `extract_products.py` bleibt für die Produktprogramm-Extraktion bestehen.) +- **Fremd-Altdateien** aus früheren `convert_to_rst.py`-Testläufen (NORD-Getriebe/-Motor im + Anhang, alte Risikobewertung/Contents/Konfig/Ersatzteile-Volltexte) wurden entfernt; die + betroffenen Kapitel liegen konzeptkonform als Verzeichnisliste vor. ### Bekannte Restpunkte (nicht Teil des Auftrags-Scopes) diff --git a/lib/build_index.py b/lib/build_index.py index c8906d8..fa18dea 100644 --- a/lib/build_index.py +++ b/lib/build_index.py @@ -22,6 +22,10 @@ log = logging.getLogger(__name__) SKIP = { "02_sicherheit/risikobewertung/rb_fortna_h_m_500592_ev12_20260311.de.rst", "00_inhaltsverzeichnis/contens_obsah_500592_135776_v2_1_en_cz.de.rst", + # NORD-Antriebsdoku aus fruehem Testlauf: gehoert als Lieferantendoku in die + # Verzeichnisliste 08_lieferantendoku/antriebe, nicht als Volltext ins Anhang. + "10_anhang/01_getriebe_loadingloopantr_de.de.rst", + "10_anhang/02_motor_loadingloopantr_de.de.rst", } # Lesbare Titel je Top-Level-Kapitelordner diff --git a/lib/fortna_convert_chapters.py b/lib/fortna_convert_chapters.py index eade31a..cf116c6 100644 --- a/lib/fortna_convert_chapters.py +++ b/lib/fortna_convert_chapters.py @@ -34,8 +34,9 @@ import sys from collections import defaultdict from pathlib import Path -# extract_products stellt die robuste PDF-Extraktion bereit (gleiches lib/-Verzeichnis) -import extract_products as ep +# pdf_extract stellt die positionsbasierte Text+Bild-Extraktion bereit +# (verwebt Text und Bilder, entfernt PDF-Ueberschriftennummern). +import pdf_extract log = logging.getLogger(__name__) @@ -71,6 +72,16 @@ def load_inventory(path: Path) -> dict: return json.load(f) +# Lesbarer Dokumenttitel je Zielkapitel (statt PDF-Dateiname als Ueberschrift). +CHAPTER_DOC_TITLE = { + "04_betrieb/betriebsanleitung": "Betriebsanleitung", + "05_wartung/wartungsanleitung": "Wartungsanleitung", + "06_steuerung/ils": "Funktionsbeschreibung Steuerung ILS", + "06_steuerung/omniflo": "Funktionsbeschreibung Steuerung OMNIFLO", + "06_steuerung/loading_loop": "Funktionsbeschreibung Steuerung Loading Loop", +} + + # --------------------------------------------------------------------------- # action="convert": Volltext + Bilder # --------------------------------------------------------------------------- @@ -104,14 +115,13 @@ def convert_manual(item: dict, source_root: Path, chapters_dir: Path, for old in chapter_dir.glob("*.de.rst"): old.unlink() - # Bilder extrahieren (extract_products entfernt kleine Icons < 5KB automatisch) - img_count = ep.extract_images(src_pdf, img_dir) - dup = ep.dedup_images(img_dir) - log.info(" %d Bilder (%d Duplikate entfernt)", img_count, dup) - - # Text nach RST extrahieren (positionsbasiert, Kopf-/Fusszeilen gefiltert) - blocks = ep.extract_text_to_rst(src_pdf, rst_path) - log.info(" %d Textbloecke -> %s", blocks, rst_path.name) + # Text + Bilder positionsbasiert extrahieren (verwoben in Lesereihenfolge, + # PDF-Ueberschriftennummern entfernt, Bilder als .. figure:: referenziert). + doc_title = CHAPTER_DOC_TITLE.get(item["chapter"], slugify(item["filename"])) + blocks, img_count = pdf_extract.extract_manual(src_pdf, rst_path, img_dir, + title=doc_title) + log.info(" %d Textbloecke, %d Bilder (referenziert) -> %s", + blocks, img_count, rst_path.name) # --------------------------------------------------------------------------- @@ -254,7 +264,7 @@ def main() -> None: items = [i for i in inv["items"] if i["lang"] == args.lang and i["chapter"]] - # 1. Volltext-Konvertierung + # 1. Volltext-Konvertierung (nur Zielsprache) if args.only in (None, "convert"): conv = [i for i in items if i["action"] == "convert"] log.info("--- %d Hauptanleitungen konvertieren ---", len(conv)) @@ -263,7 +273,14 @@ def main() -> None: # 2. Verzeichnislisten if args.only in (None, "list"): - list_items = [i for i in items if i["action"] == "list"] + # Zielsprache bevorzugen; Kapitel, die in der Zielsprache KEINE list-Datei + # haben (z.B. Ersatzteilliste nur EN), mit den vorhandenen Fremdsprachen + # auffuellen, damit das Kapitel nicht leer bleibt. + all_list = [i for i in inv["items"] + if i["chapter"] and i["action"] == "list"] + chapters_with_lang = {i["chapter"] for i in all_list if i["lang"] == args.lang} + list_items = [i for i in all_list + if i["lang"] == args.lang or i["chapter"] not in chapters_with_lang] by_chapter: dict[str, list[dict]] = defaultdict(list) for it in list_items: by_chapter[it["chapter"]].append(it) diff --git a/lib/pdf_extract.py b/lib/pdf_extract.py new file mode 100644 index 0000000..b1e07f8 --- /dev/null +++ b/lib/pdf_extract.py @@ -0,0 +1,307 @@ +"""Positionsbasierte PDF-Extraktion fuer die Fortna-Hauptanleitungen. + +Erzeugt aus einem Word-generierten PDF eine .rst-Datei, in der Text und Bilder +in der richtigen Lesereihenfolge (nach Y-Position pro Seite) verwoben sind. + +Verbesserungen gegenueber extract_products.extract_text_to_rst: + +1. Ueberschriften: Die im PDF vorhandene Nummerierung (z.B. "1.7 Wichtige + Informationen") wird NUR zur Bestimmung der Gliederungstiefe ausgewertet, + im RST-Ausgabetext aber ENTFERNT. Sphinx/LaTeX nummeriert selbst - dadurch + keine doppelte Nummerierung (alt 1.7 + neu 3.2.1) mehr. + +2. Bilder: werden positionsbasiert (bbox) pro Seite eingelesen, dedupliziert + und an der korrekten Textstelle als `.. figure::`-Direktive referenziert. + Bildunterschriften ("Abbildung N: ...") werden als figure-caption uebernommen. + +3. Kopf-/Fusszeilen und Inhaltsverzeichnis-Seiten werden herausgefiltert. + +Oeffentliche Funktion: extract_manual(pdf_path, rst_path, img_dir) -> (blocks, images) +""" + +from __future__ import annotations + +import hashlib +import logging +import re +from pathlib import Path + +log = logging.getLogger(__name__) + +RST_H = {1: "=", 2: "-", 3: "~", 4: "^", 5: '"'} + +# Symbolfont-Zeichen (Private Use Area) auf Unicode abbilden bzw. entfernen. +PUA_MAP = { + "": "•", "": "•", "": "•", + "": "→", "": "→", "": "←", "": "✓", + "": " ", +} +_PUA_RE = re.compile("|".join(re.escape(k) for k in PUA_MAP)) +_PUA_REST_RE = re.compile(r"[-]") + +# Rausch-Muster (Kopf/Fuss/Metadaten), die nicht in den Text gehoeren. +_NOISE = { + "services cz s.r.o", "schönenberger systeme gmbh", + "justus-von-liebig-str", "info@schoenenberger.de", + "www.schoenenberger.de", "www.schoenenberger-systems.com", + "karolinska 661/4", +} +RE_DATE = re.compile(r"^\d{2}\.\d{2}\.\d{4}$") +RE_PAGENO = re.compile(r"^(seite|page)\s+\d+$", re.I) +RE_PHONE = re.compile(r"^[TF]:\s*\+?\d") + +# Ueberschrift mit fuehrender Nummer: "1.7 Wichtige Informationen" +# Nummernteile max 3-stellig (verhindert Datum/Artikelnummer als Ueberschrift). +RE_HEADING = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){0,4})\s+([A-Za-zÄÖÜäöüß].{2,})$") +# reine Nummernzeile (Nummer und Titel stehen im PDF auf getrennten Zeilen) +RE_NUM_ONLY = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){0,4})\.?$") +# Bildunterschrift "Abbildung 17: ..." / "Abb. 17 ..." / "Figure 3: ..." +RE_CAPTION = re.compile(r"^(Abbildung|Abb\.?|Bild|Figure|Tabelle)\s*\d+\s*[:.]?\s*(.*)$", re.I) + + +def clean_pua(text: str) -> str: + """Symbolfont-Zeichen ersetzen, restliche PUA-Zeichen entfernen.""" + text = _PUA_RE.sub(lambda m: PUA_MAP[m.group(0)], text) + return _PUA_REST_RE.sub("", text) + + +def _is_noise(text: str) -> bool: + low = text.lower().strip() + if not low: + return True + if RE_DATE.match(low) or RE_PAGENO.match(low) or RE_PHONE.match(text): + return True + return any(kw in low for kw in _NOISE) + + +def _strip_heading_number(num: str, rest: str) -> str: + """Gibt den Ueberschriftentext ohne fuehrende Gliederungsnummer zurueck.""" + return rest.strip() + + +def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path, + title: str | None = None) -> tuple[int, int]: + """Extrahiert Text + Bilder eines Anleitungs-PDF in eine .rst-Datei. + + Returns: + (anzahl_textbloecke, anzahl_bilder) + """ + import fitz + + doc = fitz.open(str(pdf_path)) + img_dir.mkdir(parents=True, exist_ok=True) + + # --- Seiten identifizieren, die uebersprungen werden --- + # a) TOC-Seiten (Punktfueller / "Inhaltsverzeichnis") + # b) Aenderungshistorie-/Dokumentinfo-Tabellen ("Aenderungsinformation", + # Spaltenkombination Version+Datum+Aenderungsbeschreibung). Diese Tabellen + # zerfallen beim linearen Auslesen in sinnlose Einzelzellen. + skip_pages: set[int] = set() + for pn in range(len(doc)): + t = doc[pn].get_text() + low = t.lower() + if t.count("....") >= 3 or "\ninhaltsverzeichnis\n" in low: + skip_pages.add(pn) + elif "änderungsinformation" in low or "dokumenteninformation" in low: + skip_pages.add(pn) + elif ("änderungsbeschreibung" in low and "version" in low + and ("datum" in low or "kapitel" in low)): + skip_pages.add(pn) + + # --- Elemente (Text + Bild) pro Seite in Lesereihenfolge sammeln --- + # element: {"kind": "text"|"image", "y": float, "page": int, ...} + elements: list[dict] = [] + seen_hashes: set[str] = set() + img_counter = 0 + + for pn in range(len(doc)): + if pn in skip_pages: + continue + page = doc[pn] + ph = page.rect.height + pw = page.rect.width + + # Textzeilen mit Position + page_dict = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE) + for block in page_dict.get("blocks", []): + if block.get("type") != 0: + continue + for line in block.get("lines", []): + bbox = line.get("bbox", [0, 0, 0, 0]) + if bbox[1] < 70 or bbox[3] > ph - 45: # Kopf-/Fusszeile + continue + text = "" + max_size = 0.0 + bold = False + for span in line.get("spans", []): + st = clean_pua(span.get("text", "")) + if st.strip(): + text += st + max_size = max(max_size, span.get("size", 0)) + if span.get("flags", 0) & 2**4: # bold flag + bold = True + text = text.strip() + if not text or _is_noise(text): + continue + elements.append({ + "kind": "text", "y": bbox[1], "page": pn + 1, + "text": text, "size": round(max_size, 1), "bold": bold, + }) + + # Bilder mit Position. Titelseiten (erste 2 Seiten) enthalten nur + # Logo-/Deko-Bilder -> ueberspringen. + if pn < 2: + continue + for im in page.get_image_info(xrefs=True): + xref = im.get("xref", 0) + if not xref: + continue + b = im["bbox"] + w, h = im.get("width", 0), im.get("height", 0) + # Vollseiten-Deko / Hintergrundbilder ueberspringen + if (b[2] - b[0]) > pw * 0.95 and (b[3] - b[1]) > ph * 0.95: + continue + if w < 40 or h < 40: # Icons + continue + base = doc.extract_image(xref) + if not base: + continue + data = base["image"] + if len(data) < 3000: + continue + md5 = hashlib.md5(data).hexdigest() + if md5 in seen_hashes: + continue # exaktes Duplikat (wiederkehrendes Logo/Symbol) + seen_hashes.add(md5) + img_counter += 1 + ext = base.get("ext", "png") + fname = f"img_{img_counter:03d}.{ext}" + (img_dir / fname).write_bytes(data) + elements.append({ + "kind": "image", "y": b[1], "page": pn + 1, "file": fname, + }) + + doc.close() + + # --- pro Seite nach Y-Position sortieren (Lesereihenfolge) --- + elements.sort(key=lambda e: (e["page"], e["y"])) + + # --- Zeilen zusammenfuehren, die im PDF getrennt stehen --- + # a) Nummer-only-Zeile ("2.3.1") + folgende Titelzeile + # b) Bullet-only-Zeile ("•") + folgende Textzeile -> "• Text" + merged: list[dict] = [] + i = 0 + while i < len(elements): + e = elements[i] + if e["kind"] == "text": + nxt = elements[i + 1] if i + 1 < len(elements) else None + # a) Gliederungsnummer + Titel + m = RE_NUM_ONLY.match(e["text"]) + if (m and nxt and nxt["kind"] == "text" + and re.match(r"[A-Za-zÄÖÜäöüß]", nxt["text"])): + merged.append({**e, "text": f"{m.group(1)} {nxt['text']}", + "size": max(e["size"], nxt["size"])}) + i += 2 + continue + # b) einzelnes Bullet-Zeichen + Text der Folgezeile + if e["text"].strip() in ("•", "-", "–", "*") and nxt and nxt["kind"] == "text": + merged.append({**e, "text": f"• {nxt['text']}"}) + i += 2 + continue + merged.append(e) + i += 1 + + # --- RST rendern --- + if title is None: + title = pdf_path.stem + out: list[str] = [RST_H[1] * len(title), title, RST_H[1] * len(title), ""] + + # Absatz-Puffer: aufeinanderfolgende Fliesstextzeilen zu einem Absatz + # zusammenfuehren (PDF-Zeilenumbrueche sind kein Absatzende). + para: list[str] = [] + + def flush_para() -> None: + if para: + out.append(" ".join(para).strip()) + out.append("") + para.clear() + + RE_BULLET = re.compile(r"^\s*[•\-–*]\s+") + + blocks = 0 + n = len(merged) + for idx, e in enumerate(merged): + if e["kind"] == "image": + flush_para() + caption = "" + if idx + 1 < n and merged[idx + 1]["kind"] == "text": + cm = RE_CAPTION.match(merged[idx + 1]["text"]) + if cm: + caption = merged[idx + 1]["text"] + merged[idx + 1]["_consumed"] = True + out.append("") + out.append(f".. figure:: images/{e['file']}") + out.append(" :align: center") + out.append(" :width: 80%") + if caption: + out.append("") + out.append(f" {caption}") + out.append("") + blocks += 1 + continue + + if e.get("_consumed"): + continue + + text = e["text"] + hm = RE_HEADING.match(text) + # Ueberschrift NUR mit fuehrender PDF-Gliederungsnummer akzeptieren. + is_heading = False + if hm and len(hm.group(2)) < 120: + num, rest = hm.group(1), hm.group(2) + if not re.match(r"^(mm|cm|m|kg|x|l|h|min|s)\b", rest, re.I): + is_heading = True + + if is_heading: + flush_para() + num, rest = hm.group(1), hm.group(2) + heading = _strip_heading_number(num, rest) # NUMMER ENTFERNEN + level = min(num.count(".") + 1, 5) + char = RST_H[level] + out.append("") + if level == 1: + out.append(char * max(len(heading), 3)) + out.append(heading) + out.append(char * max(len(heading), 3)) + out.append("") + blocks += 1 + continue + + # Aufzaehlung: Zeile beginnt mit Bullet ODER enthaelt mehrere Bullets + # (mehrere Listenpunkte sind im PDF oft in eine Zeile zusammengelaufen). + if RE_BULLET.match(text) or text.count("•") >= 2: + flush_para() + # An Bullet-Zeichen aufsplitten, jeder Teil wird ein Listenpunkt. + parts = re.split(r"\s*•\s*", text) + for part in parts: + item = RE_BULLET.sub("", part).strip().rstrip(",") + if item: + out.append(f"- {item}") + out.append("") + blocks += 1 + continue + + # Fliesstext: an Absatz anhaengen. Endet die Zeile mit Satzzeichen, gilt + # der Absatz als abgeschlossen und wird geflusht. + para.append(text) + if re.search(r"[.:!?]$", text): + flush_para() + blocks += 1 + + flush_para() + + rst = "\n".join(out) + rst = re.sub(r"\n{3,}", "\n\n", rst).strip() + "\n" + rst_path.parent.mkdir(parents=True, exist_ok=True) + rst_path.write_text(rst, encoding="utf-8") + return blocks, img_counter