From 61f1430821782561104bdd3c9be68f9db31131e9 Mon Sep 17 00:00:00 2001 From: Michael Stangl Date: Thu, 30 Apr 2026 15:30:43 +0200 Subject: [PATCH] Hilfskripte zum Extrakt aus dem Produktprogramm erstellt. Kernkonzepte in README.md und doc --- README.md | 40 ++++- doc/Kernkonzepte.md | 222 ++++++++++++++++++++++++++++ lib/collect_partnumbers.py | 290 +++++++++++++++++++++++++++++++++++++ lib/create_examples.py | 159 ++++++++++++++++++++ lib/extract_products.py | 109 +++++++++++--- 5 files changed, 801 insertions(+), 19 deletions(-) create mode 100644 doc/Kernkonzepte.md create mode 100644 lib/collect_partnumbers.py create mode 100644 lib/create_examples.py diff --git a/README.md b/README.md index 8c93cb0..da4e308 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,17 @@ Das Zielsystem besteht aus zwei Repos: - **projekt-kunde-xyz** (pro Anlage): `anlage.json` (ERP-Export), projektspezifische Bilder, Overrides, `conf.py`, `build.py`. Bindet das Base-Repo per Symlink/Mount ein. ### Technologie-Stack +| Komponente | Technologie | +|------------------|--------------------------------------------------| +| Build-System | Sphinx (Python) | +| Textformat | reStructuredText (rST) | +| Templating | Jinja2 (Rahmenkapitel, Titelseite) | +| PDF-Erzeugung | LaTeX (pdflatex/lualatex) | +| Versionierung | Git (Gitea auf Hetzner) | +| Konfiguration | JSON (ERP-Export) + YAML (Modul-Metadaten) | +| CI/Automatisierung | Gitea Actions oder n8n | +| Bildkonvertierung | ezdxf → SVG, Pillow (Optimierung) | -Sphinx + reStructuredText + Jinja2-Templating → LaTeX → PDF. Konfiguration via JSON (ERP-Export) + YAML (Modul-Metadaten). Bildkonvertierung: ezdxf → SVG, Pillow. ### Build-Pipeline (zu implementieren) @@ -23,6 +32,7 @@ anlage.json → build.py → Versionscheck → Abhängigkeiten auflösen (meta.y → pdflatex → PDF pro Sprache ``` + ## Projektstruktur ```text @@ -64,6 +74,34 @@ docu_build/ └── results/ # Build-Ausgaben (nicht im Git) ``` +Ein Kundenprojekt enthält nur noch die Verweise auf die verbauten Teile, overrides und additionals, also das Überschreiben von vorhandenen Texten oder Ergänzungen + +``` +projekt-kunde-xyz-2026/ +├── conf.py # Importiert conf_base.py vom Base-Repo +├── anlage.json # ERP-Export: Maschinenauswahl + Metadaten +├── build.py # Hauptbuild-Skript +├── Makefile +├── README.md +├── images/ # Projektspezifische Bilder +│ ├── aufstellplan_halle3.pdf +│ ├── foto_abnahme_01.jpg +│ └── kundenlogo.pdf +├── overrides/ # Projektspezifische Überschreibungen +│ ├── modules/ +│ │ └── weiche_typ_a/ +│ │ └── beschreibung.de.rst # Überschreibt Basis wenn vorhanden +│ └── chapters/ +│ └── 01_einleitung/ +│ └── template.de.rst # Kundenspezifische Einleitung +├── additional/ # Kapitel die NUR dieses Projekt hat +│ ├── sonderausfuehrung_xyz.de.rst +│ └── sonderausfuehrung_xyz.en.rst +└── transport-docs-base/ ──────────── # Symlink/Mount zum Base-Repo (Share) +``` + +Details zur Konfiguration und Aufbau einer Doku siehe `doc/Kernkonzepte.md`. + ### Kapitelstruktur | Kapitel | Inhalt | diff --git a/doc/Kernkonzepte.md b/doc/Kernkonzepte.md new file mode 100644 index 0000000..52e3969 --- /dev/null +++ b/doc/Kernkonzepte.md @@ -0,0 +1,222 @@ +# Kernkonzepte + +### anlage.json (ERP-Export) + +Steuert die Zusammenstellung. Enthält Projektmetadaten, Zielsprachen, Normenregion, Liste verbauter Maschinen und Referenzen auf Zusatzkapitel. + +**Beispiel:** + +```json +{ + "projekt": { + "nummer": "P-2026-0042", + "kunde": "Müller Automotive GmbH", + "kunde_logo": "images/kundenlogo.pdf", + "standort": "Werk Ingolstadt, Halle 3", + "sprachen": ["de", "en"], + "normen_region": "EU", + "base_version": ">=1.0.0" + }, + "maschinen": [ + { + "modul": "weiche_typ_a", + "anzahl": 4, + "positionen": ["W01", "W02", "W03", "W04"], + "variante": "standard" + }, + { + "modul": "hubstation", + "anzahl": 2, + "positionen": ["H01", "H02"], + "variante": "schwerlast", + "optionen": ["hydraulisch"] + }, + { + "modul": "antrieb_sew", + "anzahl": 12, + "positionen": ["A01-A12"] + }, + { + "modul": "steuerung_siemens_s7", + "anzahl": 1, + "positionen": ["SPS01"], + "optionen": ["profinet", "hmi_tp700"] + } + ], + "zusatz_kapitel": [ + "additional/sonderausfuehrung_xyz" + ] +} +``` + +### meta.yaml (Modul-Metadaten) + +Pro Maschinentyp-Modul. Definiert Bezeichner, mehrsprachigen Namen, Kapitelzuordnung, Bildreferenzen und Abhängigkeiten. + +**Beispiel** (`modules/weiche_typ_a/meta.yaml`): + +```yaml +module: weiche_typ_a +display_name: + de: "Weiche Typ A" + en: "Switch Type A" + +chapters: + - 02_sicherheit: + sections: [risikomatrix] + - 03_anlagenbeschreibung: + sections: [beschreibung, technische_daten] + - 05_wartung: + sections: [wartung, schmierplan] + +images: + shared: + - photos/weiche_typ_a_foto_01.jpg + - drawings/weiche_typ_a_layout.svg + - diagrams/stromlaufplan_weiche_a.pdf + localized: + - diagramme/risikograph_weiche_a.svg + - screenshots/hmi_hauptmenu.png + module_local: + - montage_detail_01.jpg + +requires: + - antrieb_sew +``` + +### conf.py (Projekt-Konfiguration) + +Importiert die Basiskonfiguration vom gemounteten Base-Repo und setzt projektspezifische Overrides. + +**Beispiel** (`projekt-kunde-xyz-2026/conf.py`): + +```python +import sys +import os +from pathlib import Path + +# ══ Pfad zum Base-Repo (Share/Mount) ══ +BASE_DOCS = Path(os.environ.get( + "DOCS_BASE_PATH", + Path(__file__).parent / "transport-docs-base" +)) + +# Extensions und Basiskonfiguration importieren +sys.path.insert(0, str(BASE_DOCS / "_ext")) +sys.path.insert(0, str(BASE_DOCS)) +from conf_base import * # noqa: F401, F403 + +# ══ Projektspezifische Overrides ══ +project = "Anlagendokumentation P-2026-0042" +release = "1.0" +author = "Mista GmbH" +copyright = "2026, Mista GmbH" + +# Bildpfade: Projekt → Overrides → Base (Reihenfolge = Priorität) +image_search_path = [ + "images", # Projektbilder + "overrides", # Überschreibungen + str(BASE_DOCS / "assets" / "shared"), # Sprachneutrale Basis + str(BASE_DOCS / "assets" / "localized"), # Sprachspezifische Basis + str(BASE_DOCS / "modules"), # Modulbilder +] + +# Sphinx-Extensions aus Base-Repo laden +extensions += [ + "anlagen_builder", + "localized_figure", + "glossary_loader", +] + +# Glossar-Pfad +glossary_csv_path = str(BASE_DOCS / "glossary") + +# LaTeX-Templates aus Base-Repo +templates_path.insert(0, str(BASE_DOCS / "_templates")) + +# LaTeX-spezifisch +latex_elements = { + "preamble": open(BASE_DOCS / "_templates" / "latex" / "preamble.tex").read(), + "maketitle": open(BASE_DOCS / "_templates" / "latex" / "maketitle.tex").read(), + "papersize": "a4paper", + "pointsize": "11pt", +} +``` + +### Override-Mechanismus + +Suchreihenfolge beim Build: +1. `projekt/overrides/` (projektspezifisch) +2. `transport-docs-base/modules/` bzw. `chapters/` (allgemein) + +Ermöglicht kundenspezifische Anpassungen ohne Base-Repo-Änderung. + +### Versionskontrolle + +Base-Repo hat Semver in `VERSION`. Projekt-Repo kann `"base_version": ">=2.1.0"` in anlage.json setzen. build.py prüft Kompatibilität vor dem Build. + +## Mehrsprachigkeit + +### Drei Ebenen + +1. **Textbausteine**: Dateinamenskonvention `{name}.{lang}.rst`. Fallback auf `de` bei fehlender Übersetzung. +2. **Bilder**: `localized-figure`-Direktive löst `assets/localized/{lang}/pfad/bild.png` auf. Fallback auf `de`. Optional: Jinja2-SVG-Templates für Diagramme mit Text. +3. **Glossar**: CSV pro Sprache (`terms.{lang}.csv`), `glossary_loader.py` erzeugt Sphinx-Glossardirektiven. Im Text via `:term:`Fachbegriff`` verlinkt. + +### Sprachspezifische Normen + +Gesteuert über `normen_region` in anlage.json. Kapitel 02_sicherheit enthält z.B. `normen_ce.de.rst` (EU) und `normen_ukca.en.rst` (UK), Build wählt nach Region. + +## Bildverwaltung + +| Kategorie | Pfad | Beispiele | +|------------------|-----------------------------------|----------------------------------------------| +| Sprachneutral | `assets/shared/{typ}/` | Fotos, DXF/SVG-Zeichnungen, ISO-Symbole | +| Sprachspezifisch | `assets/localized/{lang}/{typ}/` | HMI-Screenshots, Risikographen, Typenschilder| +| Modulspezifisch | `modules/{modul}/images/` | Montagedetails | +| Projektspezifisch| `projekt/images/` | Aufstellpläne, Abnahmefotos, Kundenlogo | + +Formate: SVG/PDF für Zeichnungen (vektoriell), JPG für Fotos (max 2000px, 85% Qualität). + +## Build-Prozess + +``` +anlage.json ──→ build.py + │ + ├─ Phase 1: Konfiguration einlesen, Versionscheck + ├─ Phase 2: Abhängigkeiten auflösen (meta.yaml, transitiv) + │ Overrides anwenden (Projekt > Base) + │ Fehlende Module/Bilder/Übersetzungen melden + ├─ Phase 3: Sphinx-Quellen generieren (index.rst, toctrees) + │ Glossar aus CSV erzeugen + │ Jinja2-Templates mit Projektdaten füllen + └─ Phase 4: sphinx-build -b latex → pdflatex → PDF (pro Sprache) + +Ausgabe: + _output/P-2026-0042_Dokumentation_DE.pdf + _output/P-2026-0042_Documentation_EN.pdf +``` + +## Sphinx-Extensions (zu implementieren) + +### anlagen_builder.py +- Liest anlage.json + meta.yaml aller referenzierten Module +- Löst Abhängigkeiten auf (topologisch, transitiv) +- Generiert toctree-Einträge pro Kapitel +- Wendet Override-Logik an + +### localized_figure.py +- Neue Direktive `.. localized-figure::` +- Löst Bildpfad nach Build-Sprache auf +- Fallback: `de` wenn Sprachversion fehlt + +### glossary_loader.py +- Liest `terms.{lang}.csv` (Semikolon-getrennt) +- Erzeugt `.. glossary::` Blöcke +- Ermöglicht `:term:` Verlinkung im gesamten Dokument + +## Hilfsskripte + +- `scripts/dxf2svg.py` – DXF → SVG Konvertierung (ezdxf) +- `scripts/optimize_images.py` – Bildoptimierung vor Commit (Pillow, max 2000px) +- `scripts/validate_modules.py` – Prüft meta.yaml-Konsistenz, fehlende Dateien/Übersetzungen diff --git a/lib/collect_partnumbers.py b/lib/collect_partnumbers.py new file mode 100644 index 0000000..bf731aa --- /dev/null +++ b/lib/collect_partnumbers.py @@ -0,0 +1,290 @@ +""" +collect_partnumbers.py - Sammelt 9-stellige Teilenummern aus Produktprogramm-RSTs. + +Erkennt auch Bereiche wie 834372007-011 und expandiert diese zu +834372007, 834372008, 834372009, 834372010, 834372011. + +Gleicht die Nummern gegen eine Excel-Stueckliste ab und schreibt +eine CSV mit Teilenummer, Quelldatei und Bezeichnung. + +Verwendung: + python collect_partnumbers.py [--data-dir ] +""" + +from __future__ import annotations + +import argparse +import csv +import logging +import os +import re +import sys +from pathlib import Path + +log = logging.getLogger(__name__) + + +def expand_ranges(text: str) -> list[str]: + """Findet alle 9-stelligen Nummern und Bereiche in einem Text. + + Unterstuetzte Formate: + - Einzeln: 834372007 + - Bereich: 834372007-011 -> 834372007..834372011 + - OCR-verklebt: "834372001-005834372 007-011" bedeutet eigentlich + "834372001-005 834372007-011" -> zwei Bereiche mit gleicher Stammnummer + - Kurzbereich mit Stammnummer: "834372 007-011" -> 834372007..834372011 + + Returns: + Liste der gefundenen/expandierten 9-stelligen Nummern. + """ + results: list[str] = [] + + # Phase 1: OCR-verklebte Nummern trennen. + # "005834372" -> "005 834372" (3 Suffix-Ziffern gefolgt von 6-stelliger Stammnr) + # Wiederhole bis stabil. + cleaned = text + for _ in range(10): + prev = cleaned + # Nach einem Bereichs-Ende (-NNN) klebt eine Stammnummer (6 Ziffern) dran + cleaned = re.sub(r'-(\d{3})(\d{6})(?=\s|\d{3}-|\d{3}\s|$)', r'-\1 \2', cleaned) + if cleaned == prev: + break + + # Phase 2: Tokenisieren und mit Kontext parsen. + # Tokens: 9-stellige Nummern, 6-stellige Stammnummern, NNN-NNN Bereiche, + # einzelne NNN Suffixe. + # Die Stammnummer wird aus dem letzten vollstaendigen 9-Steller oder + # expliziten 6-Steller uebernommen. + + current_prefix = "" # 6-stellige Stammnummer + + # Tokenize: Zifferngruppen und Bindestriche extrahieren + # Wir verarbeiten die Zeile sequentiell + token_pat = re.compile(r'\d+') + dash_pat = re.compile(r'(\d+)-(\d+)') + + # Strategie: alle Zifferngruppen und ihre Kontexte finden + # Zuerst alle "Nummer-Nummer" Paare und einzelne Nummern identifizieren + segments: list[str] = [] + # Aufteilen an Nicht-Ziffern-nicht-Bindestrich Grenzen + # aber Bindestrich zwischen Ziffern beibehalten + seg_pat = re.compile(r'[\d]+(?:-[\d]+)*') + segments = seg_pat.findall(cleaned) + + for seg in segments: + # Fall 1: "834372007-011" (9 Stellen - 3 Stellen) + m = re.match(r'^(\d{9})-(\d{3})$', seg) + if m: + base = m.group(1) + current_prefix = base[:6] + start = int(base[6:]) + end = int(m.group(2)) + if end >= start: + for i in range(start, end + 1): + results.append(f"{current_prefix}{i:03d}") + else: + results.append(base) + continue + + # Fall 2: "834372007" (einzelne 9-stellige Nummer) + m = re.match(r'^(\d{9})$', seg) + if m: + num = m.group(1) + current_prefix = num[:6] + if num not in results: + results.append(num) + continue + + # Fall 3: "834372" (6-stellige Stammnummer, setzt Prefix) + m = re.match(r'^(\d{6})$', seg) + if m: + current_prefix = m.group(1) + continue + + # Fall 4: "007-011" (3-3 Bereich mit bekanntem Prefix) + m = re.match(r'^(\d{3})-(\d{3})$', seg) + if m and current_prefix: + start = int(m.group(1)) + end = int(m.group(2)) + if end >= start: + for i in range(start, end + 1): + results.append(f"{current_prefix}{i:03d}") + continue + + # Fall 5: "420" (einzelnes 3-stelliges Suffix mit bekanntem Prefix) + m = re.match(r'^(\d{3})$', seg) + if m and current_prefix: + num = f"{current_prefix}{m.group(1)}" + if num not in results: + results.append(num) + continue + + # Fall 6: "200-202834372" -> nach Phase 1 Trennung sollte das + # nicht mehr vorkommen, aber als Fallback: + # Mehrere Bereiche hintereinander + sub_segs = re.findall(r'(\d{9})-(\d{3})|(\d{9})|(\d{3})-(\d{3})', seg) + for ss in sub_segs: + if ss[0] and ss[1]: # 9-3 Bereich + base = ss[0] + current_prefix = base[:6] + start = int(base[6:]) + end = int(ss[1]) + if end >= start: + for i in range(start, end + 1): + results.append(f"{current_prefix}{i:03d}") + elif ss[2]: # Einzelne 9-stellige + current_prefix = ss[2][:6] + if ss[2] not in results: + results.append(ss[2]) + elif ss[3] and ss[4] and current_prefix: # 3-3 Bereich + start = int(ss[3]) + end = int(ss[4]) + if end >= start: + for i in range(start, end + 1): + results.append(f"{current_prefix}{i:03d}") + + return results + + +RE_UNDERLINE = re.compile(r"^([=\-~\^])\1{2,}$") + + +def collect_from_rst(rst_path: Path) -> dict[str, str]: + """Sammelt alle 9-stelligen Teilenummern aus einer RST-Datei. + + Returns: + Dict nummer -> kapitelueberschrift in der die Nummer gefunden wurde. + """ + lines = rst_path.read_text(encoding="utf-8").splitlines() + num_to_chapter: dict[str, str] = {} + current_heading = "" + + i = 0 + while i < len(lines): + line = lines[i].rstrip() + + # RST-Ueberschriften erkennen: + # Variante 1: overline + title + underline (Level 1) + # Variante 2: title + underline (Level 2+) + if RE_UNDERLINE.match(line): + # Pruefen ob dies eine Underline nach einem Titel ist + if i > 0 and lines[i - 1].strip(): + title = lines[i - 1].strip() + # Nur nummerierte Ueberschriften als Kapitel verwenden + if re.match(r"^\d+(\.\d+)*\s+", title): + current_heading = title + # Oder eine Overline vor einem Titel + elif i + 1 < len(lines) and lines[i + 1].strip(): + title = lines[i + 1].strip() + if re.match(r"^\d+(\.\d+)*\s+", title): + current_heading = title + i += 1 + continue + + # Teilenummern aus der Zeile extrahieren + nums = expand_ranges(line) + for n in nums: + if n[0] in ("8", "9") and n not in num_to_chapter: + num_to_chapter[n] = current_heading + + i += 1 + + return num_to_chapter + + +def load_excel_descriptions(xlsx_path: Path) -> dict[str, str]: + """Laedt Teilenummer -> Bezeichnung aus Excel-Stueckliste.""" + import openpyxl + + wb = openpyxl.load_workbook(str(xlsx_path), read_only=True) + ws = wb["Stk.-Liste SOLL"] + desc_map: dict[str, str] = {} + + for row in ws.iter_rows(min_row=5, values_only=True): + tn = str(row[1]).strip() if row[1] else "" + if re.match(r"^\d{9}$", tn) and tn not in desc_map: + bez = str(row[2]).strip() if row[2] else "" + if bez: + desc_map[tn] = bez + + wb.close() + return desc_map + + +def process(data_dir: Path) -> None: + pp_dir = data_dir / "Produktprogramm" + rst_files = sorted(pp_dir.glob("*.de.rst")) + + if not rst_files: + log.error("Keine RST-Dateien in %s gefunden", pp_dir) + sys.exit(1) + + # Teilenummern sammeln: nummer -> list[(quelldatei, kapitel)] + numbers: dict[str, list[tuple[str, str]]] = {} + for rst_path in rst_files: + basename = rst_path.name + num_chapters = collect_from_rst(rst_path) + log.info("%s: %d Teilenummern gefunden", basename, len(num_chapters)) + for num, chapter in num_chapters.items(): + numbers.setdefault(num, []).append((basename, chapter)) + + log.info("Gesamt: %d eindeutige Teilenummern", len(numbers)) + + # Excel-Bezeichnungen laden + xlsx_files = list((data_dir).glob("KA*.xlsx")) + desc_map: dict[str, str] = {} + if xlsx_files: + xlsx_path = xlsx_files[0] + log.info("Lade Excel: %s", xlsx_path.name) + desc_map = load_excel_descriptions(xlsx_path) + log.info(" %d Bezeichnungen geladen", len(desc_map)) + else: + log.warning("Keine KA*.xlsx Datei gefunden in %s", data_dir) + + # CSV schreiben + outfile = pp_dir / "teilenummern.csv" + matched = 0 + with open(outfile, "w", encoding="utf-8-sig", newline="") as fh: + writer = csv.writer(fh, delimiter=";") + writer.writerow(["Teilenummer", "Quelldatei", "Kapitel", "Bezeichnung"]) + for num in sorted(numbers.keys()): + entries = sorted(numbers[num], key=lambda e: e[0]) + sources = ", ".join(e[0] for e in entries) + chapters = ", ".join(e[1] for e in entries if e[1]) + bez = desc_map.get(num, "") + if bez: + matched += 1 + writer.writerow([num, sources, chapters, bez]) + + log.info("CSV geschrieben: %s", outfile) + log.info(" %d Nummern, %d mit Bezeichnung, %d ohne", + len(numbers), matched, len(numbers) - matched) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Sammelt 9-stellige Teilenummern aus Produktprogramm-RSTs" + ) + 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") + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(levelname)-7s %(message)s", + ) + + 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(data_dir) + + +if __name__ == "__main__": + main() diff --git a/lib/create_examples.py b/lib/create_examples.py new file mode 100644 index 0000000..0f34ce3 --- /dev/null +++ b/lib/create_examples.py @@ -0,0 +1,159 @@ +""" +create_examples.py - Liest Stuecklisten-Daten aus Excel-Dateien im Examples-Ordner. + +Verwendung: + python create_examples.py [--example-dir ] +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +log = logging.getLogger(__name__) + + +@dataclass +class StkItem: + position: str + teilenummer: str + menge: Any + + +def _cell_str(value: Any) -> str: + return str(value).strip() if value is not None else "" + + +def _find_col_index(header_row: tuple[Any, ...], candidates: list[str]) -> int | None: + normalized = [_cell_str(v).lower() for v in header_row] + for candidate in candidates: + candidate_norm = candidate.lower() + if candidate_norm in normalized: + return normalized.index(candidate_norm) + return None + + +def _extract_stk_liste_ist(xlsx_path: Path) -> list[StkItem]: + import openpyxl + + wb = openpyxl.load_workbook(str(xlsx_path), read_only=True, data_only=True) + if "Stk.-Liste IST" not in wb.sheetnames: + wb.close() + raise ValueError("Reiter 'Stk.-Liste IST' nicht gefunden") + + ws = wb["Stk.-Liste IST"] + + header_idx: tuple[int | None, int | None, int | None] | None = None + items: list[StkItem] = [] + + for row in ws.iter_rows(values_only=True): + if header_idx is None: + pos_idx = _find_col_index(list(row), ["position", "pos", "pos."]) + part_idx = _find_col_index( + list(row), + ["teilenummer", "teilnummer", "article", "artikelnummer"], + ) + qty_idx = _find_col_index(list(row), ["menge", "anzahl", "qty", "quantity"]) + if pos_idx is not None and part_idx is not None and qty_idx is not None: + header_idx = (pos_idx, part_idx, qty_idx) + continue + + pos_idx, part_idx, qty_idx = header_idx + assert pos_idx is not None and part_idx is not None and qty_idx is not None + + position = _cell_str(row[pos_idx]) if pos_idx < len(row) else "" + teilenummer = _cell_str(row[part_idx]) if part_idx < len(row) else "" + menge = row[qty_idx] if qty_idx < len(row) else None + + # Leere Zeilen ueberspringen + if not position and not teilenummer and menge in ("", None): + continue + if not position or not teilenummer: + continue + + items.append(StkItem(position=position, teilenummer=teilenummer, menge=menge)) + + wb.close() + return items + + +def process_all(example_dir: Path) -> dict[str, list[dict[str, Any]]]: + """ + Durchsucht rekursiv alle Unterverzeichnisse in example_dir. + + Pro Unterverzeichnis wird die erste gefundene .xlsx-Datei gelesen und aus + dem Reiter "Stk.-Liste IST" werden Position, Teilenummer, Menge extrahiert. + """ + if not example_dir.exists(): + log.error("Beispiel-Verzeichnis existiert nicht: %s", example_dir) + sys.exit(1) + + subdirs = sorted([p for p in example_dir.rglob("*") if p.is_dir()]) + if not subdirs: + log.error("Keine Unterverzeichnisse in %s gefunden", example_dir) + sys.exit(1) + + log.info("Gefunden: %d Unterverzeichnisse in %s", len(subdirs), example_dir) + + result: dict[str, list[dict[str, Any]]] = {} + for folder in subdirs: + xlsx_files = sorted(folder.glob("*.xlsx")) + if not xlsx_files: + continue + + xlsx_path = xlsx_files[0] + rel_folder = str(folder.relative_to(example_dir)) + log.info("Verarbeite %s: %s", rel_folder, xlsx_path.name) + try: + items = _extract_stk_liste_ist(xlsx_path) + except ValueError as exc: + log.warning("%s/%s uebersprungen: %s", rel_folder, xlsx_path.name, exc) + continue + + result[rel_folder] = [ + { + "position": item.position, + "teilenummer": item.teilenummer, + "menge": item.menge, + } + for item in items + ] + + log.info("Fertig. Gelesene Verzeichnisse mit Daten: %d", len(result)) + return result + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Liest Position/Teilenummer/Menge aus Example-Exceldateien" + ) + parser.add_argument("--example-dir", type=Path, default=None, + help="example/ Verzeichnis (default: aus PV_EXAMPLES oder ./examples)") + 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.example_dir: + example_dir = args.example_dir.resolve() + elif os.environ.get("PV_EXAMPLES"): + example_dir = Path(os.environ["PV_EXAMPLES"]).resolve() + else: + example_dir = Path("example").resolve() + + log.info("Beispiel-Verzeichnis: %s", example_dir) + data = process_all(example_dir) + log.info("Objekt erzeugt mit %d Schluesseln", len(data)) + + +if __name__ == "__main__": + main() diff --git a/lib/extract_products.py b/lib/extract_products.py index 279104a..0b230bf 100644 --- a/lib/extract_products.py +++ b/lib/extract_products.py @@ -77,6 +77,70 @@ def extract_images(pdf_path: Path, output_dir: Path) -> int: 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. + + Returns: + Dict title_text -> kapitelnummer, z.B. {"Typen von Weichen mit Bögen": "2.3.1"} + """ + import fitz + + toc_entries: dict[str, str] = {} + + for page_num in range(len(doc)): + page = doc[page_num] + text = page.get_text() + # Nur TOC-Seiten verarbeiten (enthalten "....") + 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 + + # TOC-Zeile: "Text ........... Seitennr" + # Kapitelnummer steht oft auf separater Zeile davor + 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 dot_match: + # Text vor den Punkten extrahieren + title = line[:dot_match.start()].strip() + # Seitennummer nach den Punkten + if title and current_number: + toc_entries[title] = current_number + current_number = "" + elif title: + # Nummer koennte am Anfang des Titels stehen + nm = re.match(r"^(\d+(?:\.\d+)*)\s+(.+)", title) + if nm: + toc_entries[nm.group(2).strip()] = nm.group(1) + + # Auch inline Nummern: "1.4 Abkürzungen ..... 8" + for line in lines: + line = line.strip() + dot_match = re.search(r"\.{4,}", line) + if dot_match: + title_part = line[:dot_match.start()].strip() + nm = re.match(r"^(\d+(?:\.\d+)*)\s+(.+)", title_part) + if nm: + toc_entries[nm.group(2).strip()] = nm.group(1) + + return toc_entries + + def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int: """Extrahiert Text aus PDF und schreibt ihn als .rst. @@ -86,16 +150,31 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int: 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", []): @@ -109,8 +188,6 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int: text = text.strip() if not text: continue - if _is_header_footer(text, page_num + 1): - continue raw_lines.append({ "text": text, @@ -200,6 +277,18 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int: 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 + text = f"{toc_num} {text}" + hm = RE_HEADING.match(text) + is_heading = True + log.debug(" TOC-Nummer ergaenzt: %s", text) + if is_heading: num = hm.group(1) heading_text = f"{num} {hm.group(2)}" @@ -229,22 +318,6 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int: return block_count -def _is_header_footer(text: str, page_num: int) -> bool: - """Erkennt typische Kopf-/Fusszeilen.""" - lower = text.lower().strip() - # Copyright-Zeilen - if "copyright" in lower and "schönenberger" in lower: - return True - # Seitenzahlen - if re.match(r"^seite\s+\d+$", lower): - return True - # Version/Datum Fusszeilen - if re.match(r"^version\s+\d+", lower): - return True - # "Erstellt:" / "Freigabe:" Fusszeilen - if lower.startswith("erstellt:") or lower.startswith("freigabe:"): - return True - return False