feat: Fortna-Extraktion nach chapters/ + Sphinx-PDF-Build
Skripte zum Befuellen der Kapitelstruktur aus dem Fortna-Rohmaterial und Aufbau eines vollstaendigen Sphinx/LaTeX-Builds (nur Code/Config/Doku, generierte RST/Bilder/PDFs und Rohmaterial via .gitignore ausgeschlossen). - lib/fortna_inventory.py: Rohmaterial scannen, Sprache+Zielkapitel+Aktion zuordnen, results/fortna_inventory.json schreiben - lib/fortna_convert_chapters.py: 5 Hauptanleitungen volltextig (Text+Bilder) konvertieren, Verzeichnislisten (index.de.rst) fuer uebrige Kapitel - lib/fortna_init_project.py: projekte/fortna/ (anlage.json, overrides, images) - lib/build_index.py: index.rst mit toctrees aus chapters/**/*.de.rst generieren - lib/extract_products.py: Symbolfont-Zeichen (PUA) auf Unicode mappen (clean_pua) - conf.py: Sphinx-Build (lualatex, babel statt polyglossia, iconpackage=none) - bin/: .bat/.sh-Wrapper fuer alle Skripte + build_pdf - doc/: Konzept-Plan (plan_fortna_extraktion.md) und lib-Skript-Uebersicht - requirements.txt: Sphinx + myst-parser ergaenzt Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
eb9237217e
commit
e7aa3b4e4b
@@ -0,0 +1,95 @@
|
||||
"""Generiert index.rst mit toctrees aus allen vorhandenen chapters/**/*.de.rst.
|
||||
|
||||
Baut pro Top-Level-Kapitel (00..10) einen Abschnitt mit einer toctree-Direktive,
|
||||
die alle deutschsprachigen RST-Bausteine dieses Kapitels einbindet. So bleibt der
|
||||
Build robust gegenueber neuen/entfernten Bausteinen (kein Handpflegen der toctrees).
|
||||
|
||||
Verwendung:
|
||||
python build_index.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Defekte Altbestaende aus fruehen Testlaeufen, die nicht in die toctree
|
||||
# aufgenommen werden (siehe exclude_patterns in conf.py). Relativ zu chapters/.
|
||||
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",
|
||||
}
|
||||
|
||||
# Lesbare Titel je Top-Level-Kapitelordner
|
||||
CHAPTER_TITLES = {
|
||||
"00_inhaltsverzeichnis": "Inhaltsverzeichnis",
|
||||
"01_zertifikate": "Zertifikate",
|
||||
"02_sicherheit": "Sicherheit",
|
||||
"03_anlagenbeschreibung": "Anlagenbeschreibung",
|
||||
"04_betrieb": "Betrieb",
|
||||
"05_wartung": "Wartung",
|
||||
"06_steuerung": "Steuerung",
|
||||
"07_ersatzteile": "Ersatzteile",
|
||||
"08_lieferantendoku": "Lieferantendokumentation",
|
||||
"09_technische_unterlagen": "Technische Unterlagen",
|
||||
"10_anhang": "Anhang",
|
||||
}
|
||||
|
||||
|
||||
def rst_underline(text: str, char: str) -> str:
|
||||
return f"{text}\n{char * max(len(text), 3)}\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
|
||||
|
||||
project_root = Path(os.environ.get("PROJECT", Path(__file__).resolve().parent.parent))
|
||||
chapters = project_root / "chapters"
|
||||
|
||||
# RST-Dokumentnamen (ohne .rst-Endung, posix, relativ zum project_root)
|
||||
# je Top-Level-Kapitel einsammeln.
|
||||
by_chapter: dict[str, list[str]] = {}
|
||||
for rst in sorted(chapters.rglob("*.de.rst")):
|
||||
rel = rst.relative_to(project_root)
|
||||
if rst.relative_to(chapters).as_posix() in SKIP:
|
||||
continue
|
||||
top = rel.parts[1] # chapters/<top>/...
|
||||
docname = rel.as_posix()[: -len(".rst")] # Sphinx-Docname ohne Endung
|
||||
by_chapter.setdefault(top, []).append(docname)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(".. Automatisch generiert von lib/build_index.py - nicht manuell editieren.")
|
||||
lines.append("")
|
||||
title = "Anlagendokumentation Fortna"
|
||||
lines.append("#" * len(title))
|
||||
lines.append(title)
|
||||
lines.append("#" * len(title))
|
||||
lines.append("")
|
||||
lines.append("Projekt 500592 / KA 135776 - H&M Cheb (Eger), CZ")
|
||||
lines.append("")
|
||||
|
||||
total = 0
|
||||
for top in sorted(by_chapter):
|
||||
docs = by_chapter[top]
|
||||
total += len(docs)
|
||||
heading = CHAPTER_TITLES.get(top, top)
|
||||
lines.append("")
|
||||
lines.append(rst_underline(heading, "*"))
|
||||
lines.append(".. toctree::")
|
||||
lines.append(" :maxdepth: 2")
|
||||
lines.append("")
|
||||
for doc in sorted(docs):
|
||||
lines.append(f" {doc}")
|
||||
lines.append("")
|
||||
|
||||
index_path = project_root / "index.rst"
|
||||
index_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
log.info("index.rst geschrieben: %d Kapitel, %d Bausteine", len(by_chapter), total)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+31
-1
@@ -29,6 +29,36 @@ PDF_TARGETS: list[tuple[str, str]] = [
|
||||
# rST Ueberschriften-Zeichen nach Level
|
||||
RST_HEADING_CHARS = {1: "=", 2: "-", 3: "~", 4: "^"}
|
||||
|
||||
# Private-Use-Area-Zeichen aus Word-Symbolfonts (Wingdings/Symbol) auf sinnvolle
|
||||
# Unicode-Aequivalente abbilden. Diese Zeichen haben in Standard-LaTeX-Fonts keine
|
||||
# Glyphe (erscheinen sonst als leere Kaestchen im PDF).
|
||||
PUA_REPLACEMENTS = {
|
||||
"": "•", # bullet -> •
|
||||
"": "•", # square bullet -> •
|
||||
"": "•", # bullet variante
|
||||
"": "→", # rightwards arrow -> →
|
||||
"": "→", # rightwards arrow -> →
|
||||
"": "←", # leftwards arrow -> ←
|
||||
"": "✓", # checkmark -> ✓
|
||||
"": " ", # space
|
||||
}
|
||||
|
||||
_PUA_RE = re.compile("|".join(re.escape(k) for k in PUA_REPLACEMENTS))
|
||||
# Restliche Private-Use-Area-Zeichen (U+E000..U+F8FF), die keine bekannte
|
||||
# Entsprechung haben, werden entfernt - sie haben in LaTeX-Fonts keine Glyphe.
|
||||
_PUA_REST_RE = re.compile(r"[-]")
|
||||
|
||||
|
||||
def clean_pua(text: str) -> str:
|
||||
"""Ersetzt Symbolfont-Zeichen (Private Use Area) durch Unicode-Aequivalente.
|
||||
|
||||
Bekannte Symbole (Bullet, Pfeile, Haken) werden gemappt, alle uebrigen
|
||||
PUA-Zeichen entfernt, damit im PDF keine leeren Kaestchen erscheinen.
|
||||
"""
|
||||
text = _PUA_RE.sub(lambda m: PUA_REPLACEMENTS[m.group(0)], text)
|
||||
text = _PUA_REST_RE.sub("", text)
|
||||
return text
|
||||
|
||||
|
||||
def find_target(pdf_name: str) -> str | None:
|
||||
"""Findet den Zielordner-Namen fuer eine PDF-Datei."""
|
||||
@@ -202,7 +232,7 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
|
||||
text = ""
|
||||
max_size = 0.0
|
||||
for span in line.get("spans", []):
|
||||
span_text = span.get("text", "").strip()
|
||||
span_text = clean_pua(span.get("text", "")).strip()
|
||||
if span_text:
|
||||
text += span_text + " "
|
||||
size = span.get("size", 0)
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""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
|
||||
|
||||
# extract_products stellt die robuste PDF-Extraktion bereit (gleiches lib/-Verzeichnis)
|
||||
import extract_products as ep
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# action="convert": Volltext + Bilder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def convert_manual(item: dict, source_root: Path, chapters_dir: Path,
|
||||
dry_run: bool) -> None:
|
||||
"""Konvertiert eine Hauptanleitung nach chapters/<kapitel>/."""
|
||||
src_pdf = source_root / item["rel_path"]
|
||||
chapter_dir = chapters_dir / item["chapter"]
|
||||
stem = slugify(item["filename"])
|
||||
rst_path = chapter_dir / f"{stem}.de.rst"
|
||||
img_dir = chapter_dir / "images"
|
||||
|
||||
log.info("CONVERT %s", item["chapter"])
|
||||
log.info(" Quelle: %s (%d MB)", item["rel_path"],
|
||||
item["size_bytes"] // (1024 * 1024))
|
||||
log.info(" -> %s", rst_path.relative_to(chapters_dir))
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
if not src_pdf.exists():
|
||||
log.error(" Quelle fehlt: %s", src_pdf)
|
||||
return
|
||||
|
||||
# Zielordner vorbereiten: vorhandene RST/Bilder ersetzen (aus einem Guss)
|
||||
chapter_dir.mkdir(parents=True, exist_ok=True)
|
||||
if img_dir.exists():
|
||||
shutil.rmtree(img_dir)
|
||||
# Alte .de.rst im Zielordner entfernen (Testbestand)
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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/<kapitel>/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
|
||||
if args.only in (None, "convert"):
|
||||
conv = [i for i in items if i["action"] == "convert"]
|
||||
log.info("--- %d Hauptanleitungen konvertieren ---", len(conv))
|
||||
for it in conv:
|
||||
convert_manual(it, source_root, chapters_dir, dry_run)
|
||||
|
||||
# 2. Verzeichnislisten
|
||||
if args.only in (None, "list"):
|
||||
list_items = [i for i in items if i["action"] == "list"]
|
||||
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()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Legt den projektspezifischen Ordner projekte/fortna/ an.
|
||||
|
||||
Erzeugt das Projekt-Repo-Geruest gemaess doc/Kernkonzepte.md:
|
||||
projekte/fortna/
|
||||
anlage.json # Projektmetadaten (Platzhalter, manuell zu schaerfen)
|
||||
overrides/.gitkeep # projektspezifische Ueberschreibungen (leer)
|
||||
images/.gitkeep # projektspezifische Bilder (leer)
|
||||
README.md # kurze Erlaeuterung
|
||||
|
||||
Einmaliger Generator, kein Datei-Scan. Bestehende Dateien werden per Default NICHT
|
||||
ueberschrieben (--force zum Erzwingen).
|
||||
|
||||
Verwendung:
|
||||
python fortna_init_project.py
|
||||
python fortna_init_project.py --force
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ANLAGE_JSON = {
|
||||
"projekt": {
|
||||
"nummer": "500592",
|
||||
"ka_nummer": "KA 135776",
|
||||
"kunde": "Fortna / H&M",
|
||||
"standort": "Cheb (Eger), CZ",
|
||||
"sprachen": ["de"],
|
||||
"_kommentar_sprachen": "EN/CZ vorhanden im Rohmaterial, aktuell nur DE gebaut",
|
||||
"normen_region": "EU",
|
||||
"_todo_rechtsgrundlage": "MRL 2006/42/EG (Altprojekt) vs. VO (EU) 2023/1230 - klaeren",
|
||||
"base_version": ">=0.1.0",
|
||||
},
|
||||
"quelle": {
|
||||
"_kommentar": "Rohmaterial-Verzeichnis (ausserhalb des Repos), Basis der Verzeichnislisten",
|
||||
"rohmaterial": "c:/10-Develop/projekte/Fortna",
|
||||
},
|
||||
"maschinen": [
|
||||
{"_kommentar": "Modul-Liste noch zu befuellen (03_anlagenbeschreibung via meta.yaml)"},
|
||||
],
|
||||
"zusatz_kapitel": [],
|
||||
}
|
||||
|
||||
README = """\
|
||||
# Projekt Fortna (500592 / KA 135776)
|
||||
|
||||
Projektspezifischer Ordner fuer die Fortna/H&M-Anlage in Cheb (CZ).
|
||||
|
||||
Struktur gemaess `doc/Kernkonzepte.md` (Zwei-Repo-Architektur):
|
||||
|
||||
- `anlage.json` - Projektmetadaten, Sprachen, Normenregion, Maschinenliste (ERP-Export-Platzhalter).
|
||||
- `overrides/` - projektspezifische Ueberschreibungen von Base-Modulen/Kapiteln.
|
||||
- `images/` - projektspezifische Bilder (Aufstellplan, Abnahmefotos, Kundenlogo).
|
||||
|
||||
## Herkunft der Inhalte
|
||||
|
||||
Die eigentliche Dokumentation wird aus dem Fortna-Rohmaterial
|
||||
(`c:/10-Develop/projekte/Fortna`) erzeugt:
|
||||
|
||||
1. `lib/fortna_inventory.py` - inventarisiert das Rohmaterial (`results/fortna_inventory.json`).
|
||||
2. `lib/fortna_convert_chapters.py --apply` - befuellt `chapters/`:
|
||||
- Volltext + Bilder fuer die 5 Hauptanleitungen (Betriebs-/Wartungsanleitung, Funktionsbeschreibung ILS/OMNIFLO/LoadingLoop).
|
||||
- Verzeichnislisten (`index.de.rst`) fuer alle uebrigen Kapitel (Zertifikate, Layouts, Schaltplaene, Lieferantendoku, Ersatzteile, Pruefberichte).
|
||||
|
||||
Hinweis: Die Verzeichnislisten referenzieren Pfade im Rohmaterial und kopieren keine PDFs.
|
||||
Fuer einen produktiven PDF-Build mit eingebundenen Downloads muss das Rohmaterial zugaenglich
|
||||
gemacht bzw. die referenzierten Dokumente ins Projekt uebernommen werden.
|
||||
"""
|
||||
|
||||
|
||||
def write_if_absent(path: Path, content: str, force: bool) -> None:
|
||||
if path.exists() and not force:
|
||||
log.info(" vorhanden (uebersprungen): %s", path.name)
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
log.info(" geschrieben: %s", path.name)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Legt projekte/fortna/ an.")
|
||||
parser.add_argument("--project-dir", default=None,
|
||||
help="Zielordner (default: PROJECT/projekte/fortna)")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="Bestehende Dateien ueberschreiben")
|
||||
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",
|
||||
)
|
||||
|
||||
project_root = Path(os.environ.get("PROJECT", Path(__file__).resolve().parent.parent))
|
||||
target = Path(args.project_dir) if args.project_dir else project_root / "projekte" / "fortna"
|
||||
|
||||
log.info("Projektordner: %s", target)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
write_if_absent(target / "anlage.json",
|
||||
json.dumps(ANLAGE_JSON, ensure_ascii=False, indent=2) + "\n", args.force)
|
||||
write_if_absent(target / "README.md", README, args.force)
|
||||
write_if_absent(target / "overrides" / ".gitkeep", "", args.force)
|
||||
write_if_absent(target / "images" / ".gitkeep", "", args.force)
|
||||
|
||||
log.info("Fertig.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Inventarisiert das Fortna-Rohmaterial und ordnet jede Datei einem Zielkapitel zu.
|
||||
|
||||
Scannt das Fortna-Quellverzeichnis (Standard: PV_FORTNA_SOURCE oder --source-dir)
|
||||
rekursiv, erkennt fuer jede PDF/DOCX-Datei die Sprache (aus dem Sprachordner) und
|
||||
das Ziel-Kapitel unter chapters/ (anhand einer praefix-basierten Mapping-Tabelle,
|
||||
abgeleitet aus doc/kapitelstruktur.md).
|
||||
|
||||
Ergebnis wird als results/fortna_inventory.json geschrieben und tabellarisch auf
|
||||
stdout ausgegeben. Reines Lese-/Analyseskript - schreibt nichts nach chapters/.
|
||||
|
||||
Verwendung:
|
||||
python fortna_inventory.py
|
||||
python fortna_inventory.py --source-dir "c:/10-Develop/projekte/Fortna"
|
||||
python fortna_inventory.py --lang de # nur deutsche Dateien im Report
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sprach-Erkennung aus Ordnernamen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Reihenfolge wichtig: spezifische Muster zuerst. Ein Segment des relativen
|
||||
# Pfades wird gegen diese Muster geprueft.
|
||||
LANG_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"_DE(_|$)|_DE_original|DE_f[uü]r|01_DE\b", re.I), "de"),
|
||||
(re.compile(r"_EN(_|$)|_EN_original|EN_for|1_EN_original", re.I), "en"),
|
||||
(re.compile(r"_CZ(_|$)|02_CZ|03_CZ|CZ_pro", re.I), "cz"),
|
||||
]
|
||||
|
||||
# Sprach-Suffixe im Dateinamen (Fallback, wenn Ordner nichts hergibt)
|
||||
FILENAME_LANG = [
|
||||
(re.compile(r"[_\-\[ ]DE[_\-\.\] ]|_DE\b", re.I), "de"),
|
||||
(re.compile(r"[_\-\[ ]EN[_\-\.\] ]|_EN\b", re.I), "en"),
|
||||
(re.compile(r"[_\-\[ ]CZ[_\-\.\] ]|_CZ\b", re.I), "cz"),
|
||||
]
|
||||
|
||||
|
||||
def detect_lang(rel_path: Path) -> str:
|
||||
"""Bestimmt die Sprache einer Datei anhand Pfadsegmenten und Dateiname.
|
||||
|
||||
Fallback auf 'de', wenn nichts eindeutig erkennbar (z.B. sprachneutrale
|
||||
Datenblaetter oder gemischte DE-EN-Dokumente).
|
||||
"""
|
||||
# Zuerst Ordnersegmente pruefen (aussagekraeftiger als Dateiname)
|
||||
for part in rel_path.parts[:-1]:
|
||||
for pattern, lang in LANG_PATTERNS:
|
||||
if pattern.search(part):
|
||||
return lang
|
||||
# Dann Dateiname
|
||||
name = rel_path.name
|
||||
for pattern, lang in FILENAME_LANG:
|
||||
if pattern.search(name):
|
||||
return lang
|
||||
return "de"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kapitel-Mapping: Fortna-Quellpfad-Praefix -> (Ziel-Kapitel, Aktion)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aktion: "convert" = Volltext+Bilder nach RST, "list" = nur Verzeichnisliste.
|
||||
# Regeln werden der Reihe nach geprueft; erste passende gewinnt (praefix-Match
|
||||
# gegen den mit '/' normalisierten relativen Pfad, case-insensitive).
|
||||
|
||||
@dataclass
|
||||
class MapRule:
|
||||
pattern: re.Pattern[str]
|
||||
chapter: str
|
||||
action: str # "convert" | "list"
|
||||
|
||||
|
||||
MAPPING: list[MapRule] = [
|
||||
# --- Volltext-Konvertierung: die 5 Hauptanleitungen (nur DE_original) ---
|
||||
MapRule(re.compile(r"02_INSTRUCTIONS.*/01_BETRIEBSANLEITUNG", re.I),
|
||||
"04_betrieb/betriebsanleitung", "convert"),
|
||||
MapRule(re.compile(r"02_INSTRUCTIONS.*/02_WARTUNGSANLEITUNG", re.I),
|
||||
"05_wartung/wartungsanleitung", "convert"),
|
||||
MapRule(re.compile(r"02_INSTRUCTIONS.*/03_FUNKTION_STEUERUNG_ILS", re.I),
|
||||
"06_steuerung/ils", "convert"),
|
||||
MapRule(re.compile(r"02_INSTRUCTIONS.*/04_FUNKTION_STEUERUNG_OMNIFLO", re.I),
|
||||
"06_steuerung/omniflo", "convert"),
|
||||
MapRule(re.compile(r"02_INSTRUCTIONS.*/05_FUNKTION_STEUERUNG_LOADING_LOOP", re.I),
|
||||
"06_steuerung/loading_loop", "convert"),
|
||||
|
||||
# Fremdsprach-Varianten der Anleitungen (EN: USER_MANUAL / MAINTENANCE /
|
||||
# FUNKTION_CONTROL, CZ: NAVOD / RIZENI). In diesem Durchlauf (nur DE) werden
|
||||
# sie NICHT konvertiert (action="list"), aber sie sollen nicht als UNMAPPED
|
||||
# erscheinen. Zuordnung nach Anleitungstyp anhand der fuehrenden Nummer.
|
||||
MapRule(re.compile(r"02_INSTRUCTIONS.*/01_", re.I),
|
||||
"04_betrieb/betriebsanleitung", "list"),
|
||||
MapRule(re.compile(r"02_INSTRUCTIONS.*/02_", re.I),
|
||||
"05_wartung/wartungsanleitung", "list"),
|
||||
MapRule(re.compile(r"02_INSTRUCTIONS.*/03_", re.I),
|
||||
"06_steuerung/ils", "list"),
|
||||
MapRule(re.compile(r"02_INSTRUCTIONS.*/04_", re.I),
|
||||
"06_steuerung/omniflo", "list"),
|
||||
MapRule(re.compile(r"02_INSTRUCTIONS.*/05_", re.I),
|
||||
"06_steuerung/loading_loop", "list"),
|
||||
|
||||
# --- Verzeichnislisten: alles uebrige nach Kapitel ---
|
||||
# 00 Inhaltsverzeichnis / Notes
|
||||
MapRule(re.compile(r"^00_CONTENTS", re.I),
|
||||
"00_inhaltsverzeichnis", "list"),
|
||||
# 01 Zertifikate
|
||||
MapRule(re.compile(r"^01_CERTIFICATES.*/01_SCH[OÖ]NENBERGER", re.I),
|
||||
"01_zertifikate/konformitaetserklaerung", "list"),
|
||||
MapRule(re.compile(r"^01_CERTIFICATES.*/(02_)?CONFIG", re.I),
|
||||
"01_zertifikate/konfigurationsbeschreibung", "list"),
|
||||
MapRule(re.compile(r"^01_CERTIFICATES", re.I),
|
||||
"01_zertifikate/konformitaetserklaerung", "list"),
|
||||
# 03 Ersatzteile (Fortna 03_SPARE_PARTS)
|
||||
MapRule(re.compile(r"^03_SPARE_PARTS", re.I),
|
||||
"07_ersatzteile", "list"),
|
||||
# 04 Technical Doc -> 09 / 10
|
||||
MapRule(re.compile(r"^04_TECHNICAL_DOC.*/01_LAYOUT.*/01_MECHANICAL", re.I),
|
||||
"09_technische_unterlagen/layouts/mechanisch", "list"),
|
||||
MapRule(re.compile(r"^04_TECHNICAL_DOC.*/01_LAYOUT.*/02_ELECTRICAL", re.I),
|
||||
"09_technische_unterlagen/layouts/elektrisch", "list"),
|
||||
MapRule(re.compile(r"^04_TECHNICAL_DOC.*/02_AREA_STOP", re.I),
|
||||
"09_technische_unterlagen/bereichsstopp", "list"),
|
||||
MapRule(re.compile(r"^04_TECHNICAL_DOC.*/03_EMERGENCY_STOP", re.I),
|
||||
"09_technische_unterlagen/notstopp", "list"),
|
||||
MapRule(re.compile(r"^04_TECHNICAL_DOC.*/04_DIAGRAMS", re.I),
|
||||
"09_technische_unterlagen/schaltplaene", "list"),
|
||||
MapRule(re.compile(r"^04_TECHNICAL_DOC.*/05_REPORTS", re.I),
|
||||
"10_anhang/pruefberichte", "list"),
|
||||
# 05 Supplier Doc -> 08 Lieferantendoku (nach Gewerk)
|
||||
MapRule(re.compile(r"^05_SUPPLIER_DOC.*/01_MiTek", re.I),
|
||||
"08_lieferantendoku/stahlbau", "list"),
|
||||
MapRule(re.compile(r"^05_SUPPLIER_DOC.*/02_ABS", re.I),
|
||||
"08_lieferantendoku/sicherheitseinrichtungen", "list"),
|
||||
MapRule(re.compile(r"^05_SUPPLIER_DOC.*/(03_DTS|04_Leantechnik)", re.I),
|
||||
"08_lieferantendoku/antriebe", "list"),
|
||||
MapRule(re.compile(r"^05_SUPPLIER_DOC.*/05_Electrics.*/01_Instructions.*/01_MOTORS", re.I),
|
||||
"08_lieferantendoku/antriebe", "list"),
|
||||
MapRule(re.compile(r"^05_SUPPLIER_DOC.*/05_Electrics", re.I),
|
||||
"08_lieferantendoku/elektrik", "list"),
|
||||
MapRule(re.compile(r"^05_SUPPLIER_DOC.*/(06_|07_)?Pneumatic", re.I),
|
||||
"08_lieferantendoku/pneumatik", "list"),
|
||||
MapRule(re.compile(r"^05_SUPPLIER_DOC", re.I),
|
||||
"08_lieferantendoku", "list"),
|
||||
]
|
||||
|
||||
|
||||
def classify(rel_path: Path) -> tuple[str | None, str | None]:
|
||||
"""Ordnet einen relativen Pfad Kapitel + Aktion zu.
|
||||
|
||||
Returns (chapter, action) oder (None, None), wenn keine Regel greift.
|
||||
"""
|
||||
norm = rel_path.as_posix()
|
||||
for rule in MAPPING:
|
||||
if rule.pattern.search(norm):
|
||||
return rule.chapter, rule.action
|
||||
return None, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inventar-Aufbau
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class InvItem:
|
||||
rel_path: str
|
||||
filename: str
|
||||
ext: str
|
||||
size_bytes: int
|
||||
lang: str
|
||||
fortna_top: str # oberster Fortna-Ordner
|
||||
chapter: str | None # Ziel-Kapitel unter chapters/
|
||||
action: str | None # "convert" | "list" | None
|
||||
|
||||
|
||||
def resolve_source(arg_dir: str | None) -> Path:
|
||||
"""Bestimmt das Fortna-Quellverzeichnis."""
|
||||
if arg_dir:
|
||||
src = Path(arg_dir).expanduser().resolve()
|
||||
elif os.environ.get("PV_FORTNA_SOURCE"):
|
||||
src = Path(os.environ["PV_FORTNA_SOURCE"]).expanduser().resolve()
|
||||
else:
|
||||
# Sinnvoller Default fuer diese Umgebung
|
||||
src = Path("c:/10-Develop/projekte/Fortna").resolve()
|
||||
return src
|
||||
|
||||
|
||||
def build_inventory(source: Path) -> list[InvItem]:
|
||||
"""Scannt das Quellverzeichnis und baut die Inventarliste."""
|
||||
items: list[InvItem] = []
|
||||
exts = {".pdf", ".docx"}
|
||||
|
||||
for path in sorted(source.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.suffix.lower() not in exts:
|
||||
continue
|
||||
rel = path.relative_to(source)
|
||||
chapter, action = classify(rel)
|
||||
items.append(InvItem(
|
||||
rel_path=rel.as_posix(),
|
||||
filename=path.name,
|
||||
ext=path.suffix.lower(),
|
||||
size_bytes=path.stat().st_size,
|
||||
lang=detect_lang(rel),
|
||||
fortna_top=rel.parts[0] if rel.parts else "",
|
||||
chapter=chapter,
|
||||
action=action,
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
def human_size(n: int) -> str:
|
||||
"""Formatiert Bytes menschenlesbar (KB/MB)."""
|
||||
if n >= 1024 * 1024:
|
||||
return f"{n / (1024 * 1024):.1f}M"
|
||||
if n >= 1024:
|
||||
return f"{n / 1024:.0f}K"
|
||||
return f"{n}B"
|
||||
|
||||
|
||||
def print_summary(items: list[InvItem], lang_filter: str | None) -> None:
|
||||
"""Gibt eine tabellarische Zusammenfassung auf stdout aus."""
|
||||
shown = [i for i in items if lang_filter is None or i.lang == lang_filter]
|
||||
|
||||
# Uebersicht nach Kapitel + Aktion
|
||||
by_chapter: Counter = Counter()
|
||||
for it in shown:
|
||||
key = f"{it.chapter or '(UNMAPPED)'} [{it.action or '-'}]"
|
||||
by_chapter[key] += 1
|
||||
|
||||
print("\n=== Dateien je Ziel-Kapitel ===")
|
||||
for key in sorted(by_chapter):
|
||||
print(f" {by_chapter[key]:4d} {key}")
|
||||
|
||||
# Sprachverteilung
|
||||
by_lang: Counter = Counter(i.lang for i in items)
|
||||
print("\n=== Sprachverteilung (alle) ===")
|
||||
for lang in sorted(by_lang):
|
||||
print(f" {by_lang[lang]:4d} {lang}")
|
||||
|
||||
# Nicht zugeordnete Dateien explizit auflisten
|
||||
unmapped = [i for i in shown if i.chapter is None]
|
||||
if unmapped:
|
||||
print(f"\n=== NICHT ZUGEORDNET ({len(unmapped)}) ===")
|
||||
for it in unmapped:
|
||||
print(f" {it.rel_path}")
|
||||
|
||||
# Konvertierungs-Kandidaten (Volltext) explizit auflisten
|
||||
conv = [i for i in items if i.action == "convert"]
|
||||
print(f"\n=== Volltext-Konvertierung ({len(conv)} Kandidaten, alle Sprachen) ===")
|
||||
for it in sorted(conv, key=lambda x: (x.chapter or "", x.lang)):
|
||||
print(f" [{it.lang}] {it.chapter:32s} <- {it.rel_path} ({human_size(it.size_bytes)})")
|
||||
|
||||
print(f"\nGesamt: {len(items)} Dateien, davon {len(shown)} in Filter '{lang_filter or 'alle'}'.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Inventarisiert Fortna-Rohmaterial und ordnet Zielkapitel zu."
|
||||
)
|
||||
parser.add_argument("--source-dir", default=None,
|
||||
help="Fortna-Quellverzeichnis (default: PV_FORTNA_SOURCE "
|
||||
"oder c:/10-Develop/projekte/Fortna)")
|
||||
parser.add_argument("--lang", default=None, choices=["de", "en", "cz"],
|
||||
help="Report auf eine Sprache filtern (JSON enthaelt immer alles)")
|
||||
parser.add_argument("--output", default=None,
|
||||
help="Ausgabe-JSON (default: results/fortna_inventory.json)")
|
||||
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",
|
||||
)
|
||||
|
||||
source = resolve_source(args.source_dir)
|
||||
if not source.exists() or not source.is_dir():
|
||||
log.error("Quellverzeichnis nicht gefunden: %s", source)
|
||||
sys.exit(1)
|
||||
|
||||
log.info("Scanne: %s", source)
|
||||
items = build_inventory(source)
|
||||
log.info("Gefunden: %d Dateien (.pdf/.docx)", len(items))
|
||||
|
||||
# JSON schreiben
|
||||
project_root = Path(os.environ.get("PROJECT", Path(__file__).resolve().parent.parent))
|
||||
results_dir = project_root / "results"
|
||||
results_dir.mkdir(exist_ok=True)
|
||||
out_path = Path(args.output) if args.output else results_dir / "fortna_inventory.json"
|
||||
if not out_path.is_absolute():
|
||||
out_path = results_dir / out_path
|
||||
|
||||
payload = {
|
||||
"source": str(source),
|
||||
"count": len(items),
|
||||
"items": [asdict(i) for i in items],
|
||||
}
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
log.info("Inventar geschrieben: %s", out_path)
|
||||
|
||||
print_summary(items, args.lang)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user