Compare commits

..
Author SHA1 Message Date
Michael Otto 4a7196ebb9 Fehler unter 3.44 behoben 2026-04-22 11:14:22 +02:00
Michael Otto 543ba605a7 cleanup: Debug-Logging entfernt, Legende-Fix finalisiert
- setResizeToContents(False) deaktiviert Auto-Fit auf Legende korrekt
- fitBoxToContents() + attemptResize() sizing
- Debug-Ausgaben entfernt
- Legende mit fester Breite (Kartenschild), dynamischer Höhe (Inhalt)

Funktioniert für alle Templates: standard (180mm), baufreigabe (80mm), custom.
2026-04-22 10:41:14 +02:00
Michael Otto 730c013799 fix: setResizeToContents(False) statt setResizeMode()
Die korrekten Methoden heißen setResizeToContents() und resizeToContents(),
nicht setResizeMode().

setResizeToContents(False) deaktiviert die Auto-Fit-Eigenschaft vollstaendig.
2026-04-22 10:34:38 +02:00
Michael Otto d1b2ee80bb fix: setResizeMode(0) deaktiviert Auto-Fit auf Legende
KRITISCHER FIX:
- setResizeMode(0) NACH setLocked(False) aufgerufen
  (0 = Ignore, deaktiviert Auto-Sizing auf Inhalt vollstaendig)
- Fallback zu setAutoFitToContents(False) falls setResizeMode nicht verfuegbar
- Debug-Logging aller verfuegbaren Legend-Methoden beim Erstellen

Das Problem war: Die Legende hatte 'Größe an Inhalt anpassen' aktiviert,
was ALL unsere manuellen Resize-Operationen ueberschrieben hat.
Jetzt: Resize-Mode auf 'Ignore', dann _fitBoxToContents() und _attemptResize().
2026-04-22 10:32:25 +02:00
Michael Otto 1bc6c90559 fix: Legende-Sizing mit fitBoxToContents() statt adjustBoxSize()
- Initiale Legende-Hoehe auf 50mm gesenkt (statt dynamisch berechnet)
- fitBoxToContents() als Primary-Methode fuer Legende-Auto-Sizing
- adjustBoxSize() nur als Fallback wenn fitBoxToContents() nicht verfuegbar
- Logging erweitert um zu zeigen welche Methode verwendet wurde

Problem war: adjustBoxSize() veraendert die Legende-Hoehe nicht.
fitBoxToContents() ist die korrekte Methode fuer QgsLayoutItemLegend.
2026-04-22 10:28:32 +02:00
Michael Otto 34d422b807 debug: umfassendes Logging fuer Legende-Groessenberechnung
Alle Schritte in _configure_legend_size() loggen:
- Parameter am Start
- setLocked/setColumnCount/refresh/adjustBoxSize Aufrufe
- Groessen vor und nach adjustBoxSize
- attemptResize Aufruf mit neuen Dimensionen
- Callback-Fehler

Plus Logging vor/nach erster und zweiter _configure_legend_size() Aufrufe
um den gesamten Legende-Setup-Flow zu tracken.
2026-04-22 10:23:08 +02:00
Michael Otto d50d10f830 fix: zweite Legende-Höhen-Berechnung auch verwenden
Der zweite _configure_legend_size()-Aufruf (2-spaltig) gab eine neue
Höhe zurück, diese wurde aber nicht in legende_hoehe_mm gespeichert.
Jetzt wird die aktualisierte Höhe korrekt captured und verwendet.
2026-04-22 10:20:53 +02:00
Michael Otto d4552b5afe fix: Legende-Breite in _configure_legend_size() einschränken
Die Methode wird multiple Male aufgerufen, jedes Mal wird adjustBoxSize()
auf die Breite angewendet. Nach _get_item_size_mm() wird die Breite jetzt
explizit auf width_limit_mm zurückgesetzt, während die Höhe frei anpassbar bleibt.

