6 Commits

Author SHA1 Message Date
m.stangl 6757996704 feat: bin/build_html - HTML-Build-Wrapper analog zu build_pdf
Bisher gab es nur bin/build_pdf.* fuer den PDF-Build; der HTML-Build lief
nur als manueller sphinx-build-Aufruf ohne Wrapper-Skript.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:25:17 +02:00
m.stangl 982879d9f0 fix: Ueberschriften-Erkennung, Bild-Referenzierung und Symbol-Qualitaet
Behebt mehrere Extraktionsprobleme in lib/pdf_extract.py, die zu doppelter
Nummerierung, sinnentleertem Text und falsch dimensionierten Symbolen fuehrten:

- Ueberschriften nur noch bei grosser/fetter Quellschrift (>= 12.5pt oder bold)
  statt bei jeder Zeile mit Nummern-Praefix. Vorher wurden hunderte normale
  Textzeilen (Querverweise, Aufzaehlungen) faelschlich zu Ueberschriften.
- Bildunterschriften ("Abbildung N: ...") werden nie mehr als Ueberschrift
  gerendert (Caption-Erkennung vor Heading-Check, Merge-Regel verschaerft),
  sondern als kursiver Absatz bzw. figure-Caption.
- Symbole (Warn-/Gebots-/Verbotspiktogramme) werden per Piktogramm-Heuristik
  (Signalfarben + wenige Flaechenfarben) erkannt statt nur ueber Haeufigkeit -
  findet auch Symbole, die im Dokument nur 1x vorkommen (z.B. Warndreiecke).
  Auf quadratische Canvas normalisiert (normalize_symbol) und einheitlich
  klein referenziert (:height: 10mm, :align: left), damit alle Symbole
  unabhaengig vom Seitenverhaeltnis der Quelle gleich gross erscheinen.
- Junk-Erkennung erweitert: gerenderte Kapitelnummern-Bilder, Barcodes und
  Foto-Ausschnitte werden nicht mehr als Symbol/Inhaltsbild extrahiert.
- Manuelle SYMBOL_BLOCKLIST (aHash-basiert) fuer sichtgepruefte Fehlklassi-
  fikationen (Foto-/3D-/Diagramm-Fragmente), damit sie bei erneuter
  Konvertierung nicht wieder auftauchen.
- Verzeichnislisten (Lieferantendoku etc.) werden im LaTeX-Build quer
  (landscape via pdflscape) gesetzt, da die langen Quellpfade in Hochformat
  nicht passten.

assets/shared/symbols/: auf normalisierte .png umgestellt, 7 Fehlklassi-
fikationen (Fotos/3D-Renderings/Diagramme statt Piktogramme) entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:23:29 +02:00
m.stangl 3ebfe06139 feat: Bild-Routing, Logo/Symbol/Fragment-Filter, Produktprogramm-Bilder
Bild-Verarbeitung der Anleitungen (lib/pdf_extract.py) erweitert:

- Routing nach Kapitel-Typ: generelle Kapitel (Steuerung) -> assets/shared/
  images/<subdir>, projektbezogene (Betrieb/Wartung) -> projekte/fortna/images.
  Fotos (.jpg) IMMER projektspezifisch, unabhaengig vom Kapitel.
- Benennung aus Bildunterschrift: <Stichwort>_NN.png (z.B. Anlage_Uebersicht_01);
  Fotos img_NNN.jpg, SW-Skizzen >10KB sketch_NNN.png, Rest img_NNN.png.
- Logos: nur wiederkehrende (>=3x) breite Randgrafiken gelten als Logo -> alle
  Aufloesungen nach assets/shared/logos/logo_<n>_<breite>px, nicht inline.
- Wertlose Fragmente (flach <=55px, breit, SW = gerenderte Kapitelnummern wie
  "5.2.6") werden verworfen.
- Ziel-Bildordner werden vor jedem Lauf geleert.

extract_products.py: Produktprogramm-Bilder -> assets/shared/images/
produktprogramm/{omniflo,ils,cpc,geruest,trolley} (generelle Produktbilder).

.gitignore: extrahierte Bildmassen ausgeschlossen, nur kuratierte symbols/ +
logos/ versioniert.

Voller PDF-Build sauber (lualatex exit 0, 589 Seiten, 638 Bild-Refs, 0 fehlend).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-23 14:35:19 +02:00
m.stangl bbfe17ec37 feat: Symbole zentralisieren + Fragment-Ueberschriften filtern
Perceptual-Hash-basierte Symbol-/Duplikaterkennung in lib/pdf_extract.py:

- average_hash() (aHash) + Hamming-Distanz statt exaktem Byte-Hash: erkennt
  visuell gleiche Bilder trotz unterschiedlicher Skalierung/Kompression
- Symbole (klein <=260px UND mehrfach vorkommend, z.B. Warndreiecke) werden
  EINMAL zentral nach assets/shared/symbols/symbol_<hash>.png abgelegt und im
  RST als .. image:: (12mm, zentriert) auf die eine Quelle referenziert -
  dokumentuebergreifend (registry). Ein Warndreieck: 1x gespeichert statt 58x
- grossformatige Inhaltsbild-Duplikate ebenfalls ueber aHash zusammengefuehrt
- Fragment-"Ueberschriften" (POS, li., re., "bar, max. 10 bar") unterdrueckt
- Altbestand assets/shared/symbols/img_*.png (durcheinander benannt) entfernt

Wirkung: omniflo images/ 83 -> 62, 14 zentrale Symbole statt zig Duplikate.
Voller PDF-Build sauber (lualatex exit 0, 719 Seiten).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-23 14:10:33 +02:00
m.stangl c912b1e9d5 fix: saubere PDF-Extraktion der Fortna-Anleitungen (lib/pdf_extract.py)
Neues Modul lib/pdf_extract.py fuer die Volltext-Konvertierung der 5
Hauptanleitungen, behebt die Qualitaetsprobleme der ersten Version:

- Doppelte Nummerierung entfernt: PDF-Gliederungsnummer wird nur fuer die
  Ueberschriften-Ebene ausgewertet, im RST-Text weggelassen (Sphinx nummeriert)
- Bilder positionsbasiert (bbox) extrahiert und als .. figure:: mit Caption
  an der richtigen Textstelle referenziert (625 Refs, 0 fehlende Bilder)
- Fliesstext zu Absaetzen zusammengefuehrt statt je PDF-Zeile ein Absatz
- Aufzaehlungen als RST-Listen (Bullet-only-Zeilen mit Folgezeile gemergt,
  zusammengelaufene Bullets aufgesplittet)
- Aenderungshistorie-/Dokumentinfo-Tabellen und TOC-Seiten uebersprungen
- Symbolfont-Zeichen (PUA) auf Unicode gemappt

