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