Damit funktioniert auch der zweite _configure_legend_size()-Aufruf (2-spaltig bei Platzproblemen)
korrekt und die Breite bleibt konstant beim Kartenschild.
2026-04-22 10:15:19 +02:00
Michael Otto e036d5a178 fix: Legende mit fester Breite und dynamischer Höhe
- Nach adjustBoxSize() wird die Breite explizit auf kartenschild_width_mm gesetzt
- Nur die Höhe passt sich an den Inhalt an (kein 'Größe an Inhalt anpassen' mehr nötig)
- Gilt für alle Templates (standard=180mm, baufreigabe=80mm, etc.)
2026-04-22 10:12:23 +02:00
Michael Otto f6328be375 feat: Schriftfeld-Template-Dropdown im Druck-Tab
- print_layout_baufreigabe: config_name-Parameter flexible in __init__
- print_logic.druckvorlage_anlegen: neuer schriftfeld_template-Parameter
- print_tab: QComboBox zum Auswahl von Schriftfeld-Vorlagen
- _get_available_templates: diskoveriert .json Dateien aus config/label_configs/
- Template-Auswahl in Projektvariablen persistent (tab_b_schriftfeld_template)
- Automatische Fallback auf 'standard' wenn Vorlagen-Verzeichnis leer
2026-04-22 10:02:13 +02:00
Michael Otto d346640947 refactor: Schriftfeld-Konfiguration aus JSON statt hartcodiert
- PrintLayout: config_name-Parameter, laedt label_configs/{name}.json
- PrintLayout: _TEXT_FORMAT_TEMPLATES kommt aus text_formats.json (Fallback: Defaults)
- PrintLayout: _LEGEND_WIDTH_MM jetzt aus kartenschild_width_mm der Config
- PrintLayout: neue _setup_layout_objects_from_config() ersetzt hardcodierten Label-Block
- PrintLayout: §38-Sondertext ueber Feld 'text_if_kartenname_38' in standard.json
- PrintLayoutBaufreigabe: Minimal-Wrapper, delegiert komplett an Basisklasse via config_name='baufreigabe'
- Neu: config/text_formats.json (zentrale Schriftformat-Definitionen)
- Neu: config/label_configs/standard.json (180mm Kartenschild, 11 Labels)
- Neu: config/label_configs/baufreigabe.json (80mm Kartenschild, 6 Labels)
2026-04-22 09:25:10 +02:00
26 changed files with 508 additions and 1099 deletions
-13
View File
@@ -1,13 +0,0 @@
[general]
version=
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/
QGISMaximumVersion=4.99
qgisMinimumVersion=3.40
experimental=true
deprecated=false
supportsQt6=true
-23
View File
@@ -1,26 +1,3 @@
- Landkreis-Dropdown (statt Freitext) in den Projekteigenschaften ergänzt, inkl. Landkreiskennzahl als eigene Projektvariable
- Landkreisliste liegt jetzt als JSON (sn_basis/data/landkreise.json) statt hartcodiert vor, damit auch andere Module und Nicht-Entwickler sie pflegen können
- Auswahldialog für mehrere BauRaumOderBodenordnungsrecht-Treffer auf Mehrfachauswahl umgestellt (mehrere Objekte gemeinsam laden/verschmelzen)
- Landkreisweiter Objekt-Auswahldialog jetzt als Tabelle (Spalten "Nummer"/"Name", per Spaltenkopf sortierbar, Standard aufsteigend nach Nummer) mit Suchfeld (Filter nach Nummer/Name); "Alle auswählen" berücksichtigt die Filterung; Checkbox-Text zu "Nur Objekte laden (ohne weitere Prüfungen)" geändert
- Dialoge: Titel, Wappen-Icon, kompaktere Größe, Scrollbar-Fix und größenveränderbare Liste ergänzt
- Die drei Verfahrensgebiet-spezifischen ALKIS-Dialoge (BROR-Auswahl, Landkreis-Objektauswahl, Landkreis-Auswahl) sind aus sn_basis nach sn_verfahrensgebiet verschoben (functions/verfahrensgebiet_dialoge.py), da sie fachlich dorthin gehören
---
Version 26.8.1-unstable:
-Vergleich einer CSV aus dem BI-Server mit den Beteiligten Flurstücken lt. ALKIS
---
Version 26.7.1-unstable:
- Verfahrensgebiet als shape laden, inkl. Abgleich mit BROBOR-Objekt
---
Version 26.7.1-testing:
- 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:
---
+87
View File
@@ -0,0 +1,87 @@
{
"template_name": "baufreigabe",
"description": "Baufreigabe-Layout mit 80mm Kartenschild (DIN A3/A4 Querformat)",
"kartenschild_width_mm": 80.0,
"kartenschild_height_mm": 58.0,
"kartenschild_line_offsets_y": [-10.0, -20.0, -30.0, -46.0],
"labels": [
{
"id": "Herausgeber",
"object_name": "Herausgeber",
"text": "Teilnehmergemeinschaft [%@sn_bezeichnung %] [%@sn_name %]",
"x_mm": 14.0,
"y_mm": -48.0,
"width_mm": 74.0,
"height_mm": 8.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "bottom"
},
{
"id": "Bezeichnung_Flurbereinigung",
"object_name": "Bezeichnung_Flurbereinigung",
"text": "[% @sn_bezeichnung %]",
"x_mm": 14.0,
"y_mm": -41.0,
"width_mm": 74.0,
"height_mm": 4.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Name_Flurbereinigung",
"object_name": "Name_Flurbereinigung",
"text": "[% @sn_name %]",
"x_mm": 14.0,
"y_mm": -32.0,
"width_mm": 74.0,
"height_mm": 5.0,
"format": "Format_2",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Kartenname",
"object_name": "Kartenname",
"text": "[% @sn_kartenname %]",
"x_mm": 14.0,
"y_mm": -23.0,
"width_mm": 74.0,
"height_mm": 6.0,
"format": "Format_3",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Ausgabedatum",
"object_name": "Ausgabedatum",
"text": "Ausgabedatum: [%format_date(now(), 'dd.MM.yyyy')%]",
"x_mm": 14.0,
"y_mm": -13.0,
"width_mm": 74.0,
"height_mm": 4.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Massstab",
"object_name": "Massstab",
"text": "Ma\u00dfstab: 1 : [%round(map_get(item_variables('Hauptkarte'),'map_scale'),0)%]",
"x_mm": 14.0,
"y_mm": -3.0,
"width_mm": 74.0,
"height_mm": 4.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
}
]
}
+153
View File
@@ -0,0 +1,153 @@
{
"template_name": "standard",
"description": "Standard-Layout mit 180mm Kartenschild fuer §38/§41 FlurbG Karten",
"kartenschild_width_mm": 180.0,
"kartenschild_height_mm": 92.0,
"kartenschild_line_offsets_y": [-10.0, -20.0, -30.0, -46.0, -62.0, -82.0],
"labels": [
{
"id": "Herausgeber",
"object_name": "Herausgeber",
"text": "Teilnehmergemeinschaft [%@sn_bezeichnung %] [%@sn_name %]",
"text_if_kartenname_38": "[%@sn_landkreis_user%] [%@sn_behoerde%]",
"x_mm": 16.0,
"y_mm": -84.0,
"width_mm": 171.0,
"height_mm": 4.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Bezeichnung_Flurbereinigung",
"object_name": "Bezeichnung_Flurbereinigung",
"text": "[% @sn_bezeichnung %]",
"x_mm": 16.0,
"y_mm": -75.0,
"width_mm": 171.0,
"height_mm": 4.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Name_Flurbereinigung",
"object_name": "Name_Flurbereinigung",
"text": "[% @sn_name %]",
"x_mm": 16.0,
"y_mm": -64.0,
"width_mm": 171.0,
"height_mm": 5.0,
"format": "Format_2",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Stadt_Gemeinde",
"object_name": "Stadt_Gemeinde",
"text": "[% @sn_gemeinden %]",
"x_mm": 16.0,
"y_mm": -56.0,
"width_mm": 171.0,
"height_mm": 4.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Landkreis",
"object_name": "Landkreis",
"text": "[% @sn_landkreise_proj %]",
"x_mm": 16.0,
"y_mm": -48.0,
"width_mm": 171.0,
"height_mm": 4.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Kartenname",
"object_name": "Kartenname",
"text": "[% @sn_kartenname %]",
"x_mm": 16.0,
"y_mm": -32.0,
"width_mm": 137.0,
"height_mm": 12.0,
"format": "Format_3",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Gefertigt",
"object_name": "Gefertigt",
"text": "Gefertigt: ",
"x_mm": 16.0,
"y_mm": -23.0,
"width_mm": 81.0,
"height_mm": 4.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Gepr\u00fcft",
"object_name": "Gepr\u00fcft",
"text": "Geprueft: ",
"x_mm": 101.0,
"y_mm": -23.0,
"width_mm": 81.0,
"height_mm": 4.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Massstab",
"object_name": "Massstab",
"text": "Ma\u00dfstab: 1 : [%round(map_get(item_variables('Hauptkarte'),'map_scale'),0)%]",
"x_mm": 16.0,
"y_mm": -13.0,
"width_mm": 81.0,
"height_mm": 4.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Ausgabedatum",
"object_name": "Ausgabedatum",
"text": "Ausgabedatum: [%format_date(now(), 'dd.MM.yyyy')%]",
"x_mm": 101.0,
"y_mm": -13.0,
"width_mm": 81.0,
"height_mm": 4.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
},
{
"id": "Aktenzeichen",
"object_name": "Aktenzeichen",
"text": "Aktenzeichen: XXX",
"x_mm": 16.0,
"y_mm": -3.0,
"width_mm": 171.0,
"height_mm": 4.0,
"format": "Format_1",
"html_mode": false,
"expression_enabled": true,
"v_align": "top"
}
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"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"
}
}
-15
View File
@@ -1,15 +0,0 @@
[
{"kennzahl": "11", "name": "Chemnitz, Stadt"},
{"kennzahl": "12", "name": "Dresden, Stadt"},
{"kennzahl": "13", "name": "Leipzig, Stadt"},
{"kennzahl": "21", "name": "Erzgebirgskreis"},
{"kennzahl": "22", "name": "Mittelsachsen"},
{"kennzahl": "23", "name": "Vogtlandkreis"},
{"kennzahl": "24", "name": "Zwickau"},
{"kennzahl": "25", "name": "Bautzen"},
{"kennzahl": "26", "name": "Görlitz"},
{"kennzahl": "27", "name": "Meißen"},
{"kennzahl": "28", "name": "Sächsische Schweiz-Osterzgebirge"},
{"kennzahl": "29", "name": "Leipzig"},
{"kennzahl": "30", "name": "Nordsachsen"}
]
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