fortna_convert_chapters.py: nutzt pdf_extract statt extract_products, lesbare
Dokumenttitel je Kapitel, Verzeichnislisten fuellen Kapitel ohne DE-Quelle mit
vorhandenen Fremdsprachen (Ersatzteilliste EN).
build_index.py / conf.py: Fremd-Altdateien aus dem Build ausgeschlossen.
Alte Test-.rst aus fruehen convert_to_rst.py-Laeufen entfernt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-23 13:44:09 +02:00
m.stangl e7aa3b4e4b 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>
2026-09-23 13:23:32 +02:00
57 changed files with 3147 additions and 6 deletions
+26
View File
@@ -42,6 +42,11 @@ cover/
.env
.venv
env/
# Sphinx-Build-Ausgabe (generiert, nicht versionieren)
/_build/
# generierter Build-Index (aus lib/build_index.py)
/index.rst
env/
venv/
ENV/
env.bak/
@@ -69,3 +74,24 @@ ipython_config.py
/log
/results
/_output
# IDE- / Tool-Ordner
/.vscode
/.iis
/.claude
# Fremd-/Rohmaterial und grosse Binaerdaten (nicht versionieren)
/examples
doc/*.pdf
# Generierte Dokumentations-Bausteine (reproduzierbar via lib/fortna_convert_chapters.py)
# und extrahierte Bilder. Nur Code/Config/Doku wird versioniert.
chapters/**/*.rst
chapters/**/images/
# Extrahierte Bilder (reproduzierbar) - NICHT versionieren ...
assets/shared/images/
projekte/fortna/images/
# ... AUSSER den kuratierten, wiederverwendbaren Symbolen und Logos.
!assets/shared/symbols/
!assets/shared/logos/
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+31
View File
@@ -0,0 +1,31 @@
@echo off
REM ================================================================
REM HTML-Build der Fortna-Dokumentation (DE)
REM
REM Ablauf:
REM 1. index.rst aus vorhandenen chapters/**/*.de.rst generieren
REM 2. sphinx-build -b html -> _build/html/index.html
REM
REM Voraussetzung: chapters/ ist befuellt (fortna_convert_chapters.bat --apply)
REM ================================================================
call "%~dp0setenv.bat"
if not exist "%PROJECT%\.venv" (
echo FEHLER: .venv nicht gefunden. Bitte zuerst install_py.bat ausfuehren.
exit /b 1
)
call "%PROJECT%\.venv\Scripts\activate.bat"
echo.
echo [1/2] index.rst generieren ...
python "%PV_LIB%\build_index.py"
echo.
echo [2/2] HTML erzeugen (sphinx-build -b html) ...
python -m sphinx -b html "%PROJECT%" "%PROJECT%\_build\html"
echo.
echo Fertig. HTML: %PROJECT%\_build\html\index.html
deactivate
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# ================================================================
# HTML-Build der Fortna-Dokumentation (DE)
# 1. index.rst generieren
# 2. sphinx-build -b html
# Voraussetzung: chapters/ ist befuellt (fortna_convert_chapters.sh --apply)
# ================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/setenv.sh"
if [ ! -d "$PROJECT/.venv" ]; then
echo "FEHLER: .venv nicht gefunden. Bitte zuerst install_py.sh ausfuehren."
exit 1
fi
source "$PROJECT/.venv/bin/activate" 2>/dev/null || source "$PROJECT/.venv/Scripts/activate"
echo ""
echo "[1/2] index.rst generieren ..."
python "$PV_LIB/build_index.py"
echo ""
echo "[2/2] HTML erzeugen (sphinx-build -b html) ..."
python -m sphinx -b html "$PROJECT" "$PROJECT/_build/html"
echo ""
echo "Fertig. HTML: $PROJECT/_build/html/index.html"
deactivate
+38
View File
@@ -0,0 +1,38 @@
@echo off
REM ================================================================
REM Vollstaendiger PDF-Build der Fortna-Dokumentation (DE)
REM
REM Ablauf:
REM 1. index.rst aus vorhandenen chapters/**/*.de.rst generieren
REM 2. sphinx-build -b latex -> _build/latex/*.tex
REM 3. lualatex (via latexmk) -> _build/latex/Fortna_Dokumentation_DE.pdf
REM
REM Voraussetzung: chapters/ ist befuellt (fortna_convert_chapters.bat --apply)
REM ================================================================
call "%~dp0setenv.bat"
if not exist "%PROJECT%\.venv" (
echo FEHLER: .venv nicht gefunden. Bitte zuerst install_py.bat ausfuehren.
exit /b 1
)
call "%PROJECT%\.venv\Scripts\activate.bat"
echo.
echo [1/3] index.rst generieren ...
python "%PV_LIB%\build_index.py"
echo.
echo [2/3] LaTeX-Quellen erzeugen (sphinx-build -b latex) ...
python -m sphinx -b latex "%PROJECT%" "%PROJECT%\_build\latex"
echo.
echo [3/3] PDF erzeugen (lualatex) ...
pushd "%PROJECT%\_build\latex"
latexmk -lualatex -interaction=nonstopmode Fortna_Dokumentation_DE.tex
popd
echo.
echo Fertig. PDF: %PROJECT%\_build\latex\Fortna_Dokumentation_DE.pdf
deactivate
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# ================================================================
# Vollstaendiger PDF-Build der Fortna-Dokumentation (DE)
# 1. index.rst generieren
# 2. sphinx-build -b latex
# 3. lualatex (via latexmk)
# Voraussetzung: chapters/ ist befuellt (fortna_convert_chapters.sh --apply)
# ================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/setenv.sh"
if [ ! -d "$PROJECT/.venv" ]; then
echo "FEHLER: .venv nicht gefunden. Bitte zuerst install_py.sh ausfuehren."
exit 1
fi
source "$PROJECT/.venv/bin/activate" 2>/dev/null || source "$PROJECT/.venv/Scripts/activate"
echo ""
echo "[1/3] index.rst generieren ..."
python "$PV_LIB/build_index.py"
echo ""
echo "[2/3] LaTeX-Quellen erzeugen (sphinx-build -b latex) ..."
python -m sphinx -b latex "$PROJECT" "$PROJECT/_build/latex"
echo ""
echo "[3/3] PDF erzeugen (lualatex) ..."
( cd "$PROJECT/_build/latex" && latexmk -lualatex -interaction=nonstopmode Fortna_Dokumentation_DE.tex )
echo ""
echo "Fertig. PDF: $PROJECT/_build/latex/Fortna_Dokumentation_DE.pdf"
deactivate
+26
View File
@@ -0,0 +1,26 @@
@echo off
REM ================================================================
REM Sivas-IDs aus JSON-Baeumen extrahieren
REM
REM Liest JSON-Dateien aus results/ und extrahiert 9-stellige
REM Sivas-Nummern eine Ebene ueber den Blaettern.
REM
REM Verwendung:
REM extractids.bat --filename KA135776
REM extractids.bat --filename KA135776 --tab soll
REM extractids.bat --all
REM ================================================================
call "%~dp0setenv.bat"
REM Pruefe ob venv existiert
if not exist "%PROJECT%\.venv" (
echo FEHLER: .venv nicht gefunden. Bitte zuerst install_py.bat ausfuehren.
exit /b 1
)
call "%PROJECT%\.venv\Scripts\activate.bat"
python "%PV_LIB%\extract_ids.py" %*
deactivate
+22
View File
@@ -0,0 +1,22 @@
@echo off
REM ================================================================
REM chapters/ aus dem Fortna-Inventar befuellen
REM (5 Hauptanleitungen Volltext + Verzeichnislisten)
REM
REM Verwendung:
REM fortna_convert_chapters.bat (Dry-Run)
REM fortna_convert_chapters.bat --apply
REM ================================================================
call "%~dp0setenv.bat"
if not exist "%PROJECT%\.venv" (
echo FEHLER: .venv nicht gefunden. Bitte zuerst install_py.bat ausfuehren.
exit /b 1
)
if "%PV_FORTNA_SOURCE%"=="" set "PV_FORTNA_SOURCE=C:\10-Develop\projekte\Fortna"
call "%PROJECT%\.venv\Scripts\activate.bat"
python "%PV_LIB%\fortna_convert_chapters.py" %*
deactivate
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# ================================================================
# chapters/ aus dem Fortna-Inventar befuellen
# (5 Hauptanleitungen Volltext + Verzeichnislisten)
# Verwendung:
# bash bin/fortna_convert_chapters.sh (Dry-Run)
# bash bin/fortna_convert_chapters.sh --apply
# ================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/setenv.sh"
if [ ! -d "$PROJECT/.venv" ]; then
echo "FEHLER: .venv nicht gefunden. Bitte zuerst install_py.sh ausfuehren."
exit 1
fi
export PV_FORTNA_SOURCE="${PV_FORTNA_SOURCE:-C:/10-Develop/projekte/Fortna}"
source "$PROJECT/.venv/bin/activate" 2>/dev/null || source "$PROJECT/.venv/Scripts/activate"
python "$PV_LIB/fortna_convert_chapters.py" "$@"
deactivate
+19
View File
@@ -0,0 +1,19 @@
@echo off
REM ================================================================
REM Projektspezifischen Ordner projekte/fortna/ anlegen
REM
REM Verwendung:
REM fortna_init_project.bat
REM fortna_init_project.bat --force
REM ================================================================
call "%~dp0setenv.bat"
if not exist "%PROJECT%\.venv" (
echo FEHLER: .venv nicht gefunden. Bitte zuerst install_py.bat ausfuehren.
exit /b 1
)
call "%PROJECT%\.venv\Scripts\activate.bat"
python "%PV_LIB%\fortna_init_project.py" %*
deactivate
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# ================================================================
# Projektspezifischen Ordner projekte/fortna/ anlegen
# Verwendung:
# bash bin/fortna_init_project.sh
# bash bin/fortna_init_project.sh --force
# ================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/setenv.sh"
if [ ! -d "$PROJECT/.venv" ]; then
echo "FEHLER: .venv nicht gefunden. Bitte zuerst install_py.sh ausfuehren."
exit 1
fi
source "$PROJECT/.venv/bin/activate" 2>/dev/null || source "$PROJECT/.venv/Scripts/activate"
python "$PV_LIB/fortna_init_project.py" "$@"
deactivate
+21
View File
@@ -0,0 +1,21 @@
@echo off
REM ================================================================
REM Fortna-Rohmaterial inventarisieren + Kapitel-Mapping pruefen
REM
REM Verwendung:
REM fortna_inventory.bat
REM fortna_inventory.bat --lang de
REM ================================================================
call "%~dp0setenv.bat"
if not exist "%PROJECT%\.venv" (
echo FEHLER: .venv nicht gefunden. Bitte zuerst install_py.bat ausfuehren.
exit /b 1
)
if "%PV_FORTNA_SOURCE%"=="" set "PV_FORTNA_SOURCE=C:\10-Develop\projekte\Fortna"
call "%PROJECT%\.venv\Scripts\activate.bat"
python "%PV_LIB%\fortna_inventory.py" %*
deactivate
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# ================================================================
# Fortna-Rohmaterial inventarisieren + Kapitel-Mapping pruefen
# Verwendung: bash bin/fortna_inventory.sh [--lang de]
# ================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/setenv.sh"
if [ ! -d "$PROJECT/.venv" ]; then
echo "FEHLER: .venv nicht gefunden. Bitte zuerst install_py.sh ausfuehren."
exit 1
fi
export PV_FORTNA_SOURCE="${PV_FORTNA_SOURCE:-C:/10-Develop/projekte/Fortna}"
source "$PROJECT/.venv/bin/activate" 2>/dev/null || source "$PROJECT/.venv/Scripts/activate"
python "$PV_LIB/fortna_inventory.py" "$@"
deactivate
+20
View File
@@ -0,0 +1,20 @@
@echo off
REM ================================================================
REM DOCU_BUILD - Projektdaten aus Excel-Stuecklisten extrahieren
REM ================================================================
call "%~dp0setenv.bat"
if not exist "%PROJECT%\.venv" (
echo FEHLER: Virtual environment nicht gefunden.
echo Bitte zuerst bin\install_py.bat ausfuehren.
exit /b 1
)
call "%PROJECT%\.venv\Scripts\activate.bat"
echo.
echo Extrahiere Projektdaten aus Excel-Stuecklisten ...
echo.
python "%PV_LIB%\project_from_xlsx.py" %*
+108
View File
@@ -0,0 +1,108 @@
"""Sphinx-Konfiguration fuer den Fortna-Dokumentations-Build (DE).
Vorlaeufige Konfiguration fuer den ersten vollstaendigen Build aus dem Base-Repo
heraus. Die spaetere Zwei-Repo-Architektur (conf_base.py + Projekt-conf.py, siehe
doc/Kernkonzepte.md) ist hier noch nicht umgesetzt.
"""
from __future__ import annotations
# -- Projekt ----------------------------------------------------------------
project = "Anlagendokumentation Fortna (500592 / KA 135776)"
author = "Schoenenberger Systeme GmbH / Mista GmbH"
copyright = "2026, Schoenenberger Systeme GmbH"
release = "0.1"
language = "de"
# -- Allgemein --------------------------------------------------------------
extensions = [
"myst_parser", # erlaubt .md gleichwertig zu .rst (Kernkonzepte.md)
]
# Master-Dokument
root_doc = "index"
source_suffix = {
".rst": "restructuredtext",
".md": "markdown",
}
# Nur die deutschsprachigen Bausteine in den Build aufnehmen; fremdsprachige
# {name}.en.rst / {name}.cz.rst (aktuell keine im Baum) werden ignoriert.
# Sphinx sammelt per Default ALLE .rst/.md unter dem Wurzelverzeichnis ein.
# Alles ausserhalb von chapters/ ausschliessen, damit nur die Doku-Bausteine
# in den Build gelangen (CLAUDE.md, README.md, doc/, Git-Worktrees etc. raus).
exclude_patterns = [
"_build",
"**/*.en.rst",
"**/*.cz.rst",
"*.md",
".claude",
".venv",
".git",
"doc",
"examples",
"results",
"data",
"lib",
"bin",
"cfg",
"log",
"tests",
"projekte",
"_ext",
"_templates",
"assets",
"modules",
"glossary",
"styles",
"scripts",
# Defekte Altbestaende aus fruehen Testlaeufen (nicht von den fortna_*-Skripten
# erzeugt): unvollstaendiger Bildbestand bzw. fehlende RST-Ueberschrift.
# TODO: mit fortna_convert_chapters.py sauber neu erzeugen, dann Exclude entfernen.
"chapters/02_sicherheit/risikobewertung/rb_fortna_h_m_500592_ev12_20260311.de.rst",
"chapters/00_inhaltsverzeichnis/contens_obsah_500592_135776_v2_1_en_cz.de.rst",
"chapters/10_anhang/01_getriebe_loadingloopantr_de.de.rst",
"chapters/10_anhang/02_motor_loadingloopantr_de.de.rst",
]
# -- HTML -------------------------------------------------------------------
html_theme = "alabaster"
# -- LaTeX / PDF ------------------------------------------------------------
# lualatex verkraftet die Unicode-Zeichen (tschechische Diakritik, ue/oe/ae,
# Sonderzeichen aus der PDF-Extraktion) deutlich besser als pdflatex.
latex_engine = "lualatex"
latex_documents = [
(root_doc,
"Fortna_Dokumentation_DE.tex",
"Anlagendokumentation Fortna\\\\500592 / KA 135776",
author,
"manual"),
]
# iconpackage=none: FontAwesome nicht laden. fontawesome7 (MiKTeX) bricht unter
# lualatex beim Font-Analyze ab (\faDice undefined). Ohne Icons werden Admonitions
# (.. note:: in den Verzeichnislisten) einfach ohne Symbol gesetzt.
latex_elements = {
"sphinxsetup": "iconpackage=none",
"papersize": "a4paper",
"pointsize": "11pt",
# babel statt polyglossia erzwingen: polyglossia 2.13 (MiKTeX 09/2026) wirft
# unter lualatex einen bidi-Initialisierungsfehler (\g__xpg_unibidi_str), der
# fuer rein deutschen Text irrelevant ist. babel vermeidet ihn.
"polyglossia": "",
"babel": r"\usepackage[ngerman]{babel}",
# Robustheit gegen ueberlange Zeilen aus der PDF-Textextraktion:
# - pdflscape: erlaubt \begin{landscape} fuer quergestellte Verzeichnistabellen
# - \UrlBreaks: lange Quellpfade in ``...`` duerfen umbrechen
"preamble": r"""
\usepackage{microtype}
\usepackage{pdflscape}
\emergencystretch=3em
\setcounter{tocdepth}{2}
\sloppy
\renewcommand{\sphinxbreaksbeforeactivelist}{\do\-\do\/\do\_\do\.}
""",
}
+504
View File
@@ -0,0 +1,504 @@
[ 512] .
├── [ 512] 00_CONTENTS_NOTES-OBSAH_POZNÁMKY
│   ├── [355K] Contens-Obsah_500592_135776_V2.1_EN-CZ.pdf
│   └── [348K] Contens_Obsah_500592_135776_V2.1_20260311_Rev_2.pdf
├── [ 512] 01_CERTIFICATES-CERTIFIKÁTY
│   ├── [ 512] 01_SCHÖNENBERGER
│   │   ├── [ 512] 01_EN_original
│   │   │   └── [ 74K] 00_EC Declaration of Conformity_V2_EN.pdf
│   │   └── [ 512] 02_CZ
│   │   └── [ 76K] 00_Prohlášení o shodě ES_V2_CZ.pdf
│   └── [ 512] 02_KONFIGURATIONSBESCHREIBUNG_CONFIG-DESCRIPTION_POPIS KONFIG
│   ├── [ 512] 01_DE
│   │   └── [252K] Konfigurationsbeschreibung_für_CE_V2.pdf
│   ├── [ 512] 02_EN
│   │   └── [249K] Configuration Description_CE_V2_EN.pdf
│   └── [ 512] 03_CZ
│   └── [248K] Popis konfigurace_für_CE_V2_CZ.pdf
├── [ 512] 02_INSTRUCTIONS-NÁVOD_K_POUŽITÍ
│   ├── [ 512] 01_DE_original
│   │   ├── [5.5M] 01_BETRIEBSANLEITUNG_500592_V2.3.pdf
│   │   ├── [5.5M] 02_WARTUNGSANLEITUNG_500592_V2.2.pdf
│   │   ├── [7.4M] 03_FUNKTION_STEUERUNG_ILS_500592_V2.1.pdf
│   │   ├── [4.3M] 04_FUNKTION_STEUERUNG_OMNIFLO_500592_V1.0.pdf
│   │   └── [2.5M] 05_FUNKTION_STEUERUNG_LoadingLoop_500592_V2.2.pdf
│   ├── [ 512] 02_EN
│   │   └── [2.5M] 05_FUNKTION_CONTROL_LoadingLoop_500592_V2.2_EN.pdf
│   └── [ 512] 03_CZ
│   ├── [5.4M] 01_NÁVOD_K_POUŽÍVÁNÍ_500592_135776_V2.3_CZ.pdf
│   ├── [5.5M] 02_NÁVOD_K_ÚDRŽBĚ_500592_135776_V2.2_CZ.pdf
│   ├── [7.5M] 03_FUNKCE_ŘÍZENÍ_ILS_500592_V2.1_CZ.pdf
│   └── [4.8M] 04_ŘÍZENÍ_FUNKCÍ_OMNIFLO_500592_V1.0._CZ.pdf
├── [ 512] 03_SPARE_PARTS-NÁHRADNÍ_DÍLY
│   └── [3.3M] 01_SPARE_PARTS_500592_EN_V2.1.pdf
├── [ 512] 04_TECHNICAL_DOC-TECHNICKÉ_DOKUMENTY
│   ├── [ 512] 01_LAYOUT-DISPOZICE
│   │   ├── [ 512] 01_MECHANICAL-MECHANICKÝ
│   │   │   ├── [ 512] 01_EN_original
│   │   │   │   ├── [196M] 01_500592_10_5.14_Layout_V2.1_EN.pdf
│   │   │   │   └── [ 37M] 02_500592_10_5.14_Bereiche_V2.1_EN.pdf
│   │   │   └── [ 512] 02_CZ
│   │   │   ├── [196M] 01_500592_10_5.14_dispozice_V2.1_CZ.pdf
│   │   │   └── [ 36M] 02_500592_10_5.14_oblasti_V2.1_CZ.pdf
│   │   └── [ 512] 02_ELECTRICAL-ELEKTRIKA
│   │   ├── [ 512] 01_EN_original
│   │   │   ├── [3.0M] 01_ST500592_10_5.14_Omniflo-AMR+Highway-(ST6)-[EN].pdf
│   │   │   ├── [2.4M] 02_ST500592_10_5.14_Omniflo-Outbound+Highway-(ST7)-[EN].pdf
│   │   │   ├── [2.1M] 03_ST500592_10_5-14_ILS-AMR-ILS (ST4)-[EN].pdf
│   │   │   ├── [2.3M] 04_ST500592_10_5-14_ILS-F2H-Workstation (ST5)-[EN].pdf
│   │   │   ├── [1.8M] 05_ST500592_10_5-14_ILS-Highway (ST1-ST3)-[EN].pdf
│   │   │   ├── [4.6M] 06_ST500592_10_5-14_ILS-Inbound level 0 (ST1)-[EN].pdf
│   │   │   ├── [5.6M] 07_ST500592_10_5-14_ILS-Storage Level 1 (ST2)-[EN].pdf
│   │   │   └── [5.4M] 08_ST500592_10_5-14_ILS-Storage level 2 (ST3)-[EN].pdf
│   │   └── [ 512] 02_CZ
│   │   ├── [3.7M] 01_ST500592_10_5.14_Omniflo_CZ-AMR+Highway(ST6)-[CZ].pdf
│   │   ├── [2.4M] 02_ST500592_10_5.14_Omniflo_CZ-Outbound+Highway (ST7)-[CZ].pdf
│   │   ├── [5.4M] 03_ST500592_10_5-14_ILS_CZ-Storage level 2 (ST3)-[CZ].pdf
│   │   ├── [1.8M] 04_ST500592_10_5-14_ILS_CZ-AMR-ILS (ST4)-[CZ].pdf
│   │   ├── [1.9M] 05_ST500592_10_5-14_ILS_CZ-F2H-Workstation (ST5)-[CZ].pdf
│   │   ├── [1.4M] 06_ST500592_10_5-14_ILS_CZ-Highway (ST1-ST3)-[CZ].pdf
│   │   ├── [2.7M] 07_ST500592_10_5-14_ILS_CZ-Inbound level 0 (ST1)-[CZ].pdf
│   │   └── [6.0M] 08_ST500592_10_5-14_ILS_CZ-Storage Level 1 (ST2)-[CZ].pdf
│   ├── [ 512] 02_AREA_STOP_ZASTAVENI_OBLASTI
│   │   ├── [ 512] 02_CZ
│   │   │   └── [ 22M] 01_ES500592_10_5-14_ILS-OF_Zastavení oblasti_cpl-[CZ].pdf
│   │   └── [ 512] 1_EN_original
│   │   └── [ 23M] 01_ES500592_10_5-14_ILS-OF-Area-Stop-cpl.-[EN].pdf
│   ├── [ 512] 03_EMERGENCY_STOP-NOUZOVÉ_ZASTAVENÍ
│   │   ├── [ 512] 01_EMERGENCY_STOP_NOUZOVE_ZASTAVENI
│   │   │   ├── [ 512] 01_EN_original
│   │   │   │   └── [ 30M] 01_ES500592_10_5-14_ILS-OF_cpl.-E-Stop-[EN].pdf
│   │   │   └── [ 512] 02_CZ
│   │   │   └── [ 26M] 01_ES500592_10_5-14_ILS-OF-cpl._NV-[CZ].pdf
│   │   └── [ 512] 02_EMERGENCY_STOP_MATRIX
│   │   └── [837K] 01_Emergency_Stop_Matrix_V1.5_20260213-[EN].pdf
│   ├── [ 512] 04_DIAGRAMS-SCHÉMATA
│   │   ├── [ 512] 01_EN_original
│   │   │   ├── [ 32M] 01_Circuit diagrams_Controlarea 1_EN_Update_20260416.pdf
│   │   │   ├── [ 94M] 02_Circuit diagrams_Controlarea 2_EN_Update_20260416.pdf
│   │   │   ├── [ 98M] 03_Circuit diagrams_Controlarea 3_EN_Update_20260416.pdf
│   │   │   ├── [ 17M] 04_Circuit diagrams_Controlarea 4_EN_Update_20260416.pdf
│   │   │   ├── [ 18M] 05_Circuit diagrams_Controlarea 5_EN_Update_20260416.pdf
│   │   │   ├── [ 39M] 06_Circuit diagrams_Controlarea 6_EN_Update_20260416.pdf
│   │   │   └── [ 58M] 07_Circuit diagrams_Controlarea 7_EN_Update_20260416.pdf
│   │   └── [ 512] 02_CZ
│   │   ├── [ 32M] 01_Schéma zapojení_Ovládací oblast 1_CZ_Aktualizace_20260416.pdf
│   │   ├── [ 93M] 02_Schéma zapojení_Ovládací oblast 2_CZ_Aktualizace_20260416.pdf
│   │   ├── [ 96M] 03_Schéma zapojení_Ovládací oblast 3_CZ_Aktualizace_20260416.pdf
│   │   ├── [ 17M] 04_Schéma zapojení_Ovládací oblast 4_CZ_Aktualizace_20260416.pdf
│   │   ├── [ 18M] 05_Schéma zapojení_Ovládací oblast 5_CZ_Aktualizace_20260416.pdf
│   │   ├── [ 39M] 06_Schéma zapojení_Ovládací oblast 6_CZ_Aktualizace_20260416.pdf
│   │   └── [ 58M] 07_Schéma zapojení_Ovládací oblast 7_CZ_Aktualizace_20260416.pdf
│   └── [ 512] 05_REPORTS-ZPRÁVY
│   ├── [ 512] 01_Protokoll_Wiederanlauf_20260227
│   │   └── [149K] 01_Schutz_gegen_automatischen_Wiederanlauf_V1.1.pdf
│   ├── [ 512] 02_Prüfung_Drehrichtung_Asyncronmotoren_20260227
│   │   └── [153K] 01_Drehrichtung_Asynchronmotoren_20260420.pdf
│   └── [ 512] 03_Erprobung_Anzeige_Bediengeräte_20260227
│   └── [161K] 01_Erprobung_Anzeige_Bediengeräte_20260420.pdf
├── [ 512] 05_SUPPLIER_DOC-DOKUMENTY_DODAVATELE
│   ├── [ 512] 01_MiTek_steel_structure_ocelová konstrukce
│   │   ├── [ 512] 01_As Built_drawing
│   │   │   └── [ 85M] MMS 2307-025 (As Built).pdf
│   │   ├── [ 512] 02_Structural_Calcs
│   │   │   └── [ 16M] Design_Calculation_Report_Rev_002_23.01.25_EN.pdf
│   │   ├── [ 512] 03_Instructions_Pokyny
│   │   │   ├── [ 512] 01_EN_original
│   │   │   │   ├── [ 90K] 01_Sestavení dat_CZ-Data_Assembly_EN.pdf
│   │   │   │   ├── [168K] 02_Operations and Maintenance_rev.1_EN.pdf
│   │   │   │   └── [241K] 03_marathon-prime-finish-tds_EN.pdf
│   │   │   └── [ 512] 02_CZ
│   │   │   └── [106K] 02_Provozní a údržbové postupy_rev.1_CZ.pdf
│   │   ├── [ 512] 04_MiTek DOP
│   │   │   ├── [240K] Declaration_Performance_Certificate_MMS-2307-025_EN.pdf
│   │   │   └── [204K] MMS-2307-025_Design_Compliance_Statement_EN.pdf
│   │   ├── [ 512] 05_Fabricator DOP
│   │   │   └── [228K] BRW0C96E613B534_000616.pdf
│   │   ├── [ 512] 06_Steel Test Cert
│   │   │   ├── [ 512] Lot 1
│   │   │   │   ├── [4.4M] k093444 - Test Certs.pdf
│   │   │   │   ├── [1.2M] k093445 - Test Certs.pdf
│   │   │   │   ├── [718K] k093446 - Test Certs.pdf
│   │   │   │   ├── [220K] k093452 - Test Certs.pdf
│   │   │   │   ├── [192K] k093713 - Test Certs.pdf
│   │   │   │   └── [147K] k093771 - Test Certs.pdf
│   │   │   ├── [ 512] Lot 10
│   │   │   │   ├── [1.2M] TC_7545925_001_2668830_001_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545925_002_2668830_002_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545925_003_2668830_003_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545925_004_2668830_004_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545925_005_2668830_005_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545925_006_2668830_006_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545925_007_2668830_007_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545925_008_2668830_008_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545925_009_2668830_009_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545925_010_2668830_010_0188358.pdf
│   │   │   │   └── [1.2M] TC_7545925_011_2668830_011_0188358.pdf
│   │   │   ├── [ 512] Lot 11
│   │   │   │   ├── [1.2M] TC_7545926_001_2668831_001_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_002_2668831_002_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_003_2668831_003_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_004_2668831_004_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_005_2668831_005_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_006_2668831_006_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_007_2668831_007_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_008_2668831_008_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_009_2668831_009_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_010_2668831_010_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_011_2668831_011_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_012_2668831_012_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_013_2668831_013_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_014_2668831_014_0188358.pdf
│   │   │   │   ├── [1.2M] TC_7545926_015_2668831_015_0188358.pdf
│   │   │   │   └── [1.2M] TC_7545926_016_2668831_016_0188358.pdf
│   │   │   ├── [ 512] Lot 2
│   │   │   │   ├── [2.1M] k093448 - Test Certs.pdf
│   │   │   │   ├── [1.5M] k093449 - Test Certs.pdf
│   │   │   │   ├── [2.0M] k093450 - Test Certs.pdf
│   │   │   │   ├── [220K] k093452 - Test Certs.pdf
│   │   │   │   ├── [189K] k093605 - Test Certs.pdf
│   │   │   │   ├── [192K] k093714 - Test Certs.pdf
│   │   │   │   └── [147K] k093771 - Test Certs.pdf
│   │   │   ├── [ 512] Lot 3
│   │   │   │   ├── [ 29M] SO-7537535-Test-Certs-03-12-2025-15-41-46 (10).zip
│   │   │   │   ├── [ 29M] SO-7537535-Test-Certs-03-12-2025-15-41-46 (11).zip
│   │   │   │   ├── [ 31M] SO-7537535-Test-Certs-03-12-2025-15-41-46 (12).zip
│   │   │   │   ├── [ 31M] SO-7537535-Test-Certs-03-12-2025-15-41-46 (13).zip
│   │   │   │   ├── [ 29M] SO-7537535-Test-Certs-03-12-2025-15-41-46 (14).zip
│   │   │   │   ├── [ 29M] SO-7537535-Test-Certs-03-12-2025-15-41-46 (15).zip
│   │   │   │   ├── [ 28M] SO-7537535-Test-Certs-03-12-2025-15-41-46 (16).zip
│   │   │   │   ├── [ 18M] SO-7537535-Test-Certs-03-12-2025-15-41-46 (17).zip
│   │   │   │   └── [ 28M] SO-7537535-Test-Certs-03-12-2025-15-41-46 (9).zip
│   │   │   ├── [251M] Lot 3.zip
│   │   │   ├── [ 512] Lot 4
│   │   │   │   ├── [ 30M] SO-7537547-Test-Certs-03-12-2025-16-04-28 (1).zip
│   │   │   │   ├── [ 30M] SO-7537547-Test-Certs-03-12-2025-16-04-28 (2).zip
│   │   │   │   ├── [ 30M] SO-7537547-Test-Certs-03-12-2025-16-04-28 (3).zip
│   │   │   │   ├── [ 29M] SO-7537547-Test-Certs-03-12-2025-16-04-28 (4).zip
│   │   │   │   ├── [ 30M] SO-7537547-Test-Certs-03-12-2025-16-04-28 (5).zip
│   │   │   │   └── [ 29M] SO-7537547-Test-Certs-03-12-2025-16-04-28.zip
│   │   │   ├── [ 512] Lot 5
│   │   │   │   ├── [ 30M] SO-7538540-Test-Certs-10-12-2025-11-55-15 (1).zip
│   │   │   │   ├── [ 30M] SO-7538540-Test-Certs-10-12-2025-11-55-15 (2).zip
│   │   │   │   ├── [ 27M] SO-7538540-Test-Certs-10-12-2025-11-55-15 (3).zip
│   │   │   │   ├── [ 30M] SO-7538540-Test-Certs-10-12-2025-11-55-15 (4).zip
│   │   │   │   ├── [ 28M] SO-7538540-Test-Certs-10-12-2025-11-55-15 (5).zip
│   │   │   │   └── [ 29M] SO-7538540-Test-Certs-10-12-2025-11-55-15.zip
│   │   │   ├── [ 512] Lot 6
│   │   │   │   ├── [ 29M] SO-7539198-Test-Certs-10-12-2025-12-54-38 (1).zip
│   │   │   │   ├── [ 29M] SO-7539198-Test-Certs-10-12-2025-12-54-38 (2).zip
│   │   │   │   ├── [ 28M] SO-7539198-Test-Certs-10-12-2025-12-54-38 (3).zip
│   │   │   │   ├── [ 29M] SO-7539198-Test-Certs-10-12-2025-12-54-38 (4).zip
│   │   │   │   ├── [ 30M] SO-7539198-Test-Certs-10-12-2025-12-54-38 (5).zip
│   │   │   │   ├── [ 27M] SO-7539198-Test-Certs-10-12-2025-12-54-38 (6).zip
│   │   │   │   └── [ 28M] SO-7539198-Test-Certs-10-12-2025-12-54-38.zip
│   │   │   ├── [ 512] Lot 7
│   │   │   │   ├── [ 29M] SO-7540495-Test-Certs-10-12-2025-14-22-20 (1).zip
│   │   │   │   ├── [ 28M] SO-7540495-Test-Certs-10-12-2025-14-22-20 (2).zip
│   │   │   │   ├── [ 30M] SO-7540495-Test-Certs-10-12-2025-14-22-20 (3).zip
│   │   │   │   ├── [ 18M] SO-7540495-Test-Certs-10-12-2025-14-22-20 (4).zip
│   │   │   │   └── [ 29M] SO-7540495-Test-Certs-10-12-2025-14-22-20.zip
│   │   │   ├── [ 512] Lot 8
│   │   │   └── [ 512] Lot 9
│   │   │   ├── [1.2M] TC_7545924_001_2668829_001_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_002_2668829_002_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_003_2668829_003_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_004_2668829_004_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_005_2668829_005_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_006_2668829_006_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_007_2668829_007_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_008_2668829_008_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_009_2668829_009_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_010_2668829_010_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_011_2668829_011_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_012_2668829_012_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_013_2668829_013_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_014_2668829_014_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_015_2668829_015_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_016_2668829_016_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_017_2668829_017_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_018_2668829_018_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_019_2668829_019_0188358.pdf
│   │   │   ├── [1.2M] TC_7545924_020_2668829_020_0188358.pdf
│   │   │   └── [1.2M] TC_7545924_021_2668829_021_0188358.pdf
│   │   ├── [ 512] 07_Joist Test Cert
│   │   │   ├── [ 512] Lot 13
│   │   │   │   ├── [332K] Mitek 96119739_1 H3.309.pdf
│   │   │   │   ├── [ 21K] Mitek 96119739_2 H4.87.pdf
│   │   │   │   └── [ 21K] Mitek 96119739_3 H4.134.pdf
│   │   │   ├── [ 512] Lot 14
│   │   │   │   ├── [ 89K] Mitek 96120095_1 H3.309.pdf
│   │   │   │   ├── [ 21K] Mitek 96120095_2 H4.87.pdf
│   │   │   │   └── [ 21K] Mitek 96120095_3 H4.134.pdf
│   │   │   ├── [ 512] Lot 15
│   │   │   │   ├── [238K] Mitek 96120721_1 H3.309.pdf
│   │   │   │   ├── [ 21K] Mitek 96120721_2 H4.87.pdf
│   │   │   │   └── [238K] Mitek 96120721_3 H4.79.pdf
│   │   │   ├── [ 512] Lot 16
│   │   │   │   ├── [237K] Mitek 96120781_1 H3.309.pdf
│   │   │   │   ├── [ 21K] Mitek 96120781_2 H4.87.pdf
│   │   │   │   └── [ 21K] Mitek 96120781_3 H4.80.pdf
│   │   │   └── [ 512] Lot 17
│   │   │   ├── [237K] Mitek 96121082_1 H3.309.pdf
│   │   │   ├── [ 21K] Mitek 96121082_2 H4.87.pdf
│   │   │   └── [322K] Mitek 96121082_3 H4.134.pdf
│   │   ├── [ 512] 08_Bolt Test Cert
│   │   │   ├── [ 512] BAPP
│   │   │   │   ├── [201K] 35801 LOC.pdf
│   │   │   │   ├── [201K] 35935 LOC.pdf
│   │   │   │   ├── [ 98K] FPC Hollo-Bolt 2812-CPR-0208 18-12-2023.pdf
│   │   │   │   ├── [805K] M12X100 EN 15048 8.8SB HDG - Locked.pdf
│   │   │   │   ├── [1.2M] M12X40 EN15048 8.8SB HDG - Locked.pdf
│   │   │   │   ├── [1.2M] M12X45 EN15048 8.8SB HDG.pdf
│   │   │   │   ├── [182K] M12X50 EN 15048 8.8SB HDG.pdf
│   │   │   │   ├── [2.5M] M16X50 EN15048 8.8SB HDG.pdf
│   │   │   │   └── [804K] M20X50 EN 15048 8.8SB HDG.pdf
│   │   │   └── [ 512] Senco
│   │   │   └── [ 77K] RSM562BFL.pdf
│   │   ├── [ 512] 09_Paint Cert
│   │   │   └── [268K] Marathon Prime Finish- SDS.pdf
│   │   ├── [ 512] 10_Decking Data Sheet
│   │   │   └── [ 512] Berghoef
│   │   │   └── [513K] DOP_Mezzanine U7 Deluxe TG_EN.pdf
│   │   ├── [ 512] 11_RAMS
│   │   │   ├── [279K] MMS-2307-025-HS-1055 Method Statement Schonenberger Cheb.pdf
│   │   │   ├── [648K] MMS-HS-1495 Paint Touch Up Risk Assessment_V2.pdf
│   │   │   ├── [580K] MMS-HS-815 Decking Risk Assessment_V4.pdf
│   │   │   ├── [582K] MMS-HS-820 Mobilisation & De-Mobilisation Risk Assessment_V3.pdf
│   │   │   ├── [595K] MMS-HS-825 Setting Out Risk Assessment_V3.pdf
│   │   │   ├── [602K] MMS-HS-830 Site Deliveries Risk Assessment_V3.pdf
│   │   │   └── [619K] MMS-HS-835 Steel Erection Risk Assessment_V4.pdf
│   │   └── [ 512] 12_Handover
│   │   ├── [1.5M] 1_LOT 1-3 GOH FLOOR HANDOVER.pdf
│   │   ├── [1.5M] 2_LOT 4-5 GOH FLOOR HANDOVER.pdf
│   │   ├── [1.5M] 3_LOTS 6-9 GOH FLOOR HANDOVER..pdf
│   │   ├── [2.7M] 4_LOT 1-11 COMPLETION & FINAL HANDOVER ALL LOTS..pdf
│   │   └── [244K] MMS-2307-025_PM_Handover Check Sheet.pdf
│   ├── [ 512] 02_ABS_safety_line-bezpečnostní_lano
│   │   ├── [ 512] 01_Instructions_pokyny
│   │   │   ├── [ 512] 01_DE_original
│   │   │   │   ├── [4.2M] 01_ABS-Safety-Sicherheitshinweise-DE-EN.pdf
│   │   │   │   ├── [ 29M] 02_Montagedokumentation_DE.pdf
│   │   │   │   ├── [292K] 03_ABS_Basisinformationen_DE.pdf
│   │   │   │   └── [1006K] 04_Pruefbuch_InspectionLog_DE_EN.pdf
│   │   │   └── [ 512] 02_CZ
│   │   │   ├── [1.3M] 01_ABS-Safety-Bezpečnostní pokyny_CZ.pdf
│   │   │   ├── [7.7M] 02_Montážní dokumentace_CZ.pdf
│   │   │   ├── [214K] 03_ABS_Základní informace_CZ.pdf
│   │   │   └── [1.1M] 04_Kontrolní kniha_InspectionLog_CZ.pdf
│   │   ├── [ 512] 02_Drawing
│   │   │   └── [2.0M] 24007341P-25012501Z-3-Plan 4_DE.pdf
│   │   └── [ 512] 03_Certificates
│   │   ├── [584K] Acceptance_report_rev.2_DE.pdf
│   │   ├── [973K] Certification_construction-product_DE.pdf
│   │   └── [289K] Certification_installation_DE.pdf
│   ├── [ 512] 03_DTS_Loading_Loop
│   │   └── [966K] Zertifikat_3834_DE.pdf
│   ├── [ 512] 04_Leantechnik_Loading_Loop
│   │   ├── [ 512] 01_DE_original
│   │   │   ├── [4.8M] 01_Leantechnik_Anleitung_DE.pdf
│   │   │   └── [ 11M] 02_Leantechnik_Gesamtkatalog_DE.pdf
│   │   ├── [ 512] 02_EN
│   │   │   ├── [1.2M] 01_Leantechnik_instructions_EN.pdf
│   │   │   └── [8.7M] 02_Leantechnik-complete-catalogue_EN.pdf
│   │   └── [ 512] 03_CZ
│   │   └── [1.3M] 01_Leantechnik_pokyny_CZ.pdf
│   ├── [ 512] 06_Electrics_elektrika
│   │   ├── [ 512] 01_Instructions_pokyny
│   │   │   └── [ 512] 01_MOTORS_MOTORY
│   │   │   ├── [ 512] 01_SEW
│   │   │   │   ├── [ 512] 01_DE_für_Gesamtanlage
│   │   │   │   │   ├── [ 34M] 01_Motor_25957066_DE.pdf
│   │   │   │   │   ├── [3.5M] 01_Motor_info_30589452_DE.pdf
│   │   │   │   │   ├── [ 10M] 01_Motor_korr_26857987_DE.pdf
│   │   │   │   │   └── [ 21M] 02_Getriebe_31978061_DE.pdf
│   │   │   │   ├── [ 512] 02_EN_for_entire_system
│   │   │   │   │   ├── [ 34M] 01_Motor_25957074_EN.pdf
│   │   │   │   │   ├── [3.6M] 01_Motor_info_30589460_EN.pdf
│   │   │   │   │   ├── [10.0M] 01_Motor_rev_26861313_EN.pdf
│   │   │   │   │   └── [ 20M] 02_Gear_31978088_EN.pdf
│   │   │   │   ├── [ 512] 03_CZ_pro_celé_zařízení
│   │   │   │   │   ├── [ 34M] 01_Motory_25957228_CZ.pdf
│   │   │   │   │   ├── [5.6M] 01_Motory_info_30589606_CZ.pdf
│   │   │   │   │   ├── [8.0M] 01_Motory_oprava_26861453_CZ.pdf
│   │   │   │   │   └── [ 24M] 02_Převody_31988741_CZ.pdf
│   │   │   │   └── [ 512] 04_Movimot_Loading_Loop
│   │   │   │   ├── [ 512] 01_DE_original
│   │   │   │   │   ├── [4.5M] 03_Movi_26865513_DE.pdf
│   │   │   │   │   └── [9.6M] 03_Movimot_DBC_31985297_DE.pdf
│   │   │   │   ├── [ 512] 02_EN_original
│   │   │   │   │   ├── [6.6M] 03_Movi_C_info_26870770_EN.pdf
│   │   │   │   │   └── [9.7M] 03_Movimot_DBC_31985300_EN.pdf
│   │   │   │   └── [ 512] 03_CZ
│   │   │   │   ├── [2.2M] 03_Movi_C_info_26870770_CZ.pdf
│   │   │   │   └── [ 13M] 03_Movimot_DBC_31985300_CZ.pdf
│   │   │   └── [ 512] 02_NORD
│   │   │   ├── [ 512] 01_DE_original
│   │   │   │   ├── [3.9M] 01_Getriebe_LoadingLoopAntr_DE.pdf
│   │   │   │   └── [2.3M] 02_Motor_LoadingLoopAntr_DE.pdf
│   │   │   ├── [ 512] 02_EN_original
│   │   │   │   ├── [3.8M] 01_Gear_LoadingLoopDrive_EN.pdf
│   │   │   │   └── [2.5M] 02_Motor_LoadingLoopDrive_EN.pdf
│   │   │   └── [ 512] 03_CZ
│   │   │   ├── [3.9M] 01_Převod_LoadingLoopPohon_CZ.pdf
│   │   │   └── [2.4M] 02_Motory_LoadingLoopPohon_CZ.pdf
│   │   └── [ 512] 02_Data-sheets
│   │   ├── [ 512] 01_Siemens
│   │   │   ├── [436K] 3LD22031TL53_datasheet_en.pdf
│   │   │   ├── [1.1M] 3LD23051TL13_datasheet_en.pdf
│   │   │   ├── [407K] 3LD28040TK53_datasheet_en.pdf
│   │   │   ├── [539K] 3RA21101AA151BB4_datasheet_en.pdf
│   │   │   ├── [539K] 3RA21101DA151BB4_datasheet_en.pdf
│   │   │   ├── [393K] 3RH29112FA22_datasheet_en.pdf
│   │   │   ├── [484K] 3RT20151BB41_datasheet_en.pdf
│   │   │   ├── [460K] 3RT20162BB42_datasheet_en.pdf
│   │   │   ├── [485K] 3RT20272BB40_datasheet_en.pdf
│   │   │   ├── [278K] 3RT29261BB00_datasheet_en.pdf
│   │   │   ├── [311K] 3RV29255AB_datasheet_en.pdf
│   │   │   ├── [301K] 3SU10000AB100AA0_datasheet_en.pdf
│   │   │   ├── [304K] 3SU10000BB600AA0_datasheet_en.pdf
│   │   │   ├── [320K] 3SU10001GB200AA0_datasheet_en.pdf
│   │   │   ├── [312K] 3SU10010AB500AA0_datasheet_en.pdf
│   │   │   ├── [305K] 3SU10011BA300AA0_datasheet_en.pdf
│   │   │   ├── [312K] 3SU10016AA400AA0_datasheet_en.pdf
│   │   │   ├── [320K] 3SU10022BF100AA0_datasheet_en.pdf
│   │   │   ├── [316K] 3SU10022BM100AA0_datasheet_en.pdf
│   │   │   ├── [357K] 3SU11000AB103CA0_datasheet_en.pdf
│   │   │   ├── [370K] 3SU11001HB203CH0_datasheet_en.pdf
│   │   │   ├── [376K] 3SU11002BF603BA0_datasheet_en.pdf
│   │   │   ├── [389K] 3SU11004BF113BA0_datasheet_en.pdf
│   │   │   ├── [367K] 3SU11020AB403BA0_datasheet_en.pdf
│   │   │   ├── [366K] 3SU11020AB503BA0_datasheet_en.pdf
│   │   │   ├── [367K] 3SU11020AB703BA0_datasheet_en.pdf
│   │   │   ├── [351K] 3SU11026AA203AA0_datasheet_en.pdf
│   │   │   ├── [352K] 3SU11026AA303AA0_datasheet_en.pdf
│   │   │   ├── [320K] 3SU14002AA103BA0_datasheet_en.pdf
│   │   │   ├── [320K] 3SU14002AA103CA0_datasheet_en.pdf
│   │   │   ├── [322K] 3SU14012BB303AA0_datasheet_en.pdf
│   │   │   ├── [322K] 3SU14012BB403AA0_datasheet_en.pdf
│   │   │   ├── [323K] 3SU14012BB503AA0_datasheet_en.pdf
│   │   │   ├── [271K] 3UG46152CR20_datasheet_en.pdf
│   │   │   ├── [416K] 5ST3010_datasheet_en.pdf
│   │   │   ├── [647K] 5SU13246FA10_datasheet_en.pdf
│   │   │   ├── [424K] 5SY42107_datasheet_en.pdf
│   │   │   ├── [447K] 5SY43027_datasheet_en.pdf
│   │   │   ├── [447K] 5SY43067_datasheet_en.pdf
│   │   │   ├── [447K] 5SY43107_datasheet_en.pdf
│   │   │   ├── [446K] 5SY43167_datasheet_en.pdf
│   │   │   ├── [447K] 5SY43327_datasheet_en.pdf
│   │   │   ├── [420K] 5SY62107_datasheet_en.pdf
│   │   │   ├── [239K] 5SY63027_datasheet_en.pdf
│   │   │   ├── [443K] 5SY63067_datasheet_en.pdf
│   │   │   ├── [239K] 5SY63107_datasheet_en.pdf
│   │   │   ├── [239K] 5SY63167_datasheet_en.pdf
│   │   │   ├── [444K] 5SY63257_datasheet_en.pdf
│   │   │   ├── [443K] 5SY63327_datasheet_en.pdf
│   │   │   ├── [369K] 6AV21283UB060AX1_datasheet_en.pdf
│   │   │   ├── [289K] 6EP34368SB000AY0_datasheet_en.pdf
│   │   │   ├── [281K] 6EP34378SB000AY0_datasheet_en.pdf
│   │   │   ├── [306K] 6ES71316BF010AA0_datasheet_en.pdf
│   │   │   ├── [306K] 6ES71316BH010BA0_datasheet_en.pdf
│   │   │   ├── [332K] 6ES71326BH010BA0_datasheet_en.pdf
│   │   │   ├── [285K] 6ES71366BA010CA0_datasheet_en.pdf
│   │   │   ├── [295K] 6ES71366DB000CA0_datasheet_en.pdf
│   │   │   ├── [190K] 6ES71376BD000BA0_datasheet_en.pdf
│   │   │   ├── [303K] 6ES71556AU010BN0_datasheet_en.pdf
│   │   │   ├── [208K] 6ES71936BP200BA0_datasheet_en.pdf
│   │   │   ├── [289K] 6ES71936BP200DA0_datasheet_en.pdf
│   │   │   ├── [327K] 6ES75184FP000AB0_datasheet_en.pdf
│   │   │   ├── [ 94K] 6ES79548LL040AA0_datasheet_en.pdf
│   │   │   ├── [338K] 6GK51160BA002AC2_datasheet_en.pdf
│   │   │   └── [502K] Siemens_3SU_3275_05_202107032151368800.pdf
│   │   ├── [ 512] 02_Leuze
│   │   │   ├── [452K] Datasheet_HT25CI-4P-M12_50134231_en.pdf
│   │   │   ├── [388K] Datasheet_IS-218MM-4NO-8E0-S12_50109701_en.pdf
│   │   │   ├── [393K] Datasheet_IS-218MM-4NO-8N0-S12_50109697_en.pdf
│   │   │   ├── [1.2M] Datasheet_PAL_HRT25B_2500_50134386_en.pdf
│   │   │   ├── [ 74K] Leuze_50109697_CER_CE_IS218(x)_M_de_en_fr.pdf
│   │   │   ├── [ 74K] Leuze_50109701_CER_CE_IS218(x)_M_de_en_fr.pdf
│   │   │   ├── [ 54K] Leuze_50134231_CER_CE_SR25C_de_en_fr.pdf
│   │   │   └── [ 54K] Leuze_50142302_CER_CE_HRT(x)25B_de_en_fr.pdf
│   │   ├── [ 512] 03_Cognex
│   │   │   ├── [ 512] 01_EN_original
│   │   │   │   ├── [670K] Cognex_DM280_Scanner.pdf
│   │   │   │   ├── [6.8M] DM280_Reference_Manual_EN.pdf
│   │   │   │   ├── [8.7M] DM370_Series_Reference_Manual_EN.pdf
│   │   │   │   ├── [3.6M] Datasheet_DM280_en.pdf
│   │   │   │   └── [1.8M] Datasheet_DM370_en.pdf
│   │   │   └── [ 512] 02_CZ
│   │   │   ├── [670K] Cognex_DM280_Scanner.pdf
│   │   │   ├── [8.1M] DM280_Referenční příručka_CZ.pdf
│   │   │   └── [10.0M] DM370_Series_Referenční příručka_CZ.pdf
│   │   ├── [ 512] 04_Helukabel
│   │   │   ├── [1.3M] Datasheet_HELUKABEL_51796_EN.pdf
│   │   │   └── [198K] Datasheet_HELUKABEL_EN.pdf
│   │   ├── [ 512] 05_Werma
│   │   │   ├── [270K] Datasheet_26131070_en.pdf
│   │   │   ├── [419K] Datasheet_64081000_en.pdf
│   │   │   ├── [316K] Datasheet_64580075_en.pdf
│   │   │   ├── [258K] Datasheet_64711075_en.pdf
│   │   │   ├── [240K] Datasheet_64721075_en.pdf
│   │   │   ├── [275K] Datasheet_64731075_en.pdf
│   │   │   └── [277K] Datasheet_64751075_en.pdf
│   │   ├── [ 512] 06_Phoenix
│   │   │   ├── [363K] Datasheet_0804027_en.pdf
│   │   │   ├── [186K] Datasheet_2900299_en.pdf
│   │   │   └── [101K] Datasheet_2908215_en.pdf
│   │   ├── [ 512] 07_Murrelektronik
│   │   │   ├── [223K] Datasheet_58902_en.pdf
│   │   │   ├── [230K] Datasheet_58904_en.pdf
│   │   │   ├── [190K] Datasheet_8000-88590-0000000_en.pdf
│   │   │   └── [233K] Datasheet_9000-41034-0401000_en.pdf
│   │   ├── [ 512] 08_SEW
│   │   │   ├── [ 93K] Datasheet_RF57_DRN80MK4_ASE1_Z_en.pdf
│   │   │   ├── [ 88K] Datasheet_SA57_DRN80MK4_BE1_en.pdf
│   │   │   ├── [ 93K] Datasheet_SA57_DRN80MK4_en.pdf
│   │   │   ├── [ 88K] Datasheet_SA57_DRN80MK4_li_en.pdf
│   │   │   ├── [ 93K] Datasheet_SA57_DRN90S4BE2_en.pdf
│   │   │   ├── [ 88K] Datasheet_SF67_DRN80MK4_Z_en.pdf
│   │   │   ├── [ 90K] Datasheet_WAF59_R37_DRN71M4_BE05_en.pdf
│   │   │   └── [140K] SEW_DRNxx_23114118_Conformity_CE.pdf
│   │   ├── [ 512] 09_Rittal
│   │   │   ├── [102K] Datasheet_2500210-LED system light_en.pdf
│   │   │   ├── [ 75K] Datasheet_2500470-Door-Operated Switch_en.pdf
│   │   │   ├── [357K] Rittal_KX_declaration_of_conformity_20_1852.pdf
│   │   │   └── [621K] Rittal_VX_20_2659.pdf
│   │   ├── [ 512] 10_Nord
│   │   │   └── [ 99K] Datasheet_Motor_SK1SI40-IEC71-71RP_6 TF_IE3_en.pdf
│   │   ├── [ 512] 11_Pepperl+Fuchs
│   │   │   ├── [661K] Datasheet_NBN12-18GM35-E2-0_en.pdf
│   │   │   └── [1.2M] Pepperl_326163-0134_doc4844a.pdf
│   │   ├── [ 512] 12_Bailey
│   │   │   └── [577K] Datasheet_145502_en.pdf
│   │   ├── [ 512] 13_Sick
│   │   │   ├── [544K] Datasheet_WLL180T-F232_6039098_en.pdf
│   │   │   ├── [482K] Datasheet_WLL180T-M432_6039097_en.pdf
│   │   │   └── [156K] Sick_WLL180_Konformität.pdf
│   │   ├── [ 512] 14_Schmersal
│   │   │   └── [1.1M] Datasheet_Z4V7-336-11z-M20_en.pdf
│   │   ├── [ 512] 15_ifm electronic
│   │   │   └── [352K] Datasheet_JN2201-02_en.pdf
│   │   ├── [ 512] 16_Balluff
│   │   │   └── [214K] Balluff_BES M08MH1-PSC30B-S04G-101_CE_BES02PW_PV116429_sig.pdf
│   │   └── [ 512] 17_TRelectronic
│   │   └── [ 61K] TR-Electronic_CES582M-00004_TR-ECE-KE-DGB-0374-06.pdf
│   └── [ 512] 07_Pneumatic_pneumatika
│   ├── [ 512] 01_Aventics
│   │   ├── [ 512] 01_DE-EN_original
│   │   │   ├── [1.0M] 01_IU_Filter_DE_EN.pdf
│   │   │   ├── [999K] 02_IU_Pressure_regulator_valve_DE_EN.pdf
│   │   │   ├── [891K] 03_IU-3-2-shut_off_valve_DE_EN.pdf
│   │   │   └── [562K] 04_IU-Distributor_DE_EN.pdf
│   │   └── [ 512] 02_CZ
│   │   ├── [1.8M] 01_IU_Filter_filtr_CZ.pdf
│   │   ├── [2.1M] 02_IU_Press_reg_valve_Regulační ventil tlaku_CZ.pdf
│   │   ├── [2.0M] 03_IU-3-2-Uzavírací ventil_CZ.pdf
│   │   └── [1.3M] 04_IU-Distrib_Rozdělovač_CZ.pdf
│   └── [ 512] 02_ITS
│   └── [151K] KC-0167-00-01-4 Air distributor.pdf
├── [ 11M] FUNKTION_CONTROL_LoadingLoop_500592_V2.2_EN.docx
├── [ 11M] FUNKTION_STEUERUNG_LoadingLoop_500592_V2.2.docx
├── [ 35M] RB_Fortna_H+M_500592_EV12_20260311.docx
└── [2.7M] RB_Fortna_H+M_500592_V12_20260311.docx
123 directories, 378 files
+99
View File
@@ -0,0 +1,99 @@
# Skripte in lib/
Übersicht über die Python-Hilfsskripte zur Datenaufbereitung (Excel/PDF/Word -> JSON/CSV/RST).
Alle Skripte werden über `bin/*.bat` bzw. `bin/*.sh` aufgerufen und nutzen die
Umgebungsvariablen aus `bin/setenv.*` (`PROJECT`, `PV_EXAMPLES`, `PV_DATA`, ...).
## jsonFromXlsx.py
Baut aus der Positionsspalte einer Excel-Stückliste (`Stk.-Liste SOLL` / `Stk.-Liste IST`)
eine hierarchische JSON-Baumstruktur. Positionspfade wie `10/5/1` bestimmen die Schachtelung,
als Schlüssel dient die Teilenummer (bei Duplikaten ergänzt um Bezeichnung). Sonderfälle:
- **Längenabhängige Teile** (`LAENGENABHAENGIG`-Liste, z. B. Kettenprofile): Menge wird als
`laenge` aufsummiert statt gezählt.
- **9-stellige Sivas-Nummern**: doppelte Vorkommen werden zu `{"anzahl": n, "beschreibung": ...}`
aggregiert.
```
python jsonFromXlsx.py --filename Fortna --tab soll
python jsonFromXlsx.py --all
```
Eingabe: `examples/*.xlsx` · Ausgabe: `results/<name>_<tab>.json`
## extract_ids.py
Liest die von `jsonFromXlsx.py` erzeugten JSON-Bäume und sammelt 9-stellige Sivas-Nummern,
die genau eine Ebene über den Blättern liegen (d. h. Baugruppen, deren Kinder alle Blätter sind).
Kann Ergebnisse mehrerer Projekte zu einer Summary zusammenfassen.
```
python extract_ids.py --filename KA135776
python extract_ids.py --all --sum2json
python extract_ids.py --all --sum2txt
```
Eingabe: `results/*_<tab>.json` · Ausgabe: `results/<name>_ids_<tab>.txt` bzw.
`results/summary_ids_<tab>.{json,txt}`
## project_from_xlsx.py
Durchsucht rekursiv Unterordner in `PV_EXAMPLES`, liest je Ordner die erste `.xlsx` aus dem
Reiter `Stk.-Liste IST` (Position/Teilenummer/Bezeichnung/Menge) und filtert auf Positionspfade
mit 3 oder 4 Hierarchieebenen. Optional werden pro Ordner JSON/CSV/XLSX-Ergebnisdateien erzeugt;
die XLSX-Variante aggregiert Mengen je Teilenummer und markiert bekannte Produktnummern aus
`cfg/product_numbers.cfg`.
```
python project_from_xlsx.py --example-dir KA135776 --2csv
python project_from_xlsx.py --2json --2xlsx
```
## extract_products.py
Extrahiert Bilder und Fließtext aus Produktprogramm-PDFs (`data/Produktprogramm/*.pdf`) mittels
PyMuPDF. Bilder werden nach Zielordner (Omniflo/ILS/CPC/Gerüst/Trolley, siehe `PDF_TARGETS`)
sortiert und per MD5 dedupliziert. Text wird anhand von Schriftgröße, Positions- und
Nummerierungsheuristiken (inkl. TOC-Abgleich) in `.de.rst`-Dateien mit Überschriftenhierarchie
umgewandelt.
```
python extract_products.py --data-dir data
```
Ausgabe: `data/Produktprogramm/images/<ziel>/` und `data/Produktprogramm/<pdf-name>.de.rst`
## collect_partnumbers.py
Sammelt 9-stellige Teilenummern aus den von `extract_products.py` erzeugten RST-Dateien,
inklusive Bereichsnotation (`834372007-011`) und OCR-bedingt verklebter Zahlen. Gleicht die
Nummern gegen eine Excel-Stückliste (`data/KA*.xlsx`, Reiter `Stk.-Liste SOLL`) ab und schreibt
eine CSV mit Fundstelle, Kapitel und Bezeichnung.
```
python collect_partnumbers.py --data-dir data
```
Ausgabe: `data/Produktprogramm/teilenummern.csv`
## convert_to_rst.py
Konvertiert Word-Dokumente (`.docx`, via `mammoth`) und Word-erstellte PDFs (via `pdfminer`,
erkannt über PDF-Metadaten Creator/Producer) nach `.rst`. Ordnet Quelldateien anhand des
Dateinamens automatisch einem Zielkapitel unter `chapters/` zu (`CHAPTER_MAPPING`), erkennt
benutzerdefinierte Word-Formatvorlagen (`Ü1`-`Ü4` etc.) als Überschriftenebenen und speichert
eingebettete Bilder neben der Ziel-RST.
```
python convert_to_rst.py <input_dir> chapters --dry-run
python convert_to_rst.py <input_dir> chapters --lang de
```
## Typischer Ablauf
1. `jsonFromXlsx.py --all` – Stücklisten als JSON-Bäume aufbereiten
2. `extract_ids.py --all --sum2json` – Sivas-Nummern projektübergreifend sammeln
3. `extract_products.py` – Produktprogramm-PDFs in Bilder + RST zerlegen
4. `collect_partnumbers.py` – Teilenummern aus den RSTs mit der Stückliste abgleichen
5. `convert_to_rst.py` – vorhandene Word/PDF-Dokumentation in die Kapitelstruktur überführen
+295
View File
@@ -0,0 +1,295 @@
# Konzept: Fortna-Extraktion nach chapters/ + Projektordner fortna/
Status: **Umgesetzt** (erster vollständiger Durchlauf inkl. PDF-Build). Antworten aus der
Rückfragerunde sind eingearbeitet (siehe Abschnitt "Entscheidungen"). Der tatsächliche
Ist-Stand ist unten unter "Umsetzung / Ist-Stand" dokumentiert.
## Umsetzung / Ist-Stand
Implementiert wurden vier Python-Skripte in `lib/` mit `bin/`-Wrappern (`.bat` + `.sh`):
| Skript | Zweck | Wrapper |
|---|---|---|
| `lib/fortna_inventory.py` | Scannt das Rohmaterial, ordnet jede Datei Sprache + Zielkapitel + Aktion zu, schreibt `results/fortna_inventory.json`. | `bin/fortna_inventory.*` |
| `lib/fortna_convert_chapters.py` | Volltext+Bilder für die 5 DE-Hauptanleitungen, Verzeichnislisten (`index.de.rst`) für alle übrigen Kapitel. `--dry-run` (Default) / `--apply`. | `bin/fortna_convert_chapters.*` |
| `lib/fortna_init_project.py` | Legt `projekte/fortna/` an (`anlage.json`, `overrides/`, `images/`, `README.md`). | `bin/fortna_init_project.*` |
| `lib/build_index.py` | Generiert `index.rst` mit toctrees aus allen `chapters/**/*.de.rst`. | (in `build_pdf.*`) |
| `bin/build_pdf.*` | Kompletter PDF-Build: index → `sphinx-build -b latex` → `latexmk -lualatex`. | — |
Ergebnis des ersten Laufs:
- 5 Hauptanleitungen volltextig konvertiert (Betriebs-/Wartungsanleitung, Funktionsbeschreibung
ILS/OMNIFLO/LoadingLoop), zusammen ~13.500 Textblöcke + mehrere Hundert entduplizierte Bilder.
- 9 Verzeichnislisten erzeugt (Zertifikate, Lieferantendoku nach Gewerk, Notstopp, Prüfberichte).
- `projekte/fortna/` mit `anlage.json`-Platzhalter angelegt.
- **Vollständiger PDF-Build erfolgreich**: `_build/latex/Fortna_Dokumentation_DE.pdf`, ~670 Seiten,
`latexmk`-Exit 0, keine LaTeX-Fehler.
### Umgebung / Build-Voraussetzungen (bereits erfüllt)
- Sphinx 9.1.0 + myst-parser 5.1.0 im `.venv` (ergänzt in `requirements.txt`).
- LaTeX: MiKTeX unter `C:\Program Files\MiKTeX\` (lualatex/latexmk vorhanden).
- `conf.py` erzeugt: LaTeX-Engine **lualatex**, **babel** statt polyglossia
(polyglossia 2.13 wirft unter lualatex einen bidi-Fehler), **`iconpackage=none`**
(FontAwesome7 in MiKTeX bricht unter lualatex ab). Beides in `conf.py` kommentiert.
### Extraktion: neues Modul `lib/pdf_extract.py`
Die Volltext-Konvertierung der 5 Hauptanleitungen läuft über das neue Modul
`lib/pdf_extract.py` (`extract_manual()`), das gegenüber der ersten Version deutlich
sauberere `.rst` erzeugt:
- **Keine doppelte Nummerierung mehr:** Die im PDF vorhandene Gliederungsnummer
(z. B. „1.7 Wichtige Informationen") wird nur zur Bestimmung der Überschriften-Ebene
ausgewertet und im RST-Text **entfernt**. Sphinx/LaTeX nummeriert selbst → vorher
„3.2.1 1.1 Wichtige Informationen", jetzt nur noch „3.2.1 Wichtige Informationen".
- **Überschriften-Erkennung** nur bei führender PDF-Gliederungsnummer (kein „fett allein"),
damit Tabellenzellen nicht fälschlich zu Überschriften werden.
- **Bilder positionsbasiert extrahiert und referenziert:** pro Seite werden Text und Bilder
nach Y-Position verwoben; jedes Bild wird als `.. figure::` mit korrektem Pfad und – falls
vorhanden – der folgenden „Abbildung N: …"-Zeile als Caption eingebaut. (625 figure-Refs,
0 fehlende Bilddateien.) Duplikate werden per MD5 entfernt, Titelseiten-Deko und
Vollseiten-Hintergründe herausgefiltert.
- **Symbole/Warnzeichen zentral:** Kleine (≤ 260 px), mehrfach vorkommende Bilder (Warndreiecke,
Piktogramme) werden per Perceptual-Hash (aHash + Hamming-Distanz ≤ 6) erkannt, **einmal** zentral
nach `assets/shared/symbols/symbol_<hash>.png` abgelegt und im RST als `.. image::` (12 mm,
zentriert) auf die eine Quelle verwiesen – dokumentübergreifend. Auch großformatige
Inhaltsbild-Duplikate werden über den aHash zusammengeführt (nur einmal abgelegt, mehrfach
referenziert).
- **Bild-Routing nach Kapitel-Typ + Dateityp:** Generelle Kapitel (Steuerungs-Funktionsbeschreibung
ILS/OMNIFLO/LoadingLoop) → `assets/shared/images/<subdir>` (wiederverwendbar). Projektbezogene
Kapitel (Betrieb/Wartung) → `projekte/fortna/images/<subdir>`. **Fotos (.jpg) immer**
projektspezifisch (`projekte/fortna/images/`), unabhängig vom Kapitel. Ziel-Bildordner werden
vor jedem Lauf geleert.
- **Bild-Benennung:** PNG mit direkt folgender Bildunterschrift → `<Stichwort>_NN.png` (z. B.
`Anlage_Uebersicht_01.png`, `Komponentenstatus_Barcode_Leser_01.png`). Fotos → `img_NNN.jpg`,
schwarz-weiße Skizzen (> 10 KB) → `sketch_NNN.png`, Rest → `img_NNN.png`.
- **Logos gesondert:** Nur wiederkehrende (≥ 3×) breite Grafiken im Seitenrand gelten als Logo
(unterscheidet Kopf-/Fußzeilen-Logo von einmaligen breiten Diagrammen) → alle Auflösungen nach
`assets/shared/logos/logo_<n>_<breite>px.png`, **nicht** inline referenziert (Deko).
- **Wertlose Fragmente verworfen:** flache (≤ 55 px), breite, schwarz-weiße Schnipsel sind meist
gerenderte Kapitelnummern („5.2.6", „3.2.3") und werden gar nicht extrahiert.
- **Produktprogramm-Bilder:** `lib/extract_products.py` legt die Bilder der Produktprogramm-PDFs
(`data/Produktprogramm/*.pdf`) als generelle Produktbilder nach
`assets/shared/images/produktprogramm/{omniflo,ils,cpc,geruest,trolley}` ab.
- **Versionierung:** Extrahierte Bildmassen (`assets/shared/images/`, `projekte/fortna/images/`)
sind reproduzierbar und via `.gitignore` ausgeschlossen; nur die kuratierten
`assets/shared/symbols/` und `assets/shared/logos/` werden versioniert.
- **Fragment-„Überschriften" unterdrückt:** Bildlegenden/Tabellenzellen mit führender Nummer
(„POS", „li.", „re.", „bar, max. 10 bar") werden nicht mehr als Überschrift gesetzt.
- **Fließtext zu Absätzen zusammengeführt:** PDF-Zeilenumbrüche werden nicht mehr zu je einem
Absatz; zusammenhängende Zeilen bilden einen Absatz, Satzende beendet ihn.
- **Aufzählungen als RST-Listen:** einzeln stehende Bullet-Zeichen werden mit der Folgezeile
zusammengeführt, in eine Zeile zusammengelaufene Bullets aufgesplittet → `-`-Listenpunkte.
- **Änderungshistorie-/Dokumentinfo-Tabellen** am Dokumentanfang werden übersprungen (zerfielen
vorher in sinnlose Einzelzellen), ebenso Inhaltsverzeichnis-Seiten und Kopf-/Fußzeilen.
### Behobene Extraktionsprobleme
- **Symbolfont-Zeichen** (Wingdings/Symbol Private-Use-Area, z. B. Aufzählungs-Bullet U+F0B7):
via `clean_pua()` auf echte Unicode-Zeichen (•, →, ✓) gemappt, restliche PUA-Zeichen entfernt.
Vorher erschienen leere Kästchen im PDF. (In `lib/pdf_extract.py`; die Variante in
`extract_products.py` bleibt für die Produktprogramm-Extraktion bestehen.)
- **Fremd-Altdateien** aus früheren `convert_to_rst.py`-Testläufen (NORD-Getriebe/-Motor im
Anhang, alte Risikobewertung/Contents/Konfig/Ersatzteile-Volltexte) wurden entfernt; die
betroffenen Kapitel liegen konzeptkonform als Verzeichnisliste vor.
### Bekannte Restpunkte (nicht Teil des Auftrags-Scopes)
- Zwei **Fremd-Altdateien** aus früheren `convert_to_rst.py`-Testläufen sind noch im Baum und
vom Build ausgeschlossen (`conf.py` + `build_index.py` SKIP-Liste):
`02_sicherheit/risikobewertung/rb_fortna_...de.rst` (unvollständiger Bildbestand) und
`00_inhaltsverzeichnis/contens_obsah_...de.rst` (keine RST-Überschrift). Zwei weitere
Altdateien in `10_anhang/` (Motor/Getriebe) sind noch im toctree und zeigen typische
Extraktionsartefakte – bei Bedarf mit `fortna_convert_chapters.py` sauber neu erzeugen.
- **Tabellen** (z. B. Änderungshistorie) werden weiterhin als Einzelzeilen extrahiert, nicht als
RST-Tabelle rekonstruiert (wie im Konzept angekündigt).
---
## Ausgangslage
- Reales Fortna-Rohmaterial liegt in `c:\10-Develop\projekte\Fortna\` (zusätzliches
Arbeitsverzeichnis dieser Session, **nicht** im Repo). Struktur wie in `doc/Fortna.txt`
dokumentiert (00_CONTENTS_NOTES … 05_SUPPLIER_DOC), Versionsstände dort sind aktueller als in
`doc/Fortna.txt` (z. B. Betriebsanleitung V2.4 statt V2.3, Wartungsanleitung V2.3, ILS V2.2,
OMNIFLO V1.1, LoadingLoop V2.3). `doc/Fortna.txt`/`doc/Fortna-size.txt` sind nur noch als
Struktur-Referenz zu verstehen, nicht als Quelle für den Extraktionslauf.
- `chapters/` (Base-Repo-Gerüst) existiert bereits vollständig mit `.gitkeep`-Platzhaltern gemäß
`doc/kapitelstruktur.md`. `06_steuerung/loading_loop/` wurde testweise mit `convert_to_rst.py`
aus der LoadingLoop-Word-Datei befüllt (2 RST + 74 Bilder). Qualität ist noch unzureichend:
Kopf-/Fußzeilen (Firmenadresse, Kontaktdaten) landen als normale Absätze im Fließtext,
Tabellen (Änderungshistorie) werden zu einzelnen Zeilen ohne Tabellenstruktur zerlegt. Das ist
ein bekanntes Problem der aktuellen `_html_to_blocks`-Logik in `convert_to_rst.py` und wird im
Zuge dieser Arbeit mit adressiert (siehe Abschnitt "Bekannte Schwachstellen").
- Vorhandene Skripte in `lib/` (siehe `doc/lib_skripte.md`):
- `convert_to_rst.py`: `.docx`/Word-PDF → RST, erkennt Zielkapitel nur anhand Dateiname-Pattern
im Root eines Ordners, kein Verständnis für den verschachtelten Fortna-Sprachordner-Baum.
- `extract_products.py`: Produktprogramm-PDFs → Bilder + RST (bereits fertig genutzt für
`data/Produktprogramm`).
- `jsonFromXlsx.py`, `extract_ids.py`, `project_from_xlsx.py`: Stücklisten-Auswertung, für
diese Aufgabe nicht direkt relevant.
- Kein Skript kennt bisher die komplette Fortna-Ordnerstruktur (Lieferantendoku-Baum, Layouts,
Schaltpläne, Prüfberichte) und keines erzeugt den projektspezifischen Ordner (`anlage.json`,
`overrides/`, `images/`) gemäß `doc/Kernkonzepte.md`.
## Entscheidungen (aus Rückfragen)
| Frage | Entscheidung |
|---|---|
| Lieferantendoku (~250 PDFs: SEW, Siemens, Leuze, Cognex, MiTek, Aventics, …) | **Nur Verzeichnisliste**, keine Kopie. RST-Referenztabelle mit Pfaden auf das Originalverzeichnis. |
| Sonstige PDFs (Zertifikate, Konfigurationsbeschreibung, Layouts, Schaltpläne, Notstopp/Bereichsstopp, Prüfberichte, Ersatzteilliste) | Gleiche Behandlung wie Lieferantendoku: **nur Verzeichnisliste**, keine Kopie nach `chapters/`. |
| Volltext-Konvertierung (Text + Bilder → RST) | **Nur die 5 Hauptanleitungen**: Betriebsanleitung, Wartungsanleitung, Funktionsbeschreibung ILS, Funktionsbeschreibung OMNIFLO, Funktionsbeschreibung LoadingLoop. |
| Projektordner-Ort | `projekte/fortna/` **innerhalb dieses Repos** (docu_build), pragmatisch für jetzt; spätere Auslagerung in eigenes Repo bleibt möglich. |
| Sprachumfang | **Nur Deutsch** in diesem Durchlauf. Namenskonvention `{name}.de.rst` einhalten, EN/CZ als späterer zweiter Durchlauf vorgesehen. |
| Quelle | `c:\10-Develop\projekte\Fortna\` (aktueller Stand), nicht `doc/Fortna.txt`. |
| Umgang mit bestehendem `chapters/06_steuerung/loading_loop/` | **Überschreiben/neu erzeugen** – konsistent aus einem Guss statt Altbestand stehen lassen. |
## Zielstruktur (Ergebnis nach Lauf)
```text
chapters/
01_zertifikate/
konformitaetserklaerung/index.de.rst # Verzeichnisliste -> Originalpfade
konfigurationsbeschreibung/index.de.rst
02_sicherheit/ # unverändert (kein Fortna-Rohmaterial 1:1 zuordenbar)
03_anlagenbeschreibung/ # unverändert (Modul-Mechanismus, nicht Teil dieser Aufgabe)
04_betrieb/betriebsanleitung/
betriebsanleitung_500592_135776_v2_4.de.rst # Volltext + Bilder
images/img_001.png ...
05_wartung/wartungsanleitung/
wartungsanleitung_500592_v2_3.de.rst # Volltext + Bilder
images/...
06_steuerung/
ils/funktion_steuerung_ils_500592_v2_2.de.rst # Volltext + Bilder
omniflo/funktion_steuerung_omniflo_500592_v1_1.de.rst
loading_loop/funktion_steuerung_loadingloop_500592_v2_3.de.rst # neu erzeugt, ersetzt Altbestand
07_ersatzteile/index.de.rst # Verzeichnisliste -> Originalpfad SPARE_PARTS PDF
08_lieferantendoku/
antriebe/index.de.rst # Verzeichnisliste SEW, NORD, Leantechnik, DTS
elektrik/index.de.rst # Siemens, Leuze, Cognex, Werma, ...
pneumatik/index.de.rst # Aventics, ITS
stahlbau/index.de.rst # MiTek (As-Built, Statik, Zertifikate, ...)
sicherheitseinrichtungen/index.de.rst # ABS Sicherheitslinie
09_technische_unterlagen/
layouts/mechanisch/index.de.rst
layouts/elektrisch/index.de.rst
schaltplaene/index.de.rst
notstopp/index.de.rst
bereichsstopp/index.de.rst
10_anhang/
pruefberichte/index.de.rst
projekte/
fortna/
anlage.json # Projektmetadaten (Platzhalter/Vorschlag, manuell nachzuschärfen)
overrides/ # leer angelegt, Struktur gemäß Kernkonzepte.md
images/ # leer angelegt (projektspezifische Bilder, Kundenlogo etc.)
README.md # kurze Erläuterung was hier reinkommt
```
Jede `index.de.rst`-Verzeichnisliste enthält eine Tabelle: Dateiname, Hersteller/Kategorie
(aus Ordnername abgeleitet), Originalpfad (relativ zu einer konfigurierbaren Basis, z. B.
`PV_FORTNA_SOURCE`), Dateigröße. Kein PDF wird kopiert oder eingebettet – das hält den
Kapitelbaum schlank und vermeidet Redundanz zum Rohmaterial, das ohnehin an anderer Stelle
(Nextcloud/Projektordner) verwaltet wird.
## Geplante Skripte (in `lib/`)
### 1. `fortna_inventory.py` (neu)
Scannt `c:\10-Develop\projekte\Fortna\` rekursiv und baut ein Inventar (JSON) aller Dateien mit:
Pfad, Größe, erkannte Sprache (aus Ordnername: `*_DE*`/`*_EN*`/`*_CZ*`/`*original*` → de),
vermutetem Fortna-Kapitel (oberste zwei Ebenen) und vermutetem Ziel-Kapitel gemäß Mapping-Tabelle
aus `doc/kapitelstruktur.md`. Dient als Kontrollartefakt (`results/fortna_inventory.json`), auf
dessen Basis sich beide folgenden Skripte konfigurieren bzw. dessen Output sich manuell prüfen
lässt, bevor tatsächlich geschrieben wird (`--dry-run` Default, `--apply` zum Ausführen).
Grund für ein separates Inventarskript: 387 Dateien über 6 Top-Level-Ordner mit inkonsistenter
Sprachordner-Benennung (`01_DE_original`, `01_EN_original`, `1_EN_original` [Tippfehler im
Quellbaum], `02_CZ`, `03_CZ_pro_celé_zařízení`, …) – ein Trockenlauf mit Report ist nötig, um
Mapping-Lücken zu erkennen, bevor Dateien verschoben/verarbeitet werden.
### 2. `fortna_convert_chapters.py` (neu)
Nutzt das Inventar aus Schritt 1 (oder scannt bei Bedarf direkt) und führt zwei Aktionsarten aus:
- **Volltext-Konvertierung** für die 5 Hauptanleitungen: ruft die vorhandene Konvertierungslogik
aus `convert_to_rst.py` wieder, aber mit expliziter Quelldatei-Liste (kein Verzeichnis-Scan)
und fest zugeordnetem Zielkapitel (aus der Tabelle unten), statt der generischen
`CHAPTER_MAPPING`-Heuristik. Überschreibt bestehende Ziel-RSTs/Bilder vollständig
(Ordnerinhalt vorher leeren, dann neu schreiben).
- **Verzeichnislisten-Generierung** für alle übrigen Kapitel (Zertifikate, Layouts, Schaltpläne,
Notstopp/Bereichsstopp, Prüfberichte, Ersatzteile, Lieferantendoku): läuft über die im
Inventar erkannten Dateien je Zielordner und rendert eine `index.de.rst` mit
RST-list-table (Dateiname, Pfad, Größe).
Fest zugeordnete Hauptanleitungen (Quelle → Ziel):
| Quelldatei (DE, aktuellster Stand in `projekte/Fortna`) | Zielkapitel |
|---|---|
| `02_INSTRUCTIONS.../01_DE_original/01_BETRIEBSANLEITUNG_500592_135776_V2.4.pdf` | `04_betrieb/betriebsanleitung/` |
| `02_INSTRUCTIONS.../01_DE_original/02_WARTUNGSANLEITUNG_500592_V2.3.pdf` | `05_wartung/wartungsanleitung/` |
| `02_INSTRUCTIONS.../01_DE_original/03_FUNKTION_STEUERUNG_ILS_500592_V2.2.pdf` | `06_steuerung/ils/` |
| `02_INSTRUCTIONS.../01_DE_original/04_FUNKTION_STEUERUNG_OMNIFLO_500592_V1.1.pdf` | `06_steuerung/omniflo/` |
| `02_INSTRUCTIONS.../01_DE_original/05_FUNKTION_STEUERUNG_LOADING_LOOP_500592_V2.3.pdf` | `06_steuerung/loading_loop/` (ersetzt Altbestand) |
Alle fünf sind PDFs (keine `.docx` im Quellordner mehr vorhanden), daher wird der
`is_word_pdf()`-Pfad aus `convert_to_rst.py` verwendet (pdfminer-Textextraktion). Die extra
`extract_products.py`-Textheuristik (Schriftgrößen-basierte Überschriftenerkennung,
Kopf-/Fußzeilen-Ausschluss nach Y-Position) ist für Word-erstellte PDFs wie diese *robuster* als
die aktuelle `convert_to_rst.py`-Textheuristik (siehe Bekannte Schwachstellen) – Vorschlag: die
Extraktionsroutine aus `extract_products.py` (`extract_text_to_rst`, `_is_noise`,
`extract_images`) wiederverwenden/verallgemeinern statt der schwächeren `pdfminer`-Variante aus
`convert_to_rst.py`.
### 3. `fortna_init_project.py` (neu)
Legt `projekte/fortna/` an: `anlage.json` (Platzhalter mit Projektnummer 500592/KA135776,
Sprache `["de"]`, `normen_region` offen als TODO), leere `overrides/`- und `images/`-Ordner mit
`.gitkeep`, sowie ein kurzes `README.md`. Einmaliger Lauf, kein Datei-Scan nötig – im
Wesentlichen ein Gerüst-Generator.
## Bekannte Schwachstellen der bisherigen Konvertierung (zu beheben)
Der Altbestand in `chapters/06_steuerung/loading_loop/` zeigt zwei konkrete Probleme der
aktuellen `convert_to_rst.py`-Logik, die für die 5 Hauptanleitungen behoben werden sollten:
1. **Kopf-/Fußzeilen als Fließtext**: Firmenadresse, Kontaktdaten, Projektnummer erscheinen als
normale Absätze mitten im Dokument (weil `pdfminer.high_level.extract_text` keine
Positionsinformation liefert, mit der man Kopf-/Fußzeilen herausfiltern könnte).
`extract_products.py` löst das bereits über `page.get_text("dict")` mit Y-Positions-Filter
(`bbox[1] < 100` / `bbox[3] > page_height - 50`) – diese Technik übernehmen.
2. **Tabellen zerfallen in Einzelzeilen**: Die Änderungshistorie-Tabelle wird zu einer Folge
einzelner Absätze ohne Tabellenstruktur. Vorschlag für ersten Wurf: Tabellen nicht als
RST-Table rekonstruieren (zu fehleranfällig aus PDF-Text), sondern als eingerückten
`.. code-block::` oder einfache Aufzählung kennzeichnen, mit `.. todo:: Tabelle manuell
nachbearbeiten`-Marker – ehrlich statt falsch-schön.
## Offene Punkte / Annahmen, die ich getroffen habe
- Ich gehe davon aus, dass `PV_FORTNA_SOURCE` (oder ein `--source-dir`-Argument) als neue
Umgebungsvariable/Parameter eingeführt wird, die auf `c:\10-Develop\projekte\Fortna\` zeigt,
da dieser Pfad außerhalb des Repos liegt und nicht über `PV_DATA`/`PV_EXAMPLES` erreichbar ist.
- `02_sicherheit/` und `03_anlagenbeschreibung/` bleiben in diesem Durchlauf unangetastet – es
gibt keine 1:1-Quelle dafür in `projekte/Fortna/` (Risikobewertung liegt als separate
`.docx`-Dateien direkt im Repo-Root von `chapters/`, siehe Git-Status, nicht Teil dieser
Aufgabe).
- Die generierten `index.de.rst`-Verzeichnislisten verweisen auf absolute oder
repo-relative-konfigurierbare Pfade zum Rohmaterial – das bedeutet, das Zielsystem (Sphinx)
bekommt keine eigenständig lauffähige Doku ohne Zugriff auf `projekte/Fortna/`. Das ist so
gewollt gemäß der Entscheidung "nur Verzeichnisliste", sollte aber vor dem produktiven
PDF-Build nochmal bewusst bestätigt werden (spätestens beim ersten `sphinx-build`-Versuch
fehlen dann die Downloads/Links).
## Nächste Schritte
1. Dieses Konzept abstimmen (insb. Umgang mit `02_sicherheit`/Risikobewertung-Docx im Root von
`chapters/`, sowie die Pfad-Referenzierung in den Verzeichnislisten).
2. `fortna_inventory.py` implementieren und Trockenlauf-Report gegen `projekte/Fortna/` prüfen.
3. `fortna_convert_chapters.py` implementieren (inkl. Wiederverwendung der
`extract_products.py`-Textextraktion), auf 5 Hauptanleitungen anwenden, Ergebnis stichprobenhaft
gegenlesen.
4. `fortna_init_project.py` implementieren, `projekte/fortna/` anlegen.
5. `bin/`-Wrapper-Skripte (`.bat`/`.sh`) für alle drei ergänzen, analog bestehender Konvention.
+99
View File
@@ -0,0 +1,99 @@
"""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",
# NORD-Antriebsdoku aus fruehem Testlauf: gehoert als Lieferantendoku in die
# Verzeichnisliste 08_lieferantendoku/antriebe, nicht als Volltext ins Anhang.
"10_anhang/01_getriebe_loadingloopantr_de.de.rst",
"10_anhang/02_motor_loadingloopantr_de.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()
+52 -6
View File
@@ -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)
@@ -387,10 +417,18 @@ def dedup_images(img_dir: Path) -> int:
return removed
def process_all(data_dir: Path) -> None:
"""Verarbeitet alle konfigurierten PDFs."""
def process_all(data_dir: Path, assets_dir: Path | None = None) -> None:
"""Verarbeitet alle konfigurierten Produktprogramm-PDFs.
Bilder sind generelle Produktbilder und werden nach
assets/shared/images/produktprogramm/<ziel> abgelegt (wiederverwendbar).
Die extrahierte RST bleibt zur Information neben dem PDF.
"""
pp_dir = data_dir / "Produktprogramm"
images_base = pp_dir / "images"
if assets_dir is None:
# data/ liegt unter PROJECT -> assets/shared/images/produktprogramm
assets_dir = data_dir.parent / "assets" / "shared" / "images" / "produktprogramm"
images_base = assets_dir
if not pp_dir.exists():
log.error("Verzeichnis nicht gefunden: %s", pp_dir)
@@ -402,6 +440,7 @@ def process_all(data_dir: Path) -> None:
sys.exit(1)
log.info("Gefunden: %d PDF-Dateien in %s", len(pdf_files), pp_dir)
log.info("Bilder -> %s", images_base)
for pdf_path in sorted(pdf_files):
target = find_target(pdf_path.name)
@@ -411,8 +450,11 @@ def process_all(data_dir: Path) -> None:
log.info("Verarbeite: %s -> %s", pdf_path.name, target)
# Bilder extrahieren und Duplikate entfernen
# Bilder extrahieren und Duplikate entfernen. Vorher Zielordner leeren.
img_dir = images_base / target
if img_dir.exists():
import shutil
shutil.rmtree(img_dir)
img_count = extract_images(pdf_path, img_dir)
dup_count = dedup_images(img_dir)
log.info(" %d Bilder extrahiert, %d Duplikate entfernt -> %d einzigartig in %s",
@@ -433,6 +475,9 @@ def main() -> None:
)
parser.add_argument("--data-dir", type=Path, default=None,
help="data/ Verzeichnis (default: aus PV_DATA oder ./data)")
parser.add_argument("--assets-dir", type=Path, default=None,
help="Zielordner fuer Bilder (default: "
"assets/shared/images/produktprogramm unter PROJECT)")
parser.add_argument("--verbose", "-v", action="store_true",
help="Ausfuehrliche Ausgabe")
args = parser.parse_args()
@@ -450,8 +495,9 @@ def main() -> None:
else:
data_dir = Path("data").resolve()
assets_dir = args.assets_dir.resolve() if args.assets_dir else None
log.info("Data-Verzeichnis: %s", data_dir)
process_all(data_dir)
process_all(data_dir, assets_dir=assets_dir)
if __name__ == "__main__":
+377
View File
@@ -0,0 +1,377 @@
"""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/<kapitel>/.
Bild-Routing je Kapitel:
general -> assets/shared/images/<subdir>
project -> projekte/fortna/images/<subdir>
Fotos (.jpg) gehen unabhaengig davon IMMER nach projekte/fortna/images/<subdir>.
"""
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/<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("")
# Die Verzeichnistabelle enthaelt lange Quellpfade und ist fuer Hochformat zu
# breit -> im LaTeX/PDF-Build quer (landscape) setzen. Fuer HTML wird der
# raw:latex-Block ignoriert (dort bricht die Tabelle ohnehin um).
lines.append(".. raw:: latex")
lines.append("")
lines.append(" \\begin{landscape}")
lines.append("")
lines.append(".. list-table::")
lines.append(" :header-rows: 1")
lines.append(" :widths: 30 16 8 46")
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("")
lines.append(".. raw:: latex")
lines.append("")
lines.append(" \\end{landscape}")
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()
+117
View File
@@ -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()
+317
View File
@@ -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()
+803
View File
@@ -0,0 +1,803 @@
"""Positionsbasierte PDF-Extraktion fuer die Fortna-Hauptanleitungen.
Erzeugt aus einem Word-generierten PDF eine .rst-Datei, in der Text und Bilder
in der richtigen Lesereihenfolge (nach Y-Position pro Seite) verwoben sind.
Verbesserungen gegenueber extract_products.extract_text_to_rst:
1. Ueberschriften: Die im PDF vorhandene Nummerierung (z.B. "1.7 Wichtige
Informationen") wird NUR zur Bestimmung der Gliederungstiefe ausgewertet,
im RST-Ausgabetext aber ENTFERNT. Sphinx/LaTeX nummeriert selbst - dadurch
keine doppelte Nummerierung (alt 1.7 + neu 3.2.1) mehr.
2. Bilder: werden positionsbasiert (bbox) pro Seite eingelesen, dedupliziert
und an der korrekten Textstelle als `.. figure::`-Direktive referenziert.
Bildunterschriften ("Abbildung N: ...") werden als figure-caption uebernommen.
3. Kopf-/Fusszeilen und Inhaltsverzeichnis-Seiten werden herausgefiltert.
Oeffentliche Funktion: extract_manual(pdf_path, rst_path, img_dir) -> (blocks, images)
"""
from __future__ import annotations
import hashlib
import io
import logging
import re
from pathlib import Path
log = logging.getLogger(__name__)
# Schwellwerte fuer die Symbol-/Icon-Erkennung
SYMBOL_MAX_EDGE = 340 # Bilder <= dieser Kantenlaenge gelten als Symbol-Kandidat
# (Sicherheitspiktogramme sind oft ~300 px)
SYMBOL_MIN_COUNT = 2 # ... wenn dasselbe Bild mind. so oft vorkommt
AHASH_THRESHOLD = 6 # max. Hamming-Distanz, ab der zwei aHashes als gleich gelten
# Manuelle Blockliste: untere 32 Bit des aHash von Bildern, die faelschlich als
# Symbol erkannt wurden (Fotos/3D-Renderings/Diagramm-Fragmente, KEINE echten
# Sicherheitspiktogramme). Diese werden nie als Symbol behandelt und nicht in
# assets/shared/symbols abgelegt/referenziert. (Kuratiert nach Sichtpruefung.)
SYMBOL_BLOCKLIST = {
0x1d0580bf, 0x71c70f3f, 0x808081ff, 0x818183ff,
0x81f1f3f3, 0xc3c1f7ff, 0xdbc3e7e7,
}
def average_hash(image_bytes: bytes, size: int = 8) -> int:
"""Perceptual Average-Hash (aHash) eines Bildes als Integer.
Graustufen -> size x size -> Bit je Pixel (>= Mittelwert). Zwei visuell
aehnliche Bilder (auch bei leicht anderer Skalierung/Kompression, wie
mehrfach eingebettete Warnsymbole aus Word) haben eine kleine Hamming-
Distanz. Robuster als ein exakter Byte-/Pixel-Hash.
"""
from PIL import Image
try:
im = Image.open(io.BytesIO(image_bytes)).convert("L").resize((size, size))
px = list(im.getdata())
avg = sum(px) / len(px)
bits = 0
for i, p in enumerate(px):
if p >= avg:
bits |= (1 << i)
return bits
except Exception:
# Fallback: Byte-Hash als Integer, wenn PIL das Bild nicht oeffnen kann
return int(hashlib.md5(image_bytes).hexdigest()[:16], 16)
def hamming(a: int, b: int) -> int:
"""Hamming-Distanz zweier Integer-Hashes (Anzahl unterschiedlicher Bits)."""
return bin(a ^ b).count("1")
def normalize_symbol(image_bytes: bytes, canvas: int = 200) -> bytes:
"""Zentriert ein Symbol auf einer quadratischen weissen Canvas.
So erscheinen alle Warn-/Gebots-/Verbotssymbole - unabhaengig vom Seiten-
verhaeltnis und der Aufloesung der Quelle - bei gleicher RST-Hoehe gleich
gross. Gibt PNG-Bytes zurueck.
"""
from PIL import Image
try:
im = Image.open(io.BytesIO(image_bytes)).convert("RGB")
w, h = im.size
scale = (canvas - 16) / max(w, h) # 8 px Rand ringsum
new = im.resize((max(1, int(w * scale)), max(1, int(h * scale))))
bg = Image.new("RGB", (canvas, canvas), (255, 255, 255))
bg.paste(new, ((canvas - new.width) // 2, (canvas - new.height) // 2))
buf = io.BytesIO()
bg.save(buf, format="PNG")
return buf.getvalue()
except Exception:
return image_bytes
# --- Bild-Klassifizierung -------------------------------------------------
SKETCH_MIN_BYTES = 10_000 # SW-Grafik groesser als ~10 KB gilt als Sketch
def white_ratio(image_bytes: bytes) -> float:
"""Anteil (nahezu) weisser Pixel (Helligkeit > 230) im Bild [0..1]."""
from PIL import Image
try:
im = Image.open(io.BytesIO(image_bytes)).convert("L")
data = list(im.getdata())
if not data:
return 0.0
step = max(1, len(data) // 400)
sample = data[::step]
return sum(1 for p in sample if p > 230) / len(sample)
except Exception:
return 0.0
def _dominant_color_count(im, thresh: float = 0.02) -> int:
"""Anzahl 'dominanter' Farben (>= thresh Anteil) in grober 16er-Quantisierung.
Piktogramme haben wenige Flaechenfarben (<= ~7), Fotos/Barcodes viele (>= 8).
"""
from collections import Counter
small = im.resize((48, 48))
c: Counter = Counter((r // 48, g // 48, b // 48) for r, g, b in small.getdata())
total = sum(c.values()) or 1
return sum(1 for v in c.values() if v / total >= thresh)
def _few_colors(image_bytes: bytes, max_colors: int = 7) -> bool:
"""True, wenn das Bild wenige dominante Flaechenfarben hat (Piktogramm).
Fotos und Barcodes haben viele Farbabstufungen und werden ausgeschlossen.
"""
from PIL import Image
try:
im = Image.open(io.BytesIO(image_bytes)).convert("RGB")
return _dominant_color_count(im) <= max_colors
except Exception:
return False
def looks_like_pictogram(image_bytes: bytes) -> bool:
"""True, wenn ein Bild wie ein Warn-/Gebots-/Verbotssymbol aussieht.
Piktogramme (Warndreieck gelb, Gebot blau, Verbot rot/weiss) bestehen aus
WENIGEN kraeftigen Flaechenfarben + viel Weiss/Schwarz. Kennzeichen:
- hoher Anteil an Signalfarben (gelb/orange, kraeftiges blau, rot) UND
- wenige dominante Farben (<= 7) -> schliesst Fotos & Barcodes aus, die zwar
blaue/farbige Anteile haben, aber viele Farbabstufungen.
"""
from PIL import Image
try:
im = Image.open(io.BytesIO(image_bytes)).convert("RGB")
data = list(im.getdata())
if not data:
return False
# Foto/Barcode -> kein Piktogramm. Grenze 9 (nicht 7): JPEG-komprimierte
# Verbots-/Gebotssymbole haben durch Randartefakte etwas mehr Farben.
if _dominant_color_count(im) > 9:
return False
step = max(1, len(data) // 500)
sample = data[::step]
n = len(sample)
signal = 0
for r, g, b in sample:
# gelb/orange: hohes R+G, niedriges B
if r > 180 and g > 150 and b < 110:
signal += 1
# kraeftiges blau (Gebotssymbol): b deutlich groesser als r
elif b > 110 and b - r > 50 and r < 130:
signal += 1
# rot (Verbotssymbol): r deutlich groesser als g und b
elif r > 150 and r - g > 70 and r - b > 70:
signal += 1
return signal / n > 0.12
except Exception:
return False
def is_junk_fragment(image_bytes: bytes, w: int, h: int) -> bool:
"""True fuer wertlose Text-/Nummern-Fragmente (kein echtes Symbol/Bild).
Erkennt:
a) flache (Hoehe <= 55 px), breite, schwarz-weisse Schnipsel (gerenderte
Beschriftungen/Kapitelnummern wie "5.2.6").
b) kleine, schwarz-weisse Bilder mit sehr hohem Weissanteil und geringer
Datenmenge - das sind gerenderte Kapitelnummern ("3.2.3", "3.2.4"), die
als eigenstaendige Grafik im PDF liegen (viel Weissraum, duenne Ziffern).
"""
# a) flache breite SW-Schnipsel
if h <= 55 and w / max(h, 1) >= 1.6 and is_grayscale(image_bytes):
return True
# b) kleine SW-Nummerngrafik: kleine Flaeche, SW, sehr wenig "Tinte"
if (max(w, h) <= 200 and len(image_bytes) < 4000
and is_grayscale(image_bytes) and white_ratio(image_bytes) > 0.80):
return True
return False
def is_grayscale(image_bytes: bytes) -> bool:
"""True, wenn das Bild (nahezu) schwarz-weiss/graustufig ist."""
from PIL import Image
try:
rgb = Image.open(io.BytesIO(image_bytes)).convert("RGB")
data = list(rgb.getdata())
step = max(1, len(data) // 200)
for r, g, b in data[::step]:
if abs(r - g) > 12 or abs(g - b) > 12:
return False
return True
except Exception:
return False
def classify_image(image_bytes: bytes, ext: str, w: int, h: int) -> str:
"""Klassifiziert ein Bild: 'photo' | 'sketch' | 'graphic'.
- photo: JPEG/JPG (Fotos sind meist projektspezifisch)
- sketch: schwarz-weisse Grafik groesser als ~10 KB (Skizze/Zeichnung)
- graphic: alles uebrige (farbige PNG-Grafik, Diagramm)
"""
if ext.lower() in ("jpg", "jpeg"):
return "photo"
if len(image_bytes) > SKETCH_MIN_BYTES and is_grayscale(image_bytes):
return "sketch"
return "graphic"
# Logo-Erkennung: grosse, breite Grafik (Schriftzug-Logo) auf oberen/unteren
# Seitenbereichen. Wir erkennen sie ueber Seitenposition + Seitenverhaeltnis,
# da OCR des Schriftzugs zu aufwendig waere.
def looks_like_logo(w: int, h: int, y_top: float, page_height: float) -> bool:
"""Heuristik: breites Bild (ratio > 2.2) im Kopf-/Fussbereich der Seite."""
if h == 0:
return False
ratio = w / h
in_margin = y_top < page_height * 0.18 or y_top > page_height * 0.82
return ratio > 2.2 and in_margin and w >= 200
RST_H = {1: "=", 2: "-", 3: "~", 4: "^", 5: '"'}
# Symbolfont-Zeichen (Private Use Area) auf Unicode abbilden bzw. entfernen.
PUA_MAP = {
"": "•", "": "•", "": "•",
"": "→", "": "→", "": "←", "": "✓",
"": " ",
}
_PUA_RE = re.compile("|".join(re.escape(k) for k in PUA_MAP))
_PUA_REST_RE = re.compile(r"[-]")
# Rausch-Muster (Kopf/Fuss/Metadaten), die nicht in den Text gehoeren.
_NOISE = {
"services cz s.r.o", "schönenberger systeme gmbh",
"justus-von-liebig-str", "info@schoenenberger.de",
"www.schoenenberger.de", "www.schoenenberger-systems.com",
"karolinska 661/4",
}
RE_DATE = re.compile(r"^\d{2}\.\d{2}\.\d{4}$")
RE_PAGENO = re.compile(r"^(seite|page)\s+\d+$", re.I)
RE_PHONE = re.compile(r"^[TF]:\s*\+?\d")
# Ueberschrift mit fuehrender Nummer: "1.7 Wichtige Informationen"
# Nummernteile max 3-stellig (verhindert Datum/Artikelnummer als Ueberschrift).
RE_HEADING = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){0,4})\s+([A-Za-zÄÖÜäöüß].{2,})$")
# reine Nummernzeile (Nummer und Titel stehen im PDF auf getrennten Zeilen)
RE_NUM_ONLY = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){0,4})\.?$")
# Bildunterschrift "Abbildung 17: ..." / "Abb. 17 ..." / "Figure 3: ..."
RE_CAPTION = re.compile(r"^(Abbildung|Abb\.?|Bild|Figure|Tabelle)\s*\d+\s*[:.]?\s*(.*)$", re.I)
def clean_pua(text: str) -> str:
"""Symbolfont-Zeichen ersetzen, restliche PUA-Zeichen entfernen."""
text = _PUA_RE.sub(lambda m: PUA_MAP[m.group(0)], text)
return _PUA_REST_RE.sub("", text)
def _is_noise(text: str) -> bool:
low = text.lower().strip()
if not low:
return True
if RE_DATE.match(low) or RE_PAGENO.match(low) or RE_PHONE.match(text):
return True
return any(kw in low for kw in _NOISE)
def _strip_heading_number(num: str, rest: str) -> str:
"""Gibt den Ueberschriftentext ohne fuehrende Gliederungsnummer zurueck."""
return rest.strip()
def caption_slug(caption_text: str) -> str:
"""Leitet ein kurzes Datei-Stichwort aus einer Bildunterschrift ab.
"Abbildung 17: Weiche Typ A links" -> "Weiche_Typ_A"
Nimmt die ersten Woerter nach der "Abbildung N:"-Kennung.
"""
m = RE_CAPTION.match(caption_text)
body = (m.group(2) if m else caption_text).strip()
# Umlaute/Sonderzeichen -> ASCII-nah, Wortgrenzen zu _
body = (body.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
.replace("Ä", "Ae").replace("Ö", "Oe").replace("Ü", "Ue")
.replace("ß", "ss"))
words = re.findall(r"[A-Za-z0-9]+", body)
if not words:
return ""
slug = "_".join(words[:3]) # max. 3 Woerter fuer kompakten Namen
return slug[:40]
def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
title: str | None = None,
symbols_dir: Path | None = None,
symbol_registry: dict | None = None,
logos_dir: Path | None = None,
logo_registry: dict | None = None,
image_dest: Path | None = None,
photo_dest: Path | None = None) -> tuple[int, int]:
"""Extrahiert Text + Bilder eines Anleitungs-PDF in eine .rst-Datei.
Bild-Routing (siehe doc/plan_fortna_extraktion.md):
- Symbole (klein, mehrfach): einmal zentral -> symbols_dir
(assets/shared/symbols), im RST als `.. image::` referenziert.
- Logos (breite Schriftzug-Grafik im Seitenrand): pro visueller Variante
alle Aufloesungen -> logos_dir (assets/shared/logos), NICHT inline
referenziert (reine Deko).
- Inhaltsbilder: nach image_dest (assets/... fuer generelle Kapitel bzw.
projekte/fortna/images/... fuer projektspezifische). Fotos (.jpg) landen
IMMER projektspezifisch; das steuert der Aufrufer ueber image_dest.
Benennung: PNG mit direkt folgender Bildunterschrift -> <Stichwort>_NN.png,
Fotos -> img_NNN.jpg, SW-Skizzen (>10KB) -> sketch_NNN.png, Rest img_NNN.png.
symbol_registry / logo_registry (aHash -> Datei) werden dokumentuebergreifend
wiederverwendet.
Returns:
(anzahl_textbloecke, anzahl_bilder)
"""
import fitz
if symbol_registry is None:
symbol_registry = {}
if logo_registry is None:
logo_registry = {}
# image_dest: Zielordner fuer Grafiken/Skizzen. photo_dest: fuer Fotos (.jpg,
# immer projektspezifisch). Default beider: img_dir.
if image_dest is None:
image_dest = img_dir
if photo_dest is None:
photo_dest = image_dest
doc = fitz.open(str(pdf_path))
image_dest.mkdir(parents=True, exist_ok=True)
# --- Seiten identifizieren, die uebersprungen werden ---
# a) TOC-Seiten (Punktfueller / "Inhaltsverzeichnis")
# b) Aenderungshistorie-/Dokumentinfo-Tabellen ("Aenderungsinformation",
# Spaltenkombination Version+Datum+Aenderungsbeschreibung). Diese Tabellen
# zerfallen beim linearen Auslesen in sinnlose Einzelzellen.
skip_pages: set[int] = set()
for pn in range(len(doc)):
t = doc[pn].get_text()
low = t.lower()
if t.count("....") >= 3 or "\ninhaltsverzeichnis\n" in low:
skip_pages.add(pn)
elif "änderungsinformation" in low or "dokumenteninformation" in low:
skip_pages.add(pn)
elif ("änderungsbeschreibung" in low and "version" in low
and ("datum" in low or "kapitel" in low)):
skip_pages.add(pn)
# --- Pass 1: Text- und Bild-Elemente sammeln (Bilder nur puffern) ---
# Bilder werden zunaechst nicht geschrieben, sondern per Perceptual-Hash
# (aHash) einem Cluster zugeordnet. Visuell aehnliche Bilder (gleiches Symbol
# in anderer Skalierung) landen im selben Cluster. Erst nach dem Scan steht
# fest, welches Cluster mehrfach vorkommt (Symbol-Kriterium).
elements: list[dict] = []
clusters: list[dict] = [] # [{ahash, data, ext, w, h, count, cls, margin_hits, variants}]
def _find_cluster(ah: int) -> int:
for ci, c in enumerate(clusters):
if hamming(ah, c["ahash"]) <= AHASH_THRESHOLD:
return ci
return -1
for pn in range(len(doc)):
if pn in skip_pages:
continue
page = doc[pn]
ph = page.rect.height
pw = page.rect.width
# Textzeilen mit Position
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])
if bbox[1] < 70 or bbox[3] > ph - 45: # Kopf-/Fusszeile
continue
text = ""
max_size = 0.0
bold = False
for span in line.get("spans", []):
st = clean_pua(span.get("text", ""))
if st.strip():
text += st
max_size = max(max_size, span.get("size", 0))
if span.get("flags", 0) & 2**4: # bold flag
bold = True
text = text.strip()
if not text or _is_noise(text):
continue
elements.append({
"kind": "text", "y": bbox[1], "page": pn + 1,
"text": text, "size": round(max_size, 1), "bold": bold,
})
# Bilder mit Position. Titelseiten (erste 2 Seiten) = nur Logo/Deko.
if pn < 2:
continue
for im in page.get_image_info(xrefs=True):
xref = im.get("xref", 0)
if not xref:
continue
b = im["bbox"]
w, h = im.get("width", 0), im.get("height", 0)
# Vollseiten-Deko / Hintergrundbilder ueberspringen
if (b[2] - b[0]) > pw * 0.95 and (b[3] - b[1]) > ph * 0.95:
continue
if w < 40 or h < 40: # winzige Icons
continue
base = doc.extract_image(xref)
if not base:
continue
data = base["image"]
if len(data) < 3000:
continue
# Wertlose Text-/Nummern-Fragmente (flach, SW) verwerfen
if is_junk_fragment(data, w, h):
continue
ext = base.get("ext", "png")
ah = average_hash(data)
in_margin = looks_like_logo(w, h, b[1], ph)
ci = _find_cluster(ah)
if ci < 0:
clusters.append({"ahash": ah, "data": data, "ext": ext,
"w": w, "h": h, "count": 1,
"cls": classify_image(data, ext, w, h),
"margin_hits": 1 if in_margin else 0,
# groesste Auflvariante fuer Logo-Ablage merken
"variants": [(w * h, w, h, data, ext)]})
ci = len(clusters) - 1
else:
clusters[ci]["count"] += 1
if in_margin:
clusters[ci]["margin_hits"] += 1
clusters[ci]["variants"].append((w * h, w, h, data, ext))
elements.append({
"kind": "image", "y": b[1], "page": pn + 1, "cluster": ci,
})
doc.close()
import os as _os
# --- Zielverzeichnisse + relative Pfad-Praefixe (vom RST aus) ---
rst_parent = rst_path.parent
if symbols_dir is None:
symbols_dir = rst_parent.parent.parent / "assets" / "shared" / "symbols"
if logos_dir is None:
logos_dir = symbols_dir.parent / "logos"
def _rel(target: Path) -> str:
try:
return Path(_os.path.relpath(target, rst_parent)).as_posix()
except ValueError:
return target.as_posix()
sym_rel_prefix = _rel(symbols_dir)
dest_rel_prefix = _rel(image_dest)
photo_rel_prefix = _rel(photo_dest)
# --- Pass 2a: Cluster klassifizieren (Logo / Symbol / Inhaltsbild) ---
# Logo: breit (ratio > 2.2), im Seitenrand UND wiederkehrend (>=3x) - das
# unterscheidet ein Kopf-/Fusszeilen-Logo von einem einmaligen breiten
# Diagramm. Alle Aufloesungen -> assets/shared/logos, NICHT inline.
# Symbol: klein (<=260px) UND mehrfach (>=2x) -> assets/shared/symbols.
cluster_is_symbol: list[bool] = [False] * len(clusters)
cluster_is_logo: list[bool] = [False] * len(clusters)
cluster_sympath: list[str] = [""] * len(clusters)
for ci, c in enumerate(clusters):
ratio = c["w"] / c["h"] if c["h"] else 0
is_logo = (ratio > 2.2 and c["margin_hits"] >= 3 and c["count"] >= 3
and c["w"] >= 200)
if is_logo:
cluster_is_logo[ci] = True
# bereits bekannte Logo-Variante? -> nicht neu schreiben
if any(hamming(c["ahash"], rah) <= AHASH_THRESHOLD + 2
for rah in logo_registry):
continue
logos_dir.mkdir(parents=True, exist_ok=True)
idx = len(logo_registry) + 1
for (_area, w, h, data, ext) in sorted(set(c["variants"]), reverse=True):
(logos_dir / f"logo_{idx:02d}_{w}px.{ext}").write_bytes(data)
logo_registry[c["ahash"]] = idx
continue
# Symbol-Erkennung (Warn-/Gebots-/Verbotszeichen, Bedien-Piktogramme):
# klein (<= 260px) UND quadratisch-aehnlich (ratio 0.55..1.8) UND
# entweder Piktogramm-Form (Signalfarben) ODER (mehrfach + heller Grund).
# Piktogramme werden so auch erkannt, wenn sie im Dokument nur 1x vorkommen
# (Warndreiecke stehen oft einzeln), Foto-Ausschnitte dagegen nicht.
# Manuell geblockte Fehlklassifikationen nie als Symbol behandeln.
# Vergleich ueber die unteren 32 Bit des aHash (= Wert im Dateinamen);
# bis zu 2 abweichende Bits werden toleriert (JPEG-/Skalierungsvarianz).
low = c["ahash"] & 0xffffffff
blocked = any(bin(low ^ b).count("1") <= 2 for b in SYMBOL_BLOCKLIST)
small = max(c["w"], c["h"]) <= SYMBOL_MAX_EDGE
squarish = 0.55 <= ratio <= 1.8
few_colors = _few_colors(c["data"], max_colors=9) # Piktogramm: wenige Farben
is_symbol = (not blocked) and small and squarish and few_colors and (
looks_like_pictogram(c["data"])
or (c["count"] >= SYMBOL_MIN_COUNT and white_ratio(c["data"]) > 0.35)
)
cluster_is_symbol[ci] = is_symbol
if is_symbol:
match = None
for reg_ah, reg_file in symbol_registry.items():
if hamming(c["ahash"], reg_ah) <= AHASH_THRESHOLD:
match = reg_file
break
if match is None:
symbols_dir.mkdir(parents=True, exist_ok=True)
# auf quadratische Canvas normalisieren -> immer als PNG ablegen
match = f"symbol_{c['ahash'] & 0xffffffff:08x}.png"
(symbols_dir / match).write_bytes(normalize_symbol(c["data"]))
symbol_registry[c["ahash"]] = match
cluster_sympath[ci] = f"{sym_rel_prefix}/{match}"
# --- pro Seite nach Y-Position sortieren (Lesereihenfolge) ---
elements.sort(key=lambda e: (e["page"], e["y"]))
# --- Zeilen zusammenfuehren, die im PDF getrennt stehen ---
# a) Nummer-only-Zeile ("2.3.1") + folgende Titelzeile
# b) Bullet-only-Zeile ("•") + folgende Textzeile -> "• Text"
merged: list[dict] = []
i = 0
while i < len(elements):
e = elements[i]
if e["kind"] == "text":
nxt = elements[i + 1] if i + 1 < len(elements) else None
# a) Gliederungsnummer + Titel. NUR mehrteilige Nummern (z.B. "2.3.1"),
# nicht einzelne lose Ziffern (Bildlegenden-Nummern wie "2", "24"),
# und NICHT, wenn die Folgezeile eine Bildunterschrift ist.
m = RE_NUM_ONLY.match(e["text"])
if (m and "." in m.group(1) and nxt and nxt["kind"] == "text"
and re.match(r"[A-Za-zÄÖÜäöüß]", nxt["text"])
and not RE_CAPTION.match(nxt["text"])):
merged.append({**e, "text": f"{m.group(1)} {nxt['text']}",
"size": max(e["size"], nxt["size"])})
i += 2
continue
# b) einzelnes Bullet-Zeichen + Text der Folgezeile
if e["text"].strip() in ("•", "-", "–", "*") and nxt and nxt["kind"] == "text":
merged.append({**e, "text": f"• {nxt['text']}"})
i += 2
continue
merged.append(e)
i += 1
# --- Pass 2b: Inhaltsbilder schreiben (mit Caption-Benennung + Klasse) ---
# Jetzt (nach dem Merge) ist die Folgezeile jedes Bildes bekannt -> Caption.
# Pro Cluster genau einmal schreiben; Zielname aus Caption oder Klasse.
# Fotos (.jpg) -> photo_dest (projektspezifisch), Rest -> image_dest.
image_dest.mkdir(parents=True, exist_ok=True)
photo_dest.mkdir(parents=True, exist_ok=True)
counters = {"img": 0, "sketch": 0}
used_names: set[str] = set()
slug_counters: dict[str, int] = {}
cluster_path: dict[int, str] = {} # ci -> RST-Pfad (relativ)
def _next_name(cls: str, ext: str, slug: str) -> str:
if slug:
slug_counters[slug] = slug_counters.get(slug, 0) + 1
name = f"{slug}_{slug_counters[slug]:02d}.{ext}"
elif cls == "sketch":
counters["sketch"] += 1
name = f"sketch_{counters['sketch']:03d}.{ext}"
else: # photo / graphic
counters["img"] += 1
name = f"img_{counters['img']:03d}.{ext}"
while name in used_names:
counters["img"] += 1
name = f"img_{counters['img']:03d}.{ext}"
used_names.add(name)
return name
n_m = len(merged)
for idx, e in enumerate(merged):
if e["kind"] != "image":
continue
ci = e["cluster"]
if cluster_is_logo[ci]:
e["path"] = "" # Logos werden nicht inline referenziert
e["is_symbol"] = False
continue
if cluster_is_symbol[ci]:
e["path"] = cluster_sympath[ci]
e["is_symbol"] = True
continue
e["is_symbol"] = False
if ci in cluster_path: # Duplikat -> bereits geschrieben
e["path"] = cluster_path[ci]
continue
c = clusters[ci]
# Caption der Folgezeile? -> Stichwort fuer Benennung
slug = ""
if idx + 1 < n_m and merged[idx + 1]["kind"] == "text":
cm = RE_CAPTION.match(merged[idx + 1]["text"])
if cm:
slug = caption_slug(merged[idx + 1]["text"])
name = _next_name(c["cls"], c["ext"], slug)
# Fotos immer projektspezifisch (photo_dest), sonst image_dest
if c["cls"] == "photo":
(photo_dest / name).write_bytes(c["data"])
rel = f"{photo_rel_prefix}/{name}"
else:
(image_dest / name).write_bytes(c["data"])
rel = f"{dest_rel_prefix}/{name}"
cluster_path[ci] = rel
e["path"] = rel
img_counter = len(cluster_path)
# --- RST rendern ---
if title is None:
title = pdf_path.stem
out: list[str] = [RST_H[1] * len(title), title, RST_H[1] * len(title), ""]
# Absatz-Puffer: aufeinanderfolgende Fliesstextzeilen zu einem Absatz
# zusammenfuehren (PDF-Zeilenumbrueche sind kein Absatzende).
para: list[str] = []
def _join_para(lines: list[str]) -> str:
"""Fuegt Absatzzeilen zusammen und entfernt PDF-Silbentrennung.
Endet eine Zeile auf "wort-" (Trennstrich nach Kleinbuchstabe), wird sie
ohne Leerzeichen und ohne Bindestrich mit dem naechsten Wort verbunden
("Anlagenkompo-" + "nente" -> "Anlagenkomponente"). Ein Bindestrich nach
Grossbuchstabe/Ziffer (Kompositum wie "ILS-", "24-") bleibt erhalten.
"""
result = ""
for i, ln in enumerate(lines):
ln = ln.strip()
if not result:
result = ln
continue
if re.search(r"[a-zäöüß]-$", result):
result = result[:-1] + ln # Silbentrennung aufloesen
else:
result += " " + ln
return result.strip()
def flush_para() -> None:
if para:
out.append(_join_para(para))
out.append("")
para.clear()
RE_BULLET = re.compile(r"^\s*[•\-–*]\s+")
blocks = 0
n = len(merged)
for idx, e in enumerate(merged):
if e["kind"] == "image":
flush_para()
path = e.get("path", "")
if not path:
continue
if e.get("is_symbol"):
# Symbol/Warnzeichen: einheitlich kleine Grafik, links ausgerichtet
# (Piktogramm-Charakter, daneben steht der Warntext). Feste Hoehe UND
# Breite begrenzen, damit alle Symbole - unabhaengig vom Seiten-
# verhaeltnis der Quelle - gleich gross erscheinen.
out.append("")
out.append(f".. image:: {path}")
out.append(" :align: left")
out.append(" :height: 10mm")
out.append(" :class: warnsymbol")
out.append("")
blocks += 1
continue
caption = ""
if idx + 1 < n and merged[idx + 1]["kind"] == "text":
cm = RE_CAPTION.match(merged[idx + 1]["text"])
if cm:
caption = merged[idx + 1]["text"]
merged[idx + 1]["_consumed"] = True
out.append("")
out.append(f".. figure:: {path}")
out.append(" :align: center")
out.append(" :width: 80%")
if caption:
out.append("")
out.append(f" {caption}")
out.append("")
blocks += 1
continue
if e.get("_consumed"):
continue
text = e["text"]
# Bildunterschriften ("Abbildung N: ...") sind NIE Ueberschriften. Wenn sie
# nicht direkt als figure-Caption konsumiert wurden (z.B. weil Text
# dazwischen stand), als eigenen (kursiven) Absatz ausgeben.
if RE_CAPTION.match(text):
flush_para()
out.append(f"*{text}*")
out.append("")
blocks += 1
continue
hm = RE_HEADING.match(text)
# Ueberschrift NUR, wenn ALLE Kriterien erfuellt sind:
# 1. fuehrende Gliederungsnummer ("N", "N.N", ...),
# 2. Quell-Schrift ist gross (>= 12.5pt) ODER fett -> unterscheidet echte
# Ueberschriften von normalen Textzeilen, die zufaellig mit einer Nummer
# beginnen (Kapitelverweise "gemaess 2.8.2 ...", nummerierte Aufzaehlungen).
# Das war die Hauptursache falscher Ueberschriften.
# 3. kein offensichtliches Fragment/Bildlegende ("POS", "li.", "5 mm", ...).
is_heading = False
if hm and len(hm.group(2)) < 120:
num, rest = hm.group(1), hm.group(2)
rest_stripped = rest.strip()
looks_like_title = e.get("size", 0) >= 12.5 or e.get("bold", False)
is_frag = (
len(rest_stripped) < 4 # zu kurz ("POS", "li.")
or rest_stripped.endswith((",",)) # abgeschnittene Zelle
or re.match(r"^(mm|cm|m|kg|x|l|h|min|s|bar|POS|li|re|Pos)\b",
rest_stripped, re.I)
or re.match(r"^(li|re|oben|unten|vorne|hinten|links|rechts)\.?$",
rest_stripped, re.I)
)
if looks_like_title and not is_frag:
is_heading = True
if is_heading:
flush_para()
num, rest = hm.group(1), hm.group(2)
heading = _strip_heading_number(num, rest) # NUMMER ENTFERNEN
level = min(num.count(".") + 1, 5)
char = RST_H[level]
out.append("")
if level == 1:
out.append(char * max(len(heading), 3))
out.append(heading)
out.append(char * max(len(heading), 3))
out.append("")
blocks += 1
continue
# Aufzaehlung: Zeile beginnt mit Bullet ODER enthaelt mehrere Bullets
# (mehrere Listenpunkte sind im PDF oft in eine Zeile zusammengelaufen).
if RE_BULLET.match(text) or text.count("•") >= 2:
flush_para()
# An Bullet-Zeichen aufsplitten, jeder Teil wird ein Listenpunkt.
parts = re.split(r"\s*•\s*", text)
for part in parts:
item = RE_BULLET.sub("", part).strip().rstrip(",")
if item:
out.append(f"- {item}")
out.append("")
blocks += 1
continue
# Fliesstext: an Absatz anhaengen. Endet die Zeile mit Satzzeichen, gilt
# der Absatz als abgeschlossen und wird geflusht.
para.append(text)
if re.search(r"[.:!?]$", text):
flush_para()
blocks += 1
flush_para()
rst = "\n".join(out)
rst = re.sub(r"\n{3,}", "\n\n", rst).strip() + "\n"
rst_path.parent.mkdir(parents=True, exist_ok=True)
rst_path.write_text(rst, encoding="utf-8")
return blocks, img_counter
+23
View File
@@ -0,0 +1,23 @@
# 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.
+25
View File
@@ -0,0 +1,25 @@
{
"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": []
}
View File
+2
View File
@@ -31,6 +31,8 @@ rstcheck==6.2.5
rstcheck-core==1.2.2
shellingham==1.5.4
six==1.17.0
Sphinx==9.1.0
myst-parser==5.1.0
stevedore==5.7.0
typer==0.25.0
typing-inspection==0.4.2