"""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