""" extract_products.py - Extrahiert Bilder und Text aus Produktprogramm-PDFs. Verwendet PyMuPDF (fitz) fuer zuverlaessige Bild- und Textextraktion. Verwendung: python extract_products.py [--data-dir ] """ from __future__ import annotations import argparse import logging import re import sys from pathlib import Path log = logging.getLogger(__name__) # Mapping: PDF-Dateiname (Teilmatch) -> Zielordner fuer Bilder PDF_TARGETS: list[tuple[str, str]] = [ ("01_OMNIFLO", "omniflo"), ("02_ILS", "ils"), ("03_CPC", "cpc"), ("05_GERÜST", "geruest"), ("08_Trolley", "trolley"), ] # rST Ueberschriften-Zeichen nach Level RST_HEADING_CHARS = {1: "=", 2: "-", 3: "~", 4: "^"} def find_target(pdf_name: str) -> str | None: """Findet den Zielordner-Namen fuer eine PDF-Datei.""" for pattern, target in PDF_TARGETS: if pattern.lower() in pdf_name.lower(): return target return None def extract_images(pdf_path: Path, output_dir: Path) -> int: """Extrahiert alle Bilder aus einem PDF nach output_dir. Returns: Anzahl extrahierter Bilder. """ import fitz output_dir.mkdir(parents=True, exist_ok=True) doc = fitz.open(str(pdf_path)) count = 0 for page_num in range(len(doc)): page = doc[page_num] image_list = page.get_images(full=True) for img_idx, img_info in enumerate(image_list): xref = img_info[0] base_image = doc.extract_image(xref) if not base_image: continue image_bytes = base_image["image"] ext = base_image.get("ext", "png") # Kleine Bilder (Icons, Logos < 5KB) ueberspringen if len(image_bytes) < 5000: continue count += 1 img_name = f"page{page_num + 1:03d}_img{img_idx + 1:02d}.{ext}" img_path = output_dir / img_name img_path.write_bytes(image_bytes) log.debug(" Bild: %s (%d bytes)", img_name, len(image_bytes)) doc.close() return count def _extract_toc_numbers(doc) -> dict[str, str]: """Extrahiert Kapitelnummern aus dem Inhaltsverzeichnis des PDFs. Sucht TOC-Seiten (Seiten mit '....' Punkt-Fuellern) und ordnet Ueberschriftentexte ihren Kapitelnummern zu. Nur Eintraege mit expliziter Nummer werden erfasst (bis Level 2). Returns: Dict title_text -> kapitelnummer, z.B. {"Weichen Omniflo": "2.3"} """ toc_entries: dict[str, str] = {} for page_num in range(len(doc)): page = doc[page_num] text = page.get_text() if "...." not in text: continue lines = text.splitlines() current_number = "" i = 0 while i < len(lines): line = lines[i].strip() i += 1 if not line: continue # Kapitelnummer auf separater Zeile num_match = re.match(r"^(\d+(?:\.\d+)*)\s*$", line) if num_match: current_number = num_match.group(1) continue # Zeile mit Punkt-Fuellern = TOC-Eintrag dot_match = re.search(r"\.{4,}", line) if not dot_match: continue title = line[:dot_match.start()].strip() if not title: continue # Inline-Nummer am Anfang? "1.4 Abkürzungen ....." nm = re.match(r"^(\d+(?:\.\d+)*)\s+(.+)", title) if nm: toc_entries[nm.group(2).strip()] = nm.group(1) current_number = "" elif current_number: toc_entries[title] = current_number current_number = "" return toc_entries RE_DATE = re.compile(r"^\d{2}\.\d{2}\.\d{4}$") RE_COPYRIGHT = re.compile(r"^Copyright\s+\d{4}", re.IGNORECASE) RE_VERSION_LINE = re.compile(r"^Version\s*[:.]?\s*\d", re.IGNORECASE) # Bekannte Rausch-Muster: Autorinnen, Metadaten, Copyright-Zeilen _NOISE_KEYWORDS = { "annette schopper", "erstellt:", "freigabe:", "nicht freigegeben", "nur zur information", "schönenberger systeme gmbh", "justus-von-liebig-str", "info@schoenenberger.de", "www.schoenenberger.de", } def _is_noise(text: str) -> bool: """Erkennt Rausch-Zeilen die nicht ins RST gehoeren.""" lower = text.lower().strip() if RE_COPYRIGHT.match(text): return True if RE_DATE.match(text.strip()): return True if RE_VERSION_LINE.match(text): return True for kw in _NOISE_KEYWORDS: if kw in lower: return True # Seitenzahlen if re.match(r"^Seite\s+\d+$", text, re.IGNORECASE): return True # Einzelne kurze Jahreszahlen mit Autorin: "2020 Annette Schopper" if re.match(r"^\d{4}\s+[A-Z][a-z]+\s+[A-Z][a-z]+$", text): return True return False def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int: """Extrahiert Text aus PDF und schreibt ihn als .rst. Returns: Anzahl extrahierter Bloecke. """ import fitz doc = fitz.open(str(pdf_path)) # TOC-Nummern vorab extrahieren toc_numbers = _extract_toc_numbers(doc) raw_lines: list[dict] = [] for page_num in range(len(doc)): page = doc[page_num] page_height = page.rect.height 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]) # Positionsbasierte Kopf-/Fusszeilen-Erkennung # Kopfzeile: Y < 100pt (Logos, Kapitelname, OMNIFLO etc.) if bbox[1] < 100: continue # Fusszeile: Y > page_height - 50pt if bbox[3] > page_height - 50: continue text = "" max_size = 0.0 for span in line.get("spans", []): span_text = span.get("text", "").strip() if span_text: text += span_text + " " size = span.get("size", 0) if size > max_size: max_size = size text = text.strip() if not text: continue if _is_noise(text): continue raw_lines.append({ "text": text, "size": max_size, "page": page_num + 1, }) doc.close() if not raw_lines: return 0 # --- Phase 1: Nummern-Zeilen mit nachfolgender Text-Zeile zusammenfuehren --- # Im PDF stehen "2.3.1" und "Antriebsstationen" auf separaten Zeilen. # Zusammenfuehren nur wenn: # - Die Nummer ein Abschnitts-Format hat (N oder N.N.N...) # - Die naechste Zeile mit einem Buchstaben beginnt # - Die Schriftgroesse >= 12pt (verhindert Artikelnummern) RE_SECTION_NUM = re.compile(r"^(\d+(?:\.\d+)*)$") merged: list[dict] = [] i = 0 while i < len(raw_lines): m = RE_SECTION_NUM.match(raw_lines[i]["text"]) if m and i + 1 < len(raw_lines) and raw_lines[i]["size"] >= 12.0: next_line = raw_lines[i + 1] next_text = next_line["text"].strip() # Naechste Zeile muss mit Buchstabe beginnen if next_text and re.match(r"[A-Za-zÄÖÜäöüß]", next_text): merged.append({ "text": f"{raw_lines[i]['text']} {next_line['text']}", "size": max(raw_lines[i]["size"], next_line["size"]), "page": raw_lines[i]["page"], }) i += 2 continue merged.append(raw_lines[i]) i += 1 # --- Phase 2: Inhaltsverzeichnis / Index entfernen --- # Zuerst TOC-Seiten identifizieren (Seiten mit "...." Punkt-Fuellern) RE_TOC_LINE = re.compile(r"\.{4,}") toc_pages: set[int] = set() for entry in merged: if RE_TOC_LINE.search(entry["text"]): toc_pages.add(entry["page"]) if entry["text"].strip().lower() in ("inhalt", "inhaltsverzeichnis"): toc_pages.add(entry["page"]) # Alle Eintraege auf TOC-Seiten entfernen filtered: list[dict] = [] for entry in merged: if entry["page"] in toc_pages: continue filtered.append(entry) # --- Phase 3: Ueberschriften erkennen und RST generieren --- # Heading: Nummerierung wie "1 Text", "2.3 Text", "2.3.1.4 Text" # Text muss mit einem Buchstaben beginnen (verhindert "610 014 001" etc.) # Jeder Nummerierungsteil max 3 Stellen (verhindert Datumsangaben wie "10.12.2020") # Titeltext muss mit einem Wort von mind. 3 Buchstaben beginnen # (verhindert "2 B. def.", "19.8 mm (LW13)") RE_HEADING = re.compile( r"^(\d{1,3}(?:\.\d{1,3})*)\s+([A-Za-zÄÖÜäöüß]{3,}.*)$" ) rst_lines: list[str] = [] title = pdf_path.stem rst_lines.append(RST_HEADING_CHARS[1] * len(title)) rst_lines.append(title) rst_lines.append(RST_HEADING_CHARS[1] * len(title)) rst_lines.append("") prev_page = 0 block_count = 0 for entry in filtered: text = entry["text"] # Seitenumbruch-Kommentar if entry["page"] != prev_page: if prev_page > 0: rst_lines.append("") rst_lines.append(f".. Seite {entry['page']}") rst_lines.append("") prev_page = entry["page"] # Nummerierte Ueberschrift? # Fuer Level-1 (keine Punkte, z.B. "1 Text") zusaetzlich grosse # Schrift (>= 12pt) verlangen um Fehlerkennungen zu vermeiden. hm = RE_HEADING.match(text) is_heading = False if hm and len(hm.group(2)) < 120: num = hm.group(1) has_dots = "." in num is_heading = has_dots or entry["size"] >= 12.0 # Phase 3b: Nicht-nummerierte Texte mit grosser Schrift pruefen, # ob sie im TOC eine Kapitelnummer haben (fehlende PDF-Nummerierung) if not is_heading and entry["size"] >= 12.0: # Text gegen TOC abgleichen toc_num = toc_numbers.get(text) if toc_num: # Nummer aus TOC voranstellen candidate = f"{toc_num} {text}" hm = RE_HEADING.match(candidate) if hm: text = candidate is_heading = True log.debug(" TOC-Nummer ergaenzt: %s", text) if is_heading: num = hm.group(1) heading_text = f"{num} {hm.group(2)}" # Level aus Tiefe der Nummerierung: 1=level1, 1.1=level2, etc. depth = num.count(".") + 1 level = min(depth, 4) char = RST_HEADING_CHARS[level] rst_lines.append("") if level == 1: rst_lines.append(char * len(heading_text)) rst_lines.append(heading_text) rst_lines.append(char * len(heading_text)) rst_lines.append("") else: rst_lines.append(text) rst_lines.append("") block_count += 1 # Mehrfache Leerzeilen reduzieren rst = "\n".join(rst_lines) rst = re.sub(r"\n{3,}", "\n\n", rst) # 9-stellige Nummern mit Leerzeichen zusammenfuegen: "834 372 007" -> "834372007" rst = re.sub(r"\b(\d{3})\s(\d{3})\s(\d{3})\b", r"\1\2\3", rst) rst = re.sub(r"\b(\d{3})\s(\d{6})\b", r"\1\2", rst) rst = re.sub(r"\b(\d{6})\s(\d{3})\b", r"\1\2", rst) rst = rst.strip() + "\n" rst_path.parent.mkdir(parents=True, exist_ok=True) rst_path.write_text(rst, encoding="utf-8") return block_count def dedup_images(img_dir: Path) -> int: """Entfernt doppelte Bilder per MD5-Hash aus einem Verzeichnis. Returns: Anzahl entfernter Duplikate. """ import hashlib if not img_dir.exists(): return 0 seen: dict[str, Path] = {} removed = 0 for img_path in sorted(img_dir.iterdir()): if not img_path.is_file(): continue md5 = hashlib.md5(img_path.read_bytes()).hexdigest() if md5 in seen: log.debug(" Duplikat entfernt: %s (== %s)", img_path.name, seen[md5].name) img_path.unlink() removed += 1 else: seen[md5] = img_path return removed def process_all(data_dir: Path) -> None: """Verarbeitet alle konfigurierten PDFs.""" pp_dir = data_dir / "Produktprogramm" images_base = pp_dir / "images" if not pp_dir.exists(): log.error("Verzeichnis nicht gefunden: %s", pp_dir) sys.exit(1) pdf_files = list(pp_dir.glob("*.pdf")) if not pdf_files: log.error("Keine PDF-Dateien in %s gefunden", pp_dir) sys.exit(1) log.info("Gefunden: %d PDF-Dateien in %s", len(pdf_files), pp_dir) for pdf_path in sorted(pdf_files): target = find_target(pdf_path.name) if target is None: log.info("Uebersprungen (kein Ziel konfiguriert): %s", pdf_path.name) continue log.info("Verarbeite: %s -> %s", pdf_path.name, target) # Bilder extrahieren und Duplikate entfernen img_dir = images_base / target img_count = extract_images(pdf_path, img_dir) dup_count = dedup_images(img_dir) log.info(" %d Bilder extrahiert, %d Duplikate entfernt -> %d einzigartig in %s", img_count, dup_count, img_count - dup_count, img_dir) # Text als RST extrahieren rst_name = pdf_path.stem + ".de.rst" rst_path = pp_dir / rst_name block_count = extract_text_to_rst(pdf_path, rst_path) log.info(" %d Textbloecke extrahiert nach %s", block_count, rst_path.name) log.info("Fertig.") def main() -> None: parser = argparse.ArgumentParser( description="Extrahiert Bilder und Text aus Produktprogramm-PDFs" ) parser.add_argument("--data-dir", type=Path, default=None, help="data/ Verzeichnis (default: aus PV_DATA oder ./data)") parser.add_argument("--verbose", "-v", action="store_true", help="Ausfuehrliche Ausgabe") args = parser.parse_args() logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(levelname)-7s %(message)s", ) import os if args.data_dir: data_dir = args.data_dir.resolve() elif os.environ.get("PV_DATA"): data_dir = Path(os.environ["PV_DATA"]).resolve() else: data_dir = Path("data").resolve() log.info("Data-Verzeichnis: %s", data_dir) process_all(data_dir) if __name__ == "__main__": main()