"""Befuellt chapters/ aus dem Fortna-Rohmaterial anhand des Inventars. Zwei Aktionsarten (siehe doc/plan_fortna_extraktion.md): - action="convert": Die 5 Hauptanleitungen (Betriebs-/Wartungsanleitung, Funktionsbeschreibung ILS/OMNIFLO/LoadingLoop) werden volltextig mit Bildern nach .rst konvertiert. Genutzt wird die positionsbasierte Extraktion aus extract_products.py (Kopf-/Fusszeilen-Filter, Schriftgroessen-Heuristik), die fuer Word-erstellte PDFs robuster ist als die pdfminer-Variante. - action="list": Alle uebrigen Kapitel (Zertifikate, Layouts, Schaltplaene, Notstopp/Bereichsstopp, Pruefberichte, Ersatzteile, Lieferantendoku) erhalten eine generierte index.de.rst mit einer RST-list-table (Dateiname, Kategorie, Groesse, Originalpfad). Es werden KEINE PDFs kopiert. Nur deutschsprachige Quellen (lang == "de") werden verarbeitet. Verwendung: python fortna_convert_chapters.py --dry-run python fortna_convert_chapters.py --apply python fortna_convert_chapters.py --apply --only convert python fortna_convert_chapters.py --apply --only list """ from __future__ import annotations import argparse import json import logging import os import re import shutil import sys from collections import defaultdict from pathlib import Path # pdf_extract stellt die positionsbasierte Text+Bild-Extraktion bereit # (verwebt Text und Bilder, entfernt PDF-Ueberschriftennummern). import pdf_extract log = logging.getLogger(__name__) RST_H = {1: "=", 2: "-", 3: "~", 4: "^"} # --------------------------------------------------------------------------- # Hilfen # --------------------------------------------------------------------------- def slugify(name: str) -> str: """Erzeugt einen sauberen Dateinamen-Stamm aus einem PDF-Namen.""" stem = Path(name).stem.lower() stem = re.sub(r"[^a-z0-9]+", "_", stem) stem = re.sub(r"_+", "_", stem).strip("_") return stem def rst_heading(text: str, level: int = 1) -> list[str]: """Erzeugt eine RST-Ueberschrift als Zeilenliste.""" char = RST_H[level] lines = [] if level == 1: lines.append(char * max(len(text), 3)) lines.append(text) lines.append(char * max(len(text), 3)) lines.append("") return lines def load_inventory(path: Path) -> dict: with open(path, encoding="utf-8") as f: 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", } # Kapitel-Typ: "general" -> Bilder sind wiederverwendbar (Maschinenelemente, # Steuerungs-Funktionsbeschreibungen) -> assets/shared # "project" -> projektspezifisch (Betrieb/Wartung der Anlage) # -> projekte/fortna/images # Fotos (.jpg) landen unabhaengig davon IMMER projektspezifisch (siehe # convert_manual: Routing ueber Klassifizierung im Aufrufer wird durch # pdf_extract je Bild nicht getrennt - daher legen wir generelle Kapitel nach # assets, projektbezogene nach projekte/fortna). Feinsteuerung .jpg->projekt # erfolgt zusaetzlich unten. CHAPTER_TYPE = { "04_betrieb/betriebsanleitung": "project", "05_wartung/wartungsanleitung": "project", "06_steuerung/ils": "general", "06_steuerung/omniflo": "general", "06_steuerung/loading_loop": "general", } # kurzer Unterordner-Name je Kapitel fuer die Bildablage CHAPTER_IMG_SUBDIR = { "04_betrieb/betriebsanleitung": "betrieb", "05_wartung/wartungsanleitung": "wartung", "06_steuerung/ils": "steuerung_ils", "06_steuerung/omniflo": "steuerung_omniflo", "06_steuerung/loading_loop": "steuerung_loading_loop", } # --------------------------------------------------------------------------- # action="convert": Volltext + Bilder # --------------------------------------------------------------------------- def convert_manual(item: dict, source_root: Path, chapters_dir: Path, dry_run: bool, symbols_dir: Path | None = None, symbol_registry: dict | None = None, logos_dir: Path | None = None, logo_registry: dict | None = None, project_dir: Path | None = None, assets_dir: Path | None = None) -> None: """Konvertiert eine Hauptanleitung nach chapters//. Bild-Routing je Kapitel: general -> assets/shared/images/ project -> projekte/fortna/images/ Fotos (.jpg) gehen unabhaengig davon IMMER nach projekte/fortna/images/. """ chapter = item["chapter"] src_pdf = source_root / item["rel_path"] chapter_dir = chapters_dir / chapter stem = slugify(item["filename"]) rst_path = chapter_dir / f"{stem}.de.rst" subdir = CHAPTER_IMG_SUBDIR.get(chapter, slugify(chapter)) ctype = CHAPTER_TYPE.get(chapter, "project") assets_base = assets_dir or (chapters_dir.parent / "assets" / "shared" / "images") project_base = (project_dir or (chapters_dir.parent / "projekte" / "fortna")) / "images" # generelle Grafiken -> assets, projektbezogene -> projekt; Fotos immer projekt image_dest = (assets_base if ctype == "general" else project_base) / subdir photo_dest = project_base / subdir log.info("CONVERT %s (%s)", chapter, ctype) log.info(" Quelle: %s (%d MB)", item["rel_path"], item["size_bytes"] // (1024 * 1024)) log.info(" Bilder -> %s (Fotos -> %s)", image_dest.relative_to(chapters_dir.parent), photo_dest.relative_to(chapters_dir.parent)) if dry_run: return if not src_pdf.exists(): log.error(" Quelle fehlt: %s", src_pdf) return # Zielordner vorbereiten und ALLE vorher existierenden Bilder darin loeschen. chapter_dir.mkdir(parents=True, exist_ok=True) for d in (image_dest, photo_dest, chapter_dir / "images"): if d.exists(): shutil.rmtree(d) for old in chapter_dir.glob("*.de.rst"): old.unlink() doc_title = CHAPTER_DOC_TITLE.get(chapter, slugify(item["filename"])) blocks, img_count = pdf_extract.extract_manual( src_pdf, rst_path, chapter_dir / "images", title=doc_title, symbols_dir=symbols_dir, symbol_registry=symbol_registry, logos_dir=logos_dir, logo_registry=logo_registry, image_dest=image_dest, photo_dest=photo_dest) log.info(" %d Textbloecke, %d Bilder -> %s", blocks, img_count, rst_path.name) # --------------------------------------------------------------------------- # action="list": Verzeichnisliste index.de.rst # --------------------------------------------------------------------------- def human_size(n: int) -> str: if n >= 1024 * 1024: return f"{n / (1024 * 1024):.1f} MB" if n >= 1024: return f"{n / 1024:.0f} KB" return f"{n} B" def category_from_path(rel_path: str, chapter: str) -> str: """Leitet eine Kategorie (z.B. Hersteller) aus dem Quellpfad ab. Nimmt das Ordnersegment direkt unterhalb des Fortna-Top-Level bzw. ein aussagekraeftiges Zwischensegment (Herstellername bei Lieferantendoku). """ parts = Path(rel_path).parts # Lieferantendoku: Herstellername steckt meist tiefer im Pfad # Heuristik: letztes Segment vor der Datei, das keine Sprach-/Nummernkennung ist lang_dir = re.compile(r"^\d*_?(DE|EN|CZ)(_|$)", re.I) skip = re.compile(r"^\d+_", ) candidates = [p for p in parts[1:-1] if not lang_dir.match(p)] if candidates: # bevorzugt ein Segment mit Buchstaben (Herstellername) for c in reversed(candidates): cleaned = skip.sub("", c) if re.search(r"[A-Za-z]{3,}", cleaned): return cleaned.split("-")[0].strip() return "" def generate_index(chapter: str, items: list[dict], source_root: Path, chapters_dir: Path, dry_run: bool) -> None: """Erzeugt chapters//index.de.rst mit RST-list-table.""" chapter_dir = chapters_dir / chapter rst_path = chapter_dir / "index.de.rst" title_map = { "00_inhaltsverzeichnis": "Inhaltsverzeichnis / Dokumentennotizen", "01_zertifikate/konformitaetserklaerung": "Konformitaetserklaerungen", "01_zertifikate/konfigurationsbeschreibung": "Konfigurationsbeschreibung", "07_ersatzteile": "Ersatzteillisten", "08_lieferantendoku/antriebe": "Lieferantendoku - Antriebe", "08_lieferantendoku/elektrik": "Lieferantendoku - Elektrik", "08_lieferantendoku/pneumatik": "Lieferantendoku - Pneumatik", "08_lieferantendoku/stahlbau": "Lieferantendoku - Stahlbau", "08_lieferantendoku/sicherheitseinrichtungen": "Lieferantendoku - Sicherheitseinrichtungen", "09_technische_unterlagen/layouts/mechanisch": "Layout mechanisch", "09_technische_unterlagen/layouts/elektrisch": "Layout elektrisch", "09_technische_unterlagen/schaltplaene": "Schaltplaene", "09_technische_unterlagen/notstopp": "Not-Stopp", "09_technische_unterlagen/bereichsstopp": "Bereichsstopp", "10_anhang/pruefberichte": "Pruefberichte", } title = title_map.get(chapter, chapter) log.info("LIST %-45s (%d Dateien) -> %s", chapter, len(items), rst_path.relative_to(chapters_dir)) if dry_run: return chapter_dir.mkdir(parents=True, exist_ok=True) lines: list[str] = [] lines += rst_heading(title, 1) lines.append(".. note::") lines.append("") lines.append(" Automatisch generierte Verzeichnisliste aus dem Fortna-Quellmaterial.") lines.append(" Die referenzierten Dokumente liegen im Projekt-Rohmaterial und werden") lines.append(" hier nicht dupliziert.") lines.append("") lines.append(".. list-table::") lines.append(" :header-rows: 1") lines.append(" :widths: 34 18 12 36") lines.append("") lines.append(" * - Dokument") lines.append(" - Kategorie") lines.append(" - Groesse") lines.append(" - Quellpfad") for it in sorted(items, key=lambda x: x["rel_path"]): cat = category_from_path(it["rel_path"], chapter) lines.append(f" * - {it['filename']}") lines.append(f" - {cat}") lines.append(f" - {human_size(it['size_bytes'])}") lines.append(f" - ``{it['rel_path']}``") lines.append("") rst_path.write_text("\n".join(lines), encoding="utf-8") # --------------------------------------------------------------------------- # Hauptlogik # --------------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser( description="Befuellt chapters/ aus dem Fortna-Inventar (Volltext + Verzeichnislisten)." ) parser.add_argument("--inventory", default=None, help="Pfad zum Inventar-JSON (default: results/fortna_inventory.json)") parser.add_argument("--source-dir", default=None, help="Fortna-Quellverzeichnis (default: aus Inventar-JSON)") parser.add_argument("--chapters-dir", default=None, help="Ziel chapters/-Verzeichnis (default: PROJECT/chapters)") parser.add_argument("--lang", default="de", choices=["de", "en", "cz"], help="Zu verarbeitende Sprache (default: de)") parser.add_argument("--only", choices=["convert", "list"], default=None, help="Nur eine Aktionsart ausfuehren") grp = parser.add_mutually_exclusive_group() grp.add_argument("--dry-run", action="store_true", default=True, help="Nur anzeigen (Default)") grp.add_argument("--apply", action="store_true", help="Aenderungen tatsaechlich schreiben") 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", ) dry_run = not args.apply project_root = Path(os.environ.get("PROJECT", Path(__file__).resolve().parent.parent)) inv_path = Path(args.inventory) if args.inventory else project_root / "results" / "fortna_inventory.json" if not inv_path.exists(): log.error("Inventar nicht gefunden: %s (erst fortna_inventory.py laufen lassen)", inv_path) sys.exit(1) inv = load_inventory(inv_path) source_root = Path(args.source_dir) if args.source_dir else Path(inv["source"]) chapters_dir = Path(args.chapters_dir) if args.chapters_dir else project_root / "chapters" log.info("Quelle: %s", source_root) log.info("Ziel: %s", chapters_dir) log.info("Sprache: %s | Modus: %s", args.lang, "APPLY" if args.apply else "DRY-RUN") items = [i for i in inv["items"] if i["lang"] == args.lang and i["chapter"]] # 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)) # Symbole + Logos dokumentuebergreifend zentral in assets/shared ablegen # und nur einmal referenzieren. Registries ueber alle Dokumente teilen. base = chapters_dir.parent symbols_dir = base / "assets" / "shared" / "symbols" logos_dir = base / "assets" / "shared" / "logos" assets_img_dir = base / "assets" / "shared" / "images" project_dir = base / "projekte" / "fortna" symbol_registry: dict = {} logo_registry: dict = {} # Vor dem Lauf zentrale Ziel-Bildordner leeren (frischer Stand). if not dry_run: for d in (symbols_dir, logos_dir, assets_img_dir, project_dir / "images"): if d.exists(): shutil.rmtree(d) for it in conv: convert_manual(it, source_root, chapters_dir, dry_run, symbols_dir=symbols_dir, symbol_registry=symbol_registry, logos_dir=logos_dir, logo_registry=logo_registry, project_dir=project_dir, assets_dir=assets_img_dir) if not dry_run: log.info("Symbole zentral: %d | Logos: %d -> assets/shared/", len(symbol_registry), len(logo_registry)) # 2. Verzeichnislisten if args.only in (None, "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) log.info("--- %d Verzeichnislisten erzeugen ---", len(by_chapter)) for chapter in sorted(by_chapter): generate_index(chapter, by_chapter[chapter], source_root, chapters_dir, dry_run) if dry_run: log.info("DRY-RUN abgeschlossen. Mit --apply tatsaechlich schreiben.") else: log.info("Fertig.") if __name__ == "__main__": main()