+1 -2
View File
@@ -20,8 +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, ask_ergaenzen_entfernen_ersetzen_abbrechen, ask_detailpruefung_oder_vg_laden
from .laenderkennung import get_laenderkennung, LAENDERKENNUNGEN
from .dialog_wrapper import ask_yes_no, ask_overwrite_append_cancel_custom, ask_ergaenzen_entfernen_ersetzen_abbrechen
from .message_wrapper import (
_get_message_bar,
-112
View File
@@ -182,102 +182,6 @@ def ask_ergaenzen_entfernen_ersetzen_abbrechen(
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"
def ask_detailpruefung_oder_abbrechen(
parent: Any,
title: str,
message: str,
) -> Literal["detailpruefung", "abbrechen"]:
"""Zeigt Dialog mit zwei Optionen nach erkannter Flächenabweichung (Shape-Workflow).
Optionen:
- **Detailprüfung starten**: Knickpunktbasierte Prüfung des Shape-VG-Umrings
- **Abbrechen**: Vorgang beenden, Shape-Verfahrensgebiet unverändert behalten
Parameters
----------
parent :
Eltern-Widget oder None.
title : str
Dialog-Titel.
message : str
Hauptmeldung mit Flächen-Informationen.
Returns
-------
Literal["detailpruefung", "abbrechen"]
Genaue Entscheidung des Nutzers.
"""
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_detailpruefung_oder_abbrechen('{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)
msg.addButton("Abbrechen", QMessageBox.ButtonRole.RejectRole)
exec_dialog(msg)
clicked = msg.clickedButton()
if clicked == detail_btn:
return "detailpruefung"
else:
return "abbrechen"
class ProgressDialog:
def __init__(self, total: int, title: str = "Fortschritt", label: str = "Verarbeite..."):
self.total = max(total, 1)
@@ -370,22 +274,6 @@ 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)
-51
View File
@@ -1,51 +0,0 @@
"""
sn_basis/functions/laenderkennung.py ALKIS-Länderkennung je Plugin-Präfix.
AGLB-länderspezifische Funktionen (z.B. Flurstückskennzeichen-Bildung) sollen
nicht hart auf Sachsen ("14") verdrahtet sein, sondern die Länderkennung über
dieses generische Mapping ermitteln. Für neue Bundesländer genügt ein weiterer
Eintrag in ``LAENDERKENNUNGEN`` die aufrufende Logik bleibt unverändert.
"""
#: Mapping von sn_*-Plugin-Präfix (ohne Unterstrich) auf die zweistellige
#: ALKIS-Länderkennung. Aktuell nur Sachsen befüllt; weitere Bundesländer
#: können hier ergänzt werden, sobald Schwesterplugins dafür existieren.
LAENDERKENNUNGEN: dict[str, str] = {
"sn": "14", # Sachsen
}
def get_laenderkennung(plugin_praefix: str | None = None) -> str:
"""Ermittelt die ALKIS-Länderkennung für einen Plugin-Präfix.
Parameters
----------
plugin_praefix : str, optional
Plugin-Präfix ohne Unterstrich, z.B. ``"sn"``. Wird kein Präfix
übergeben, wird er aus dem Package-Namen des aufrufenden Moduls
abgeleitet (``__name__`` des Aufrufers, erstes ``_``-Segment).
Returns
-------
str
Zweistellige Länderkennung, z.B. ``"14"``.
Raises
------
KeyError
Wenn für den ermittelten Präfix keine Länderkennung hinterlegt ist.
"""
if not plugin_praefix:
import inspect
frame = inspect.stack()[1]
caller_module = frame.frame.f_globals.get("__name__", "")
plugin_praefix = caller_module.split(".")[0].split("_")[0]
plugin_praefix = plugin_praefix.lower()
if plugin_praefix not in LAENDERKENNUNGEN:
raise KeyError(
f"Keine Länderkennung für Plugin-Präfix '{plugin_praefix}' hinterlegt. "
f"Bekannte Präfixe: {sorted(LAENDERKENNUNGEN)}"
)
return LAENDERKENNUNGEN[plugin_praefix]
-47
View File
@@ -1,47 +0,0 @@
"""sn_basis/functions/landkreise.py Sächsische Landkreise/Helfer mit Kennzahl.
Daten liegen in sn_basis/data/landkreise.json (auch von anderen sn_*-Modulen
lesbar, z.B. per ``from sn_basis.functions.landkreise import LANDKREISE``).
"""
import json
import os
try:
from qgis.core import Qgis, QgsMessageLog
except Exception: # Mock-Modus / außerhalb QGIS
Qgis = None
QgsMessageLog = None
# .../sn_basis/data/landkreise.json (eine Ebene über dieser Datei, dann data/)
_DATA_DATEI = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "landkreise.json")
def _lade_landkreise() -> "list[tuple[str, str]]":
try:
with open(_DATA_DATEI, "r", encoding="utf-8") as f:
rohdaten = json.load(f)
return [(eintrag["kennzahl"], eintrag["name"]) for eintrag in rohdaten]
except Exception as exc:
if QgsMessageLog is not None:
QgsMessageLog.logMessage(
f"landkreise.json konnte nicht geladen werden ({_DATA_DATEI}): {exc}",
"sn_basis",
Qgis.Warning,
)
else:
print(f"⚠️ landkreise.json konnte nicht geladen werden ({_DATA_DATEI}): {exc}")
return []
#: Liste von (Landkreiskennzahl, Landkreis/Helfer)-Tupeln, in Datei-Reihenfolge.
LANDKREISE: "list[tuple[str, str]]" = _lade_landkreise()
#: Kennzahl -> Name
KENNZAHL_ZU_NAME: "dict[str, str]" = dict(LANDKREISE)
#: Name -> Kennzahl
NAME_ZU_KENNZAHL: "dict[str, str]" = {name: kennzahl for kennzahl, name in LANDKREISE}
#: Sentinel-"Kennzahl" für die Auswahl "Alle Landkreise" (kein Landkreis-Filter).
ALLE_LANDKREISE = "ALLE"
-19
View File
@@ -5,25 +5,6 @@ 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.
-168
View File
@@ -24,7 +24,6 @@ QgsFeature: Type[Any]
QgsField: Type[Any]
QgsGeometry: Type[Any]
QgsFeatureRequest: Type[Any]
QgsRectangle: Type[Any]
QgsCoordinateTransform: Type[Any]
QgsCoordinateReferenceSystem: Type[Any]
QgsPrintLayout: Type[Any]
@@ -38,12 +37,6 @@ 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
@@ -83,7 +76,6 @@ try:
QgsField as _QgsField,
QgsGeometry as _QgsGeometry,
QgsFeatureRequest as _QgsFeatureRequest,
QgsRectangle as _QgsRectangle,
QgsCoordinateTransform as _QgsCoordinateTransform,
QgsCoordinateReferenceSystem as _QgsCoordinateReferenceSystem,
QgsPrintLayout as _QgsPrintLayout,
@@ -97,9 +89,6 @@ try:
QgsLayoutSize as _QgsLayoutSize,
QgsUnitTypes as _QgsUnitTypes,
QgsLayoutItem as _QgsLayoutItem,
QgsBlockingNetworkRequest as _QgsBlockingNetworkRequest,
QgsJsonExporter as _QgsJsonExporter,
QgsJsonUtils as _QgsJsonUtils,
)
QgsProject = _QgsProject
@@ -113,7 +102,6 @@ try:
QgsField = _QgsField
QgsGeometry = _QgsGeometry
QgsFeatureRequest = _QgsFeatureRequest
QgsRectangle = _QgsRectangle
QgsCoordinateTransform = _QgsCoordinateTransform
QgsCoordinateReferenceSystem = _QgsCoordinateReferenceSystem
QgsPrintLayout = _QgsPrintLayout
@@ -127,32 +115,6 @@ try:
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:
@@ -812,60 +774,6 @@ 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
# ---------------------------------------------------------
@@ -1043,79 +951,3 @@ 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
+12 -225
View File
@@ -45,16 +45,6 @@ QComboBox: Type[Any] = object
QCheckBox: Type[Any] = object
QHBoxLayout: Type[Any] = object
QFont: Type[Any] = object
QSettings: Type[Any] = object
QDialog: Type[Any] = object
QDialogButtonBox: Type[Any] = object
QListWidget: Type[Any] = object
QListWidgetItem: Type[Any] = object
QAbstractItemView: Type[Any] = object
QTableWidget: Type[Any] = object
QTableWidgetItem: Type[Any] = object
QTableWidgetSelectionRange: Type[Any] = object
QIcon: Type[Any] = object
def exec_dialog(dialog: Any) -> Any:
@@ -101,24 +91,15 @@ try:
QComboBox as _QComboBox,
QCheckBox as _QCheckBox,
QHBoxLayout as _QHBoxLayout,
QDialog as _QDialog,
QDialogButtonBox as _QDialogButtonBox,
QListWidget as _QListWidget,
QListWidgetItem as _QListWidgetItem,
QAbstractItemView as _QAbstractItemView,
QTableWidget as _QTableWidget,
QTableWidgetItem as _QTableWidgetItem,
QTableWidgetSelectionRange as _QTableWidgetSelectionRange,
)
from qgis.PyQt.QtGui import QFont as _QFont, QIcon as _QIcon
from qgis.PyQt.QtGui import QFont as _QFont
from qgis.PyQt.QtCore import (
QEventLoop as _QEventLoop,
QTimer as _QTimer,
QUrl as _QUrl,
QCoreApplication as _QCoreApplication,
Qt as _Qt,
QVariant as _QVariant,
QSettings as _QSettings,
QVariant as _QVariant
)
from qgis.PyQt.QtNetwork import (
QNetworkRequest as _QNetworkRequest,
@@ -159,16 +140,6 @@ try:
QVariant = _QVariant
QHBoxLayout = _QHBoxLayout
QFont = _QFont
QSettings = _QSettings
QDialog = _QDialog
QDialogButtonBox = _QDialogButtonBox
QListWidget = _QListWidget
QListWidgetItem = _QListWidgetItem
QAbstractItemView = _QAbstractItemView
QTableWidget = _QTableWidget
QTableWidgetItem = _QTableWidgetItem
QTableWidgetSelectionRange = _QTableWidgetSelectionRange
QIcon = _QIcon
# ✅ QT6 ENUMS
YES = QMessageBox.StandardButton.Yes
NO = QMessageBox.StandardButton.No
@@ -189,12 +160,7 @@ try:
DockWidgetClosable = QDockWidget.DockWidgetFeature.DockWidgetClosable
DockAreaLeft = Qt.DockWidgetArea.LeftDockWidgetArea
DockAreaRight = Qt.DockWidgetArea.RightDockWidgetArea
DialogAccepted = QDialog.DialogCode.Accepted
ExtendedSelection = QAbstractItemView.SelectionMode.ExtendedSelection
SelectRows = QAbstractItemView.SelectionBehavior.SelectRows
NoEditTriggers = QAbstractItemView.EditTrigger.NoEditTriggers
AscendingOrder = Qt.SortOrder.AscendingOrder
def exec_dialog(dialog: Any) -> Any:
return dialog.exec()
@@ -225,24 +191,15 @@ except (ImportError, AttributeError):
QComboBox as _QComboBox,
QCheckBox as _QCheckBox,
QHBoxLayout as _QHBoxLayout,
QDialog as _QDialog,
QDialogButtonBox as _QDialogButtonBox,
QListWidget as _QListWidget,
QListWidgetItem as _QListWidgetItem,
QAbstractItemView as _QAbstractItemView,
QTableWidget as _QTableWidget,
QTableWidgetItem as _QTableWidgetItem,
QTableWidgetSelectionRange as _QTableWidgetSelectionRange,
)
from PyQt5.QtGui import QFont as _QFont, QIcon as _QIcon
from PyQt5.QtGui import QFont as _QFont
from PyQt5.QtCore import (
QEventLoop as _QEventLoop,
QTimer as _QTimer,
QUrl as _QUrl,
QCoreApplication as _QCoreApplication,
Qt as _Qt,
QVariant as _QVariant,
QSettings as _QSettings,
QVariant as _QVariant
)
from PyQt5.QtNetwork import (
QNetworkRequest as _QNetworkRequest,
@@ -281,17 +238,7 @@ except (ImportError, AttributeError):
QVariant = _QVariant
QHBoxLayout= _QHBoxLayout
QFont = _QFont
QSettings = _QSettings
QDialog = _QDialog
QDialogButtonBox = _QDialogButtonBox
QListWidget = _QListWidget
QListWidgetItem = _QListWidgetItem
QAbstractItemView = _QAbstractItemView
QTableWidget = _QTableWidget
QTableWidgetItem = _QTableWidgetItem
QTableWidgetSelectionRange = _QTableWidgetSelectionRange
QIcon = _QIcon
# ✅ PYQT5 ENUMS
YES = QMessageBox.Yes
NO = QMessageBox.No
@@ -313,12 +260,7 @@ except (ImportError, AttributeError):
DockWidgetClosable = QDockWidget.DockWidgetClosable
DockAreaLeft = Qt.LeftDockWidgetArea
DockAreaRight = Qt.RightDockWidgetArea
DialogAccepted = QDialog.Accepted
ExtendedSelection = QAbstractItemView.ExtendedSelection
SelectRows = QAbstractItemView.SelectRows
NoEditTriggers = QAbstractItemView.NoEditTriggers
AscendingOrder = Qt.AscendingOrder
def exec_dialog(dialog: Any) -> Any:
return dialog.exec_()
@@ -372,10 +314,6 @@ except (ImportError, AttributeError):
def getText(parent, title, label, mode=None, text=""):
return text, True
@staticmethod
def getItem(parent, title, label, items, current=0, editable=False):
return (items[current] if items else ""), True
QInputDialog = _MockQInputDialog
class _MockQEventLoop:
@@ -539,19 +477,13 @@ except (ImportError, AttributeError):
class _MockComboBox:
def __init__(self, parent=None):
self._items = [] # Liste von (text, userData)
self._items = []
self._index = -1
self.currentTextChanged = type('Signal', (), {'connect': lambda s, cb: None, 'emit': lambda s, v: None})()
def addItem(self, text: str, userData=None) -> None:
self._items.append((text, userData))
if self._index < 0:
self._index = 0
def addItem(self, text: str) -> None: self._items.append(text)
def addItems(self, items): [self.addItem(it) for it in items]
def findText(self, text: str) -> int:
for i, (t, _) in enumerate(self._items):
if t == text:
return i
return -1
def findText(self, text: str) -> int:
return self._items.index(text) if text in self._items else -1
def setCurrentIndex(self, idx: int) -> None:
if 0 <= idx < len(self._items):
self._index = idx
@@ -560,11 +492,7 @@ except (ImportError, AttributeError):
idx = self.findText(text)
if idx >= 0: self.setCurrentIndex(idx)
def currentText(self) -> str:
return self._items[self._index][0] if 0 <= self._index < len(self._items) else ""
def currentData(self):
return self._items[self._index][1] if 0 <= self._index < len(self._items) else None
def count(self) -> int:
return len(self._items)
return self._items[self._index] if 0 <= self._index < len(self._items) else ""
QComboBox = _MockComboBox
@@ -632,31 +560,6 @@ 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
@@ -672,122 +575,6 @@ except (ImportError, AttributeError):
self._checked = checked
QCheckBox = _MockQCheckBox
DialogAccepted = 1
ExtendedSelection = 3
SelectRows = 1
NoEditTriggers = 0
AscendingOrder = 0
class _MockQDialog(_MockWidget):
DialogCode = type("DialogCode", (), {"Accepted": DialogAccepted, "Rejected": 0})
def __init__(self, *args, **kwargs):
self._layout = None
def setWindowTitle(self, title: str) -> None: pass
def setWindowIcon(self, icon) -> None: pass
def exec(self) -> int: return DialogAccepted
def exec_(self) -> int: return DialogAccepted
def accept(self) -> None: pass
def reject(self) -> None: pass
def resize(self, *args, **kwargs) -> None: pass
def adjustSize(self) -> None: pass
def sizeHint(self):
return type("Size", (), {"width": lambda self=None: 200, "height": lambda self=None: 100})()
QDialog = _MockQDialog
class _MockQDialogButtonBox(_MockWidget):
StandardButton = type("StandardButton", (), {"Ok": 1, "Cancel": 2})
Ok = 1
Cancel = 2
def __init__(self, *args, **kwargs):
self.accepted = type('Signal', (), {'connect': lambda s, cb: None})()
self.rejected = type('Signal', (), {'connect': lambda s, cb: None})()
QDialogButtonBox = _MockQDialogButtonBox
class _MockQListWidgetItem:
def __init__(self, text: str = ""):
self._text = text
self._selected = False
def setSelected(self, value: bool) -> None: self._selected = value
def isSelected(self) -> bool: return self._selected
QListWidgetItem = _MockQListWidgetItem
class _MockQListWidget(_MockWidget):
def __init__(self, *args, **kwargs):
self._items = []
def addItem(self, item) -> None: self._items.append(item)
def item(self, index: int): return self._items[index] if 0 <= index < len(self._items) else None
def count(self) -> int: return len(self._items)
def row(self, item) -> int: return self._items.index(item) if item in self._items else -1
def selectedItems(self): return [i for i in self._items if i.isSelected()]
def setSelectionMode(self, *args, **kwargs) -> None: pass
def sizeHintForRow(self, index: int) -> int: return 20
def setFixedHeight(self, height: int) -> None: pass
def frameWidth(self) -> int: return 1
QListWidget = _MockQListWidget
class _MockQAbstractItemView:
SelectionMode = type("SelectionMode", (), {"ExtendedSelection": ExtendedSelection})
QAbstractItemView = _MockQAbstractItemView
class _MockQTableWidgetItem:
def __init__(self, text: str = ""):
self._text = text
def text(self) -> str: return self._text
def setText(self, text: str) -> None: self._text = text
QTableWidgetItem = _MockQTableWidgetItem
class _MockQTableWidget(_MockWidget):
def __init__(self, rows: int = 0, cols: int = 0, *args, **kwargs):
self._items = {}
self._row_count = rows
self._col_count = cols
self._hidden_rows = set()
def setItem(self, row: int, col: int, item) -> None: self._items[(row, col)] = item
def item(self, row: int, col: int): return self._items.get((row, col))
def rowCount(self) -> int: return self._row_count
def setRowCount(self, rows: int) -> None: self._row_count = rows
def setHorizontalHeaderLabels(self, labels) -> None: pass
def setSelectionMode(self, *args, **kwargs) -> None: pass
def setSelectionBehavior(self, *args, **kwargs) -> None: pass
def setEditTriggers(self, *args, **kwargs) -> None: pass
def setSortingEnabled(self, *args, **kwargs) -> None: pass
def setRowHidden(self, row: int, hidden: bool) -> None:
if hidden: self._hidden_rows.add(row)
else: self._hidden_rows.discard(row)
def isRowHidden(self, row: int) -> bool: return row in self._hidden_rows
def selectRow(self, row: int) -> None: pass
def selectedItems(self): return []
def setRangeSelected(self, *args, **kwargs) -> None: pass
def sortByColumn(self, *args, **kwargs) -> None: pass
def resizeColumnToContents(self, col: int) -> None: pass
def setColumnWidth(self, col: int, width: int) -> None: pass
def columnWidth(self, col: int) -> int: return 100
def columnCount(self) -> int: return self._col_count
def verticalHeader(self): return _MockWidget()
def horizontalHeader(self): return _MockWidget()
def frameWidth(self) -> int: return 1
QTableWidget = _MockQTableWidget
class _MockQTableWidgetSelectionRange:
def __init__(self, *args, **kwargs): pass
QTableWidgetSelectionRange = _MockQTableWidgetSelectionRange
class _MockQIcon:
def __init__(self, *args, **kwargs): pass
QIcon = _MockQIcon
def exec_dialog(dialog: Any) -> Any:
return YES
# --------------------------- TEST ---------------------------
-1
View File
@@ -20,7 +20,6 @@ class SettingsLogic:
"amt",
"behoerde",
"landkreis_user",
"landkreis_kennzahl",
"sachgebiet",
"bezeichnung",
"name",
+2 -2
View File
@@ -1,11 +1,11 @@
[general]
version=26.7.2-testing
version=26.4.7-unstable
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/testing/
repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/src/branch/unstable/
QGISMaximumVersion=4.99
qgisMinimumVersion=3.40
experimental=true
+2 -13
View File
@@ -30,6 +30,7 @@ from sn_basis.modules.linkpruefer import Linkpruefer
from sn_basis.modules.layerpruefer import Layerpruefer
from sn_basis.modules.stilpruefer import Stilpruefer
from sn_basis.modules.excel_importer import ExcelImporter
from sn_plan41.modules.listenauswerter import Listenauswerter
SourceType = Literal["service", "database", "excel", "unknown"]
@@ -285,19 +286,7 @@ class DataGrabber:
}
source_dict["rows"].append(result_row)
# Validierung über Listenauswerter Import erst hier (statt am Modul-
# anfang), da DataGrabber generisch für alle sn_*-Plugins nutzbar sein
# soll und nicht bei jedem Aufruf zwingend sn_plan41 installiert ist;
# nur die Excel-Quellenverarbeitung braucht Listenauswerter.
try:
from sn_plan41.modules.listenauswerter import Listenauswerter
except ImportError as exc:
raise ImportError(
"Die Verarbeitung von Excel-Quellen benötigt das Plugin "
"sn_plan41 (sn_plan41.modules.listenauswerter.Listenauswerter), "
"das hier nicht installiert ist."
) from exc
# Validierung über Listenauswerter
listenauswerter = Listenauswerter(self.pruefmanager, self.stil_pruefer or Stilpruefer())
validated, validation_results = listenauswerter.validate_rows(source_dict)
results.extend(validation_results)
+12 -55
View File
@@ -19,7 +19,7 @@ Designprinzipien
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, quote
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse
import json
import time
@@ -132,7 +132,6 @@ class Datenabruf:
# außer bei WMS (WMS bleibt unverändert).
use_bbox = (raumfilter != "ohne") and (str(provider).upper() != "WMS")
url = self._build_provider_url(link=link, provider=str(provider), bbox_geom=bbox_geom if use_bbox else None)
print(f"[Datenabruf] URL für {thema!r} ({provider}): {url}")
# 2b) Fachdaten abrufen
features, error_msg = self._fetch_features(
@@ -310,11 +309,8 @@ class Datenabruf:
if callable(extent_to_bbox):
bbox_str = extent_to_bbox(extent_obj)
else:
# Fallback: xmin/ymin/xmax/ymax-Extraktion (duck-typing)
# QgsRectangle verwendet xMinimum()/yMinimum()/xMaximum()/yMaximum()
if hasattr(extent_obj, "xMinimum") and callable(getattr(extent_obj, "xMinimum")):
bbox_str = f"{extent_obj.xMinimum()},{extent_obj.yMinimum()},{extent_obj.xMaximum()},{extent_obj.yMaximum()}"
elif hasattr(extent_obj, "xmin") and callable(getattr(extent_obj, "xmin")):
# Fallback: einfache xmin/ymin/xmax/ymax-Extraktion (duck-typing)
if hasattr(extent_obj, "xmin") and callable(getattr(extent_obj, "xmin")):
bbox_str = f"{extent_obj.xmin()},{extent_obj.ymin()},{extent_obj.xmax()},{extent_obj.ymax()}"
elif isinstance(extent_obj, (tuple, list)) and len(extent_obj) == 4:
bbox_str = f"{extent_obj[0]},{extent_obj[1]},{extent_obj[2]},{extent_obj[3]}"
@@ -329,26 +325,12 @@ class Datenabruf:
if provider_norm == "WFS":
query_params.setdefault("service", "WFS")
query_params.setdefault("request", "GetFeature")
query_params.setdefault("VERSION", "2.0.0")
# WFS 2.0: 'typename' → 'TYPENAMES'
if "typename" in query_params and "TYPENAMES" not in query_params:
query_params["TYPENAMES"] = query_params.pop("typename")
# resultType=hits: nur Anzahl vom Server abfragen, kein GML-Download
query_params.setdefault("resultType", "hits")
# CRS in OGC-URN-Format (wie QGIS Datenquellenverwaltung)
srsname_urn: Optional[str] = None
if crs_authid:
if crs_authid.upper().startswith("EPSG:"):
srsname_urn = f"urn:ogc:def:crs:EPSG::{crs_authid.split(':', 1)[1]}"
else:
srsname_urn = crs_authid
query_params.setdefault("outputFormat", "application/json")
if bbox_str:
# 5. Komponente: CRS-URN anhängen (Standard für WFS 2.0)
bbox_with_crs = f"{bbox_str},{srsname_urn}" if srsname_urn else bbox_str
query_params.setdefault("BBOX", bbox_with_crs)
if srsname_urn:
query_params.setdefault("SRSNAME", srsname_urn)
new_query = urlencode(query_params, doseq=True, quote_via=quote, safe=':/')
query_params.setdefault("BBOX", bbox_str)
if crs_authid:
query_params.setdefault("SRSNAME", crs_authid)
new_query = urlencode(query_params, doseq=True)
rebuilt = parsed._replace(query=new_query)
return urlunparse(rebuilt)
@@ -407,14 +389,14 @@ class Datenabruf:
query_params.setdefault("inSR", srid)
query_params.setdefault("outSR", srid)
new_query = urlencode(query_params, doseq=True, quote_via=quote, safe=':/')
new_query = urlencode(query_params, doseq=True)
rebuilt = parsed_rest._replace(query=new_query)
return urlunparse(rebuilt)
# Default: generischer bbox-Parameter (nur wenn vorhanden)
if bbox_str:
query_params.setdefault("bbox", bbox_str)
new_query = urlencode(query_params, doseq=True, quote_via=quote, safe=':/')
new_query = urlencode(query_params, doseq=True)
rebuilt = parsed._replace(query=new_query)
return urlunparse(rebuilt)
@@ -591,33 +573,7 @@ class Datenabruf:
# Sonst: gib das gesamte JSON als einzelnes Objekt zurück
return [parsed], None
except json.JSONDecodeError:
# Nicht-JSON-Antwort (GML/XML). Für WFS resultType=hits: Anzahl aus XML lesen.
if prov == "WFS":
try:
import xml.etree.ElementTree as ET
root = ET.fromstring(response_text)
# WFS 2.0: numberOfMatched; WFS 1.x: numberOfFeatures
n_str = (
root.get("numberOfMatched")
or root.get("numberMatched")
or root.get("numberOfFeatures")
or root.get("numberOfReturned")
)
if n_str is not None:
try:
count = int(n_str)
return [{}] * count, None
except (ValueError, TypeError):
pass
# Fallback: wfs:member-Elemente zählen
members = [
c for c in root
if c.tag.lower().endswith(("member", "featuremember"))
]
return [{}] * len(members), None
except Exception as exc:
return [], f"WFS-XML-Antwort konnte nicht geparst werden: {exc}"
# Nicht-WFS, nicht-JSON: GML via temporärer Datei + OGR
# Nicht-JSON-Antwort (z. B. GML). Wenn QGIS verfügbar, versuche GML via temporärer Datei + OGR
if getattr(qgiscore, "QGIS_AVAILABLE", False):
try:
import tempfile
@@ -632,4 +588,5 @@ class Datenabruf:
return [], "GML-Antwort konnte nicht als Layer geladen werden"
except Exception as exc:
return [], f"Fehler beim Parsen von GML: {exc}"
# Wenn alles fehlschlägt:
return [], "Antwort konnte nicht als JSON oder GML geparst werden"
+1 -1
View File
@@ -209,7 +209,7 @@ class Datenschreiber:
continue
layer_name_raw = thema or str(ident)
layer_name = re.sub(r"[^A-Za-z0-9_ÄäÖöÜüß]+", "_", layer_name_raw).strip("_")
layer_name = re.sub(r"[^A-Za-z0-9_]+", "_", layer_name_raw).strip("_")
if not layer_name:
layer_name = f"layer_{ident}"
+1 -27
View File
@@ -122,33 +122,7 @@ class LayerLoader:
try:
if provider_lower == "wfs":
raw_link = link.strip()
if raw_link.lower().startswith("url="):
raw_link = raw_link[4:]
# QGIS WFS-Provider erwartet die Basis-URL und 'typename' als
# separaten URI-Parameter. GetFeature-spezifische Parameter
# (request, outputFormat, resultType, BBOX, …) werden entfernt.
try:
from urllib.parse import urlparse as _up, parse_qsl as _pqsl, urlunparse as _uu, urlencode as _ue
_parsed = _up(raw_link)
_params = dict(_pqsl(_parsed.query, keep_blank_values=True))
_typename = (
_params.pop("typename", None)
or _params.pop("TYPENAME", None)
or _params.pop("TYPENAMES", None)
)
for _k in ("request", "REQUEST", "outputFormat", "OUTPUTFORMAT",
"resultType", "RESULTTYPE", "BBOX", "bbox",
"STARTINDEX", "COUNT"):
_params.pop(_k, None)
_params.setdefault("service", "WFS")
_params.setdefault("version", "2.0.0")
_base_url = _uu(_parsed._replace(query=_ue(_params)))
uri = f"url={_base_url}"
if _typename:
uri += f" typename={_typename}"
except Exception:
uri = link if link.strip().lower().startswith("url=") else f"url={link}"
uri = link if link.strip().lower().startswith("url=") else f"url={link}"
layer = QgsVectorLayer(uri, thema, "WFS")
elif provider_lower == "wms":
uri = link if link.strip().lower().startswith("url=") else f"url={link}"
+146 -61
View File
@@ -4,7 +4,9 @@ sn_basis/modules/print_layout.py - Aufbau von Einzelblatt-Drucklayouts.
from __future__ import annotations
import importlib
import json
import math
from pathlib import Path
from typing import Any
from sn_basis.functions.qt_wrapper import QFont, Qt
@@ -45,13 +47,19 @@ MM = QgsUnitTypes.LayoutMillimeters
class PrintLayout:
"""Erzeugt ein QGIS-Einzelblatt-Layout fuer den Druck."""
"""Erzeugt ein QGIS-Einzelblatt-Layout fuer den Druck.
Der optionale Parameter config_name waehlt ein Schriftfeld-Template aus
config/label_configs/{config_name}.json. Wird kein Name angegeben, wird
"standard" verwendet. Neue Templates koennen ohne Code-Aenderungen durch
Erstellen einer neuen JSON-Datei hinzugefuegt werden.
"""
_FRAME_STROKE_WIDTH_MM = 0.5
_LEGEND_WIDTH_MM = 180.0
_GRID_INTERVAL_MAP_UNITS = 500.0
_TEXT_FORMAT_TEMPLATES = {
# Eingebaute Fallback-Formate fuer den Fall, dass text_formats.json fehlt
_TEXT_FORMAT_TEMPLATES_DEFAULT = {
"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"},
@@ -91,9 +99,51 @@ class PrintLayout:
"mit teilweise angepasster Signatur<br>"
)
def __init__(self, project: Any | None = None) -> None:
def __init__(self, project: Any | None = None, config_name: str = "standard") -> None:
self.project = project or QgsProject.instance()
self._current_layout: Any | None = None
self.config_name = config_name
self._label_config = self._load_label_config(config_name)
self._TEXT_FORMAT_TEMPLATES = self._load_text_formats()
self._LEGEND_WIDTH_MM = float(
self._label_config.get("kartenschild_width_mm", 180.0)
)
@staticmethod
def _get_config_dir() -> Path:
return Path(__file__).parent.parent / "config"
def _load_label_config(self, config_name: str) -> dict:
"""Laedt Schriftfeld-Konfiguration aus config/label_configs/{config_name}.json.
Raises:
FileNotFoundError: Wenn die Konfigurationsdatei nicht existiert.
ValueError: Wenn die JSON-Datei nicht geparst werden kann.
"""
config_file = self._get_config_dir() / "label_configs" / f"{config_name}.json"
if not config_file.exists():
raise FileNotFoundError(
f"Label-Config '{config_name}' nicht gefunden: {config_file}"
)
try:
with open(config_file, encoding="utf-8") as fh:
return json.load(fh)
except json.JSONDecodeError as exc:
raise ValueError(f"JSON-Fehler in {config_file}: {exc}") from exc
def _load_text_formats(self) -> dict:
"""Laedt zentrale Schriftformat-Definitionen aus config/text_formats.json.
Faellt auf eingebaute Standardwerte zurueck, wenn die Datei fehlt.
"""
formats_file = self._get_config_dir() / "text_formats.json"
if not formats_file.exists():
return dict(self._TEXT_FORMAT_TEMPLATES_DEFAULT)
try:
with open(formats_file, encoding="utf-8") as fh:
return json.load(fh)
except (json.JSONDecodeError, OSError):
return dict(self._TEXT_FORMAT_TEMPLATES_DEFAULT)
def _create_map_item(
self,
@@ -247,17 +297,51 @@ class PrintLayout:
map_height_mm: float,
kartenname_auswahl: str = "",
) -> None:
"""Richtet Legende, Quellenangabe und Kartenschild ein.
Delegiert an _setup_layout_objects_from_config() unter Aufloesung
des optionalen kartenname_auswahl-Parameters fuer §38-spezifische Texte.
"""
self._setup_layout_objects_from_config(
kartenfenster_untere_rechte_ecke_mm,
hauptkarte,
map_top_mm,
map_height_mm,
kartenname_auswahl=kartenname_auswahl,
)
def _setup_layout_objects_from_config(
self,
kartenfenster_untere_rechte_ecke_mm: tuple,
hauptkarte: Any,
map_top_mm: float,
map_height_mm: float,
kartenname_auswahl: str = "",
) -> None:
"""Erstellt Legende, Quellenangabe, Kartenschild und alle Labels aus der JSON-Config.
Die Label-Definitionen werden vollstaendig aus self._label_config geladen.
Einzige Ausnahme: Das Feld "text_if_kartenname_38" in einem Label-Eintrag
erlaubt einen alternativen Text speziell fuer kartenname_auswahl == '§38'.
"""
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]:
kartenschild_width_mm = float(self._label_config.get("kartenschild_width_mm", 180.0))
kartenschild_height_mm = float(self._label_config.get("kartenschild_height_mm", 92.0))
kartenschild_line_offsets_y: list[float] = [
float(v) for v in self._label_config.get("kartenschild_line_offsets_y", [-10.0, -20.0, -30.0, -46.0, -62.0, -82.0])
]
def _coords(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
legende_x = kartenfenster_untere_rechte_ecke_mm[0] + 10.0
legende_height_mm = max(map_height_mm - 122.0, 20.0)
legende_height_mm = 50.0 # Klein anfangen, fitBoxToContents() wird es vergrößern
legende_y = map_top_mm
legende = self._create_legend_item(
self._current_layout,
@@ -265,36 +349,36 @@ class PrintLayout:
hauptkarte,
legende_x,
legende_y,
width_mm=self._LEGEND_WIDTH_MM,
width_mm=kartenschild_width_mm,
height_mm=legende_height_mm,
object_name="Legende",
)
legende_hoehe_mm = self._configure_legend_size(
legende,
width_limit_mm=self._LEGEND_WIDTH_MM,
width_limit_mm=kartenschild_width_mm,
column_count=1,
equal_column_width=False,
)
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
quellenangabe_x, quellenangabe_y = _coords(10.0, -102.0)
quellenangabe_obere_y = quellenangabe_y - 20.0
legende_untere_y = legende_y + legende_hoehe_mm
if legende_untere_y > quellenangabe_obere_y:
self._configure_legend_size(
legende_hoehe_mm = self._configure_legend_size(
legende,
width_limit_mm=self._LEGEND_WIDTH_MM,
width_limit_mm=kartenschild_width_mm,
column_count=2,
equal_column_width=True,
)
# Quellenangabe
self.add_text_label(
self._QUELLENANGABE_TEXT,
quellenangabe_x,
quellenangabe_y,
width_mm=self._LEGEND_WIDTH_MM,
width_mm=kartenschild_width_mm,
height_mm=20.0,
text_format="Format_1",
html_mode=True,
@@ -302,72 +386,52 @@ class PrintLayout:
object_name="Quellenangabe",
)
kartenschild_x, kartenschild_y = _coords_rel_untere_rechte_ecke(10.0, 0.0)
# Kartenschild-Rahmen
kartenschild_x, kartenschild_y = _coords(10.0, 0.0)
self._create_rectangle_box_item(
self._current_layout,
"Kartenschild",
kartenschild_x,
kartenschild_y,
self._LEGEND_WIDTH_MM,
92.0,
kartenschild_width_mm,
kartenschild_height_mm,
stroke_width_mm=self._FRAME_STROKE_WIDTH_MM,
)
kartenschild_line_x, _ = _coords_rel_untere_rechte_ecke(10.0, 0.0)
kartenschild_line_offsets_y = (-10.0, -20.0, -30.0, -46.0, -62.0, -82.0)
# Kartenschild-Trennlinien
kartenschild_line_x, _ = _coords(10.0, 0.0)
for index, offset_y in enumerate(kartenschild_line_offsets_y, start=1):
_, line_y = _coords_rel_untere_rechte_ecke(0.0, offset_y)
_, line_y = _coords(0.0, offset_y)
self._create_rectangle_box_item(
self._current_layout,
f"Kartenschild_Linie_{index}",
kartenschild_line_x,
line_y,
self._LEGEND_WIDTH_MM,
kartenschild_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 aus Config
for label_def in self._label_config.get("labels", []):
# Alternativer Text fuer §38-Karten (falls in Config definiert)
if kartenname_auswahl == "§38" and "text_if_kartenname_38" in label_def:
text = label_def["text_if_kartenname_38"]
else:
text = label_def["text"]
labels = [
(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, -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", "Geprüft"),
(
"Maßstab: 1 : [%round(map_get(item_variables('Hauptkarte'),'map_scale'),0)%]",
16.0,
-13.0,
81.0,
4.0,
"Format_1",
"Massstab",
),
("Ausgabedatum: [%format_date(now(), 'dd.MM.yyyy')%]", 101.0, -13.0, 81.0, 4.0, "Format_1", "Ausgabedatum"),
("Aktenzeichen: ", 16.0, -3.0, 171.0, 4.0, "Format_1", "Aktenzeichen"),
]
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)
x, y = _coords(float(label_def["x_mm"]), float(label_def["y_mm"]))
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,
width_mm=float(label_def["width_mm"]),
height_mm=float(label_def["height_mm"]),
text_format=label_def.get("format", "Format_1"),
html_mode=bool(label_def.get("html_mode", False)),
expression_enabled=bool(label_def.get("expression_enabled", True)),
object_name=label_def.get("object_name") or label_def.get("id"),
v_align=label_def.get("v_align", "top"),
)
def _create_label_item(
@@ -661,6 +725,14 @@ class PrintLayout:
except Exception:
pass
# Deaktiviere Auto-Fit: Größe an Inhalt anpassen soll aus sein
set_resize_to_contents = getattr(legend, "setResizeToContents", None)
if callable(set_resize_to_contents):
try:
set_resize_to_contents(False)
except Exception:
pass
set_column_count = getattr(legend, "setColumnCount", None)
if callable(set_column_count):
try:
@@ -682,17 +754,30 @@ class PrintLayout:
except Exception:
pass
adjust_box_size = getattr(legend, "adjustBoxSize", None)
if callable(adjust_box_size):
# Versuche fitBoxToContents() zuerst, dann Fallback zu adjustBoxSize()
fit_box_to_contents = getattr(legend, "fitBoxToContents", None)
if callable(fit_box_to_contents):
try:
adjust_box_size()
fit_box_to_contents()
except Exception:
pass
else:
adjust_box_size = getattr(legend, "adjustBoxSize", None)
if callable(adjust_box_size):
try:
adjust_box_size()
except Exception:
pass
_, updated_height_mm = self._get_item_size_mm(legend)
if updated_height_mm is None:
updated_height_mm = 20.0
# Breite auf Kartenschild-Breite setzen, Höhe an Inhalt angepasst behalten
legend.attemptResize(
QgsLayoutSize(width_limit_mm, updated_height_mm, MM)
)
if callable(set_locked):
try:
set_locked(True)
+11 -189
View File
@@ -1,9 +1,8 @@
"""
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.
Baufreigabe-Layouts verwenden immer feste DIN A3/A4 Seitengroessen im Querformat.
Das Schriftfeld (80mm breit) wird ueber config/label_configs/baufreigabe.json definiert.
Atlas-Integration erfolgt nach Layouterstellung durch externe Konfiguration
in der PrintLogic.
@@ -12,11 +11,7 @@ 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,
@@ -32,60 +27,19 @@ MM = QgsUnitTypes.LayoutMillimeters
class PrintLayoutBaufreigabe(PrintLayout):
"""Erzeugt QGIS-Einzelblatt-Layouts mit fester DIN A3/A4 Querformat-Groesse und 80mm Kartenschild."""
"""Erzeugt QGIS-Einzelblatt-Layouts mit fester DIN A3/A4 Querformat-Groesse.
Schriftfeld-Definition kommt aus config/label_configs/baufreigabe.json.
"""
_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 __init__(self, project: Any | None = None, config_name: str = "baufreigabe") -> None:
super().__init__(project, config_name=config_name)
def create_baufreigabe_layout(
self,
@@ -110,9 +64,10 @@ class PrintLayoutBaufreigabe(PrintLayout):
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
atlas_driven: Atlas-Steuerung fuer die Hauptkarte aktivieren
Returns:
QgsPrintLayout objektInstance (wird im Layout-Dialog geoeffnet)
QgsPrintLayout-Instanz (wird im Layout-Designer geoeffnet)
Raises:
ValueError: Falls Name bereits existiert oder Format ungueltig ist
@@ -174,7 +129,6 @@ class PrintLayoutBaufreigabe(PrintLayout):
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):
@@ -247,7 +201,7 @@ class PrintLayoutBaufreigabe(PrintLayout):
self._configure_hauptkarte_grids(hauptkarte)
self._setup_layout_objects_baufreigabe(
self._setup_layout_objects(
kartenfenster_untere_rechte_ecke_mm,
hauptkarte,
map_top_mm,
@@ -259,138 +213,6 @@ class PrintLayoutBaufreigabe(PrintLayout):
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
+5 -2
View File
@@ -1166,6 +1166,7 @@ class PrintLogic:
zielgroesse: str,
formfaktor: bool,
druckbereich_auswahl: str,
schriftfeld_template: str = "",
) -> dict:
resolved_layer = layer or self._resolve_layer_from_project()
@@ -1270,8 +1271,9 @@ class PrintLogic:
try:
if kartenname_auswahl == KARTENNAME_BAUFREIGABE:
# Baufreigabe: feste DIN A3/A4 Querformat-Logik mit 80mm Kartenschild
config_to_use = schriftfeld_template or "baufreigabe"
din_format = "DIN A3" if auto_page_w >= 420.0 else "DIN A4"
PrintLayoutBaufreigabe().create_baufreigabe_layout(
PrintLayoutBaufreigabe(config_name=config_to_use).create_baufreigabe_layout(
name=vorlage_name if vorlage_name != kartenname else "Baufreigabe",
din_format=din_format,
map_width_mm=auto_map_w,
@@ -1284,7 +1286,8 @@ class PrintLogic:
)
else:
# Standard-Layout: dynamische Groessenanpassung
PrintLayout().create_single_page_layout(
config_to_use = schriftfeld_template or "standard"
PrintLayout(config_name=config_to_use).create_single_page_layout(
name=vorlage_name,
page_width_mm=auto_page_w,
page_height_mm=auto_page_h,
-31
View File
@@ -1,31 +0,0 @@
"""sn_basis/tests/test_laenderkennung.py
Unit-Tests für :mod:`sn_basis.functions.laenderkennung`.
"""
import unittest
from sn_basis.functions.laenderkennung import get_laenderkennung, LAENDERKENNUNGEN
class TestGetLaenderkennung(unittest.TestCase):
def test_bekannter_praefix_gibt_kennung_zurueck(self):
self.assertEqual(get_laenderkennung("sn"), "14")
def test_praefix_gross_klein_unabhaengig(self):
self.assertEqual(get_laenderkennung("SN"), "14")
def test_unbekannter_praefix_wirft_keyerror(self):
with self.assertRaises(KeyError):
get_laenderkennung("xx")
def test_ohne_argument_wird_aus_aufrufendem_modul_abgeleitet(self):
# __name__ dieses Testmoduls beginnt mit "sn_basis" -> Präfix "sn"
self.assertEqual(get_laenderkennung(), "14")
def test_mapping_enthaelt_sachsen(self):
self.assertEqual(LAENDERKENNUNGEN.get("sn"), "14")
if __name__ == "__main__":
unittest.main()
+47
View File
@@ -3,6 +3,7 @@ sn_basis/ui/tabs/print_tab.py - Zentraler Druck-Tab fuer sn-Plugins.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
from sn_basis.functions.qt_wrapper import (
@@ -85,6 +86,7 @@ class PrintTab(QWidget):
self._atlas_erzeugen_cb: Optional[Any] = None
self._atlas_button: Optional[Any] = None
self._atlas_content: Optional[Any] = None
self._schriftfeld_combo: Optional[Any] = None
self._build_ui()
self._restore_state()
@@ -256,6 +258,16 @@ class PrintTab(QWidget):
self._atlas_content = atlas_content
main_layout.addWidget(atlas_content)
schriftfeld_label = QLabel("Schriftfeld-Template")
schriftfeld_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
main_layout.addWidget(schriftfeld_label)
schriftfeld_combo = QComboBox(self)
schriftfeld_combo.addItems(self._get_available_templates())
schriftfeld_combo.currentTextChanged.connect(self._on_schriftfeld_changed)
self._schriftfeld_combo = schriftfeld_combo
main_layout.addWidget(schriftfeld_combo)
btn_vorlage_erstellen = QPushButton("Vorlage erstellen", self)
btn_vorlage_erstellen.clicked.connect(self._on_vorlage_erstellen)
self._btn_vorlage_erstellen = btn_vorlage_erstellen
@@ -329,6 +341,14 @@ class PrintTab(QWidget):
self._atlas_erzeugen_cb.setChecked(atlas_checked)
self._on_atlas_erzeugen_changed(1 if atlas_checked else 0)
if self._schriftfeld_combo:
saved_schriftfeld = get_variable("tab_b_schriftfeld_template", scope="project")
available_templates = self._get_available_templates()
if saved_schriftfeld in available_templates:
self._schriftfeld_combo.setCurrentText(saved_schriftfeld)
else:
self._schriftfeld_combo.setCurrentText("standard")
self._restore_atlas_layer_selection()
def _on_kartenname_changed(self, value: str) -> None:
@@ -436,6 +456,7 @@ class PrintTab(QWidget):
zielgroesse=self._zielgroesse_combo.currentText() if self._zielgroesse_combo else DIN_STANDARD,
formfaktor=self._endlosrolle_cb.isChecked() if self._endlosrolle_cb else False,
druckbereich_auswahl=self._druckbereich_combo.currentText() if self._druckbereich_combo else DRUCKBEREICH_VERFAHRENSGEBIET,
schriftfeld_template=self._schriftfeld_combo.currentText() if self._schriftfeld_combo else "",
)
def _disconnect_project_signals(self) -> None:
@@ -706,3 +727,29 @@ class PrintTab(QWidget):
pass
self._atlas_layer_combo.blockSignals(False)
def _get_available_templates(self) -> list[str]:
"""Liest verfügbare Schriftfeld-Vorlagen aus dem config/label_configs Verzeichnis."""
config_dir = Path(__file__).parent.parent / "config" / "label_configs"
templates: list[str] = []
try:
if config_dir.exists() and config_dir.is_dir():
for json_file in config_dir.glob("*.json"):
template_name = json_file.stem
if template_name and template_name not in templates:
templates.append(template_name)
except Exception:
pass
if not templates:
templates = ["standard", "baufreigabe"]
elif "standard" not in templates:
templates.insert(0, "standard")
return sorted(templates)
def _on_schriftfeld_changed(self, template_name: str) -> None:
"""Handler wenn Schriftfeld-Template wechselt."""
set_variable("tab_b_schriftfeld_template", template_name, scope="project")
+11 -42
View File
@@ -1,5 +1,5 @@
"""
sn_basis/ui/tabs/settings_tab.py Einstellungen-Tab für Projektinformationen.
sn_basis/ui/tabs/settings_tab.py Einstellungen-Tab für Projekteinformationen.
Verwaltet benutzerspezifische und projektspezifische Einstellungen.
"""
@@ -8,33 +8,28 @@ from sn_basis.functions.qt_wrapper import (
QGridLayout,
QLabel,
QLineEdit,
QComboBox,
QGroupBox,
QVBoxLayout,
QPushButton,
)
from sn_basis.functions.settings_logic import SettingsLogic
from sn_basis.functions.landkreise import LANDKREISE
#: Feldschlüssel, die als Landkreis-Dropdown (statt Freitext) dargestellt werden.
_LANDKREIS_DROPDOWN_FELDER = {"landkreis_user"}
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)
"""
@@ -69,7 +64,7 @@ class SettingsTab(QWidget):
self.user_inputs = {}
for row, (key, label) in enumerate(self.user_fields.items()):
input_widget = self._build_input(key)
input_widget = QLineEdit()
self.user_inputs[key] = input_widget
user_layout.addWidget(QLabel(label), row, 0)
@@ -85,7 +80,7 @@ class SettingsTab(QWidget):
self.project_inputs = {}
for row, (key, label) in enumerate(self.project_fields.items()):
input_widget = self._build_input(key)
input_widget = QLineEdit()
self.project_inputs[key] = input_widget
project_layout.addWidget(QLabel(label), row, 0)
@@ -112,35 +107,14 @@ class SettingsTab(QWidget):
# Daten laden
self.load_data()
# ---------------------------------------------------------
# Eingabewidget je Feld erzeugen
# ---------------------------------------------------------
def _build_input(self, key: str):
if key in _LANDKREIS_DROPDOWN_FELDER:
combo = QComboBox()
combo.addItem("", None) # Platzhalter: keine Vorbelegung bei leerem Projekt
for kennzahl, name in LANDKREISE:
combo.addItem(name, kennzahl)
return combo
return QLineEdit()
# ---------------------------------------------------------
# Speichern
# ---------------------------------------------------------
def save_data(self):
fields = {}
for key, widget in {**self.user_inputs, **self.project_inputs}.items():
if isinstance(widget, QComboBox):
fields[key] = widget.currentText()
else:
fields[key] = widget.text()
# Landkreiskennzahl aus der Dropdown-Auswahl zusätzlich als eigene
# Variable bereitstellen (siehe landkreise.py).
landkreis_widget = self.user_inputs.get("landkreis_user")
if isinstance(landkreis_widget, QComboBox):
fields["landkreis_kennzahl"] = landkreis_widget.currentData() or ""
fields = {
key: widget.text()
for key, widget in {**self.user_inputs, **self.project_inputs}.items()
}
self.logic.save(fields)
# ---------------------------------------------------------
@@ -149,9 +123,4 @@ class SettingsTab(QWidget):
def load_data(self):
data = self.logic.load()
for key, widget in {**self.user_inputs, **self.project_inputs}.items():
if isinstance(widget, QComboBox):
gespeicherter_name = data.get(key, "")
index = widget.findText(gespeicherter_name)
widget.setCurrentIndex(index if index >= 0 else 0)
else:
widget.setText(data.get(key, ""))
widget.setText(data.get(key, ""))