Compare commits

...
Author SHA1 Message Date
Daniel 970343cd98 changelog.txt aktualisiert
Release Plugin / release (push) Successful in 4s
2026-06-19 11:52:15 +02:00
Daniel 3d9bfdb316 Merge pull request 'Api integration plan41' (#50) from API-Integration_Plan41 into unstable
Reviewed-on: #50
2026-06-19 11:48:35 +02:00
Daniel 15e48f58af VLN-API integriert 2026-06-19 11:47:36 +02:00
release-bot 933b3f9cb6 Release 26.5.2-testing 2026-05-22 12:30:19 +00:00
Daniel f6b62395dd Merge pull request 'Feature/vkz differenzierung verfahrensgebiet' (#49) from feature/vkz-differenzierung-verfahrensgebiet into testing
Release Plugin / release (push) Successful in 4s
Reviewed-on: #49
2026-05-22 14:29:36 +02:00
Daniel 025a5f5e13 Messagebox-Fix "VG" in "Verfahrensgebiet" 2026-05-22 14:26:58 +02:00
Daniel e196a4896f Fokusverlust bei Dialogauswahl gefixt 2026-05-22 08:56:26 +02:00
Daniel 63a2e8d63a dialogbox für BROR-Abfrage ergänzt 2026-05-22 08:41:12 +02:00
release-bot 79b827c009 Release 26.5.1-testing 2026-05-06 11:14:30 +00:00
Daniel 0e695ba595 Merge pull request 'Grenzpunkt-Funktionen für Verfahrensgebiet' (#47) from unstable into testing
Release Plugin / release (push) Successful in 4s
Reviewed-on: #47
2026-05-06 13:13:33 +02:00
release-bot e11f84e89b Release 26.5.1-unstable 2026-05-06 08:40:38 +00:00
Daniel 171ebcbe4f Merge pull request 'Verfahrensgebiet-Prüfung als Knickpunkte' (#46) from feature/grenzpunkte_laden into unstable
Release Plugin / release (push) Successful in 3s
Reviewed-on: #46
2026-05-06 10:39:54 +02:00
Daniel b7dfac68f1 Verfahrensgebiet-Prüfung als Knickpunkte 2026-05-05 19:47:25 +02:00
release-bot 62e9bcee1e Release 26.4.8-unstable 2026-04-24 04:52:28 +00:00
Daniel e9d9a2294d Merge pull request 'verfahrensgebiet aus Flst' (#35) from verfahrensgebiet_aus_flst into unstable
Release Plugin / release (push) Successful in 4s
Reviewed-on: #35
2026-04-23 22:30:12 +02:00
Daniel c8fde119b4 boundary-Unterscheidung für QGIS 3/4 2026-04-23 22:23:50 +02:00
Daniel 692f690fea verfahrensgebiet aus Flst 2026-04-22 14:53:34 +02:00
release-bot 94b957bb73 Release 26.4.7-unstable 2026-04-22 06:02:15 +00:00
release-bot 00023c632f Release 26.4.6-unstable
Release Plugin / release (push) Successful in 3s
2026-04-22 05:42:26 +00:00
22ottomi fba89478a0 metadata.txt aktualisiert
Release Plugin / release (push) Successful in 4s
QGISMaximumVersion=4.99 ergänzt
2026-04-22 07:41:07 +02:00
release-bot 3c1d8fc4e5 Release 26.4.5-unstable 2026-04-21 20:22:12 +00:00
Daniel 44eee228a8 Merge pull request 'Verfahrensgebiet bilden ergänzt' (#29) from feature/Verfahrensgebiet_bilden into unstable
Release Plugin / release (push) Successful in 4s
Reviewed-on: #29
2026-04-21 22:19:13 +02:00
Daniel 34e2ff4f66 Flst_Laden, VerfGebiet bilden 2026-04-21 21:50:33 +02:00
Daniel 91669b34d6 tests überarbeitet 2026-04-20 15:36:38 +02:00
Daniel d2035139d9 pdoc-Dockstrings ergänzt 2026-04-20 14:31:16 +02:00
release-bot 30bfcc597d Release 26.4.4-unstable 2026-04-20 11:31:23 +00:00
Daniel 88761eab22 exit_Crash_fix
Release Plugin / release (push) Successful in 4s
2026-04-20 13:28:00 +02:00
Daniel d762f2d94f Merge branch 'unstable' of https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis into unstable 2026-04-20 10:32:19 +02:00
Daniel 9f7e81e8b2 Merge branch 'feature/Drucklayout-überarbeiten' into unstable 2026-04-20 10:19:36 +02:00
Daniel b4433222f3 Kartenschild angepasst 2026-04-17 17:15:13 +02:00
Daniel 779a1ed63f cleanup 2026-04-17 16:57:26 +02:00
Daniel 4049090e92 Druck-refactor 2026-04-17 16:52:53 +02:00
Daniel 4b842bf635 Vertikale Ausrichtung für Textfelder ergänzt; default: top 2026-04-17 14:44:30 +02:00
release-bot 943aab7509 Release 26.4.3-unstable 2026-04-17 12:17:57 +00:00
Daniel 5564c06d5c 3.40exit-crash-fix als plan in assets 2026-04-17 14:16:50 +02:00
22ottomi 5b4cbc3127 Merge pull request 'fix: Access Violation beim Beenden von QGIS in PrintTab beheben' (#27) from bug/crash_on_exit into unstable
Release Plugin / release (push) Successful in 9s
Reviewed-on: #27
2026-04-17 14:15:56 +02:00
Michael Otto f0944aab99 fix: Access Violation beim Beenden von QGIS in PrintTab beheben
QgsProject-Signale (readProject, newProjectCreated, cleared) wurden beim
Shutdown von QGIS nicht getrennt. Das Signal 'cleared' wurde emittiert
waehrend QgsProject sich selbst zerstoerte, was _on_project_changed
triggerte und zu einem Zugriff auf bereits destrukturierte Objekte fuehrte.

Loesung: aboutToQuit-Signal verbunden mit neuer _cleanup_signals()-Methode,
die alle QGIS-Signalverbindungen sauber trennt, bevor QGIS Kernobjekte
zerstoert.
2026-04-17 14:06:28 +02:00
Daniel 9306028df8 Anpassungen am Kartenschild 2026-04-17 13:48:14 +02:00
Daniel abf5808117 Herausgeber case sensitiv 2026-04-13 19:58:06 +02:00
Daniel 7ad8ba054f Baufreigabe-Layout fix auf A3/A4 2026-04-10 23:26:58 +02:00
Daniel 0900c6d3b0 neues Layout "Baufreigabe" hinzugefügt 2026-04-10 22:56:34 +02:00
Daniel 40c224959d Maßstab gefixt 2026-04-10 21:39:27 +02:00
Daniel 9d949d158f Umlaute im Kartenschild und Quellenangabe gefixt 2026-04-10 21:37:37 +02:00
Daniel dbccb5322f Koordinatengitter eingefügt, Rahmenbreiten einheitlich auf 0,5mm gesetzt 2026-04-10 21:30:44 +02:00
Daniel fef72b7e53 Position Kartenname angepasst 2026-04-10 19:32:50 +02:00
Daniel 8e80ffb02e unnötige assets gelöscht 2026-04-10 19:21:07 +02:00
release-bot 5dce7c4d96 Release 26.4.2-testing 2026-04-10 10:13:02 +00:00
42 changed files with 3140 additions and 642 deletions
+16
View File
@@ -1,3 +1,19 @@
"""
sn_basis QGIS-Plugin für Basis-Verwaltung und zentrale Koordination.
Orchestriert die Navigation und initialisiert abhängige sn_-Plugins.
"""
def classFactory(iface):
"""
Factory-Funktion für QGIS.
Wird von QGIS aufgerufen, um die Plugin-Instanz zu erstellen.
Args:
iface: QGIS-Schnittstellenobjekt
Returns:
BasisPlugin: Instanziiertes Plugin
"""
from .main import BasisPlugin
return BasisPlugin(iface)
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 115 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 160 KiB

-188
View File
@@ -1,188 +0,0 @@
# WrapperArchitektur Übersicht
Die WrapperArchitektur von sn_basis bildet das Fundament für eine robuste, testbare und zukunftssichere QGISPluginEntwicklung.
Sie kapselt sämtliche QGIS und QtAbhängigkeiten hinter klar definierten Schnittstellen und ermöglicht dadurch:
Mockfähige UnitTests ohne QGIS
PyQt5/6Kompatibilität ohne Code‑Änderungen
saubere Trennung von UI, Logik und Infrastruktur
stabile APIs, die unabhängig von QGISVersionen bleiben
klare Erweiterbarkeit für zukünftige Module und Plugins
Die WrapperSchicht ist das zentrale Bindeglied zwischen der PluginLogik und der QGIS/QtUmgebung.
## Ziele der WrapperArchitektur
🎯 1. Entkopplung von QGIS und Qt
Alle direkten Importe wie from qgis.core import ... oder from qgis.PyQt.QtWidgets import ... verschwinden aus der PluginLogik.
Stattdessen werden sie über WrapperModule abstrahiert.
🎯 2. Testbarkeit ohne QGIS
Im MockModus liefern die Wrapper:
DummyObjekte
simulierte Rückgabewerte
speicherbare Zustände (z.B. Variablen, Layer, Nachrichten)
Damit laufen Tests in jeder CIUmgebung.
🎯 3. Einheitliche API für alle Plugins
Plugins greifen nicht mehr direkt auf QGIS zu, sondern nutzen:
Code
sn_basis.functions.qgiscore_wrapper
sn_basis.functions.qgisui_wrapper
sn_basis.functions.qt_wrapper
sn_basis.functions.variable_wrapper
sn_basis.functions.message_wrapper
sn_basis.functions.dialog_wrapper
🎯 4. Zukunftssicherheit
Ändert sich die QGIS oder QtAPI, wird nur der Wrapper angepasst, nicht jedes Plugin.
## Architekturüberblick
Die WrapperSchicht besteht aus mehreren Modulen, die jeweils einen klar abgegrenzten Verantwortungsbereich haben.
### 1. qt_wrapper QtAbstraktion
Kapselt alle QtWidgets, Dialoge und Konstanten:
QWidget, QDialog, QMessageBox, QToolBar, QMenu, …
Layouts, Buttons, Labels, LineEdits
QtKonstanten wie YES, NO, DockAreas
MockModus:
Stellt DummyWidgets bereit, die keine UI öffnen.
### 2. qgiscore_wrapper QGISCoreAbstraktion
Abstraktion für:
QgsProject
LayerZugriff
ProjektMetadaten
Pfade, CRS, FeatureZugriff
MockModus:
Simuliert ein Projekt und LayerContainer.
### 3. qgisui_wrapper QGISUIAbstraktion
Kapselt UIbezogene QGISFunktionen:
Zugriff auf iface
DockManagement
Menü‑ und ToolbarIntegration
HauptfensterZugriff
MockModus:
Stellt ein DummyInterface bereit.
### 4. variable_wrapper QGISVariablen
Abstraktion für:
Projektvariablen (projectScope)
globale Variablen (globalScope)
MockSpeicher für Tests
Vorteile:
keine QGISAbhängigkeit in der Logik
testbare Variablenverwaltung
einheitliches API
### 5. message_wrapper Meldungen & Logging
Einheitliche Schnittstelle für:
Fehlermeldungen
Warnungen
InfoMeldungen
Logging
MockModus:
Speichert Nachrichten statt sie an QGIS zu senden.
### 6. dialog_wrapper BenutzerDialoge
Abstraktion für:
Ja/NeinDialoge
spätere Erweiterungen (Eingabedialoge, Dateidialoge, etc.)
MockModus:
Gibt DefaultWerte zurück, öffnet keine UI.
### 7. DockManager & Navigation
Diese Module nutzen die WrapperSchicht, um:
DockWidgets sicher zu verwalten
Toolbars und Menüs zu erzeugen
Reloadsichere UIStrukturen aufzubauen
Sie sind keine Wrapper, sondern WrapperKonsumenten.
## Designprinzipien
🧱 1. Single Source of Truth
Jede QGIS oder QtFunktionalität wird nur an einer Stelle implementiert.
🔄 2. Austauschbarkeit
MockModus und Echtmodus sind vollständig austauschbar.
🧪 3. Testbarkeit
Jede Funktion kann ohne QGIS getestet werden.
🧼 4. Saubere Trennung
UI → qt_wrapper
QGISCore → qgiscore_wrapper
QGISUI → qgisui_wrapper
Logik → settings_logic, layer_logic, prüfmanager, …
🔌 5. Erweiterbarkeit
Neue Wrapper können jederzeit ergänzt werden, ohne bestehende Plugins zu brechen.
## Vorteile für Entwickler
Keine QGISAbhängigkeiten in der Logik
IDEfreundlich (Pylance, Autocomplete, Typing)
CIfähig (Tests ohne QGIS)
saubere Architektur
leichte Wartbarkeit
klare Dokumentation
## Fazit
Die WrapperArchitektur ist das Herzstück von sn_basis.
Sie ermöglicht eine moderne, modulare und testbare QGISPluginEntwicklung, die unabhängig von QGISVersionen, QtVersionen und Entwicklungsumgebungen funktioniert.
Sie bildet die Grundlage für:
stabile APIs
saubere UIAbstraktion
automatisierte Tests
nachhaltige Weiterentwicklung
-144
View File
@@ -1,144 +0,0 @@
# WrapperArchitektur Übersicht
Die WrapperArchitektur von sn_basis bildet das Fundament für eine robuste, testbare und zukunftssichere QGISPluginEntwicklung.
Sie kapselt sämtliche QGIS und QtAbhängigkeiten hinter klar definierten Schnittstellen und ermöglicht dadurch:
- Mockfähige UnitTests ohne QGIS
- PyQt5/6Kompatibilität ohne Code‑Änderungen
- saubere Trennung von UI, Logik und Infrastruktur
- stabile APIs, die unabhängig von QGISVersionen bleiben
- klare Erweiterbarkeit für zukünftige Module und Plugins
Die WrapperSchicht ist das zentrale Bindeglied zwischen der PluginLogik und der QGIS/QtUmgebung.
## Ziele der WrapperArchitektur
1. Entkopplung von QGIS und Qt
Alle direkten Importe wie from qgis.core import ... oder from qgis.PyQt.QtWidgets import ... verschwinden aus der PluginLogik.
Stattdessen werden sie über WrapperModule abstrahiert.
2. Testbarkeit ohne QGIS
Im MockModus liefern die Wrapper:
- DummyObjekte
- simulierte Rückgabewerte
- speicherbare Zustände (z.B. Variablen, Layer, Nachrichten)
Damit laufen Tests in jeder CIUmgebung.
3. Einheitliche API für alle Plugins
Plugins greifen nicht mehr direkt auf QGIS zu, sondern nutzen:
- sn_basis.functions.qgiscore_wrapper
- sn_basis.functions.qgisui_wrapper
- sn_basis.functions.qt_wrapper
- sn_basis.functions.variable_wrapper
- sn_basis.functions.message_wrapper
- sn_basis.functions.dialog_wrapper
Aufgrund des Umfangs ist der Wrapper für die Layerbehandlung aufgeteilt:
- ly_existence_wrapper
- ly_geometry_wrapper
- ly_Metadata_wrapper
- ly_style_wrapper
- ly_visibility_wrapper
4. Zukunftssicherheit
Ändert sich die QGIS oder QtAPI, wird nur der Wrapper angepasst, nicht jedes Plugin.
## Architekturüberblick
Die WrapperSchicht besteht aus mehreren Modulen, die jeweils einen klar abgegrenzten Verantwortungsbereich haben.
### 1. qt_wrapper QtAbstraktion
Kapselt alle QtWidgets, Dialoge und Konstanten:
- QWidget, QDialog, QMessageBox, QToolBar, QMenu, …
- Layouts, Buttons, Labels, LineEdits
- QtKonstanten wie YES, NO, DockAreas
MockModus:
Stellt DummyWidgets bereit, die keine UI öffnen.
### 2. qgiscore_wrapper QGISCoreAbstraktion
Abstraktion für:
- QgsProject
- LayerZugriff
- ProjektMetadaten
- Pfade, CRS, FeatureZugriff
MockModus:
Simuliert ein Projekt und LayerContainer.
### 3. qgisui_wrapper QGISUIAbstraktion
Kapselt UIbezogene QGISFunktionen:
- Zugriff auf iface
- DockManagement
- Menü‑ und ToolbarIntegration
- HauptfensterZugriff
MockModus:
Stellt ein DummyInterface bereit.
### 4. variable_wrapper QGISVariablen
Abstraktion für:
- Projektvariablen (projectScope)
- globale Variablen (globalScope)
- MockSpeicher für Tests
Vorteile:
- keine QGISAbhängigkeit in der Logik
- testbare Variablenverwaltung
- einheitliches API
### 5. message_wrapper Meldungen & Logging
Einheitliche Schnittstelle für:
- Fehlermeldungen
- Warnungen
- InfoMeldungen
- Logging
MockModus:
Speichert Nachrichten statt sie an QGIS zu senden.
### 6. dialog_wrapper BenutzerDialoge
Abstraktion für:
- Ja/NeinDialoge
- spätere Erweiterungen (Eingabedialoge, Dateidialoge, etc.)
MockModus:
Gibt DefaultWerte zurück, öffnet keine UI.
### 7. DockManager & Navigation
Diese Module nutzen die WrapperSchicht, um:
- DockWidgets sicher zu verwalten
- Toolbars und Menüs zu erzeugen
- Reloadsichere UIStrukturen aufzubauen
Sie sind keine Wrapper, sondern WrapperKonsumenten. Alle Fach-Plugins nutzen den Dockmanager des Basisplugins.
## Designprinzipien
1. Single Source of Truth
Jede QGIS oder QtFunktionalität wird nur an einer Stelle implementiert.
2. Austauschbarkeit
MockModus und Echtmodus sind vollständig austauschbar.
3. Testbarkeit
Jede Funktion kann ohne QGIS getestet werden.
4. Saubere Trennung
- UI → qt_wrapper
- QGISCore → qgiscore_wrapper
- QGISUI → qgisui_wrapper
- Logik → settings_logic, layer_logic, prüfmanager, …
5. Erweiterbarkeit
Neue Wrapper können jederzeit ergänzt werden, ohne bestehende Plugins zu brechen.
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

-38
View File
@@ -1,38 +0,0 @@
```mermaid
flowchart TD
subgraph Plugin
P[sn_plan41 Fachplugin]
A[Adapter Plan41LinklistAdapter]
PM[Pruefmanager]
LP[Layerpruefer]
KP[Linkpruefer]
SP[Stilpruefer]
end
subgraph Core
DG[DataGrabber]
NL[normalized entries]
LL[Layer Loader Provider Dispatch]
SM[Spatial Matcher]
ST[Storage GPKG / PostGIS]
PR[Project QGIS - addMapLayer]
LOG[Log / Ergebnisstruktur]
end
P -->|gibt Adapter, Prüfer, Pruefmanager| DG
A -->|load liefert Rohdaten| DG
DG -->|adapter.normalize| NL
NL --> DG
DG -->|für jeden Eintrag: _check_link -> KP.check| KP
DG -->|für jeden Eintrag: _check_style -> SP.check| SP
DG -->|prüfe vorhandene Layer| LP
DG -->|lade Layer via provider| LL
LL -->|Features| SM
SM -->|Abgleich| DG
DG -->|speichern| ST
ST --> PR
DG --> PR
DG -->|Ergebnis/Fehler| LOG
LOG --> PM
DG --> PM
```
Binary file not shown.
-9
View File
@@ -1,9 +0,0 @@
```mermaid
graph TD
M1["<div style='text-align:center'><b>sn_basis</b><br/><br/></b></div><div style='text-align:left'>➡ Initialisierung der GUI<br/> ➡ Exception Handling<br/> ➡ Bereitstellung der Stile"]
M2["<div style='text-align:center'><b>sn_verfahrensgebiet</b><br/><br/><div style='text-align:left'>➡ Abruf und Aufbereitung der Gebietsgrenze"<br/>➡ Erstellung neuer Gebietsgrenzen <br/>➡ Grenzpunktextraktion<br/>➡ Grenzpunktprüfung]
M3["<div style='text-align:center'><b>sn_Plan41</b><br/><br/><div style='text-align:left'>➡ Fachdatenabruf<br/>➡Versionierung der Fachdaten<br/>➡ Planung der TG-Maßnahmen <br/>➡Kartenerzeugung (NGG und P41)<br/>➡ Erzeugung der Begleitdokumente (Anlagenverzeichnis, MVZ, Maßnahmeblätter)"]
M1 --> M2
M1 --> M3
```
+57
View File
@@ -0,0 +1,57 @@
# Plan: QGIS 3.40 Exit-Crash absichern
Der wahrscheinlichste Auslöser ist fehlendes Signal-Cleanup beim Plugin-Unload: globale Projekt-Listener und PrintTab-Listener bleiben aktiv, während QgsProject beim QGIS-Exit bereits zerstört wird. Die empfohlene Lösung ist ein minimaler, versionsneutraler Cleanup-Pfad (disconnect + defensive Guards), ohne Fachlogik zu ändern.
## Steps
1. Teardown-Funktion für globale Verfahrensgebiet-Listener ergänzen, inkl. idempotentem Disconnect und Rücksetzen des Installationsflags.
- Datei: `sn_basis/functions/verfahrensgebiet_manager.py` (nach `setup_verfahrensgebiet_listener`, ca. Zeile 132)
- Neue Funktion `teardown_verfahrensgebiet_listener()` disconnect alle 5 QgsProject-Signale (`layersAdded`, `layerRemoved`, `readProject`, `newProjectCreated`, `cleared`) und setzt `_listener_installed = False` zurück.
- RuntimeError abfangen (Projekt wird gerade zerstört → normal).
2. Teardown in Unload-Pfad vor UI-Abbau aufrufen. (depends on step 1)
- Datei: `sn_basis/main.py` (in `BasisPlugin.unload`, ca. Zeile 52)
- Import und Aufruf von `teardown_verfahrensgebiet_listener()` als erste Zeile in `unload()`, bevor `self.ui.remove_all()` ausgeführt wird.
3. PrintTab um kleinen Projekt-Signal-Cleanup ergänzen, symmetrisch zu bestehendem Theme-Cleanup. (parallel mit step 1)
- Datei: `sn_basis/ui/tabs/print_tab.py`
- Neue Methode `cleanup()` nach `_disconnect_theme_collection_signals` (ca. Zeile 485).
- Disconnectet QgsProject-Signale (`readProject`, `newProjectCreated`, `cleared`) von `self._on_project_changed` (analog zu bestehendem Theme-Disconnect-Muster).
- Ruft danach `self._disconnect_theme_collection_signals()` auf.
4. BaseDockWidget.closeEvent so erweitern, dass Tab-Cleanup-Hooks vor Schließen aufgerufen werden. (depends on step 3)
- Datei: `sn_basis/ui/base_dockwidget.py` (in `closeEvent`, ca. Zeile 97)
- Vor dem bestehenden `self.action.setChecked(False)` Block: über alle Tabs iterieren, auf `cleanup`-Attribut prüfen, aufrufen; Exception abfangen.
5. Optional nur bei Restinstabilität: defensive Guards bei Projektzugriffen in kritischen Handlern ergänzen.
- Datei: `sn_basis/functions/variable_wrapper.py` (in `get_variable`/`set_variable`, ca. Zeile 60 und 89)
- Datei: `sn_basis/functions/verfahrensgebiet_manager.py` (in `_on_layer_removed`, ca. Zeile 114)
- `QgsProject.instance()` auf None/RuntimeError prüfen und bei Zerstörung früh zurückkehren.
## Relevant files
- `sn_basis/functions/verfahrensgebiet_manager.py` — Listener-Setup/Teardown, Handler-Guards
- `sn_basis/main.py` — Unload-Reihenfolge
- `sn_basis/ui/tabs/print_tab.py` — Projekt-Signal-Cleanup
- `sn_basis/ui/base_dockwidget.py` — zentraler Cleanup-Hook
- `sn_basis/ui/dockmanager.py` — asynchrones deleteLater als Lifecycle-Kontext
- `sn_basis/functions/variable_wrapper.py` — optional defensive Guards
## Decisions
- Include: minimaler Lifecycle-Fix über disconnect + Guards.
- Exclude: Refactoring der gesamten Signal-Architektur, größere UI-Umbauten, versionsspezifische If-Branches.
- Begründung: geringstes Risiko für Regressionen bei maximaler Wirkung gegen Shutdown-Crashs.
- Keine Änderung an Business-Logik, keine Änderung an Variablennamen, keine QGIS-versionsspezifischen Sonderpfade — rein sichere Disconnect/Guard-Muster.
## Verification
1. Plugin laden, Dock öffnen/schließen, Projekt neu laden, Layer „Verfahrensgebiet" add/remove, dann QGIS 3.40.7 beenden → kein Access Violation.
2. Dasselbe unter QGIS 4 → unverändertes Verhalten, keine Regression.
3. Mehrfacher Plugin-Reload in einer Sitzung → keine doppelten Signal-Reaktionen, keine Exceptions beim Unload.
4. Optionaler Stresstest: Unit-Test für idempotentes Teardown (mehrfaches unload ohne Exception).
## Further considerations
- Step 5 (defensive Guards in variable_wrapper) nur umsetzen, wenn nach Steps 14 noch reproduzierbare Exit-Crashs auftreten.
- Ein kleiner Unit-Test für idempotentes Teardown (mehrfaches `unload()` ohne Exception) wäre empfehlenswert, falls die Testsuite erweitert wird.
+19
View File
@@ -1,3 +1,22 @@
- VLN-API zum Laden der Plan41-Maßnahmen integriert
---
Version 26.5.2-testing:
---
Version 26.5.1-testing:
---
Version 26.5.1-unstable:
---
Version 26.4.8-unstable:
---
Version 26.4.7-unstable:
---
Version 26.4.6-unstable:
---
Version 26.4.5-unstable:
---
Version 26.4.4-unstable:
---
Version 26.4.3-unstable:
---
Version 26.04.1-unstable:
---
+6 -1
View File
@@ -1,3 +1,8 @@
"""
sn_basis/functions Wrapper und Hilfsfunktionen für QGIS-Kompatibilität.
Bietet abstrahierte Schnittstellen für Qt, QGIS und OS-Operationen.
"""
from .ly_existence_wrapper import layer_exists
from .ly_geometry_wrapper import (
get_layer_geometry_type,
@@ -15,7 +20,7 @@ from .ly_metadata_wrapper import (
is_layer_editable,
)
from .ly_style_wrapper import apply_style
from .dialog_wrapper import ask_yes_no, ask_overwrite_append_cancel_custom
from .dialog_wrapper import ask_yes_no, ask_overwrite_append_cancel_custom, ask_ergaenzen_entfernen_ersetzen_abbrechen, ask_detailpruefung_oder_vg_laden
from .message_wrapper import (
_get_message_bar,
+170
View File
@@ -128,6 +128,110 @@ def ask_overwrite_append_cancel_custom(
return "cancel"
def ask_ergaenzen_entfernen_ersetzen_abbrechen(
parent: Any,
title: str,
message: str,
) -> Literal["ergaenzen", "entfernen", "ersetzen", "abbrechen"]:
"""Zeigt Dialog mit vier Optionen für Verfahrensgebiet-Editoperationen.
Optionen:
- **Ergänzen**: ausgewählte Flurstücke zum Verfahrensgebiet hinzufügen
- **Entfernen**: ausgewählte Flurstücke aus dem Verfahrensgebiet entfernen
- **Ersetzen**: Verfahrensgebiet vollständig durch Auswahl ersetzen
- **Abbrechen**: Vorgang beenden ohne Änderung
Parameters
----------
parent :
Eltern-Widget oder None.
title : str
Dialog-Titel.
message : str
Hauptmeldung mit Erklärung.
Returns
-------
Literal["ergaenzen", "entfernen", "ersetzen", "abbrechen"]
Genaue Entscheidung des Nutzers.
"""
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_ergaenzen_entfernen_ersetzen_abbrechen('{title}') → abbrechen")
return "abbrechen"
msg = QMessageBox(parent)
msg.setIcon(ICON_QUESTION)
msg.setWindowTitle(title)
msg.setText(message)
ergaenzen_btn = msg.addButton("Ergänzen", QMessageBox.ButtonRole.ActionRole)
entfernen_btn = msg.addButton("Entfernen", QMessageBox.ButtonRole.ActionRole)
ersetzen_btn = msg.addButton("Ersetzen", QMessageBox.ButtonRole.AcceptRole)
msg.addButton("Abbrechen", QMessageBox.ButtonRole.RejectRole)
exec_dialog(msg)
clicked = msg.clickedButton()
if clicked == ergaenzen_btn:
return "ergaenzen"
elif clicked == entfernen_btn:
return "entfernen"
elif clicked == ersetzen_btn:
return "ersetzen"
else:
return "abbrechen"
def ask_detailpruefung_oder_vg_laden(
parent: Any,
title: str,
message: str,
) -> Literal["detailpruefung", "als_vg_laden", "abbrechen"]:
"""Zeigt Dialog mit drei Optionen nach erkannter Flächenabweichung.
Optionen:
- **Detailprüfung starten**: Knickpunktbasierte Prüfung des BROR-Umrings
- **Bodenordnungsobjekt als VG laden**: BROR direkt als Verfahrensgebiet übernehmen
- **Abbrechen**: Vorgang beenden ohne weitere Aktion
Parameters
----------
parent :
Eltern-Widget oder None.
title : str
Dialog-Titel.
message : str
Hauptmeldung mit Flächen-Informationen.
Returns
-------
Literal["detailpruefung", "als_vg_laden", "abbrechen"]
Genaue Entscheidung des Nutzers.
"""
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_detailpruefung_oder_vg_laden('{title}') → abbrechen")
return "abbrechen"
msg = QMessageBox(parent)
msg.setIcon(ICON_QUESTION)
msg.setWindowTitle(title)
msg.setText(message)
detail_btn = msg.addButton("Detailprüfung starten", QMessageBox.ButtonRole.AcceptRole)
vg_btn = msg.addButton("Bodenordnungsobjekt als Verfahrensgebiet laden", QMessageBox.ButtonRole.ActionRole)
msg.addButton("Abbrechen", QMessageBox.ButtonRole.RejectRole)
exec_dialog(msg)
clicked = msg.clickedButton()
if clicked == detail_btn:
return "detailpruefung"
elif clicked == vg_btn:
return "als_vg_laden"
else:
return "abbrechen"
class ProgressDialog:
def __init__(self, total: int, title: str = "Fortschritt", label: str = "Verarbeite..."):
self.total = max(total, 1)
@@ -220,7 +324,73 @@ class ProgressDialog:
if self._dlg is not None:
self._dlg.close()
def raise_to_front(self) -> None:
"""Bringt den Fortschrittsdialog in den Vordergrund.
Wird aufgerufen, nachdem ein modaler Subdialog (z.B. Auswahlbox)
geschlossen wurde und der Fortschrittsdialog wieder sichtbar sein soll.
"""
if QT_VERSION == 0:
return
if self._dlg is not None:
try:
self._dlg.raise_()
self._dlg.activateWindow()
QCoreApplication.processEvents()
except Exception:
pass
def create_progress_dialog(total: int, title: str = "Fortschritt", label: str = "Verarbeite...") -> ProgressDialog:
return ProgressDialog(total, title, label)
def ask_bror_kombination(
parent: Any,
kombinationen: list[tuple[str, str]],
) -> Optional[tuple[str, str]]:
"""Zeigt Dialog zur Auswahl einer BROR-Kombination (adv_name + bezeichnung).
Wird aufgerufen, wenn der WFS mehrere verschiedene Kombinationen aus
``adv_name`` und ``bezeichnung`` zurückliefert, sodass der Nutzer
entscheiden muss, welche Objekte in die Pipeline einfließen sollen.
Parameters
----------
parent :
Eltern-Widget oder None.
kombinationen :
Sortierte Liste von ``(adv_name, bezeichnung)``-Tupeln.
Returns
-------
Optional[tuple[str, str]]
Das gewählte ``(adv_name, bezeichnung)``-Tupel oder ``None`` bei
Abbruch bzw. leerer Eingabe.
"""
if not kombinationen:
return None
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_bror_kombination → {kombinationen[0]}")
return kombinationen[0]
items = [f"{adv} | {bez}" for adv, bez in kombinationen]
item, ok = QInputDialog.getItem(
parent,
"BROR-Objekt auswählen",
"Mehrere BauRaumOderBodenordnungsrecht-Objekte gefunden.\n"
"Bitte wählen Sie die gewünschte Kombination\n"
"aus ADV-Name und Bezeichnung:",
items,
0,
False,
)
if not ok:
return None
try:
idx = items.index(item)
return kombinationen[idx]
except ValueError:
return None
+19
View File
@@ -5,6 +5,25 @@ from sn_basis.functions.sys_wrapper import get_plugin_root, join_path
from sn_basis.modules.stilpruefer import Stilpruefer
from typing import Optional
def apply_style_from_path(layer, style_path: str) -> bool:
"""
Wendet einen Layerstil anhand eines vollständigen Pfades an.
Im Gegensatz zu :func:`apply_style` wird der Pfad extern bereitgestellt;
es findet keine eigene Stilprüfung statt.
"""
if not layer_exists(layer):
return False
try:
ok, _ = layer.loadNamedStyle(str(style_path))
if ok:
getattr(layer, "triggerRepaint", lambda: None)()
return True
except Exception:
pass
return False
def apply_style(layer, style_name: str) -> bool:
"""
Wendet einen Layerstil an, sofern er gültig ist.
+462
View File
@@ -24,16 +24,45 @@ QgsFeature: Type[Any]
QgsField: Type[Any]
QgsGeometry: Type[Any]
QgsFeatureRequest: Type[Any]
QgsRectangle: Type[Any]
QgsCoordinateTransform: Type[Any]
QgsCoordinateReferenceSystem: Type[Any]
QgsPrintLayout: Type[Any]
QgsLayoutItemMap: Type[Any]
QgsLayoutItemMapGrid: Type[Any]
QgsTextFormat: Type[Any]
QgsTextBackgroundSettings: Type[Any]
QgsLayoutItemLabel: Type[Any]
QgsLayoutItemLegend: Type[Any]
QgsLayoutPoint: Type[Any]
QgsLayoutSize: Type[Any]
QgsUnitTypes: Type[Any]
QgsLayoutItem: Type[Any]
QgsBlockingNetworkRequest: Type[Any]
QgsJsonExporter: Type[Any]
QgsJsonUtils: Type[Any]
GEOM_POINT: Any = None
GEOM_LINE: Any = None
GEOM_POLYGON: Any = None
MAP_GRID_STYLE_MARKERS: Any = None
MAP_GRID_STYLE_FRAME_ANNOTATIONS: Any = None
MAP_GRID_FRAME_STYLE_INTERIOR_TICKS: Any = None
MAP_GRID_UNIT_MAP_UNIT: Any = None
MAP_GRID_ANNOTATION_FORMAT_DECIMAL: Any = None
MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL: Any = None
MAP_GRID_ANNOTATION_DISPLAY_SHOW_ALL: Any = None
MAP_GRID_ANNOTATION_DISPLAY_SHOW_X: Any = None
MAP_GRID_ANNOTATION_DISPLAY_SHOW_Y: Any = None
MAP_GRID_BORDER_LEFT: Any = None
MAP_GRID_BORDER_RIGHT: Any = None
MAP_GRID_BORDER_TOP: Any = None
MAP_GRID_BORDER_BOTTOM: Any = None
MAP_GRID_ANNOTATION_POSITION_INSIDE_FRAME: Any = None
TEXT_BACKGROUND_SHAPE_RECTANGLE: Any = None
TEXT_BACKGROUND_SIZE_BUFFER: Any = None
TEXT_RENDER_UNIT_MILLIMETERS: Any = None
TEXT_RENDER_UNIT_POINTS: Any = None
QGIS_AVAILABLE = False
@@ -54,16 +83,23 @@ try:
QgsField as _QgsField,
QgsGeometry as _QgsGeometry,
QgsFeatureRequest as _QgsFeatureRequest,
QgsRectangle as _QgsRectangle,
QgsCoordinateTransform as _QgsCoordinateTransform,
QgsCoordinateReferenceSystem as _QgsCoordinateReferenceSystem,
QgsPrintLayout as _QgsPrintLayout,
QgsLayoutItemMap as _QgsLayoutItemMap,
QgsLayoutItemMapGrid as _QgsLayoutItemMapGrid,
QgsTextFormat as _QgsTextFormat,
QgsTextBackgroundSettings as _QgsTextBackgroundSettings,
QgsLayoutItemLabel as _QgsLayoutItemLabel,
QgsLayoutItemLegend as _QgsLayoutItemLegend,
QgsLayoutPoint as _QgsLayoutPoint,
QgsLayoutSize as _QgsLayoutSize,
QgsUnitTypes as _QgsUnitTypes,
QgsLayoutItem as _QgsLayoutItem,
QgsBlockingNetworkRequest as _QgsBlockingNetworkRequest,
QgsJsonExporter as _QgsJsonExporter,
QgsJsonUtils as _QgsJsonUtils,
)
QgsProject = _QgsProject
@@ -77,16 +113,138 @@ try:
QgsField = _QgsField
QgsGeometry = _QgsGeometry
QgsFeatureRequest = _QgsFeatureRequest
QgsRectangle = _QgsRectangle
QgsCoordinateTransform = _QgsCoordinateTransform
QgsCoordinateReferenceSystem = _QgsCoordinateReferenceSystem
QgsPrintLayout = _QgsPrintLayout
QgsLayoutItemMap = _QgsLayoutItemMap
QgsLayoutItemMapGrid = _QgsLayoutItemMapGrid
QgsTextFormat = _QgsTextFormat
QgsTextBackgroundSettings = _QgsTextBackgroundSettings
QgsLayoutItemLabel = _QgsLayoutItemLabel
QgsLayoutItemLegend = _QgsLayoutItemLegend
QgsLayoutPoint = _QgsLayoutPoint
QgsLayoutSize = _QgsLayoutSize
QgsUnitTypes = _QgsUnitTypes
QgsLayoutItem = _QgsLayoutItem
QgsBlockingNetworkRequest = _QgsBlockingNetworkRequest
QgsJsonExporter = _QgsJsonExporter
QgsJsonUtils = _QgsJsonUtils
# Geometrietyp-Konstanten (QGIS 4 / QGIS 3.30+ vs. ältere QGIS-3)
def _resolve_geom_type(*paths):
for obj, *attrs in paths:
val = obj
for attr in attrs:
val = getattr(val, attr, None)
if val is None:
break
else:
if val is not None:
return val
return None
GEOM_POINT = _resolve_geom_type(
(_Qgis, "GeometryType", "Point"),
) or getattr(__import__("qgis.core", fromlist=["QgsWkbTypes"]), "QgsWkbTypes", type("_", (), {"PointGeometry": 0})).PointGeometry
GEOM_LINE = _resolve_geom_type(
(_Qgis, "GeometryType", "Line"),
) or getattr(__import__("qgis.core", fromlist=["QgsWkbTypes"]), "QgsWkbTypes", type("_", (), {"LineGeometry": 1})).LineGeometry
GEOM_POLYGON = _resolve_geom_type(
(_Qgis, "GeometryType", "Polygon"),
) or getattr(__import__("qgis.core", fromlist=["QgsWkbTypes"]), "QgsWkbTypes", type("_", (), {"PolygonGeometry": 2})).PolygonGeometry
def _resolve_qgis_enum(*paths: tuple[Any, ...]) -> Any:
for path in paths:
value: Any = path[0]
ok = True
for name in path[1:]:
value = getattr(value, name, None)
if value is None:
ok = False
break
if ok:
return value
return None
MAP_GRID_STYLE_MARKERS = _resolve_qgis_enum(
(_Qgis, "MapGridStyle", "Markers"),
(_QgsLayoutItemMapGrid, "Markers"),
)
MAP_GRID_STYLE_FRAME_ANNOTATIONS = _resolve_qgis_enum(
(_Qgis, "MapGridStyle", "FrameAndAnnotationsOnly"),
(_QgsLayoutItemMapGrid, "FrameAndAnnotationsOnly"),
(_Qgis, "MapGridStyle", "FrameAnnotations"),
(_QgsLayoutItemMapGrid, "FrameAnnotations"),
)
MAP_GRID_FRAME_STYLE_INTERIOR_TICKS = _resolve_qgis_enum(
(_Qgis, "MapGridFrameStyle", "InteriorTicks"),
(_QgsLayoutItemMapGrid, "InteriorTicks"),
)
MAP_GRID_UNIT_MAP_UNIT = _resolve_qgis_enum(
(_Qgis, "MapGridUnit", "MapUnits"),
(_QgsLayoutItemMapGrid, "MapUnits"),
(_Qgis, "MapGridUnit", "MapUnit"),
(_QgsLayoutItemMapGrid, "MapUnit"),
)
MAP_GRID_ANNOTATION_FORMAT_DECIMAL = _resolve_qgis_enum(
(_Qgis, "MapGridAnnotationFormat", "Decimal"),
(_QgsLayoutItemMapGrid, "Decimal"),
)
MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL = _resolve_qgis_enum(
(_Qgis, "MapGridComponentVisibility", "HideAll"),
(_QgsLayoutItemMapGrid, "HideAll"),
)
MAP_GRID_ANNOTATION_DISPLAY_SHOW_ALL = _resolve_qgis_enum(
(_Qgis, "MapGridComponentVisibility", "ShowAll"),
(_QgsLayoutItemMapGrid, "ShowAll"),
)
MAP_GRID_ANNOTATION_DISPLAY_SHOW_X = _resolve_qgis_enum(
(_Qgis, "MapGridComponentVisibility", "LongitudeOnly"),
(_QgsLayoutItemMapGrid, "LongitudeOnly"),
(_Qgis, "MapGridComponentVisibility", "ShowLongitudeOnly"),
(_QgsLayoutItemMapGrid, "ShowLongitudeOnly"),
)
MAP_GRID_ANNOTATION_DISPLAY_SHOW_Y = _resolve_qgis_enum(
(_Qgis, "MapGridComponentVisibility", "LatitudeOnly"),
(_QgsLayoutItemMapGrid, "LatitudeOnly"),
(_Qgis, "MapGridComponentVisibility", "ShowLatitudeOnly"),
(_QgsLayoutItemMapGrid, "ShowLatitudeOnly"),
)
MAP_GRID_BORDER_LEFT = _resolve_qgis_enum(
(_Qgis, "MapGridBorderSide", "Left"),
(_QgsLayoutItemMapGrid, "Left"),
)
MAP_GRID_BORDER_RIGHT = _resolve_qgis_enum(
(_Qgis, "MapGridBorderSide", "Right"),
(_QgsLayoutItemMapGrid, "Right"),
)
MAP_GRID_BORDER_TOP = _resolve_qgis_enum(
(_Qgis, "MapGridBorderSide", "Top"),
(_QgsLayoutItemMapGrid, "Top"),
)
MAP_GRID_BORDER_BOTTOM = _resolve_qgis_enum(
(_Qgis, "MapGridBorderSide", "Bottom"),
(_QgsLayoutItemMapGrid, "Bottom"),
)
MAP_GRID_ANNOTATION_POSITION_INSIDE_FRAME = _resolve_qgis_enum(
(_Qgis, "MapGridAnnotationPosition", "InsideMapFrame"),
(_QgsLayoutItemMapGrid, "InsideMapFrame"),
)
TEXT_BACKGROUND_SHAPE_RECTANGLE = _resolve_qgis_enum(
(_QgsTextBackgroundSettings, "ShapeRectangle"),
)
TEXT_BACKGROUND_SIZE_BUFFER = _resolve_qgis_enum(
(_QgsTextBackgroundSettings, "SizeBuffer"),
)
TEXT_RENDER_UNIT_MILLIMETERS = _resolve_qgis_enum(
(_Qgis, "RenderUnit", "Millimeters"),
(_QgsUnitTypes, "RenderMillimeters"),
)
TEXT_RENDER_UNIT_POINTS = _resolve_qgis_enum(
(_Qgis, "RenderUnit", "Points"),
(_QgsUnitTypes, "RenderPoints"),
)
QGIS_AVAILABLE = True
@@ -208,6 +366,7 @@ except Exception:
self.follow_visibility_preset = False
self.follow_visibility_preset_name = ""
self.keep_layer_set = False
self._grids = _MockQgsLayoutItemMapGridStack(self)
def setId(self, item_id: str) -> None:
self.item_id = item_id
@@ -233,6 +392,154 @@ except Exception:
def setKeepLayerSet(self, enabled: bool) -> None:
self.keep_layer_set = enabled
def grids(self):
return self._grids
def updateBoundingRect(self) -> None:
pass
def refresh(self) -> None:
pass
class _MockQgsLayoutItemMapGrid:
Markers = "Markers"
FrameAndAnnotationsOnly = "FrameAndAnnotationsOnly"
InteriorTicks = "InteriorTicks"
MapUnits = "MapUnits"
Decimal = "Decimal"
HideAll = "HideAll"
ShowAll = "ShowAll"
LongitudeOnly = "LongitudeOnly"
LatitudeOnly = "LatitudeOnly"
Left = "Left"
Right = "Right"
Top = "Top"
Bottom = "Bottom"
InsideMapFrame = "InsideMapFrame"
def __init__(self, name: str, map_item: Any):
self.name = name
self.map_item = map_item
self.enabled = False
self.style = None
self.units = None
self.interval_x = None
self.interval_y = None
self.annotation_enabled = False
self.annotation_format = None
self.annotation_precision = None
self.annotation_display: dict[Any, Any] = {}
self.annotation_position: dict[Any, Any] = {}
self.frame_style = None
self.annotation_text_format = None
def setEnabled(self, enabled: bool) -> None:
self.enabled = enabled
def setStyle(self, style: Any) -> None:
self.style = style
def setUnits(self, units: Any) -> None:
self.units = units
def setIntervalX(self, interval: float) -> None:
self.interval_x = interval
def setIntervalY(self, interval: float) -> None:
self.interval_y = interval
def setAnnotationEnabled(self, enabled: bool) -> None:
self.annotation_enabled = enabled
def setAnnotationFormat(self, annotation_format: Any) -> None:
self.annotation_format = annotation_format
def setAnnotationPrecision(self, precision: int) -> None:
self.annotation_precision = precision
def setAnnotationDisplay(self, display: Any, border: Any) -> None:
self.annotation_display[border] = display
def setAnnotationPosition(self, position: Any, border: Any) -> None:
self.annotation_position[border] = position
def setFrameStyle(self, frame_style: Any) -> None:
self.frame_style = frame_style
def setAnnotationTextFormat(self, annotation_text_format: Any) -> None:
self.annotation_text_format = annotation_text_format
class _MockQgsTextBackgroundSettings:
ShapeRectangle = "ShapeRectangle"
SizeBuffer = "SizeBuffer"
def __init__(self):
self.enabled = False
self.shape_type = None
self.size_type = None
self.size = None
self.size_unit = None
self.offset = None
self.offset_unit = None
self.fill_color = None
def setEnabled(self, enabled: bool) -> None:
self.enabled = enabled
def setType(self, shape_type: Any) -> None:
self.shape_type = shape_type
def setSizeType(self, size_type: Any) -> None:
self.size_type = size_type
def setSize(self, size: Any) -> None:
self.size = size
def setSizeUnit(self, unit: Any) -> None:
self.size_unit = unit
def setOffset(self, offset: Any) -> None:
self.offset = offset
def setOffsetUnit(self, unit: Any) -> None:
self.offset_unit = unit
def setFillColor(self, color: Any) -> None:
self.fill_color = color
class _MockQgsTextFormat:
def __init__(self):
self.font = None
self.size = None
self.size_unit = None
self.background = None
def setFont(self, font: Any) -> None:
self.font = font
def setSize(self, size: float) -> None:
self.size = size
def setSizeUnit(self, unit: Any) -> None:
self.size_unit = unit
def setBackground(self, background: Any) -> None:
self.background = background
class _MockQgsLayoutItemMapGridStack:
def __init__(self, map_item: Any):
self.map_item = map_item
self._grids: list[_MockQgsLayoutItemMapGrid] = []
def addGrid(self, grid: Any) -> None:
self._grids.append(grid)
def removeGrid(self, grid_id: str) -> None:
self._grids = [grid for grid in self._grids if getattr(grid, "name", "") != grid_id]
def asList(self) -> list[Any]:
return list(self._grids)
class _MockQgsLayoutItemLabel:
ModeHtml = 1
@@ -254,6 +561,9 @@ except Exception:
def setReferencePoint(self, point: Any) -> None:
pass
def setVAlign(self, alignment: Any) -> None:
pass
def attemptMove(self, point: Any) -> None:
pass
@@ -338,6 +648,9 @@ except Exception:
QgsPrintLayout = _MockQgsPrintLayout
QgsLayoutItemMap = _MockQgsLayoutItemMap
QgsLayoutItemMapGrid = _MockQgsLayoutItemMapGrid
QgsTextFormat = _MockQgsTextFormat
QgsTextBackgroundSettings = _MockQgsTextBackgroundSettings
QgsLayoutItemLabel = _MockQgsLayoutItemLabel
QgsLayoutItemLegend = _MockQgsLayoutItemLegend
QgsLayoutPoint = _MockQgsLayoutPoint
@@ -345,6 +658,25 @@ except Exception:
QgsUnitTypes = _MockQgsUnitTypes
QgsLayoutItem = _MockQgsLayoutItem
MAP_GRID_STYLE_MARKERS = _MockQgsLayoutItemMapGrid.Markers
MAP_GRID_STYLE_FRAME_ANNOTATIONS = _MockQgsLayoutItemMapGrid.FrameAndAnnotationsOnly
MAP_GRID_FRAME_STYLE_INTERIOR_TICKS = _MockQgsLayoutItemMapGrid.InteriorTicks
MAP_GRID_UNIT_MAP_UNIT = _MockQgsLayoutItemMapGrid.MapUnits
MAP_GRID_ANNOTATION_FORMAT_DECIMAL = _MockQgsLayoutItemMapGrid.Decimal
MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL = _MockQgsLayoutItemMapGrid.HideAll
MAP_GRID_ANNOTATION_DISPLAY_SHOW_ALL = _MockQgsLayoutItemMapGrid.ShowAll
MAP_GRID_ANNOTATION_DISPLAY_SHOW_X = _MockQgsLayoutItemMapGrid.LongitudeOnly
MAP_GRID_ANNOTATION_DISPLAY_SHOW_Y = _MockQgsLayoutItemMapGrid.LatitudeOnly
MAP_GRID_BORDER_LEFT = _MockQgsLayoutItemMapGrid.Left
MAP_GRID_BORDER_RIGHT = _MockQgsLayoutItemMapGrid.Right
MAP_GRID_BORDER_TOP = _MockQgsLayoutItemMapGrid.Top
MAP_GRID_BORDER_BOTTOM = _MockQgsLayoutItemMapGrid.Bottom
MAP_GRID_ANNOTATION_POSITION_INSIDE_FRAME = _MockQgsLayoutItemMapGrid.InsideMapFrame
TEXT_BACKGROUND_SHAPE_RECTANGLE = _MockQgsTextBackgroundSettings.ShapeRectangle
TEXT_BACKGROUND_SIZE_BUFFER = _MockQgsTextBackgroundSettings.SizeBuffer
TEXT_RENDER_UNIT_MILLIMETERS = "Millimeters"
TEXT_RENDER_UNIT_POINTS = "Points"
class _MockQgsFeature:
def __init__(self):
self._geometry = None
@@ -480,6 +812,60 @@ except Exception:
QgsVectorFileWriter = _MockQgsVectorFileWriter
# Geometrietyp-Konstanten (Mock-Werte entsprechen QgsWkbTypes)
GEOM_POINT = 0
GEOM_LINE = 1
GEOM_POLYGON = 2
class _MockQgsBlockingNetworkRequest:
NoError = 0
def __init__(self):
self._reply = None
def get(self, request: Any) -> int:
return self.NoError
def post(self, request: Any, data: bytes) -> int:
return self.NoError
def put(self, request: Any, data: bytes) -> int:
return self.NoError
def reply(self) -> Any:
return self._reply
def errorMessage(self) -> str:
return ""
QgsBlockingNetworkRequest = _MockQgsBlockingNetworkRequest
class _MockQgsJsonUtils:
@staticmethod
def stringToFields(text: str) -> Any:
return type("_Fields", (), {"toList": lambda self: []})()
@staticmethod
def stringToFeatureList(text: str, fields: Any) -> list:
return []
QgsJsonUtils = _MockQgsJsonUtils
class _MockQgsJsonExporter:
def __init__(self, layer: Any = None):
self._layer = layer
def setSourceCrs(self, crs: Any) -> None:
pass
def setTransformGeometries(self, transform: bool) -> None:
pass
def exportFeatures(self, features: list) -> str:
return '{"type":"FeatureCollection","features":[]}'
QgsJsonExporter = _MockQgsJsonExporter
# ---------------------------------------------------------
# Netzwerk
# ---------------------------------------------------------
@@ -657,3 +1043,79 @@ def layer_exists_in_gpkg(gpkg_path: str, layer_name: str) -> bool:
pass
return False
# ---------------------------------------------------------
# QGIS-3/4-kompatible Geometrie-Hilfsfunktionen
# ---------------------------------------------------------
def polygon_zu_linie(geom: Any) -> Optional[Any]:
"""Konvertiert ein Polygon/MultiPolygon zur Umringslinie (QGIS 3 + 4).
In QGIS 3.x existiert ``QgsGeometry.boundary()``, in QGIS 4.x wurde sie
entfernt. Diese Funktion kapselt die Versionsunterschiede und stellt dem
Fachplugin eine stabile API bereit.
Strategie (dreistufig):
1. ``geom.boundary()`` funktioniert in QGIS 3.x
2. ``geom.convertToType(1, False)`` ``1`` = LineGeometry-Wert,
identisch in ``QgsWkbTypes.LineGeometry`` (3.x) und
``Qgis.GeometryType.Line`` (4.x)
3. Manuelle Ring-Extraktion via ``asPolygon()`` / ``asMultiPolygon()``
+ ``QgsGeometry.fromPolylineXY()``
Parameters
----------
geom :
Polygon- oder MultiPolygon-``QgsGeometry``.
Returns
-------
Optional[QgsGeometry]
Liniengeometrie (Umring) oder ``None`` bei Fehler / leerer Eingabe.
"""
if geom is None:
return None
try:
if geom.isEmpty():
return None
except Exception:
return None
# Stufe 1: QGIS 3.x .boundary() existiert und liefert alle Ringe korrekt
if hasattr(geom, "boundary"):
try:
result = geom.boundary()
if result is not None and not result.isEmpty():
return result
except Exception:
pass
# Stufe 2: Manuelle Ring-Extraktion (primärer QGIS-4.x-Pfad).
# convertToType(1, False) würde bei MultiPolygon nur den ersten Außenring
# zurückgeben und alle weiteren Polygone sowie Innenringe verwerfen → daher
# direkt die vollständige manuelle Extraktion als nächste Stufe.
lines = []
try:
if geom.isMultipart():
for polygon in geom.asMultiPolygon():
for ring in polygon:
lines.append(QgsGeometry.fromPolylineXY(ring))
else:
for ring in geom.asPolygon():
lines.append(QgsGeometry.fromPolylineXY(ring))
except Exception:
pass
if lines:
return QgsGeometry.unaryUnion(lines)
# Stufe 3: Letzter Fallback convertToType mit destMultipart=True, damit
# zumindest alle Außenringe eines MultiPolygons erfasst werden.
try:
result = geom.convertToType(1, True)
if result is not None and not result.isEmpty():
return result
except Exception:
pass
return None
+32 -2
View File
@@ -45,6 +45,7 @@ QComboBox: Type[Any] = object
QCheckBox: Type[Any] = object
QHBoxLayout: Type[Any] = object
QFont: Type[Any] = object
QSettings: Type[Any] = object
def exec_dialog(dialog: Any) -> Any:
@@ -99,7 +100,8 @@ try:
QUrl as _QUrl,
QCoreApplication as _QCoreApplication,
Qt as _Qt,
QVariant as _QVariant
QVariant as _QVariant,
QSettings as _QSettings,
)
from qgis.PyQt.QtNetwork import (
QNetworkRequest as _QNetworkRequest,
@@ -140,6 +142,7 @@ try:
QVariant = _QVariant
QHBoxLayout = _QHBoxLayout
QFont = _QFont
QSettings = _QSettings
# ✅ QT6 ENUMS
YES = QMessageBox.StandardButton.Yes
NO = QMessageBox.StandardButton.No
@@ -199,7 +202,8 @@ except (ImportError, AttributeError):
QUrl as _QUrl,
QCoreApplication as _QCoreApplication,
Qt as _Qt,
QVariant as _QVariant
QVariant as _QVariant,
QSettings as _QSettings,
)
from PyQt5.QtNetwork import (
QNetworkRequest as _QNetworkRequest,
@@ -238,6 +242,7 @@ except (ImportError, AttributeError):
QVariant = _QVariant
QHBoxLayout= _QHBoxLayout
QFont = _QFont
QSettings = _QSettings
# ✅ PYQT5 ENUMS
YES = QMessageBox.Yes
@@ -560,6 +565,31 @@ except (ImportError, AttributeError):
pass
QHBoxLayout = _MockQHBoxLayout
class _MockQSettings:
def __init__(self, *args, **kwargs):
self._data: dict = {}
self._group = ""
def beginGroup(self, group: str) -> None:
self._group = group
def endGroup(self) -> None:
self._group = ""
def setValue(self, key: str, value: Any) -> None:
full = "%s/%s" % (self._group, key) if self._group else key
self._data[full] = value
def value(self, key: str, default: Any = "") -> Any:
full = "%s/%s" % (self._group, key) if self._group else key
return self._data.get(full, default)
def remove(self, key: str) -> None:
full = "%s/%s" % (self._group, key) if self._group else key
self._data.pop(full, None)
QSettings = _MockQSettings
class _MockQCheckBox:
def __init__(self, text: str = "", *args, **kwargs):
self._text = text
-14
View File
@@ -1,14 +0,0 @@
mermaid´´´
flowchart TD
A[Projekt]
subgraph children[ ]
direction TB
B[src]
C[docs]
D[README.md]
end
A --> B
A --> C
A --> D
+43
View File
@@ -171,3 +171,46 @@ def setup_verfahrensgebiet_listener() -> None:
except Exception:
pass
def teardown_verfahrensgebiet_listener() -> None:
"""
Trennt globale Event-Listener vom QgsProject-Singleton.
Diese Funktion ist idempotent und kann mehrfach aufgerufen werden.
"""
global _listener_installed
if not _listener_installed:
return
try:
project = QgsProject.instance()
if project is None:
return
try:
project.layersAdded.disconnect(_on_layers_added)
except Exception:
pass
try:
project.layerRemoved.disconnect(_on_layer_removed)
except Exception:
pass
for signal_name in ("readProject", "newProjectCreated", "cleared"):
signal = getattr(project, signal_name, None)
if signal is None:
continue
try:
signal.disconnect(_on_project_changed)
except Exception:
pass
except RuntimeError:
pass
except Exception:
pass
finally:
_listener_installed = False
+6 -1
View File
@@ -4,7 +4,10 @@ from qgis.utils import plugins
from sn_basis.functions.qt_wrapper import QCoreApplication
from sn_basis.functions.sys_wrapper import getattr_safe
from sn_basis.functions.verfahrensgebiet_manager import setup_verfahrensgebiet_listener
from sn_basis.functions.verfahrensgebiet_manager import (
setup_verfahrensgebiet_listener,
teardown_verfahrensgebiet_listener,
)
from sn_basis.ui.navigation import Navigation
@@ -53,6 +56,8 @@ class BasisPlugin:
"""
Räumt UI-Komponenten sauber auf.
"""
teardown_verfahrensgebiet_listener()
if self.ui:
self.ui.remove_all()
self.ui = None
+3 -3
View File
@@ -1,12 +1,12 @@
[general]
version=26.04.1-unstable
version=26.5.2-testing
name=LNO Sachsen | Plugin Basisfunktionen
description=Plugin mit Basisfunktionen
author=Daniel Helbig, Michael Otto
homepage=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis
tracker=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/issues
repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/src/branch/unstable/
repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/src/branch/testing/
QGISMaximumVersion=4.99
qgisMinimumVersion=3.40
experimental=true
deprecated=false
+20 -2
View File
@@ -46,7 +46,7 @@ class Dateipruefer:
standarddatei: Optional[str] = None,
temporaer_erlaubt: bool = False,
*,
verfahrens_db_modus: bool = True, # 🆕 Verfahrens-DB-spezifische Logik
verfahrens_db_modus: bool = False,
) -> None:
"""
Parameters
@@ -90,6 +90,8 @@ class Dateipruefer:
def _pfad(self, relativer_pfad: str) -> Path:
"""Erzeugt OS-unabhängigen Pfad relativ zum Basisverzeichnis."""
if relativer_pfad.startswith(("/", "\\")):
return Path(relativer_pfad)
return join_path(self.basis_pfad, relativer_pfad)
@@ -152,6 +154,22 @@ class Dateipruefer:
#Excel-dateien erkennen
dateityp = self.erkenne_dateityp(pfad)
if not self.verfahrens_db_modus:
if not file_exists(pfad):
return pruef_ergebnis(
ok=False,
meldung=f"Datei '{self.pfad}' wurde nicht gefunden.",
aktion="datei_nicht_gefunden",
kontext=pfad,
)
return pruef_ergebnis(
ok=True,
meldung="Datei ist gültig.",
aktion="ok",
kontext=pfad,
)
if dateityp == "excel":
if not file_exists(pfad):
return pruef_ergebnis(
@@ -176,7 +194,7 @@ class Dateipruefer:
)
# 🆕 2.c: Ungültiger GPKG-Pfad?
if not self.verfahrens_db_modus or not self._ist_gueltiger_gpkg_pfad(pfad):
if not self._ist_gueltiger_gpkg_pfad(pfad):
return pruef_ergebnis(
ok=False,
meldung=f"Der Pfad '{self.pfad}' ist kein gültiger GPKG-Pfad.",
+40 -1
View File
@@ -1 +1,40 @@
#Datenbankpruefer.py
"""
sn_basis/modules/Datenbankpruefer.py Prüfung von Datenbanken.
Validiert Datenbank-Verbindungen und -Strukturen.
"""
from typing import Optional, Any
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
class Datenbankpruefer:
"""
Prüft Datenbankverbindungen und Datenbankstrukturen auf Gültigkeit.
Gibt pruef_ergebnis für die Weiterverarbeitung durch Pruefmanager zurück.
"""
def __init__(self, pfad: Optional[str] = None) -> None:
"""
Initialisiert den Datenbankprüfer.
Args:
pfad: Optional Pfad zur Datenbankdatei (z.B. .gpkg, .sqlite)
"""
self.pfad = pfad
def pruefe(self) -> pruef_ergebnis:
"""
Prüft die Datenbank auf Gültigkeit und Verbindbarkeit.
Returns:
pruef_ergebnis: Ergebnis der Prüfung mit ok-Flag und Aktion
"""
if not self.pfad:
return pruef_ergebnis(
ok=False,
meldung="Kein Datenbankpfad angegeben.",
aktion="kein_dateipfad",
kontext=None,
)
# Implementierung folgt
return pruef_ergebnis(ok=True, meldung="Datenbank OK", aktion="ok", kontext=self.pfad)
+63 -31
View File
@@ -5,9 +5,9 @@ sn_basis/modules/Pruefmanager.py
from __future__ import annotations
from typing import Optional, Any
from sn_basis.functions import ask_yes_no, info, warning, error, ask_overwrite_append_cancel_custom
from sn_basis.functions import ask_yes_no, info, warning, error, ask_overwrite_append_cancel_custom, ask_ergaenzen_entfernen_ersetzen_abbrechen
from sn_basis.functions.ly_visibility_wrapper import set_layer_visible
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion
print("DEBUG: Pruefmanager DATEI GELADEN:", __file__)
class Pruefmanager:
def __init__(self, ui_modus: str = "qgis", parent: Optional[Any] = None) -> None:
@@ -146,10 +146,7 @@ class Pruefmanager:
# ------------------------------------------------------------------
def _handle_basic_decision(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis:
"""Basis-Entscheidung für einfache Ja/Nein-Fragen."""
print(f"DEBUG _handle_basic_decision: aktion='{ergebnis.aktion}', ui_modus='{self.ui_modus}'")
if self.ui_modus != "qgis":
print("DEBUG: Nicht QGIS → ergebnis unverändert")
return ergebnis
title_map = {
@@ -163,27 +160,32 @@ class Pruefmanager:
meldung = ergebnis.meldung or ""
try:
print(f"DEBUG ask_yes_no: title='{title}', meldung='{meldung[:50]}...'")
yes = ask_yes_no(title, meldung, default=False, parent=self.parent)
print(f"DEBUG ask_yes_no: yes={yes}")
except Exception as e:
print(f"DEBUG ask_yes_no Exception: {e}")
except Exception:
return ergebnis
if not yes:
print("DEBUG: Nutzer sagte Nein → ok=False")
return ergebnis
# Nutzer sagte Ja
if ergebnis.aktion == "temporaer_erlaubt":
print("DEBUG: temporaer_erlaubt bestätigt → ok=True")
return pruef_ergebnis(
ok=True,
aktion="temporaer_erlaubt",
aktion="temporaer_erzeugen",
kontext=ergebnis.kontext
)
print("DEBUG: Andere Aktion bestätigt → ok=True, aktion='ok'")
if ergebnis.aktion == "layer_unsichtbar":
try:
set_layer_visible(ergebnis.kontext, True)
except Exception:
return ergebnis
return pruef_ergebnis(
ok=True,
aktion="ok",
kontext=ergebnis.kontext,
)
return pruef_ergebnis(
ok=True,
aktion="ok",
@@ -194,50 +196,80 @@ class Pruefmanager:
# Hauptlogik: verarbeite() (KORRIGIERT!)
# ------------------------------------------------------------------
def verarbeite(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis:
print("🔥 verarbeite() START")
print("DEBUG Pruefmanager:", ergebnis.ok, ergebnis.aktion)
print("DEBUG ergebnis.aktion TYPE:", type(ergebnis.aktion), repr(ergebnis.aktion))
# 1. Erfolg → direkt weiter
print("🔍 Schritt 1: Prüfe ergebnis.ok =", ergebnis.ok)
if ergebnis.ok:
print("✅ Schritt 1: ok=True → return")
return ergebnis
# 2. VERFAHRENS-DB: Bestehende Datei
print("🔍 Schritt 2: Prüfe datei_existiert =", ergebnis.aktion == "datei_existiert")
if ergebnis.aktion == "datei_existiert":
print("✅ Schritt 2: _handle_datei_existiert")
return self._handle_datei_existiert(ergebnis)
# 3. Verfahrensgebiet-Editentscheidung
if ergebnis.aktion == "verfahrensgebiet_layer_existiert":
return self._handle_verfahrensgebiet_layer_existiert(ergebnis)
# 3. Basis interaktive Aktionen
print("🔍 Schritt 3: Definiere interactive_actions")
interactive_actions = {
"leereingabe_erlaubt",
"standarddatei_vorschlagen",
"temporaer_erlaubt",
"layer_unsichtbar",
}
print("DEBUG interactive_actions:", repr(interactive_actions))
print("DEBUG ergebnis.aktion in interactive_actions?", ergebnis.aktion in interactive_actions)
if ergebnis.aktion in interactive_actions:
print("✅ Schritt 3: Interaktive Aktion → _handle_basic_decision")
decision = self._handle_basic_decision(ergebnis)
print(f"DEBUG: _handle_basic_decision Ergebnis: ok={decision.ok}, aktion='{decision.aktion}'")
return decision
return self._handle_basic_decision(ergebnis)
# 4. Fehler behandeln
print("❌ Schritt 4: FEHLER BEHANDELN")
self.report_error(
thema=ergebnis.aktion or "pruefung",
meldung=ergebnis.meldung or "",
aktion=ergebnis.aktion,
kontext=ergebnis.kontext,
)
print("🔥 verarbeite() ENDE mit ok=False")
return ergebnis
def _handle_verfahrensgebiet_layer_existiert(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis:
"""Fragt Nutzer, was mit dem bestehenden Verfahrensgebiet-Layer geschehen soll.
Zeigt einen Dialog mit vier Optionen:
- **Ergänzen**: neue Flurstücke hinzufügen
- **Entfernen**: ausgewählte Flurstücke entfernen
- **Ersetzen**: Verfahrensgebiet vollständig ersetzen
- **Abbrechen**: Vorgang beenden
Returns
-------
pruef_ergebnis
ok=True mit aktion ``verfahrensgebiet_ergaenzen`` / ``verfahrensgebiet_entfernen`` /
``verfahrensgebiet_ersetzen``, oder ok=False mit ``verfahrensgebiet_abbrechen``.
"""
if self.ui_modus != "qgis":
return ergebnis
titel = "Verfahrensgebiet bereits vorhanden"
meldung = (
ergebnis.meldung
or "Ein Verfahrensgebiet-Layer existiert bereits.\nWas soll geschehen?"
)
entscheidung = ask_ergaenzen_entfernen_ersetzen_abbrechen(
parent=self.parent,
title=titel,
message=meldung,
)
aktion_map = {
"ergaenzen": "verfahrensgebiet_ergaenzen",
"entfernen": "verfahrensgebiet_entfernen",
"ersetzen": "verfahrensgebiet_ersetzen",
"abbrechen": "verfahrensgebiet_abbrechen",
}
return pruef_ergebnis(
ok=(entscheidung != "abbrechen"),
aktion=aktion_map[entscheidung],
kontext=ergebnis.kontext,
)
def _ask_use_or_replace_pufferlayer(self) -> str:
"""
Fragt den Nutzer, ob ein vorhandener Pufferlayer verwendet
+281 -30
View File
@@ -7,16 +7,35 @@ import importlib
import math
from typing import Any
from sn_basis.functions.qt_wrapper import QFont
from sn_basis.functions.qt_wrapper import QFont, Qt
from sn_basis.functions.qgiscore_wrapper import (
MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL,
MAP_GRID_ANNOTATION_DISPLAY_SHOW_X,
MAP_GRID_ANNOTATION_DISPLAY_SHOW_Y,
MAP_GRID_ANNOTATION_FORMAT_DECIMAL,
MAP_GRID_ANNOTATION_POSITION_INSIDE_FRAME,
MAP_GRID_BORDER_BOTTOM,
MAP_GRID_BORDER_LEFT,
MAP_GRID_BORDER_RIGHT,
MAP_GRID_BORDER_TOP,
MAP_GRID_FRAME_STYLE_INTERIOR_TICKS,
MAP_GRID_STYLE_FRAME_ANNOTATIONS,
MAP_GRID_UNIT_MAP_UNIT,
TEXT_BACKGROUND_SHAPE_RECTANGLE,
TEXT_BACKGROUND_SIZE_BUFFER,
TEXT_RENDER_UNIT_MILLIMETERS,
TEXT_RENDER_UNIT_POINTS,
QgsLayoutItem,
QgsLayoutItemLabel,
QgsLayoutItemLegend,
QgsLayoutItemMap,
QgsLayoutItemMapGrid,
QgsLayoutPoint,
QgsLayoutSize,
QgsPrintLayout,
QgsProject,
QgsTextBackgroundSettings,
QgsTextFormat,
QgsUnitTypes,
)
from sn_basis.functions.qgisui_wrapper import open_layout_designer
@@ -28,6 +47,10 @@ MM = QgsUnitTypes.LayoutMillimeters
class PrintLayout:
"""Erzeugt ein QGIS-Einzelblatt-Layout fuer den Druck."""
_FRAME_STROKE_WIDTH_MM = 0.5
_LEGEND_WIDTH_MM = 180.0
_GRID_INTERVAL_MAP_UNITS = 500.0
_TEXT_FORMAT_TEMPLATES = {
"Format_1": {"font_family": "Arial", "font_size": 11, "font_style": "normal"},
"Format_2": {"font_family": "Arial", "font_size": 13, "font_style": "bold"},
@@ -61,7 +84,7 @@ class PrintLayout:
"Quelle Geobasisdaten: GeoSN, "
"<a href=\"https://www.govdata.de/dl-de/by-2-0\">dl-de/by-2-0</a><br>"
"Quelle Fachdaten: Darstellung auf der Grundlage von Daten und mit Erlaubnis des "
"Saechsischen Landesamtes fuer Umwelt, Landwirtschaft und Geologie<br>"
"Sächsischen Landesamtes für Umwelt, Landwirtschaft und Geologie<br>"
"Basemap:"
"© GeoBasis-DE / <a href=\"https://www.bkg.bund.de\">BKG</a> ([%year($now)%]) "
"<a href=\"https://creativecommons.org/licences/by/4.0/\">CC BY 4.0</a> "
@@ -197,6 +220,7 @@ class PrintLayout:
html_mode: bool = False,
expression_enabled: bool = False,
object_name: str | None = None,
v_align: str = "top",
) -> Any:
if self._current_layout is None:
raise RuntimeError("Kein Layout aktiv. Bitte create_single_page_layout() aufrufen.")
@@ -212,6 +236,7 @@ class PrintLayout:
html_mode=html_mode,
expression_enabled=expression_enabled,
object_name=object_name,
v_align=v_align,
)
def _setup_layout_objects(
@@ -220,6 +245,7 @@ class PrintLayout:
hauptkarte: Any,
map_top_mm: float,
map_height_mm: float,
kartenname_auswahl: str = "",
) -> None:
if self._current_layout is None:
raise RuntimeError("Kein Layout aktiv.")
@@ -239,14 +265,14 @@ class PrintLayout:
hauptkarte,
legende_x,
legende_y,
width_mm=180.0,
width_mm=self._LEGEND_WIDTH_MM,
height_mm=legende_height_mm,
object_name="Legende",
)
legende_hoehe_mm = self._configure_legend_size(
legende,
width_limit_mm=180.0,
width_limit_mm=self._LEGEND_WIDTH_MM,
column_count=1,
equal_column_width=False,
)
@@ -259,7 +285,7 @@ class PrintLayout:
if legende_untere_y > quellenangabe_obere_y:
self._configure_legend_size(
legende,
width_limit_mm=180.0,
width_limit_mm=self._LEGEND_WIDTH_MM,
column_count=2,
equal_column_width=True,
)
@@ -268,7 +294,7 @@ class PrintLayout:
self._QUELLENANGABE_TEXT,
quellenangabe_x,
quellenangabe_y,
width_mm=180.0,
width_mm=self._LEGEND_WIDTH_MM,
height_mm=20.0,
text_format="Format_1",
html_mode=True,
@@ -282,9 +308,9 @@ class PrintLayout:
"Kartenschild",
kartenschild_x,
kartenschild_y,
180.0,
self._LEGEND_WIDTH_MM,
92.0,
stroke_width_mm=1.0,
stroke_width_mm=self._FRAME_STROKE_WIDTH_MM,
)
kartenschild_line_x, _ = _coords_rel_untere_rechte_ecke(10.0, 0.0)
@@ -296,22 +322,27 @@ class PrintLayout:
f"Kartenschild_Linie_{index}",
kartenschild_line_x,
line_y,
180.0,
self._LEGEND_WIDTH_MM,
0.0,
stroke_width_mm=0.1,
)
if kartenname_auswahl == "§38":
herausgeber_text = "[%@sn_landkreis_user%] [%@sn_behoerde%]"
else:
herausgeber_text = "Teilnehmergemeinschaft [%@sn_bezeichnung %] [%@sn_name %]"
labels = [
("[% @sn_amt %]", 16.0, -84.0, 171.0, 4.0, "Format_1", "Herausgeber"),
(herausgeber_text, 16.0, -84.0, 171.0, 4.0, "Format_1", "Herausgeber"),
("[% @sn_bezeichnung %]", 16.0, -75.0, 171.0, 4.0, "Format_1", "Bezeichnung_Flurbereinigung"),
("[% @sn_name %]", 16.0, -64.0, 171.0, 5.0, "Format_2", "Name_Flurbereinigung"),
("[% @sn_gemeinden %]", 16.0, -56.0, 171.0, 4.0, "Format_1", "Stadt_Gemeinde"),
("[% @sn_landkreise_proj %]", 16.0, -48.0, 171.0, 4.0, "Format_1", "Landkreis"),
("[% @sn_kartenname %]", 16.0, -34.0, 137.0, 5.0, "Format_3", "Kartenname"),
("[% @sn_kartenname %]", 16.0, -32.0, 137.0, 12.0, "Format_3", "Kartenname"),
("Gefertigt: ", 16.0, -23.0, 81.0, 4.0, "Format_1", "Gefertigt"),
("Geprueft: ", 101.0, -23.0, 81.0, 4.0, "Format_1", "Geprueft"),
("Geprueft: ", 101.0, -23.0, 81.0, 4.0, "Format_1", "Geprüft"),
(
"Massstab: 1 : [%round(map_get(item_variables('Hauptkarte'),'map_scale'),0)%]",
"Maßstab: 1 : [%round(map_get(item_variables('Hauptkarte'),'map_scale'),0)%]",
16.0,
-13.0,
81.0,
@@ -323,7 +354,9 @@ class PrintLayout:
("Aktenzeichen: ", 16.0, -3.0, 171.0, 4.0, "Format_1", "Aktenzeichen"),
]
for text, rx, ry, w, h, fmt, obj_name in labels:
for entry in labels:
text, rx, ry, w, h, fmt, obj_name = entry[:7]
v_align = entry[7] if len(entry) > 7 else "top"
x, y = _coords_rel_untere_rechte_ecke(rx, ry)
self.add_text_label(
text,
@@ -334,6 +367,7 @@ class PrintLayout:
text_format=fmt,
expression_enabled=True,
object_name=obj_name,
v_align=v_align,
)
def _create_label_item(
@@ -353,6 +387,7 @@ class PrintLayout:
html_mode: bool = False,
expression_enabled: bool = False,
object_name: str | None = None,
v_align: str = "top",
) -> Any:
template = self._OBJECT_TEMPLATES[template_name]
label = QgsLayoutItemLabel(layout)
@@ -395,6 +430,25 @@ class PrintLayout:
if callable(set_reference_point) and lower_left is not None:
set_reference_point(lower_left)
align_map = {
"top": "AlignTop",
"center": "AlignVCenter",
"bottom": "AlignBottom",
}
align_attr = align_map.get(str(v_align).lower(), "AlignTop")
align_qt = getattr(Qt, align_attr, None)
if align_qt is None:
alignment_flag = getattr(Qt, "AlignmentFlag", None)
if alignment_flag is not None:
align_qt = getattr(alignment_flag, align_attr, None)
set_v_align = getattr(label, "setVAlign", None)
if callable(set_v_align) and align_qt is not None:
try:
set_v_align(align_qt)
except Exception:
pass
template_width, template_height = template.get("size_mm", (0.0, 0.0))
label.attemptMove(QgsLayoutPoint(x_mm, y_mm, MM))
label.attemptResize(
@@ -527,7 +581,7 @@ class PrintLayout:
except Exception:
pass
self._set_item_frame_stroke_width(legend, 0.3)
self._set_item_frame_stroke_width(legend, self._FRAME_STROKE_WIDTH_MM)
set_background_enabled = getattr(legend, "setBackgroundEnabled", None)
if callable(set_background_enabled):
@@ -635,20 +689,9 @@ class PrintLayout:
except Exception:
pass
width_mm, height_mm = self._get_item_size_mm(legend)
resolved_width = width_mm if width_mm is not None else width_limit_mm
resolved_height = height_mm if height_mm is not None else 20.0
final_width = min(resolved_width, width_limit_mm)
legend.attemptResize(QgsLayoutSize(final_width, resolved_height, MM))
if callable(refresh):
try:
refresh()
except Exception:
pass
_, updated_height_mm = self._get_item_size_mm(legend)
if updated_height_mm is None:
updated_height_mm = 20.0
if callable(set_locked):
try:
@@ -656,7 +699,211 @@ class PrintLayout:
except Exception:
pass
return updated_height_mm if updated_height_mm is not None else resolved_height
return updated_height_mm
def _configure_hauptkarte_grids(self, hauptkarte: Any) -> None:
grids_method = getattr(hauptkarte, "grids", None)
if not callable(grids_method):
return
try:
grid_stack = grids_method()
except Exception:
return
if grid_stack is None:
return
remove_grid = getattr(grid_stack, "removeGrid", None)
if callable(remove_grid):
for grid_id in ("SN_FrameAnnotationGrid", "SN_MarkerGrid"):
try:
remove_grid(grid_id)
except Exception:
pass
add_grid = getattr(grid_stack, "addGrid", None)
if not callable(add_grid):
return
if QgsLayoutItemMapGrid is object:
return
frame_annotation_grid = QgsLayoutItemMapGrid("SN_FrameAnnotationGrid", hauptkarte)
self._apply_grid_common_settings(frame_annotation_grid)
self._safe_grid_call(frame_annotation_grid, "setStyle", MAP_GRID_STYLE_FRAME_ANNOTATIONS)
self._safe_grid_call(frame_annotation_grid, "setFrameStyle", MAP_GRID_FRAME_STYLE_INTERIOR_TICKS)
self._safe_grid_call(frame_annotation_grid, "setAnnotationEnabled", True)
self._safe_grid_call(frame_annotation_grid, "setAnnotationFormat", MAP_GRID_ANNOTATION_FORMAT_DECIMAL)
self._safe_grid_call(frame_annotation_grid, "setAnnotationPrecision", 0)
self._safe_grid_call(
frame_annotation_grid,
"setAnnotationDisplay",
MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL,
MAP_GRID_BORDER_LEFT,
)
self._safe_grid_call(
frame_annotation_grid,
"setAnnotationDisplay",
MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL,
MAP_GRID_BORDER_TOP,
)
self._safe_grid_call(
frame_annotation_grid,
"setAnnotationDisplay",
MAP_GRID_ANNOTATION_DISPLAY_SHOW_Y,
MAP_GRID_BORDER_RIGHT,
)
self._safe_grid_call(
frame_annotation_grid,
"setAnnotationDisplay",
MAP_GRID_ANNOTATION_DISPLAY_SHOW_X,
MAP_GRID_BORDER_BOTTOM,
)
self._safe_grid_call(
frame_annotation_grid,
"setAnnotationPosition",
MAP_GRID_ANNOTATION_POSITION_INSIDE_FRAME,
MAP_GRID_BORDER_RIGHT,
)
self._safe_grid_call(
frame_annotation_grid,
"setAnnotationPosition",
MAP_GRID_ANNOTATION_POSITION_INSIDE_FRAME,
MAP_GRID_BORDER_BOTTOM,
)
self._apply_grid_annotation_text_style(frame_annotation_grid)
add_grid(frame_annotation_grid)
update_bounding_rect = getattr(hauptkarte, "updateBoundingRect", None)
if callable(update_bounding_rect):
try:
update_bounding_rect()
except Exception:
pass
refresh = getattr(hauptkarte, "refresh", None)
if callable(refresh):
try:
refresh()
except Exception:
pass
def _apply_grid_common_settings(self, grid: Any) -> None:
self._safe_grid_call(grid, "setEnabled", True)
self._safe_grid_call(grid, "setUnits", MAP_GRID_UNIT_MAP_UNIT)
self._safe_grid_call(grid, "setIntervalX", self._GRID_INTERVAL_MAP_UNITS)
self._safe_grid_call(grid, "setIntervalY", self._GRID_INTERVAL_MAP_UNITS)
def _safe_grid_call(self, grid: Any, method_name: str, *args: Any) -> None:
if any(arg is None for arg in args):
return
method = getattr(grid, method_name, None)
if not callable(method):
return
try:
method(*args)
except Exception:
pass
def _apply_grid_annotation_text_style(self, grid: Any) -> None:
if QgsTextFormat is object:
self._safe_grid_call(
grid,
"setAnnotationTextFormat",
{
"font_family": "Arial",
"font_size": 10,
"background": {
"enabled": True,
"shape": "rectangle",
"size_type": "buffer",
"buffer_mm": 1.0,
"fill_color": (255, 255, 255),
},
},
)
return
try:
text_format = QgsTextFormat()
text_format.setFont(QFont("Arial", 10))
if TEXT_RENDER_UNIT_POINTS is not None:
set_size_unit = getattr(text_format, "setSizeUnit", None)
if callable(set_size_unit):
set_size_unit(TEXT_RENDER_UNIT_POINTS)
set_size = getattr(text_format, "setSize", None)
if callable(set_size):
set_size(10)
if QgsTextBackgroundSettings is not object:
try:
qt_core = importlib.import_module("qgis.PyQt.QtCore")
qt_gui = importlib.import_module("qgis.PyQt.QtGui")
QSizeF = getattr(qt_core, "QSizeF", None)
QPointF = getattr(qt_core, "QPointF", None)
QColor = getattr(qt_gui, "QColor", None)
except Exception:
QSizeF = None
QPointF = None
QColor = None
background = QgsTextBackgroundSettings()
set_enabled = getattr(background, "setEnabled", None)
if callable(set_enabled):
set_enabled(True)
set_type = getattr(background, "setType", None)
if callable(set_type) and TEXT_BACKGROUND_SHAPE_RECTANGLE is not None:
set_type(TEXT_BACKGROUND_SHAPE_RECTANGLE)
set_size_type = getattr(background, "setSizeType", None)
if callable(set_size_type) and TEXT_BACKGROUND_SIZE_BUFFER is not None:
set_size_type(TEXT_BACKGROUND_SIZE_BUFFER)
#steuert die Größe des Hintergrunds, wenn die Größe auf "Puffer" eingestellt ist
set_size = getattr(background, "setSize", None)
if callable(set_size) and QSizeF is not None:
set_size(QSizeF(0.0, 0.0))
set_size_unit = getattr(background, "setSizeUnit", None)
if callable(set_size_unit) and TEXT_RENDER_UNIT_MILLIMETERS is not None:
set_size_unit(TEXT_RENDER_UNIT_MILLIMETERS)
set_offset = getattr(background, "setOffset", None)
if callable(set_offset):
try:
if QPointF is not None:
set_offset(QPointF(0.0, -1.0))
elif QSizeF is not None:
set_offset(QSizeF(0.0, -1.0))
else:
set_offset((0.0, -1.0))
except Exception:
set_offset_x = getattr(background, "setOffsetX", None)
set_offset_y = getattr(background, "setOffsetY", None)
if callable(set_offset_x) and callable(set_offset_y):
try:
set_offset_x(0.0)
set_offset_y(-1.0)
except Exception:
pass
set_offset_unit = getattr(background, "setOffsetUnit", None)
if callable(set_offset_unit) and TEXT_RENDER_UNIT_MILLIMETERS is not None:
set_offset_unit(TEXT_RENDER_UNIT_MILLIMETERS)
set_fill_color = getattr(background, "setFillColor", None)
if callable(set_fill_color) and QColor is not None:
set_fill_color(QColor(255, 255, 255))
set_background = getattr(text_format, "setBackground", None)
if callable(set_background):
set_background(background)
self._safe_grid_call(grid, "setAnnotationTextFormat", text_format)
except Exception:
pass
def _create_rectangle_box_item(
self,
@@ -720,6 +967,7 @@ class PrintLayout:
map_height_mm: float,
extent: Any,
plotmassstab: float,
kartenname_auswahl: str = "",
thema: str = "",
fit_extent_to_map: bool = True,
) -> Any:
@@ -776,7 +1024,7 @@ class PrintLayout:
set_frame_stroke_width = getattr(hauptkarte, "setFrameStrokeWidth", None)
if callable(set_frame_stroke_width):
try:
self._set_item_frame_stroke_width(hauptkarte, 0.5)
self._set_item_frame_stroke_width(hauptkarte, self._FRAME_STROKE_WIDTH_MM)
except Exception:
pass
@@ -813,11 +1061,14 @@ class PrintLayout:
except Exception:
pass
self._configure_hauptkarte_grids(hauptkarte)
self._setup_layout_objects(
kartenfenster_untere_rechte_ecke_mm,
hauptkarte,
map_top_mm,
map_height_mm,
kartenname_auswahl,
)
layout_manager.addLayout(layout)
+402
View File
@@ -0,0 +1,402 @@
"""
sn_basis/modules/print_layout_baufreigabe.py - Layout-Erstellung fuer Baufreigabe-Vorlagen.
Baufreigabe-Layouts verwenden immer feste DIN A3/A4 Seitengroessen im Querformat,
ein 120 mm breites Kartenschild (statt 180 mm) und erbenerben die Standard-Logik
fuer Hauptkarte, Quellenangabe und Legende vom PrintLayout.
Atlas-Integration erfolgt nach Layouterstellung durch externe Konfiguration
in der PrintLogic.
"""
from __future__ import annotations
from typing import Any
from sn_basis.functions.qt_wrapper import QFont
from sn_basis.functions.qgiscore_wrapper import (
QgsLayoutItem,
QgsLayoutItemLabel,
QgsLayoutItemLegend,
QgsLayoutItemMap,
QgsLayoutPoint,
QgsLayoutSize,
QgsPrintLayout,
QgsProject,
QgsUnitTypes,
)
from sn_basis.functions.qgisui_wrapper import open_layout_designer
from sn_basis.modules.print_layout import PrintLayout
MM = QgsUnitTypes.LayoutMillimeters
class PrintLayoutBaufreigabe(PrintLayout):
"""Erzeugt QGIS-Einzelblatt-Layouts mit fester DIN A3/A4 Querformat-Groesse und 80mm Kartenschild."""
_FRAME_STROKE_WIDTH_MM = 0.5
_KARTENSCHILD_WIDTH_MM = 80.0
_KARTENSCHILD_HEIGHT_MM = 58.0
_ATLAS_FRAME_MARGIN_MM = 10.0
_GRID_INTERVAL_MAP_UNITS = 500.0
# DIN-Formate: nur A3 und A4 im Querformat
_DIN_A3_LANDSCAPE = (420, 297) # Breite x Hoehe (Querformat)
_DIN_A4_LANDSCAPE = (297, 210) # Querformat
_TEXT_FORMAT_TEMPLATES = {
"Format_1": {"font_family": "Arial", "font_size": 11, "font_style": "normal"},
"Format_2": {"font_family": "Arial", "font_size": 13, "font_style": "bold"},
"Format_3": {"font_family": "Arial", "font_size": 16, "font_style": "bold"},
}
_OBJECT_TEMPLATES = {
"hauptkarte": {"id": "Hauptkarte", "object_name": "Hauptkarte"},
"quellenangabe": {
"id": "Quellenangabe",
"object_name": "Quellenangabe",
"size_mm": (80.0, 100.0),
"format": "Format_1",
},
"textfeld": {
"id": "Textfeld",
"object_name": "Textfeld",
"size_mm": (60.0, 8.0),
"format": "Format_1",
"html_mode": True,
"text": "",
},
"legende": {
"id": "Legende",
"object_name": "Legende",
"size_mm": (80.0, 60.0),
},
}
_QUELLENANGABE_TEXT = (
"Quelle Geobasisdaten: GeoSN, "
"<a href=\"https://www.govdata.de/dl-de/by-2-0\">dl-de/by-2-0</a><br>"
"Quelle Fachdaten: Darstellung auf der Grundlage von Daten und mit Erlaubnis des "
"Sächsischen Landesamtes für Umwelt, Landwirtschaft und Geologie<br>"
"Basemap:"
"© GeoBasis-DE / <a href=\"https://www.bkg.bund.de\">BKG</a> ([%year($now)%]) "
"<a href=\"https://creativecommons.org/licences/by/4.0/\">CC BY 4.0</a> "
"mit teilweise angepasster Signatur<br>"
)
def __init__(self, project: Any | None = None) -> None:
super().__init__(project)
def create_baufreigabe_layout(
self,
name: str,
din_format: str,
map_width_mm: float,
map_height_mm: float,
extent: Any,
plotmassstab: float,
thema: str = "",
fit_extent_to_map: bool = True,
atlas_driven: bool = True,
) -> Any:
"""Erstellt ein Baufreigabe-Layout mit fester DIN A3/A4 Querformat-Groesse.
Args:
name: Name des Layouts
din_format: "DIN A3" oder "DIN A4"
map_width_mm: Breite der Hauptkarte
map_height_mm: Hoehe der Hauptkarte
extent: QgsRectangle der Kartenausdehnung
plotmassstab: Plotmassstab (z.B. 5000 fuer 1:5000)
thema: Name des Visibility-Presets (optional)
fit_extent_to_map: Extent bei Bedarf auf das Karten-Seitenverhaeltnis erweitern
Returns:
QgsPrintLayout objektInstance (wird im Layout-Dialog geoeffnet)
Raises:
ValueError: Falls Name bereits existiert oder Format ungueltig ist
"""
# Seitenformat auswaehlen (nur A3/A4 im Querformat)
if din_format == "DIN A3":
page_width_mm, page_height_mm = self._DIN_A3_LANDSCAPE
elif din_format == "DIN A4":
page_width_mm, page_height_mm = self._DIN_A4_LANDSCAPE
else:
raise ValueError(f"Baufreigabe: Format {din_format} ist nicht unterstützt. Nur DIN A3/A4 Querformat.")
layout_manager = self.project.layoutManager()
existing_layout = layout_manager.layoutByName(name)
if existing_layout is not None:
raise ValueError(f"Eine Vorlage mit der Bezeichnung '{name}' existiert bereits.")
layout = QgsPrintLayout(self.project)
layout.initializeDefaults()
layout.setName(name)
self._current_layout = layout
page = layout.pageCollection().page(0)
page.setPageSize(QgsLayoutSize(page_width_mm, page_height_mm, MM))
map_left_mm = 10.0
map_top_mm = 10.0
map_right_mm = map_left_mm + map_width_mm
map_bottom_mm = map_top_mm + map_height_mm
kartenfenster_untere_rechte_ecke_mm = (map_right_mm, map_bottom_mm)
hauptkarte = self._create_map_item(
layout,
"hauptkarte",
map_left_mm,
map_top_mm,
map_width_mm,
map_height_mm,
)
fitted_extent = extent
if fit_extent_to_map:
fitted_extent = self._fit_extent_to_map_item(extent, map_width_mm, map_height_mm)
if fitted_extent is not None and hasattr(fitted_extent, "isNull") and callable(fitted_extent.isNull) and not fitted_extent.isNull():
try:
set_extent = getattr(hauptkarte, "setExtent", None)
if callable(set_extent):
set_extent(fitted_extent)
else:
hauptkarte.zoomToExtent(fitted_extent)
except Exception:
pass
if isinstance(plotmassstab, (int, float)) and plotmassstab > 0:
try:
hauptkarte.setScale(plotmassstab)
except Exception:
pass
# Atlas-Steuerung nur aktivieren, wenn Atlas tatsaechlich genutzt wird.
# Andernfalls bleiben Extent/Scale unveraendert (z.B. Canvas-Ausschnitt).
if atlas_driven:
set_atlas_driven_fn = getattr(hauptkarte, "setAtlasDriven", None)
if callable(set_atlas_driven_fn):
try:
set_atlas_driven_fn(True)
except Exception:
pass
set_atlas_scaling_mode = getattr(hauptkarte, "setAtlasScalingMode", None)
atlas_mode_auto = getattr(getattr(QgsLayoutItemMap, "AtlasScalingMode", object), "Auto", None)
if callable(set_atlas_scaling_mode) and atlas_mode_auto is not None:
try:
set_atlas_scaling_mode(atlas_mode_auto)
except Exception:
pass
set_atlas_margin = getattr(hauptkarte, "setAtlasMargin", None)
if callable(set_atlas_margin):
try:
set_atlas_margin(self._calc_atlas_margin_percent(map_width_mm, map_height_mm))
except Exception:
pass
set_frame_enabled = getattr(hauptkarte, "setFrameEnabled", None)
if callable(set_frame_enabled):
try:
set_frame_enabled(True)
except Exception:
pass
set_frame_stroke_width = getattr(hauptkarte, "setFrameStrokeWidth", None)
if callable(set_frame_stroke_width):
try:
self._set_item_frame_stroke_width(hauptkarte, self._FRAME_STROKE_WIDTH_MM)
except Exception:
pass
if thema and thema != "aktuell":
follow_theme = getattr(hauptkarte, "setFollowVisibilityPreset", None)
set_theme_name = getattr(hauptkarte, "setFollowVisibilityPresetName", None)
if callable(follow_theme):
try:
follow_theme(True)
except Exception:
pass
if callable(set_theme_name):
try:
set_theme_name(thema)
except Exception:
pass
set_keep_layer_set = getattr(hauptkarte, "setKeepLayerSet", None)
if callable(set_keep_layer_set):
try:
set_keep_layer_set(True)
except Exception:
pass
set_refresh_strategy = getattr(hauptkarte, "setRefreshStrategy", None)
if callable(set_refresh_strategy):
refresh_cache = (
getattr(hauptkarte, "RefreshLaterOnly", None)
or getattr(hauptkarte, "RefreshWhenRequested", None)
or 0
)
try:
set_refresh_strategy(refresh_cache)
except Exception:
pass
self._configure_hauptkarte_grids(hauptkarte)
self._setup_layout_objects_baufreigabe(
kartenfenster_untere_rechte_ecke_mm,
hauptkarte,
map_top_mm,
map_height_mm,
)
layout_manager.addLayout(layout)
self._current_layout = None
open_layout_designer(layout)
return layout
def _setup_layout_objects_baufreigabe(
self,
kartenfenster_untere_rechte_ecke_mm: tuple,
hauptkarte: Any,
map_top_mm: float,
map_height_mm: float,
) -> None:
"""Erstellt Legende, Quellenangabe und Kartenschild fuer Baufreigabe-Layout.
Angepasst fuer 80mm Kartenschild statt 180mm.
"""
if self._current_layout is None:
raise RuntimeError("Kein Layout aktiv.")
def _coords_rel_untere_rechte_ecke(rel_x_mm: float, rel_y_mm: float) -> tuple[float, float]:
return (
kartenfenster_untere_rechte_ecke_mm[0] + rel_x_mm,
kartenfenster_untere_rechte_ecke_mm[1] + rel_y_mm,
)
# Legende: 80mm Breite, hoehe adaptive wie Standard
legende_x = kartenfenster_untere_rechte_ecke_mm[0] + 10.0
legende_height_mm = max(map_height_mm - 122.0, 20.0)
legende_y = map_top_mm
legende = self._create_legend_item(
self._current_layout,
"legende",
hauptkarte,
legende_x,
legende_y,
width_mm=self._KARTENSCHILD_WIDTH_MM,
height_mm=legende_height_mm,
object_name="Legende",
)
legende_hoehe_mm = self._configure_legend_size(
legende,
width_limit_mm=self._KARTENSCHILD_WIDTH_MM,
column_count=1,
equal_column_width=False,
)
# Quellenangabe: 80mm Breite
quellenangabe_x, quellenangabe_y = _coords_rel_untere_rechte_ecke(10.0, -102.0)
quellenangabe_hoehe_mm = 20.0
quellenangabe_obere_y = quellenangabe_y - quellenangabe_hoehe_mm
legende_untere_y = legende_y + legende_hoehe_mm
if legende_untere_y > quellenangabe_obere_y:
self._configure_legend_size(
legende,
width_limit_mm=self._KARTENSCHILD_WIDTH_MM,
column_count=2,
equal_column_width=True,
)
self.add_text_label(
self._QUELLENANGABE_TEXT,
quellenangabe_x,
quellenangabe_y,
width_mm=self._KARTENSCHILD_WIDTH_MM,
height_mm=20.0,
text_format="Format_1",
html_mode=True,
expression_enabled=True,
object_name="Quellenangabe",
)
# Kartenschild: 80mm Breite
kartenschild_x, kartenschild_y = _coords_rel_untere_rechte_ecke(10.0, 0.0)
self._create_rectangle_box_item(
self._current_layout,
"Kartenschild",
kartenschild_x,
kartenschild_y,
self._KARTENSCHILD_WIDTH_MM,
self._KARTENSCHILD_HEIGHT_MM,
stroke_width_mm=self._FRAME_STROKE_WIDTH_MM,
)
# Kartenschild-Linien: 6 Trennlinien (Positionen gleich)
kartenschild_line_x, _ = _coords_rel_untere_rechte_ecke(10.0, 0.0)
kartenschild_line_offsets_y = (-10.0, -20.0, -30.0, -46.0)
for index, offset_y in enumerate(kartenschild_line_offsets_y, start=1):
_, line_y = _coords_rel_untere_rechte_ecke(0.0, offset_y)
self._create_rectangle_box_item(
self._current_layout,
f"Kartenschild_Linie_{index}",
kartenschild_line_x,
line_y,
self._KARTENSCHILD_WIDTH_MM,
0.0,
stroke_width_mm=0.1,
)
# Textfelder im Kartenschild ohne Skalierungslogik.
# Positionen werden bewusst als feste Werte gesetzt und bei Bedarf manuell angepasst.
labels = [
("Teilnehmergemeinschaft [%@sn_bezeichnung %] [%@sn_name %]", 14.0, -48.0, 74.0, 8.0, "Format_1", "Herausgeber","Bottom"),
("[% @sn_bezeichnung %]", 14.0, -41.0, 74.0, 4.0, "Format_1", "Bezeichnung_Flurbereinigung"),
("[% @sn_name %]", 14.0, -32.0, 74.0, 5.0, "Format_2", "Name_Flurbereinigung"),
("[% @sn_kartenname %]", 14.0, -23.0, 74.0, 6.0, "Format_3", "Kartenname"),
("Ausgabedatum: [%format_date(now(), 'dd.MM.yyyy')%]", 14.0, -13.0, 74.0, 4.0, "Format_1", "Ausgabedatum"),
(
"Maßstab: 1 : [%round(map_get(item_variables('Hauptkarte'),'map_scale'),0)%]",
14.0,
-3.0,
74.0,
4.0,
"Format_1",
"Massstab",
),
]
for entry in labels:
text, rx, ry, w, h, fmt, obj_name = entry[:7]
v_align = entry[7] if len(entry) > 7 else "top"
x, y = _coords_rel_untere_rechte_ecke(rx, ry)
self.add_text_label(
text,
x,
y,
width_mm=w,
height_mm=h,
text_format=fmt,
expression_enabled=True,
object_name=obj_name,
v_align=v_align,
)
def _calc_atlas_margin_percent(self, map_width_mm: float, map_height_mm: float) -> float:
"""Berechnet Atlas-Margin in Prozent, sodass etwa 10 mm Rand sichtbar bleiben."""
margin_mm = self._ATLAS_FRAME_MARGIN_MM
if map_width_mm <= 2 * margin_mm or map_height_mm <= 2 * margin_mm:
return 5.0
p_width = (100.0 * margin_mm) / (map_width_mm - 2.0 * margin_mm)
p_height = (100.0 * margin_mm) / (map_height_mm - 2.0 * margin_mm)
return max(p_width, p_height)
+680 -56
View File
@@ -4,7 +4,7 @@ sn_basis/modules/print_logic.py - Fachlogik fuer den Druck-Tab (nur Einzelblatt)
from __future__ import annotations
import importlib
from typing import Any, cast
from typing import Any, cast, TypedDict
from sn_basis.functions.qgiscore_wrapper import QgsProject, get_layer_extent
from sn_basis.functions.qgisui_wrapper import iface
@@ -12,6 +12,48 @@ from sn_basis.functions.variable_wrapper import get_variable, set_variable
from sn_basis.modules.layerpruefer import Layerpruefer
from sn_basis.modules.Pruefmanager import Pruefmanager
from sn_basis.modules.print_layout import PrintLayout
from sn_basis.modules.print_layout_baufreigabe import PrintLayoutBaufreigabe
class AtlasConfig(TypedDict, total=False):
"""Strukturierte Konfiguration fuer Atlas-Optionen.
Alle Felder sind optional (total=False) und haben vordefinierte Standartwerte.
"""
# Basis-Konfiguration
enabled: bool # Atlas aktivieren (Standard: True nach Coverage-Layer-Setzung)
page_name_expression: str # QGIS Expression für Seitennamen (z.B. "@atlas_featurenumber")
# Filterung
filter_features: bool # Features filtern? (Standard: False)
filter_expression: str # QGIS Expression für Feature-Filter
# Sortierung
sort_features: bool # Features sortieren? (Standard: False)
sort_expression: str # QGIS Expression/Feldname zum Sortieren
sort_ascending: bool # Aufsteigend sortieren? (Standard: True)
# Rendering
hide_coverage: bool # Coverage-Layer in Layout verstecken? (Standard: False)
limit_render_to_current_feature: bool # Rendering auf aktuelles Feature limitieren (QGIS 4.0+, Standard: False)
# Metadaten
description: str # Beschreibung der Atlas-Konfiguration
# Vordefinierte Standardkonfigurationen für Baufreigabe
ATLAS_CONFIG_BAUFREIGABE_DEFAULT: AtlasConfig = {
"enabled": True,
"page_name_expression": "@atlas_featurenumber",
"filter_features": False,
"filter_expression": "",
"sort_features": False,
"sort_expression": "",
"sort_ascending": True,
"hide_coverage": False,
"limit_render_to_current_feature": True,
"description": "Baufreigabe Atlas mit Standardkonfiguration",
}
KARTENNAME_VAR = "kartenname"
@@ -19,22 +61,26 @@ PLOTMASSSTAB_VAR = "plotmassstab"
VIEW_VAR = "view"
ZIELGROESSE_VAR = "zielgroesse"
FORMFAKTOR_VAR = "formfaktor"
ATLAS_ERZEUGEN_UI_VAR = "tab_b_atlas_erzeugen"
DRUCKBEREICH_VAR = "druckbereich"
DRUCKPUFFER_VAR = "druckpuffer"
DRUCKRECHTECK_LAYER_NAME = "Druckrechteck"
KARTENNAME_38 = "§38"
KARTENNAME_41 = "§41"
KARTENNAME_BAUFREIGABE = "Anlage zur Baufreigabe"
MASSSTAB_WIE_KARTENFENSTER = "Wie Kartenfenster"
THEMA_WIE_KARTENFENSTER = "wie kartenfenster"
DRUCKBEREICH_VERFAHRENSGEBIET = "Verfahrensgebiet"
DRUCKBEREICH_AKTUELLE_AUSWAHL = "aktuelle Auswahl"
DRUCKBEREICH_RECHTECK = "Rechteck"
DRUCKBEREICH_WIE_KARTENFENSTER = "Wie Kartenfenster"
KARTENNAME_BY_AUSWAHL = {
KARTENNAME_38: "Planungsuebersicht §38 FlurbG",
KARTENNAME_41: "Karte zum Plan ueber die gemeinschaftlichen und oeffentlichen Anlagen (§ 41 FlurbG)",
KARTENNAME_38: "Planungsübersicht §38 FlurbG",
KARTENNAME_41: "Karte zum Plan über die gemeinschaftlichen und öffentlichen Anlagen (§ 41 FlurbG)",
KARTENNAME_BAUFREIGABE: "Anlage zur Baufreigabe",
}
PLOTMASSSTAB_BY_AUSWAHL = {
@@ -54,8 +100,16 @@ DIN_GROESSEN: dict[str, tuple[int, int]] = {
"DIN A3": (297, 420),
"DIN A4": (210, 297),
}
DIN_AUTO_REIHENFOLGE = ("DIN A4", "DIN A3", "DIN A2", "DIN A1", "DIN A0")
DIN_STANDARD = "DIN A0"
DRUCKPUFFER_STANDARD_CM = 1
ENDLOSROLLE_MAX_BREITE_MM = 2000.0
STANDARD_LAYOUT_FIXBREITE_MM = 210.0
STANDARD_LAYOUT_FIXHOEHE_MM = 20.0
BAUFREIGABE_LAYOUT_FIXBREITE_MM = 110.0
BAUFREIGABE_LAYOUT_FIXHOEHE_MM = 20.0
STANDARD_A4_LANDSCAPE_W_MM = 297.0
STANDARD_A4_LANDSCAPE_H_MM = 210.0
class PrintLogic:
@@ -66,6 +120,21 @@ class PrintLogic:
self._rectangle_tool: Any | None = None
self._previous_map_tool: Any | None = None
def _failed_result(self) -> dict:
return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
def _show_target_size_too_small(self) -> dict:
self.pruefmanager.zeige_hinweis(
"Zielgroesse zu klein",
"Die gewaehlte Zielgroesse reicht fuer den Einzelblatt-Druck nicht aus.",
)
return self._failed_result()
def _is_enabled_flag(self, raw_value: Any) -> bool:
if raw_value in (None, ""):
return True
return str(raw_value).strip().lower() in {"1", "true", "ja", "yes", "on"}
def _resolve_layer_from_project(self) -> object | None:
project = QgsProject.instance()
# Primär: neue zentrale Variable aus sn_verfahrensgebiet_manager
@@ -253,6 +322,295 @@ class PrintLogic:
except Exception:
pass
def create_atlas_layer_from_selection(self, source_layer: object | None = None) -> tuple[object | None, str]:
"""Erstellt einen neuen Layer 'Atlaslayer' aus der Auswahl des Quell-Layers.
Args:
source_layer: Der Layer mit der Auswahl (wenn None, wird aktiver Layer verwendet)
Returns:
(created_layer, layer_id) oder (None, "") bei Fehler
"""
# Source-Layer bestimmen
if source_layer is None:
source_layer = self._resolve_active_layer()
if source_layer is None:
self.pruefmanager.zeige_hinweis(
"Atlas Layer erstellen",
"Kein Layer mit Auswahl gefunden.",
)
return None, ""
# Pruefen ob Auswahl vorhanden ist
selected_count = getattr(source_layer, "selectedFeatureCount", None)
if callable(selected_count):
try:
if int(cast(Any, selected_count())) == 0:
self.pruefmanager.zeige_hinweis(
"Atlas Layer erstellen",
"Auf dem Layer sind keine Features ausgewaehlt.",
)
return None, ""
except Exception:
pass
# Bestehenden Atlaslayer loeschen (optional Wiederverwedung)
existing_atlas = self._get_existing_layer_by_name("Atlaslayer")
if existing_atlas is not None:
try:
QgsProject.instance().removeMapLayer(existing_atlas)
except Exception:
pass
# Neuen Memory-Layer mit Geometrie-Type des Quell-Layers erstellen
qgs_vector_layer = self._import_qgis_symbol("qgis.core", "QgsVectorLayer")
if qgs_vector_layer is None:
self.pruefmanager.zeige_hinweis(
"Atlas Layer erstellen",
"QgsVectorLayer ist nicht verfuegbar.",
)
return None, ""
# CRS vom Quell-Layer übernehmen
crs_authid = "EPSG:25832"
try:
source_crs = getattr(source_layer, "crs", lambda: None)()
if source_crs is not None:
crs_authid_method = getattr(source_crs, "authid", lambda: None)
if callable(crs_authid_method):
crs_authid = crs_authid_method() or crs_authid
except Exception:
pass
# Geometrie-Type vom Quell-Layer bestimmen (vereinfacht: Polygon für Atlas)
# TODO: Intelligentere Bestimmung des Geometrie-Types
atlas_layer = qgs_vector_layer(f"Polygon?crs={crs_authid}", "Atlaslayer", "memory")
if not atlas_layer or not getattr(atlas_layer, "isValid", lambda: False)():
self.pruefmanager.zeige_hinweis(
"Atlas Layer erstellen",
"Der Atlaslayer konnte nicht erstellt werden.",
)
return None, ""
# Ausgewaehlte Features kopieren
try:
get_selected = getattr(source_layer, "selectedFeatures", None)
if not callable(get_selected):
raise Exception("selectedFeatures ist nicht aufrufbar")
selected_features = list(cast(Any, get_selected()))
if not selected_features:
raise Exception("Keine Features ausgewaehlt")
qgs_feature = self._import_qgis_symbol("qgis.core", "QgsFeature")
if qgs_feature is None:
raise Exception("QgsFeature Symbol nicht verfuegbar")
data_provider = getattr(atlas_layer, "dataProvider", lambda: None)()
if data_provider is None:
raise Exception("dataProvider nicht verfuegbar")
new_features = []
for source_feature in selected_features:
geometry = getattr(source_feature, "geometry", lambda: None)()
if geometry is None:
continue
new_feature = qgs_feature()
set_geometry = getattr(new_feature, "setGeometry", None)
if callable(set_geometry):
try:
set_geometry(geometry)
new_features.append(new_feature)
except Exception:
pass
if new_features:
add_features = getattr(data_provider, "addFeatures", None)
if callable(add_features):
add_features(new_features)
update_fields = getattr(atlas_layer, "updateFields", None)
if callable(update_fields):
update_fields()
update_extents = getattr(atlas_layer, "updateExtents", None)
if callable(update_extents):
update_extents()
except Exception as exc:
self.pruefmanager.zeige_hinweis(
"Atlas Layer erstellen",
f"Fehler beim Kopieren der Features: {exc}",
)
return None, ""
# Layer ins Projekt aufnehmen
try:
QgsProject.instance().addMapLayer(atlas_layer)
except Exception:
self.pruefmanager.zeige_hinweis(
"Atlas Layer erstellen",
"Der Layer konnte nicht ins Projekt aufgenommen werden.",
)
return None, ""
# Layer-ID speichern
layer_id = getattr(atlas_layer, "id", lambda: "")() or ""
set_variable("sn_abdeckungslayer", layer_id, scope="project")
self.pruefmanager.zeige_hinweis(
"Atlas Layer erstellen",
f"Atlas Layer 'Atlaslayer' erfolgreich erstellt.",
)
return atlas_layer, layer_id
def configure_atlas_for_baufreigabe_layout(
self,
layout_name: str = "",
atlas_config: AtlasConfig | None = None,
) -> bool:
"""Konfiguriert den Atlas fuer ein Baufreigabe-Layout mit erweiterten Optionen.
Wird nach erfolgreicher Baufreigabe-Layout-Erstellung aufgerufen.
Setzt den Coverage-Layer (aus sn_abdeckungslayer Variable) und konfiguriert den Atlas
mit allen Optionen aus der AtlasConfig.
"""
try:
if atlas_config is None:
atlas_config = ATLAS_CONFIG_BAUFREIGABE_DEFAULT
project = QgsProject.instance()
layout_manager = getattr(project, "layoutManager", lambda: None)()
if layout_manager is None:
self.pruefmanager.zeige_hinweis("Atlas konfigurieren", "Layout Manager nicht verfuegbar.")
return False
if not layout_name:
layouts_fn = getattr(layout_manager, "layouts", None)
if not callable(layouts_fn):
return False
try:
layout_iterable = layouts_fn()
if layout_iterable is None:
return False
last_layout: Any | None = None
for candidate_layout in cast(Any, layout_iterable):
last_layout = candidate_layout
if last_layout is None:
return False
layout = last_layout
except Exception:
return False
else:
layout_by_name = getattr(layout_manager, "layoutByName", None)
if not callable(layout_by_name):
return False
layout = layout_by_name(layout_name)
if layout is None:
return False
atlas = getattr(layout, "atlas", lambda: None)()
if atlas is None:
return False
atlas_enabled = bool(atlas_config.get("enabled", True))
if not atlas_enabled:
set_enabled = getattr(atlas, "setEnabled", None)
if callable(set_enabled):
try:
set_enabled(False)
except Exception:
pass
# Hauptkarte ebenfalls aus Atlas-Steuerung lösen
item_by_id = getattr(layout, "itemById", None)
if callable(item_by_id):
try:
hauptkarte = item_by_id("Hauptkarte")
set_atlas_driven = getattr(hauptkarte, "setAtlasDriven", None)
if callable(set_atlas_driven):
set_atlas_driven(False)
except Exception:
pass
return True
coverage_layer_id = get_variable("sn_abdeckungslayer", scope="project") or ""
if not coverage_layer_id:
return True
coverage_layer = project.mapLayer(coverage_layer_id)
if coverage_layer is None:
self.pruefmanager.zeige_hinweis(
"Atlas konfigurieren",
f"Atlas-Layer mit ID '{coverage_layer_id}' nicht gefunden.",
)
return False
set_coverage = getattr(atlas, "setCoverageLayer", None)
if callable(set_coverage):
set_coverage(coverage_layer)
page_name_expr = atlas_config.get("page_name_expression", "")
if page_name_expr:
set_page_name = getattr(atlas, "setPageNameExpression", None)
if callable(set_page_name):
set_page_name(page_name_expr)
if atlas_config.get("filter_features", False):
set_filter_features = getattr(atlas, "setFilterFeatures", None)
if callable(set_filter_features):
set_filter_features(True)
filter_expr = atlas_config.get("filter_expression", "")
if filter_expr:
set_filter_expr = getattr(atlas, "setFilterExpression", None)
if callable(set_filter_expr):
set_filter_expr(filter_expr)
if atlas_config.get("sort_features", False):
set_sort_features = getattr(atlas, "setSortFeatures", None)
if callable(set_sort_features):
set_sort_features(True)
sort_expr = atlas_config.get("sort_expression", "")
if sort_expr:
set_sort_expr = getattr(atlas, "setSortExpression", None)
if callable(set_sort_expr):
set_sort_expr(sort_expr)
set_sort_asc = getattr(atlas, "setSortAscending", None)
if callable(set_sort_asc):
set_sort_asc(atlas_config.get("sort_ascending", True))
set_hide_coverage = getattr(atlas, "setHideCoverage", None)
if callable(set_hide_coverage):
set_hide_coverage(atlas_config.get("hide_coverage", False))
set_limit_render = getattr(atlas, "setLimitCoverageLayerRenderToCurrentFeature", None)
if callable(set_limit_render):
try:
set_limit_render(atlas_config.get("limit_render_to_current_feature", False))
except Exception:
pass
num_features = 0
update_features = getattr(atlas, "updateFeatures", None)
if callable(update_features):
try:
num_features = update_features() or 0
except Exception:
num_features = 0
set_enabled = getattr(atlas, "setEnabled", None)
if callable(set_enabled):
set_enabled(atlas_config.get("enabled", True))
return True
except Exception as exc:
self.pruefmanager.zeige_hinweis(
"Atlas konfigurieren",
f"Fehler beim Konfigurieren des Atlas: {exc}",
)
return False
def _store_druckrechteck(self, layer: object, rectangle: Any) -> bool:
qgs_feature = self._import_qgis_symbol("qgis.core", "QgsFeature")
qgs_geometry = self._import_qgis_symbol("qgis.core", "QgsGeometry")
@@ -504,12 +862,69 @@ class PrintLogic:
return None
return self._build_rectangle(x_min, y_min, x_max, y_max)
def _get_canvas_extent(self) -> Any | None:
map_canvas_fn = getattr(iface, "mapCanvas", None)
if not callable(map_canvas_fn):
return None
try:
canvas = map_canvas_fn()
except Exception:
return None
if canvas is None:
return None
extent_fn = getattr(canvas, "extent", None)
if not callable(extent_fn):
return None
try:
extent = extent_fn()
except Exception:
return None
if extent is None:
return None
is_null = getattr(extent, "isNull", None)
if callable(is_null):
try:
if is_null():
return None
except Exception:
return None
width_fn = getattr(extent, "width", None)
height_fn = getattr(extent, "height", None)
if not callable(width_fn) or not callable(height_fn):
return None
try:
width_value = float(cast(Any, width_fn()))
height_value = float(cast(Any, height_fn()))
if width_value <= 0 or height_value <= 0:
return None
except Exception:
return None
return extent
def _resolve_extent(
self,
resolved_layer: object | None,
druckbereich_auswahl: str,
plotmassstab: float,
) -> tuple[Any | None, object | None]:
if druckbereich_auswahl == DRUCKBEREICH_WIE_KARTENFENSTER:
extent = self._get_canvas_extent()
if extent is None:
self.pruefmanager.zeige_hinweis(
"Druckbereich",
"Das aktuelle Kartenfenster liefert keine gueltige Ausdehnung.",
)
return None, None
return extent, None
if druckbereich_auswahl == DRUCKBEREICH_AKTUELLE_AUSWAHL:
selection_layer = self._resolve_selection_layer(resolved_layer)
extent = self._get_selected_extent(selection_layer)
@@ -526,7 +941,7 @@ class PrintLogic:
if extent is None:
self.activate_druckrechteck_capture(resolved_layer)
return None, None
return self._buffer_extent(extent, plotmassstab), None
return extent, None
lp = Layerpruefer(layer=resolved_layer)
ergebnis = lp.pruefe()
@@ -573,12 +988,176 @@ class PrintLogic:
def set_druckbereich_for_auswahl(self, auswahl: str) -> None:
if auswahl not in {
DRUCKBEREICH_VERFAHRENSGEBIET,
DRUCKBEREICH_WIE_KARTENFENSTER,
DRUCKBEREICH_AKTUELLE_AUSWAHL,
DRUCKBEREICH_RECHTECK,
}:
auswahl = DRUCKBEREICH_VERFAHRENSGEBIET
set_variable(DRUCKBEREICH_VAR, auswahl, scope="project")
def _get_baufreigabe_page_dimensions(
self,
required_map_width_mm: float,
required_map_height_mm: float,
max_format: str = "DIN A3",
) -> tuple[str, float, float, float, float, bool]:
"""Waehlt fuer Baufreigabe ein Blatt im Querformat.
Args:
required_map_width_mm: benoetigte Breite der Hauptkarte in mm
required_map_height_mm: benoetigte Hoehe der Hauptkarte in mm
max_format: maximale Blattgroesse, standardmaessig DIN A3
Returns:
(din_format, page_width_mm, page_height_mm, map_w_mm, map_h_mm, fallback_applied)
"""
candidates = (
("DIN A4", 297.0, 210.0),
("DIN A3", 420.0, 297.0),
)
try:
max_index = [candidate[0] for candidate in candidates].index(max_format)
except ValueError:
max_index = len(candidates) - 1
limited_candidates = candidates[: max_index + 1]
for din_format, page_w, page_h in limited_candidates:
map_w = page_w - BAUFREIGABE_LAYOUT_FIXBREITE_MM
map_h = page_h - BAUFREIGABE_LAYOUT_FIXHOEHE_MM
if map_w >= required_map_width_mm and map_h >= required_map_height_mm:
return din_format, page_w, page_h, map_w, map_h, False
# Wenn der gewuenschte Ausschnitt nicht passt, verwende das maximal erlaubte Blatt
# und erweitere die Hauptkarte auf die verfuegbare Kartenflaeche (zentriert per fit_extent_to_map).
din_format, page_w, page_h = limited_candidates[-1]
map_w = max(page_w - BAUFREIGABE_LAYOUT_FIXBREITE_MM, 50.0)
map_h = max(page_h - BAUFREIGABE_LAYOUT_FIXHOEHE_MM, 50.0)
return din_format, page_w, page_h, map_w, map_h, True
def _does_plot_fit_page(
self,
page_width_mm: float,
page_height_mm: float,
map_width_mm: float,
map_height_mm: float,
fixed_width_mm: float,
fixed_height_mm: float,
) -> bool:
required_w = map_width_mm + fixed_width_mm
required_h = map_height_mm + fixed_height_mm
return required_w <= page_width_mm and required_h <= page_height_mm
def _get_plot_dimensions_mm(
self,
map_width_mm: float,
map_height_mm: float,
fixed_width_mm: float,
fixed_height_mm: float,
) -> tuple[float, float]:
return map_width_mm + fixed_width_mm, map_height_mm + fixed_height_mm
def _get_required_map_dimensions_mm(self, extent: Any, plotmassstab: float) -> tuple[float, float]:
"""Berechnet die benoetigte Kartenfenstergroesse in mm fuer Extent und Massstab."""
if extent is None or not isinstance(plotmassstab, (int, float)) or plotmassstab <= 0:
return 0.0, 0.0
try:
width_mm = float(extent.width()) * 1000.0 / float(plotmassstab)
height_mm = float(extent.height()) * 1000.0 / float(plotmassstab)
except Exception:
return 0.0, 0.0
return max(width_mm, 0.0), max(height_mm, 0.0)
def _select_standard_page_layout(
self,
required_map_width_mm: float,
required_map_height_mm: float,
max_format: str,
) -> tuple[float, float, float, float] | None:
"""Waehlt das kleinste passende DIN-Blatt fuer das Standardlayout."""
try:
max_index = DIN_AUTO_REIHENFOLGE.index(max_format)
except ValueError:
max_index = len(DIN_AUTO_REIHENFOLGE) - 1
for fmt in DIN_AUTO_REIHENFOLGE[: max_index + 1]:
base_w, base_h = DIN_GROESSEN[fmt]
fitting_candidates: list[tuple[float, float, float, float]] = []
for page_w, page_h in ((float(base_w), float(base_h)), (float(base_h), float(base_w))):
map_w = page_w - STANDARD_LAYOUT_FIXBREITE_MM
map_h = page_h - STANDARD_LAYOUT_FIXHOEHE_MM
if map_w >= required_map_width_mm and map_h >= required_map_height_mm:
fitting_candidates.append((page_w, page_h, map_w, map_h))
if fitting_candidates:
return min(
fitting_candidates,
key=lambda candidate: (candidate[2] - required_map_width_mm) + (candidate[3] - required_map_height_mm),
)
return None
def _get_standard_layout_dimensions(
self,
required_map_width_mm: float,
required_map_height_mm: float,
max_format: str,
formfaktor: bool,
) -> tuple[float, float, float, float, bool] | None:
if formfaktor:
plotgroesse_w, plotgroesse_h = self._get_plot_dimensions_mm(
required_map_width_mm,
required_map_height_mm,
STANDARD_LAYOUT_FIXBREITE_MM,
STANDARD_LAYOUT_FIXHOEHE_MM,
)
din_dims = DIN_GROESSEN.get(max_format, DIN_GROESSEN[DIN_STANDARD])
max_page_h = float(max(din_dims))
page_w = min(plotgroesse_w, ENDLOSROLLE_MAX_BREITE_MM)
page_h = min(plotgroesse_h, max_page_h)
return self._apply_standard_minimum_a4_fallback(
page_w,
page_h,
required_map_width_mm,
required_map_height_mm,
)
selected_layout = self._select_standard_page_layout(
required_map_width_mm,
required_map_height_mm,
max_format,
)
if selected_layout is None:
return None
page_w, page_h, map_w, map_h = selected_layout
force_fit_extent = map_w > required_map_width_mm or map_h > required_map_height_mm
return page_w, page_h, map_w, map_h, force_fit_extent
def _apply_standard_minimum_a4_fallback(
self,
page_width_mm: float,
page_height_mm: float,
map_width_mm: float,
map_height_mm: float,
) -> tuple[float, float, float, float, bool]:
"""Erzwingt bei zu kleinem Standardlayout mindestens DIN A4 im Querformat.
Liefert zusätzlich ein Flag zurück, damit die Extent-Anpassung im Layout
zur zentrierten Darstellung aktiviert werden kann.
"""
fallback_page_w = max(page_width_mm, STANDARD_A4_LANDSCAPE_W_MM)
fallback_page_h = max(page_height_mm, STANDARD_A4_LANDSCAPE_H_MM)
if fallback_page_w == page_width_mm and fallback_page_h == page_height_mm:
return page_width_mm, page_height_mm, map_width_mm, map_height_mm, False
fallback_map_w = max(map_width_mm, fallback_page_w - STANDARD_LAYOUT_FIXBREITE_MM, 50.0)
fallback_map_h = max(map_height_mm, fallback_page_h - STANDARD_LAYOUT_FIXHOEHE_MM, 50.0)
return fallback_page_w, fallback_page_h, fallback_map_w, fallback_map_h, True
def druckvorlage_anlegen(
self,
layer: object | None,
@@ -590,26 +1169,34 @@ class PrintLogic:
) -> dict:
resolved_layer = layer or self._resolve_layer_from_project()
if kartenname_auswahl not in (KARTENNAME_38, KARTENNAME_41):
if kartenname_auswahl not in (KARTENNAME_38, KARTENNAME_41, KARTENNAME_BAUFREIGABE):
self.pruefmanager.zeige_hinweis("Kartennamen waehlen", "Kartennamen waehlen")
return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
return self._failed_result()
if massstab_auswahl == MASSSTAB_WIE_KARTENFENSTER:
massstab_str = get_variable(PLOTMASSSTAB_VAR, scope="project") or ""
# Maßstab direkt vom aktuellen Canvas lesen; Projektvariable als Fallback.
try:
massstab_zahl = float(massstab_str)
except (ValueError, TypeError):
canvas = iface.mapCanvas()
live_scale = canvas.scale() if canvas is not None else 0.0
massstab_zahl = float(live_scale) if live_scale > 0 else 0.0
except Exception:
massstab_zahl = 0.0
if massstab_zahl <= 0:
massstab_str = get_variable(PLOTMASSSTAB_VAR, scope="project") or ""
try:
massstab_zahl = float(massstab_str)
except (ValueError, TypeError):
massstab_zahl = 0.0
else:
massstab_zahl = float(PLOTMASSSTAB_BY_AUSWAHL.get(massstab_auswahl, 0))
if massstab_zahl <= 0:
self.pruefmanager.zeige_hinweis("Massstab fehlt", "Kein gueltiger Massstab angegeben.")
return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
return self._failed_result()
extent, layer_for_extent = self._resolve_extent(resolved_layer, druckbereich_auswahl, massstab_zahl)
if extent is None:
return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
return self._failed_result()
if layer_for_extent is not None:
layer_id = getattr(layer_for_extent, "id", lambda: "")() or ""
@@ -619,42 +1206,46 @@ class PrintLogic:
# Alt: Rückwärtskompatibilität
set_variable("verfahrensgebietslayer", layer_id, scope="project")
kartenbild_w = extent.width() * 1000.0 / massstab_zahl
kartenbild_h = extent.height() * 1000.0 / massstab_zahl
kartenbild_w, kartenbild_h = self._get_required_map_dimensions_mm(extent, massstab_zahl)
plotgroesse_w = kartenbild_w + 210.0
plotgroesse_h = kartenbild_h + 20.0
din_dims = DIN_GROESSEN.get(zielgroesse, DIN_GROESSEN[DIN_STANDARD])
din_w = float(din_dims[0])
din_h = float(din_dims[1])
din_max_h = float(max(din_dims))
if formfaktor:
ziel_w, ziel_h = plotgroesse_w, min(plotgroesse_h, din_max_h)
force_fit_extent_for_min_a4 = False
if kartenname_auswahl == KARTENNAME_BAUFREIGABE:
_, auto_page_w, auto_page_h, auto_map_w, auto_map_h, _bf_fallback = self._get_baufreigabe_page_dimensions(
kartenbild_w,
kartenbild_h,
max_format=zielgroesse if zielgroesse in {"DIN A4", "DIN A3"} else "DIN A3",
)
else:
cap_w, cap_h = din_w, din_h
if not (plotgroesse_w <= cap_w and plotgroesse_h <= cap_h):
if plotgroesse_w <= din_h and plotgroesse_h <= din_w:
cap_w, cap_h = din_h, din_w
ziel_w = min(plotgroesse_w, cap_w)
ziel_h = min(plotgroesse_h, cap_h)
if ziel_w < DIN_GROESSEN["DIN A4"][0] or ziel_h < DIN_GROESSEN["DIN A4"][1]:
self.pruefmanager.zeige_hinweis(
"Blattgroesse kleiner als DIN A4",
"Die Layouterstellung wird trotzdem fortgesetzt.",
standard_layout = self._get_standard_layout_dimensions(
kartenbild_w,
kartenbild_h,
zielgroesse,
formfaktor,
)
if standard_layout is None:
return self._show_target_size_too_small()
if plotgroesse_w > ziel_w or plotgroesse_h > ziel_h:
if plotgroesse_w <= ziel_h and plotgroesse_h <= ziel_w:
ziel_w, ziel_h = ziel_h, ziel_w
auto_page_w, auto_page_h, auto_map_w, auto_map_h, force_fit_extent_for_min_a4 = standard_layout
if plotgroesse_w > ziel_w or plotgroesse_h > ziel_h:
self.pruefmanager.zeige_hinweis(
"Zielgroesse zu klein",
"Die gewaehlte Zielgroesse reicht fuer den Einzelblatt-Druck nicht aus.",
)
return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
# Finale Sicherheitspruefung: alle Layoutobjekte muessen auf die Seite passen.
if kartenname_auswahl == KARTENNAME_BAUFREIGABE:
fixed_width_mm = BAUFREIGABE_LAYOUT_FIXBREITE_MM
fixed_height_mm = BAUFREIGABE_LAYOUT_FIXHOEHE_MM
else:
fixed_width_mm = STANDARD_LAYOUT_FIXBREITE_MM
fixed_height_mm = STANDARD_LAYOUT_FIXHOEHE_MM
fits_page = self._does_plot_fit_page(
auto_page_w,
auto_page_h,
auto_map_w,
auto_map_h,
fixed_width_mm,
fixed_height_mm,
)
if not fits_page:
return self._show_target_size_too_small()
kartenname = get_variable(KARTENNAME_VAR, scope="project") or KARTENNAME_BY_AUSWAHL.get(
kartenname_auswahl, "Vorlage"
@@ -667,30 +1258,63 @@ class PrintLogic:
default_text=kartenname,
)
if not bestaetigt:
return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
return self._failed_result()
vorlage_name = (vorlage_name or "").strip() or kartenname
# Atlas-Status vorab bestimmen, damit er sowohl an create_ als auch configure_ uebergeben wird.
atlas_raw_bf = get_variable(ATLAS_ERZEUGEN_UI_VAR, scope="project")
atlas_erzeugen_bf = self._is_enabled_flag(atlas_raw_bf)
# Verzweigung: Baufreigabe vs. Standard-Layout
try:
PrintLayout().create_single_page_layout(
name=vorlage_name,
page_width_mm=ziel_w,
page_height_mm=ziel_h,
map_width_mm=kartenbild_w,
map_height_mm=kartenbild_h,
extent=extent,
plotmassstab=massstab_zahl,
thema=thema,
fit_extent_to_map=(druckbereich_auswahl != DRUCKBEREICH_VERFAHRENSGEBIET),
)
if kartenname_auswahl == KARTENNAME_BAUFREIGABE:
# Baufreigabe: feste DIN A3/A4 Querformat-Logik mit 80mm Kartenschild
din_format = "DIN A3" if auto_page_w >= 420.0 else "DIN A4"
PrintLayoutBaufreigabe().create_baufreigabe_layout(
name=vorlage_name if vorlage_name != kartenname else "Baufreigabe",
din_format=din_format,
map_width_mm=auto_map_w,
map_height_mm=auto_map_h,
extent=extent,
plotmassstab=massstab_zahl,
thema=thema,
fit_extent_to_map=True,
atlas_driven=atlas_erzeugen_bf,
)
else:
# Standard-Layout: dynamische Groessenanpassung
PrintLayout().create_single_page_layout(
name=vorlage_name,
page_width_mm=auto_page_w,
page_height_mm=auto_page_h,
map_width_mm=auto_map_w,
map_height_mm=auto_map_h,
extent=extent,
plotmassstab=massstab_zahl,
kartenname_auswahl=kartenname_auswahl,
thema=thema,
fit_extent_to_map=(druckbereich_auswahl != DRUCKBEREICH_VERFAHRENSGEBIET) or force_fit_extent_for_min_a4,
)
except ValueError as exc:
self.pruefmanager.zeige_hinweis("Vorlage anlegen", str(exc))
return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
return self._failed_result()
except Exception as exc:
self.pruefmanager.zeige_hinweis(
"Vorlage anlegen",
f"Die Vorlage konnte nicht angelegt werden: {exc}",
)
return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
return self._failed_result()
# Nach erfolgreicher Layout-Erstellung: Atlas ggf. konfigurieren (nur falls aktiviert)
if kartenname_auswahl == KARTENNAME_BAUFREIGABE:
if atlas_erzeugen_bf:
self.configure_atlas_for_baufreigabe_layout()
else:
self.configure_atlas_for_baufreigabe_layout(
atlas_config={
"enabled": False,
}
)
return {"ok": True, "switch_to_tab_a": False, "atlas_seiten": 1}
+7
View File
@@ -74,6 +74,13 @@ PruefAktion = Literal[
"stil_not_implemented",
"datei_unbekannt",
"needs_user_action",
# Verfahrensgebiet-Editoperationen
"verfahrensgebiet_layer_existiert", # bestehender Layer → Nutzerentscheidung
"verfahrensgebiet_ergaenzen", # Flurstücke hinzufügen
"verfahrensgebiet_entfernen", # Flurstücke entfernen
"verfahrensgebiet_ersetzen", # Verfahrensgebiet vollständig ersetzen
"verfahrensgebiet_abbrechen", # Nutzer hat abgebrochen
]
+5 -4
View File
@@ -5,6 +5,7 @@ Die Anwendung erfolgt später über eine Aktion.
"""
import os
from pathlib import Path
from sn_basis.functions.os_wrapper import is_absolute_path
from sn_basis.functions.sys_wrapper import get_plugin_root, file_exists, join_path
@@ -43,7 +44,7 @@ class Stilpruefer:
pfad = str(stil_pfad)
if not is_absolute_path(pfad):
if not is_absolute_path(pfad) and not pfad.startswith(("/", "\\")):
plugin_root = get_plugin_root()
pfad = str(join_path(plugin_root, "sn_plan41", "assets", pfad))
@@ -55,7 +56,7 @@ class Stilpruefer:
ok=False,
meldung=f"Die Stil-Datei '{stil_pfad}' wurde nicht gefunden.",
aktion="datei_nicht_gefunden",
kontext=pfad,
kontext=Path(pfad),
)
# -----------------------------------------------------
@@ -66,7 +67,7 @@ class Stilpruefer:
ok=False,
meldung="Die Stil-Datei muss die Endung '.qml' haben.",
aktion="falsche_endung",
kontext=pfad,
kontext=Path(pfad),
)
# -----------------------------------------------------
@@ -76,5 +77,5 @@ class Stilpruefer:
ok=True,
meldung="Stil-Datei ist gültig.",
aktion="stil_anwendbar",
kontext=pfad,
kontext=Path(pfad),
)
+4 -1
View File
@@ -1 +1,4 @@
#Testordner
"""
sn_basis/tests Test-Suite für das sn_basis Plugin.
Enthält Unit-Tests und Integrationstests für alle Module.
"""
+127
View File
@@ -0,0 +1,127 @@
import unittest
from typing import Any, cast
from unittest.mock import patch
from sn_basis.modules.DataGrabber import DataGrabber
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
class _DummyPruefmanager:
def verarbeite(self, ergebnis: Any):
return ergebnis
class TestDataGrabber(unittest.TestCase):
def _make_grabber(self) -> DataGrabber:
return DataGrabber(pruefmanager=cast(Any, _DummyPruefmanager()))
@patch("sn_basis.modules.DataGrabber.Dateipruefer")
def test_analyze_source_type_detects_excel(self, mock_dateipruefer):
mock_dateipruefer.return_value.pruefe.return_value = pruef_ergebnis(
ok=True,
meldung="ok",
aktion="ok",
kontext="C:/tmp/list.xlsx",
)
grabber = self._make_grabber()
source_type, result = grabber.analyze_source_type("C:/tmp/list.xlsx")
self.assertEqual(source_type, "excel")
self.assertTrue(result.ok)
@patch("sn_basis.modules.DataGrabber.Dateipruefer")
def test_analyze_source_type_returns_unknown_filetype(self, mock_dateipruefer):
mock_dateipruefer.return_value.pruefe.return_value = pruef_ergebnis(
ok=True,
meldung="ok",
aktion="ok",
kontext="C:/tmp/file.txt",
)
grabber = self._make_grabber()
source_type, _result = grabber.analyze_source_type("C:/tmp/file.txt")
self.assertEqual(source_type, "unbekannter_dateityp")
@patch("sn_basis.modules.DataGrabber.Linkpruefer")
@patch("sn_basis.modules.DataGrabber.Dateipruefer")
def test_analyze_source_type_detects_service_when_file_check_fails(self, mock_dateipruefer, mock_linkpruefer):
mock_dateipruefer.return_value.pruefe.return_value = pruef_ergebnis(
ok=False,
meldung="missing",
aktion="datei_nicht_gefunden",
kontext=None,
)
mock_linkpruefer.return_value.pruefe.return_value = pruef_ergebnis(
ok=True,
meldung="ok",
aktion="url_ok",
kontext="https://example.test",
)
grabber = self._make_grabber()
source_type, result = grabber.analyze_source_type("https://example.test")
self.assertEqual(source_type, "dienst")
self.assertTrue(result.ok)
@patch("sn_basis.modules.DataGrabber.Linkpruefer")
@patch("sn_basis.modules.DataGrabber.Dateipruefer")
def test_analyze_source_type_returns_unbekannte_quelle_on_full_failure(self, mock_dateipruefer, mock_linkpruefer):
mock_dateipruefer.return_value.pruefe.return_value = pruef_ergebnis(
ok=False,
meldung="missing",
aktion="datei_nicht_gefunden",
kontext=None,
)
mock_linkpruefer.return_value.pruefe.return_value = pruef_ergebnis(
ok=False,
meldung="bad",
aktion="url_nicht_erreichbar",
kontext=None,
)
grabber = self._make_grabber()
source_type, result = grabber.analyze_source_type("broken-input")
self.assertEqual(source_type, "unbekannte_quelle")
self.assertFalse(result.ok)
@patch.object(DataGrabber, "analyze_source_type")
@patch.object(DataGrabber, "_process_excel_source")
def test_run_returns_success_summary_with_valid_rows(self, mock_process_excel, mock_analyze):
mock_analyze.return_value = (
"excel",
pruef_ergebnis(ok=True, meldung="ok", aktion="ok", kontext=None),
)
mock_process_excel.return_value = (
{"rows": [{"ident": "A1"}]},
[],
)
grabber = self._make_grabber()
source_dict, summary = grabber.run("C:/tmp/list.xlsx")
self.assertEqual(len(source_dict["rows"]), 1)
self.assertTrue(summary.ok)
self.assertEqual(summary.aktion, "ok")
@patch.object(DataGrabber, "analyze_source_type")
def test_run_returns_failure_summary_without_rows(self, mock_analyze):
mock_analyze.return_value = (
"unbekannte_quelle",
pruef_ergebnis(ok=False, meldung="bad", aktion="unbekannte_quelle", kontext=None),
)
grabber = self._make_grabber()
source_dict, summary = grabber.run("???")
self.assertEqual(source_dict, {})
self.assertFalse(summary.ok)
self.assertEqual(summary.aktion, "keine_validen_eintraege")
if __name__ == "__main__":
unittest.main()
+97
View File
@@ -2,6 +2,19 @@ import unittest
from unittest.mock import patch
from sn_basis.functions.qgiscore_wrapper import (
MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL,
MAP_GRID_ANNOTATION_DISPLAY_SHOW_X,
MAP_GRID_ANNOTATION_DISPLAY_SHOW_Y,
MAP_GRID_BORDER_BOTTOM,
MAP_GRID_BORDER_LEFT,
MAP_GRID_BORDER_RIGHT,
MAP_GRID_BORDER_TOP,
MAP_GRID_FRAME_STYLE_INTERIOR_TICKS,
MAP_GRID_STYLE_FRAME_ANNOTATIONS,
TEXT_BACKGROUND_SHAPE_RECTANGLE,
TEXT_BACKGROUND_SIZE_BUFFER,
TEXT_RENDER_UNIT_MILLIMETERS,
TEXT_RENDER_UNIT_POINTS,
QgsLayoutItemLegend,
QgsLayoutItemMap,
QgsPrintLayout,
@@ -15,6 +28,29 @@ class _FakeExtent:
return False
class _FakeLegendForResize:
def __init__(self) -> None:
self.last_resize = None
def setLocked(self, _enabled: bool) -> None:
pass
def setColumnCount(self, _count: int) -> None:
pass
def setEqualColumnWidth(self, _enabled: bool) -> None:
pass
def refresh(self) -> None:
pass
def adjustBoxSize(self) -> None:
pass
def attemptResize(self, size) -> None:
self.last_resize = size
class TestPrintLayout(unittest.TestCase):
def test_create_legend_item_links_map_and_enables_map_filtering(self):
@@ -54,6 +90,67 @@ class TestPrintLayout(unittest.TestCase):
root_group = legend.model().rootGroup()
self.assertEqual(root_group.layers, [])
def test_configure_legend_size_uses_content_adaptive_size(self):
project = QgsProject.instance()
print_layout = PrintLayout(project=project)
legend = _FakeLegendForResize()
with patch.object(print_layout, "_get_item_size_mm", return_value=(95.0, 33.0)):
height = print_layout._configure_legend_size(
legend,
width_limit_mm=180.0,
column_count=2,
equal_column_width=True,
)
self.assertIsNone(legend.last_resize)
self.assertEqual(height, 33.0)
def test_configure_hauptkarte_grids_uses_wrapper_constants(self):
project = QgsProject.instance()
print_layout = PrintLayout(project=project)
layout = QgsPrintLayout(project)
hauptkarte = QgsLayoutItemMap(layout)
print_layout._configure_hauptkarte_grids(hauptkarte)
grid_stack = hauptkarte.grids()
grids = grid_stack.asList()
self.assertEqual(len(grids), 1)
frame_grid = grids[0]
self.assertEqual(frame_grid.style, MAP_GRID_STYLE_FRAME_ANNOTATIONS)
self.assertEqual(frame_grid.frame_style, MAP_GRID_FRAME_STYLE_INTERIOR_TICKS)
self.assertTrue(frame_grid.annotation_enabled)
self.assertEqual(frame_grid.annotation_display[MAP_GRID_BORDER_LEFT], MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL)
self.assertEqual(frame_grid.annotation_display[MAP_GRID_BORDER_TOP], MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL)
self.assertEqual(frame_grid.annotation_display[MAP_GRID_BORDER_RIGHT], MAP_GRID_ANNOTATION_DISPLAY_SHOW_Y)
self.assertEqual(frame_grid.annotation_display[MAP_GRID_BORDER_BOTTOM], MAP_GRID_ANNOTATION_DISPLAY_SHOW_X)
text_format = frame_grid.annotation_text_format
self.assertIsNotNone(text_format)
self.assertEqual(getattr(text_format, "size", None), 10)
self.assertEqual(getattr(text_format, "size_unit", None), TEXT_RENDER_UNIT_POINTS)
background = getattr(text_format, "background", None)
self.assertIsNotNone(background)
self.assertTrue(getattr(background, "enabled", False))
self.assertEqual(getattr(background, "shape_type", None), TEXT_BACKGROUND_SHAPE_RECTANGLE)
self.assertEqual(getattr(background, "size_type", None), TEXT_BACKGROUND_SIZE_BUFFER)
self.assertEqual(getattr(background, "size_unit", None), TEXT_RENDER_UNIT_MILLIMETERS)
self.assertEqual(getattr(background, "offset_unit", None), TEXT_RENDER_UNIT_MILLIMETERS)
offset = getattr(background, "offset", None)
self.assertIsNotNone(offset)
if isinstance(offset, tuple):
self.assertEqual(offset[1], -1.0)
else:
y_value = getattr(offset, "y", None)
if callable(y_value):
self.assertEqual(y_value(), -1.0)
else:
self.assertEqual(getattr(offset, "y", None), -1.0)
@patch("sn_basis.modules.print_layout.open_layout_designer")
def test_create_single_page_layout_keeps_legend_linked_to_hauptkarte_and_theme(self, _mock_open_designer):
project = QgsProject.instance()
+191
View File
@@ -0,0 +1,191 @@
import unittest
from unittest.mock import MagicMock, patch
from sn_basis.modules.print_logic import (
PrintLogic,
DRUCKBEREICH_RECHTECK,
DRUCKBEREICH_WIE_KARTENFENSTER,
ENDLOSROLLE_MAX_BREITE_MM,
)
class _FakeExtent:
def __init__(self, width: float = 100.0, height: float = 80.0) -> None:
self._width = width
self._height = height
def width(self) -> float:
return self._width
def height(self) -> float:
return self._height
def isNull(self) -> bool:
return False
class _FakeCanvas:
def __init__(self, extent: object | None) -> None:
self._extent = extent
def extent(self) -> object | None:
return self._extent
class TestPrintLogic(unittest.TestCase):
def setUp(self) -> None:
self.logic = PrintLogic(pruefmanager=MagicMock())
def test_get_baufreigabe_page_dimensions_prefers_a4_for_small_extent(self):
din_format, page_w, page_h, map_w, map_h, fallback = self.logic._get_baufreigabe_page_dimensions(80.0, 120.0)
self.assertEqual(din_format, "DIN A4")
self.assertEqual((page_w, page_h), (297.0, 210.0))
self.assertEqual((map_w, map_h), (187.0, 190.0))
self.assertFalse(fallback)
def test_get_baufreigabe_page_dimensions_escalates_to_a3(self):
din_format, page_w, page_h, map_w, map_h, fallback = self.logic._get_baufreigabe_page_dimensions(200.0, 180.0)
self.assertEqual(din_format, "DIN A3")
self.assertEqual((page_w, page_h), (420.0, 297.0))
self.assertEqual((map_w, map_h), (310.0, 277.0))
self.assertFalse(fallback)
def test_get_baufreigabe_page_dimensions_respects_a4_as_maximum_with_fallback(self):
din_format, page_w, page_h, map_w, map_h, fallback = self.logic._get_baufreigabe_page_dimensions(
200.0,
180.0,
max_format="DIN A4",
)
self.assertEqual(din_format, "DIN A4")
self.assertEqual((page_w, page_h), (297.0, 210.0))
self.assertEqual((map_w, map_h), (187.0, 190.0))
self.assertTrue(fallback)
def test_get_baufreigabe_page_dimensions_falls_back_to_a3_for_oversize_extent(self):
din_format, page_w, page_h, map_w, map_h, fallback = self.logic._get_baufreigabe_page_dimensions(400.0, 300.0)
self.assertEqual(din_format, "DIN A3")
self.assertEqual((page_w, page_h), (420.0, 297.0))
self.assertEqual((map_w, map_h), (310.0, 277.0))
self.assertTrue(fallback)
def test_select_standard_page_layout_uses_a4_as_minimum_sheet(self):
selected = self.logic._select_standard_page_layout(60.0, 100.0, max_format="DIN A3")
self.assertEqual(selected, (297.0, 210.0, 87.0, 190.0))
def test_select_standard_page_layout_limits_hauptkarte_to_a3(self):
selected = self.logic._select_standard_page_layout(120.0, 240.0, max_format="DIN A3")
self.assertEqual(selected, (420.0, 297.0, 210.0, 277.0))
def test_select_standard_page_layout_returns_none_if_a3_is_too_small(self):
selected = self.logic._select_standard_page_layout(260.0, 280.0, max_format="DIN A3")
self.assertIsNone(selected)
def test_get_standard_layout_dimensions_uses_a4_and_forces_fit_for_small_extent(self):
page_w, page_h, map_w, map_h, force_fit = self.logic._get_standard_layout_dimensions(
60.0,
100.0,
max_format="DIN A3",
formfaktor=False,
)
self.assertEqual((page_w, page_h), (297.0, 210.0))
self.assertEqual((map_w, map_h), (87.0, 190.0))
self.assertTrue(force_fit)
def test_get_standard_layout_dimensions_caps_endlosrolle_width_at_2000_mm(self):
page_w, page_h, map_w, map_h, force_fit = self.logic._get_standard_layout_dimensions(
2500.0,
100.0,
max_format="DIN A0",
formfaktor=True,
)
self.assertEqual(page_w, ENDLOSROLLE_MAX_BREITE_MM)
self.assertEqual(page_h, 210.0)
self.assertEqual((map_w, map_h), (2500.0, 190.0))
self.assertTrue(force_fit)
def test_apply_standard_minimum_a4_fallback_for_small_page(self):
page_w, page_h, map_w, map_h, forced = self.logic._apply_standard_minimum_a4_fallback(
250.0,
180.0,
40.0,
20.0,
)
self.assertTrue(forced)
self.assertEqual((page_w, page_h), (297.0, 210.0))
self.assertEqual((map_w, map_h), (87.0, 190.0))
def test_apply_standard_minimum_a4_fallback_keeps_large_page(self):
page_w, page_h, map_w, map_h, forced = self.logic._apply_standard_minimum_a4_fallback(
420.0,
297.0,
210.0,
277.0,
)
self.assertFalse(forced)
self.assertEqual((page_w, page_h), (420.0, 297.0))
self.assertEqual((map_w, map_h), (210.0, 277.0))
def test_does_plot_fit_page_true_for_standard_a4(self):
fits = self.logic._does_plot_fit_page(297.0, 210.0, 87.0, 190.0, 210.0, 20.0)
self.assertTrue(fits)
def test_does_plot_fit_page_false_if_objects_exceed_page(self):
fits = self.logic._does_plot_fit_page(297.0, 210.0, 120.0, 190.0, 210.0, 20.0)
self.assertFalse(fits)
@patch("sn_basis.modules.print_logic.set_variable")
def test_set_druckbereich_accepts_wie_kartenfenster(self, mock_set_variable):
self.logic.set_druckbereich_for_auswahl(DRUCKBEREICH_WIE_KARTENFENSTER)
mock_set_variable.assert_called_once_with("druckbereich", DRUCKBEREICH_WIE_KARTENFENSTER, scope="project")
@patch("sn_basis.modules.print_logic.iface")
def test_get_canvas_extent_returns_valid_extent(self, mock_iface):
extent = _FakeExtent(120.0, 90.0)
mock_iface.mapCanvas.return_value = _FakeCanvas(extent)
result = self.logic._get_canvas_extent()
self.assertIs(result, extent)
@patch.object(PrintLogic, "_buffer_extent")
@patch.object(PrintLogic, "_get_canvas_extent")
def test_resolve_extent_uses_canvas_extent_without_buffer(self, mock_get_canvas_extent, mock_buffer_extent):
extent = _FakeExtent(120.0, 90.0)
mock_get_canvas_extent.return_value = extent
result_extent, result_layer = self.logic._resolve_extent(None, DRUCKBEREICH_WIE_KARTENFENSTER, 5000.0)
self.assertIs(result_extent, extent)
self.assertIsNone(result_layer)
mock_buffer_extent.assert_not_called()
@patch.object(PrintLogic, "_buffer_extent")
@patch.object(PrintLogic, "_get_druckrechteck_extent")
def test_resolve_extent_uses_rectangle_extent_without_buffer(self, mock_get_rect_extent, mock_buffer_extent):
extent = _FakeExtent(120.0, 90.0)
mock_get_rect_extent.return_value = extent
result_extent, result_layer = self.logic._resolve_extent(None, DRUCKBEREICH_RECHTECK, 5000.0)
self.assertIs(result_extent, extent)
self.assertIsNone(result_layer)
mock_buffer_extent.assert_not_called()
if __name__ == "__main__":
unittest.main()
+34
View File
@@ -141,6 +141,40 @@ class TestPruefmanager(unittest.TestCase):
self.assertFalse(entscheidung.ok)
self.assertEqual(entscheidung.aktion, "unbekannt")
@patch("sn_basis.modules.Pruefmanager.ask_overwrite_append_cancel_custom", return_value="overwrite")
def test_datei_existiert_overwrite(self, _mock_choice):
ergebnis = pruef_ergebnis(False, "Datei existiert", "datei_existiert", "C:/tmp/v.gpkg")
entscheidung = self.manager.verarbeite(ergebnis)
self.assertTrue(entscheidung.ok)
self.assertEqual(entscheidung.aktion, "datei_existiert_ueberschreiben")
self.assertEqual(entscheidung.kontext, "C:/tmp/v.gpkg")
@patch("sn_basis.modules.Pruefmanager.ask_overwrite_append_cancel_custom", return_value="append")
def test_datei_existiert_append(self, _mock_choice):
ergebnis = pruef_ergebnis(False, "Datei existiert", "datei_existiert", "C:/tmp/v.gpkg")
entscheidung = self.manager.verarbeite(ergebnis)
self.assertTrue(entscheidung.ok)
self.assertEqual(entscheidung.aktion, "datei_existiert_anhaengen")
@patch("sn_basis.modules.Pruefmanager.ask_overwrite_append_cancel_custom", return_value="cancel")
def test_datei_existiert_cancel(self, _mock_choice):
ergebnis = pruef_ergebnis(False, "Datei existiert", "datei_existiert", "C:/tmp/v.gpkg")
entscheidung = self.manager.verarbeite(ergebnis)
self.assertTrue(entscheidung.ok)
self.assertEqual(entscheidung.aktion, "datei_existiert_ueberspringen")
def test_datei_existiert_in_non_qgis_mode_returns_input(self):
manager = Pruefmanager(ui_modus="test")
ergebnis = pruef_ergebnis(False, "Datei existiert", "datei_existiert", "C:/tmp/v.gpkg")
entscheidung = manager.verarbeite(ergebnis)
self.assertFalse(entscheidung.ok)
self.assertEqual(entscheidung.aktion, "datei_existiert")
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -55,6 +55,32 @@ class TestSettingsLogic(unittest.TestCase):
self.assertIn("gemeinden", saved_keys)
self.assertNotIn("unbekannt", saved_keys)
@patch("sn_basis.functions.settings_logic.get_variable")
def test_load_handles_partial_values_with_empty_fallback(self, mock_get):
values = {
"amt": "A1",
"behoerde": "",
"landkreis_user": "L1",
}
mock_get.side_effect = lambda key, scope="project": values.get(key, "")
logic = SettingsLogic()
daten = logic.load()
self.assertEqual(daten["amt"], "A1")
self.assertEqual(daten["behoerde"], "")
self.assertEqual(daten["landkreis_user"], "L1")
self.assertEqual(daten["sachgebiet"], "")
@patch("sn_basis.functions.settings_logic.set_variable")
def test_save_empty_string_value_is_persisted(self, mock_set):
logic = SettingsLogic()
logic.save({"amt": "", "behoerde": "B2"})
mock_set.assert_any_call("amt", "", scope="project")
mock_set.assert_any_call("behoerde", "B2", scope="project")
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -51,6 +51,28 @@ class TestVariableWrapper(unittest.TestCase):
"new_value",
)
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.setGlobalVariable")
def test_set_variable_global_writes_prefixed_name(self, mock_set_global):
variable_wrapper.set_variable("verfahrensnummer", "123", scope="global")
mock_set_global.assert_called_once_with("sn_verfahrensnummer", "123")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.globalScope")
def test_get_variable_global_returns_empty_string_fallback(self, mock_global_scope):
mock_global_scope.return_value = _Scope("")
value = variable_wrapper.get_variable("nicht_vorhanden", scope="global")
self.assertEqual(value, "")
def test_get_variable_invalid_scope_raises(self):
with self.assertRaises(ValueError):
variable_wrapper.get_variable("foo", scope="invalid")
def test_set_variable_invalid_scope_raises(self):
with self.assertRaises(ValueError):
variable_wrapper.set_variable("foo", "bar", scope="invalid")
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -0,0 +1,4 @@
"""
sn_basis/ui Benutzeroberflächen-Module für sn_basis.
Enthält Dock-Manager, Navigation und einzelne Tabs.
"""
+14
View File
@@ -99,6 +99,20 @@ class BaseDockWidget(QDockWidget):
Wird aufgerufen, wenn das Dock geschlossen wird.
Setzt die zugehörige Toolbar-Action zurück.
"""
try:
tab_widget = self.widget()
if tab_widget and hasattr(tab_widget, "count") and hasattr(tab_widget, "widget"):
for index in range(tab_widget.count()):
tab = tab_widget.widget(index)
cleanup = getattr(tab, "cleanup", None)
if callable(cleanup):
try:
cleanup()
except Exception:
pass
except Exception:
pass
try:
if self.action:
self.action.setChecked(False)
+8 -3
View File
@@ -1,4 +1,9 @@
from sn_basis.ui.tabs.print_tab import PrintTab
from sn_basis.ui.tabs.settings_tab import SettingsTab
"""
sn_basis/ui/tabs Tab-Widgets für die sn_basis Benutzeroberfläche.
Stellt print_tab und settings_tab bereit.
"""
__all__ = ["PrintTab", "SettingsTab"]
# from sn_basis.ui.tabs.print_tab import PrintTab
# from sn_basis.ui.tabs.settings_tab import SettingsTab
# __all__ = ["PrintTab", "SettingsTab"]
+263 -7
View File
@@ -13,9 +13,13 @@ from sn_basis.functions.qt_wrapper import (
QCheckBox,
QHBoxLayout,
QPushButton,
QToolButton,
ToolButtonTextBesideIcon,
ArrowDown,
ArrowRight,
)
from sn_basis.functions.qgiscore_wrapper import QgsProject
from sn_basis.functions.qgisui_wrapper import iface
from sn_basis.functions.qgiscore_wrapper import QgsProject, QgsMapLayerProxyModel
from sn_basis.functions.qgisui_wrapper import iface, QgsMapLayerComboBox
from sn_basis.functions.variable_wrapper import get_variable, set_variable
from sn_basis.modules.Pruefmanager import Pruefmanager
from sn_basis.modules.DataGrabber import DataGrabber
@@ -30,6 +34,7 @@ from sn_basis.modules.print_logic import (
DRUCKPUFFER_VAR,
DRUCKPUFFER_STANDARD_CM,
DRUCKBEREICH_VERFAHRENSGEBIET,
DRUCKBEREICH_WIE_KARTENFENSTER,
DRUCKBEREICH_AKTUELLE_AUSWAHL,
DRUCKBEREICH_RECHTECK,
)
@@ -39,12 +44,15 @@ KARTENNAME_VAR = "tab_b_kartenname"
KARTENNAME_PLACEHOLDER = "Kartenname waehlen"
KARTENNAME_38 = "§38"
KARTENNAME_41 = "§41"
KARTENNAME_BAUFREIGABE = "Anlage zur Baufreigabe"
MASSSTAB_VAR = "tab_b_massstab"
THEMA_VAR = "tab_b_thema"
ZIELGROESSE_UI_VAR = "tab_b_zielgroesse"
FORMFAKTOR_UI_VAR = "tab_b_formfaktor"
ATLAS_ERZEUGEN_VAR = "tab_b_atlas_erzeugen"
DRUCKBEREICH_OPTIONEN = [
DRUCKBEREICH_VERFAHRENSGEBIET,
DRUCKBEREICH_WIE_KARTENFENSTER,
DRUCKBEREICH_AKTUELLE_AUSWAHL,
DRUCKBEREICH_RECHTECK,
]
@@ -57,9 +65,11 @@ class PrintTab(QWidget):
def __init__(self, parent: Optional[Any] = None):
super().__init__(parent)
self.pruefmanager: Optional[Pruefmanager] = None
self.logic: Optional[PrintLogic] = None
self.logic = PrintLogic(self.pruefmanager)
#self.logic: Optional[PrintLogic] = None
self._kartenname_combo: Optional[Any] = None
self._massstab_combo: Optional[Any] = None
self._thema_combo: Optional[Any] = None
@@ -70,6 +80,11 @@ class PrintTab(QWidget):
self._endlosrolle_cb: Optional[Any] = None
self._druckpuffer_button: Optional[Any] = None
self._btn_vorlage_erstellen: Optional[Any] = None
self._atlas_layer_combo: Optional[Any] = None
self._btn_auswahl_as_atlas: Optional[Any] = None
self._atlas_erzeugen_cb: Optional[Any] = None
self._atlas_button: Optional[Any] = None
self._atlas_content: Optional[Any] = None
self._build_ui()
self._restore_state()
@@ -110,6 +125,7 @@ class PrintTab(QWidget):
kartenname_combo.addItem(KARTENNAME_PLACEHOLDER)
kartenname_combo.addItem(KARTENNAME_38)
kartenname_combo.addItem(KARTENNAME_41)
kartenname_combo.addItem(KARTENNAME_BAUFREIGABE)
kartenname_combo.currentTextChanged.connect(self._on_kartenname_changed)
self._kartenname_combo = kartenname_combo
main_layout.addWidget(kartenname_combo)
@@ -191,6 +207,55 @@ class PrintTab(QWidget):
main_layout.addLayout(druckbereich_row)
# Atlas-Bereich (einklappbar, analog Tab A)
atlas_button = QToolButton()
atlas_button.setText("Atlas")
atlas_button.setCheckable(True)
atlas_button.setChecked(False)
atlas_button.setToolButtonStyle(ToolButtonTextBesideIcon)
atlas_button.setArrowType(ArrowRight)
atlas_button.setStyleSheet("font-weight: bold; margin-top: 6px;")
atlas_button.toggled.connect(self._toggle_atlas)
self._atlas_button = atlas_button
main_layout.addWidget(atlas_button)
atlas_content = QWidget()
atlas_layout = QVBoxLayout()
atlas_layout.setSpacing(4)
atlas_layout.setContentsMargins(10, 4, 4, 4)
atlas_erzeugen_cb = QCheckBox("Atlas erzeugen", self)
atlas_erzeugen_cb.setChecked(True)
atlas_erzeugen_cb.stateChanged.connect(self._on_atlas_erzeugen_changed)
self._atlas_erzeugen_cb = atlas_erzeugen_cb
atlas_layout.addWidget(atlas_erzeugen_cb)
atlas_layer_label = QLabel("Abdeckungslayer", self)
atlas_layout.addWidget(atlas_layer_label)
atlas_layer_combo = QgsMapLayerComboBox(self)
set_filters = getattr(atlas_layer_combo, "setFilters", None)
if callable(set_filters):
set_filters(QgsMapLayerProxyModel.VectorLayer)
layer_changed = getattr(atlas_layer_combo, "layerChanged", None)
if layer_changed is not None:
try:
layer_changed.connect(self._on_atlas_layer_changed)
except Exception:
pass
self._atlas_layer_combo = atlas_layer_combo
atlas_layout.addWidget(atlas_layer_combo)
btn_auswahl_as_atlas = QPushButton("Auswahl als Atlas anlegen", self)
btn_auswahl_as_atlas.clicked.connect(self._on_auswahl_as_atlas_clicked)
self._btn_auswahl_as_atlas = btn_auswahl_as_atlas
atlas_layout.addWidget(btn_auswahl_as_atlas)
atlas_content.setLayout(atlas_layout)
atlas_content.setVisible(False)
self._atlas_content = atlas_content
main_layout.addWidget(atlas_content)
btn_vorlage_erstellen = QPushButton("Vorlage erstellen", self)
btn_vorlage_erstellen.clicked.connect(self._on_vorlage_erstellen)
self._btn_vorlage_erstellen = btn_vorlage_erstellen
@@ -208,7 +273,7 @@ class PrintTab(QWidget):
return
saved_kartenname = get_variable(KARTENNAME_VAR, scope="project")
if saved_kartenname in (KARTENNAME_38, KARTENNAME_41):
if saved_kartenname in (KARTENNAME_38, KARTENNAME_41, KARTENNAME_BAUFREIGABE):
self._kartenname_combo.setCurrentText(saved_kartenname)
else:
self._kartenname_combo.setCurrentText(KARTENNAME_PLACEHOLDER)
@@ -252,12 +317,30 @@ class PrintTab(QWidget):
druckpuffer_cm = DRUCKPUFFER_STANDARD_CM
self._druckpuffer_button.setText(self._format_druckpuffer_label(max(0, druckpuffer_cm)))
if self._atlas_erzeugen_cb:
atlas_raw = get_variable(ATLAS_ERZEUGEN_VAR, scope="project")
atlas_checked = True if atlas_raw in (None, "") else str(atlas_raw).strip().lower() in {
"1",
"true",
"ja",
"yes",
"on",
}
self._atlas_erzeugen_cb.setChecked(atlas_checked)
self._on_atlas_erzeugen_changed(1 if atlas_checked else 0)
self._restore_atlas_layer_selection()
def _on_kartenname_changed(self, value: str) -> None:
if value in (KARTENNAME_38, KARTENNAME_41):
if value in (KARTENNAME_38, KARTENNAME_41, KARTENNAME_BAUFREIGABE):
set_variable(KARTENNAME_VAR, value, scope="project")
else:
set_variable(KARTENNAME_VAR, "", scope="project")
# Atlas-Bereich automatisch ein-/ausklappen je nach Kartenname.
if self._atlas_button:
self._atlas_button.setChecked(value == KARTENNAME_BAUFREIGABE)
if self.logic:
self.logic.set_kartenname_for_auswahl(value)
@@ -291,6 +374,21 @@ class PrintTab(QWidget):
if value == DRUCKBEREICH_RECHTECK:
self.logic.activate_druckrechteck_capture()
def _on_atlas_erzeugen_changed(self, state: int) -> None:
enabled = bool(state)
set_variable(ATLAS_ERZEUGEN_VAR, "1" if enabled else "0", scope="project")
if self._atlas_layer_combo:
set_enabled = getattr(self._atlas_layer_combo, "setEnabled", None)
if callable(set_enabled):
try:
set_enabled(enabled)
except Exception:
pass
if self._btn_auswahl_as_atlas:
self._btn_auswahl_as_atlas.setEnabled(enabled)
def _on_druckpuffer_clicked(self) -> None:
if not self.pruefmanager:
return
@@ -340,8 +438,38 @@ class PrintTab(QWidget):
druckbereich_auswahl=self._druckbereich_combo.currentText() if self._druckbereich_combo else DRUCKBEREICH_VERFAHRENSGEBIET,
)
def _disconnect_project_signals(self) -> None:
try:
project = QgsProject.instance()
except Exception:
return
if project is None:
return
for signal_name in ("readProject", "newProjectCreated", "cleared"):
signal = getattr(project, signal_name, None)
if signal is None:
continue
try:
signal.disconnect(self._on_project_changed)
except Exception:
pass
def cleanup(self) -> None:
"""Trennt Signalverbindungen vor dem Schliessen des Tabs."""
self._disconnect_theme_collection_signals()
self._disconnect_project_signals()
def _connect_project_signals(self) -> None:
project = QgsProject.instance()
try:
project = QgsProject.instance()
except Exception:
return
if project is None:
return
for signal_name in ("readProject", "newProjectCreated", "cleared"):
signal = getattr(project, signal_name, None)
if signal is None:
@@ -358,6 +486,36 @@ class PrintTab(QWidget):
self._connect_theme_collection_signals()
self._restore_state()
def _toggle_atlas(self, checked: bool) -> None:
if self._atlas_button:
self._atlas_button.setArrowType(ArrowDown if checked else ArrowRight)
if self._atlas_content:
self._atlas_content.setVisible(checked)
def _restore_atlas_layer_selection(self) -> None:
if not self._atlas_layer_combo:
return
layer_id = (get_variable("sn_abdeckungslayer", scope="project") or "").strip()
if not layer_id:
return
try:
layer = QgsProject.instance().mapLayer(layer_id)
if layer is None:
return
set_current_layer = getattr(self._atlas_layer_combo, "setLayer", None)
if callable(set_current_layer):
set_current_layer(layer)
return
set_current_layer = getattr(self._atlas_layer_combo, "setCurrentLayer", None)
if callable(set_current_layer):
set_current_layer(layer)
except Exception:
pass
def _disconnect_theme_collection_signals(self) -> None:
collection = self._connected_theme_collection
if collection is not None:
@@ -450,3 +608,101 @@ class PrintTab(QWidget):
return float(scale) if scale else None
except Exception:
return None
def _on_atlas_layer_changed(self, layer_or_name: Any) -> None:
"""Speichert den ausgewaehlten Atlas-Layer als Layer-ID in Projektvariablen."""
if layer_or_name is None or layer_or_name == "":
set_variable("sn_abdeckungslayer", "", scope="project")
return
# Standardfall QgsMapLayerComboBox: echtes Layer-Objekt
layer_id_getter = getattr(layer_or_name, "id", None)
if callable(layer_id_getter):
layer_id = layer_id_getter() or ""
set_variable("sn_abdeckungslayer", layer_id, scope="project")
return
# Fallback: ggf. Name aus Mock/anderen Widgets
value = str(layer_or_name).strip()
if not value:
set_variable("sn_abdeckungslayer", "", scope="project")
return
try:
project = QgsProject.instance()
map_layers = project.mapLayers()
for layer_id, layer in map_layers.items():
if getattr(layer, "name", lambda: "")() == value:
set_variable("sn_abdeckungslayer", layer_id, scope="project")
return
except Exception:
pass
def _on_auswahl_as_atlas_clicked(self) -> None:
"""Handler fuer Button 'Auswahl als Atlas anlegen'.
Erstellt einen neuen Layer 'Atlaslayer' aus der aktuellen Feature-Auswahl
und traegt ihn ins Atlas-Dropdown ein.
"""
if not self.logic or not self.pruefmanager:
return
atlas_layer, layer_id = self.logic.create_atlas_layer_from_selection()
if atlas_layer is None or not layer_id:
return
if self._atlas_button and self._atlas_content:
self._atlas_button.setChecked(True)
self._toggle_atlas(True)
# Neu erstellten Layer im Layer-Combo setzen
if self._atlas_layer_combo:
set_current_layer = getattr(self._atlas_layer_combo, "setLayer", None)
if callable(set_current_layer):
try:
set_current_layer(atlas_layer)
return
except Exception:
pass
set_current_layer = getattr(self._atlas_layer_combo, "setCurrentLayer", None)
if callable(set_current_layer):
try:
set_current_layer(atlas_layer)
return
except Exception:
pass
# Fallback nur fuer Mock/klassische ComboBox
self._populate_atlas_layer_combo()
def _populate_atlas_layer_combo(self) -> None:
"""Fallback-Befuellung, falls kein echtes QgsMapLayerComboBox Verhalten verfuegbar ist."""
if not self._atlas_layer_combo:
return
clear_fn = getattr(self._atlas_layer_combo, "clear", None)
add_item_fn = getattr(self._atlas_layer_combo, "addItem", None)
if not callable(clear_fn) or not callable(add_item_fn):
return
self._atlas_layer_combo.blockSignals(True)
clear_fn()
add_item_fn("")
try:
project = QgsProject.instance()
map_layers = getattr(project, "mapLayers", None)
if not callable(map_layers):
self._atlas_layer_combo.blockSignals(False)
return
layers = map_layers().values() if hasattr(map_layers(), "values") else map_layers()
for layer in layers:
layer_name = getattr(layer, "name", lambda: None)()
if layer_name and layer_name not in ("", "Druckrechteck"):
add_item_fn(layer_name)
except Exception:
pass
self._atlas_layer_combo.blockSignals(False)
+19 -1
View File
@@ -1,4 +1,8 @@
#sn_basis/ui/tabs/settings_tab.py
"""
sn_basis/ui/tabs/settings_tab.py Einstellungen-Tab für Projekteinformationen.
Verwaltet benutzerspezifische und projektspezifische Einstellungen.
"""
from sn_basis.functions.qt_wrapper import (
QWidget,
QGridLayout,
@@ -12,9 +16,23 @@ from sn_basis.functions.settings_logic import SettingsLogic
class SettingsTab(QWidget):
"""
Tab-Widget für die Verwaltung von Projekt- und Benutzereinstellungen.
Attributes:
tab_title: Anzeigetitel des Tabs ("Projekteigenschaften")
logic: SettingsLogic-Instanz für persistente Einstellungen
"""
tab_title = "Projekteigenschaften"
def __init__(self, parent=None):
"""
Initialisiert den Settings-Tab mit Eingabefeldern.
Args:
parent: Übergeordnetes Widget (Standard: None)
"""
super().__init__(parent)
self.logic = SettingsLogic()