Merge pull request 'Feature/88 basemap legende' (#89) from feature/88-basemap-legende into unstable

Reviewed-on: #89
This commit was merged in pull request #89.
This commit is contained in:
2026-10-02 10:17:20 +00:00
21 changed files with 5996 additions and 0 deletions
+3
View File
@@ -1,3 +1,6 @@
- Basemap-Legende für Drucklayouts (#88): Beim Erstellen einer §38-/§41-Vorlage wird ausgewertet, welche Objekte des basemap.de-Vektorkachel-Layers im Verfahrensgebiet (wahlweise im Druckbereich) tatsächlich dargestellt werden; ein Auswahldialog lässt Kategorien abwählen, die gewählten erscheinen mit der Signatur des Projektlayers in einem eigenen Legendenelement „Kartengrundlage (basemap.de)“ über der Quellenangabe. Zuordnung von 434 Basemap-Styles zu 190 Legendeneinträgen (Beschriftungen, Nummernschilder und Hintergrund ausgenommen) in `data/basemap_legende.json` (erzeugt mit `modules/basemap_katalog.py`, fachlich nachbearbeitet)
- Neuer einklappbarer Bereich „Basemap“ im Druck-Tab: Auswertebereich, Basemap-Legende beim Erstellen an/aus, „Basemap-Legende aktualisieren“ für bestehende Vorlagen und lokale Stilvorlagen des Basemap-Layers (mehrere Varianten, laden, speichern, als Standard markieren, löschen) – z. B. einmal Hausnummern ausschalten und in jedem Projekt wieder laden (#88)
- Neuer allgemeiner Dialog `ask_mehrfachauswahl` (Liste mit Häkchen, Gruppen, Suche, Symbolvorschau) in `functions/dialog_wrapper.py` (#88)
- Bugfix: `Navigation.remove_all()` ruft jetzt `deleteLater()` auf Menü und Toolbar auf, bevor die Referenzen verworfen werden – vorher blieb die Toolbar beim Unload als Kind von main_window bestehen und der Plugin Reloader meldete beim Neuladen "removing duplicated widget(s) not cleaned up by the plugin during unload: LnoSachsenToolbar" (#72)
- Restliche „ungeklärt"-Funktionen aus dem Prüfstand entschieden: Tests für Dateipruefer (`_ist_leer`, Priorität in `_handle_leere_eingabe`), Datenschreiber (`_store_style_in_gpkg` – Tabelle anlegen, Default-Stil zurücksetzen, Geometriespalte übernehmen) und Pruefmanager (`_handle_verfahrensgebiet_layer_existiert` „entfernen"/„ersetzen"); Linkpruefer (`_ist_url`, `_pruefe_url`, `_pruefe_dateipfad`), `Pruefmanager._handle_datei_existiert` und `Pruefmanager._ask_use_or_replace_pufferlayer` (toter Code, Bug #60) bekamen den Vermerk „von Test abgesehen" – sn_basis steht jetzt bei 0 ungeklärten Funktionen (#70)
- Kategorie „vielleicht testen" abgeschlossen: 30 weitere Funktionen bekamen Tests (os_wrapper, qgiscore_wrapper, qgisui_wrapper, DataGrabber, pruef_ergebnis, Navigation, SettingsTab, PrintTab – Zustandslogik, Signal-Handling, Atlas-/Theme-Verwaltung), `PrintTab._build_ui` bekam den Vermerk „Test ausgesetzt, zunächst Refactor" (#67)
File diff suppressed because it is too large Load Diff
+110
View File
@@ -409,3 +409,113 @@ def create_progress_dialog(total: int, title: str = "Fortschritt", label: str =
"""von Test abgesehen, weil vernachlässigbar; HD 18.09.2026"""
return ProgressDialog(total, title, label)
def ask_mehrfachauswahl(
titel: str,
hinweis: str,
eintraege: list[dict],
vorauswahl: set[str] | list[str],
parent: Any = None,
) -> Optional[list[str]]:
"""Zeigt eine Liste mit Häkchen, in der Einträge an- und abgewählt werden.
Parameters
----------
eintraege :
Dicts mit ``schluessel`` und ``text``, optional ``gruppe`` (Zwischenüberschrift; Einträge
einer Gruppe müssen aufeinander folgen) und ``icon`` (z. B. Symbolvorschau).
vorauswahl :
Schlüssel der anfangs angehakten Einträge.
Returns
-------
list[str] | None
Schlüssel der angehakten Einträge in Listenreihenfolge, ``None`` bei Abbruch.
Im Mock-Modus die Vorauswahl.
"""
vorauswahl = set(vorauswahl)
if QT_VERSION == 0: # Mock-Modus
return [e["schluessel"] for e in eintraege if e["schluessel"] in vorauswahl]
from sn_basis.functions.qt_wrapper import (
QDialog, QDialogButtonBox, QFont, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton,
QVBoxLayout, DialogAccepted, ItemIsEnabled, ItemIsUserCheckable, CheckStateChecked,
CheckStateUnchecked, UserRole,
)
dialog = QDialog(parent)
dialog.setWindowTitle(titel)
layout = QVBoxLayout(dialog)
beschriftung = QLabel(hinweis)
beschriftung.setWordWrap(True)
layout.addWidget(beschriftung)
suche = QLineEdit()
suche.setPlaceholderText("Suche …")
layout.addWidget(suche)
liste = QListWidget()
kopf_schrift = QFont()
kopf_schrift.setBold(True)
zeilen: list[tuple[Any, dict]] = []
koepfe: dict[str, Any] = {}
letzte_gruppe = None
for eintrag in eintraege:
gruppe = eintrag.get("gruppe")
if gruppe and gruppe != letzte_gruppe:
kopf = QListWidgetItem(gruppe)
kopf.setFlags(ItemIsEnabled)
kopf.setFont(kopf_schrift)
liste.addItem(kopf)
koepfe[gruppe] = kopf
letzte_gruppe = gruppe
zeile = QListWidgetItem(eintrag["text"])
if eintrag.get("icon") is not None:
zeile.setIcon(eintrag["icon"])
zeile.setFlags(ItemIsUserCheckable | ItemIsEnabled)
zeile.setCheckState(CheckStateChecked if eintrag["schluessel"] in vorauswahl else CheckStateUnchecked)
zeile.setData(UserRole, eintrag["schluessel"])
liste.addItem(zeile)
zeilen.append((zeile, eintrag))
layout.addWidget(liste, 1)
def _filtern(text: str) -> None:
suchtext = text.strip().lower()
sichtbare_gruppen = set()
for zeile, eintrag in zeilen:
treffer = not suchtext or suchtext in eintrag["text"].lower() \
or suchtext in (eintrag.get("gruppe") or "").lower()
zeile.setHidden(not treffer)
if treffer:
sichtbare_gruppen.add(eintrag.get("gruppe"))
for gruppe, kopf in koepfe.items():
kopf.setHidden(gruppe not in sichtbare_gruppen)
def _alle_setzen(zustand: Any) -> None:
for zeile, _ in zeilen:
if not zeile.isHidden():
zeile.setCheckState(zustand)
suche.textChanged.connect(_filtern)
knopfzeile = QHBoxLayout()
alle = QPushButton("Alle auswählen")
alle.clicked.connect(lambda *_: _alle_setzen(CheckStateChecked))
keine = QPushButton("Keine auswählen")
keine.clicked.connect(lambda *_: _alle_setzen(CheckStateUnchecked))
knopfzeile.addWidget(alle)
knopfzeile.addWidget(keine)
knopfzeile.addStretch(1)
layout.addLayout(knopfzeile)
knoepfe = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
knoepfe.accepted.connect(dialog.accept)
knoepfe.rejected.connect(dialog.reject)
layout.addWidget(knoepfe)
dialog.resize(460, 560)
if exec_dialog(dialog) != DialogAccepted:
return None
return [eintrag["schluessel"] for zeile, eintrag in zeilen if zeile.checkState() == CheckStateChecked]
+255
View File
@@ -872,6 +872,261 @@ except Exception:
QgsJsonExporter = _MockQgsJsonExporter
# ---------------------------------------------------------
# Vektorkacheln, Ausdrücke, Symbole und Legende (Basemap-Legende, Basis #88)
# Eigener Block, damit ein fehlendes Symbol den übrigen Wrapper nicht in den Mock-Modus zwingt.
# ---------------------------------------------------------
QgsVectorTileLayer: Type[Any]
QgsSelectionContext: Type[Any]
QgsTileXYZ: Type[Any]
QgsExpression: Type[Any]
QgsExpressionContext: Type[Any]
QgsExpressionContextScope: Type[Any]
QgsProperty: Type[Any]
QgsCategorizedSymbolRenderer: Type[Any]
QgsRendererCategory: Type[Any]
QgsLegendRenderer: Type[Any]
QgsLegendStyle: Type[Any]
QgsMapLayer: Type[Any]
QgsApplication: Type[Any]
QgsSymbolLayerUtils: Type[Any]
QgsLayoutMeasurement: Type[Any]
QgsLayoutUtils: Type[Any]
SELECT_BEHAVIOR_SET: Any = None
SELECT_RELATION_INTERSECT: Any = None
PROPERTY_TYPE_EXPRESSION: Any = None
LEGEND_STYLE_HIDDEN: Any = None
LEGEND_STYLE_SUBGROUP: Any = None
STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG: Any = None
VECTOR_TILES_AVAILABLE = False
try:
from qgis.core import (
QgsVectorTileLayer as _QgsVectorTileLayer,
QgsSelectionContext as _QgsSelectionContext,
QgsTileXYZ as _QgsTileXYZ,
QgsExpression as _QgsExpression,
QgsExpressionContext as _QgsExpressionContext,
QgsExpressionContextScope as _QgsExpressionContextScope,
QgsProperty as _QgsProperty,
QgsCategorizedSymbolRenderer as _QgsCategorizedSymbolRenderer,
QgsRendererCategory as _QgsRendererCategory,
QgsLegendRenderer as _QgsLegendRenderer,
QgsLegendStyle as _QgsLegendStyle,
QgsMapLayer as _QgsMapLayer,
QgsApplication as _QgsApplication,
QgsSymbolLayerUtils as _QgsSymbolLayerUtils,
QgsLayoutMeasurement as _QgsLayoutMeasurement,
QgsLayoutUtils as _QgsLayoutUtils,
Qgis as _QgisVT,
)
QgsVectorTileLayer = _QgsVectorTileLayer
QgsSelectionContext = _QgsSelectionContext
QgsTileXYZ = _QgsTileXYZ
QgsExpression = _QgsExpression
QgsExpressionContext = _QgsExpressionContext
QgsExpressionContextScope = _QgsExpressionContextScope
QgsProperty = _QgsProperty
QgsCategorizedSymbolRenderer = _QgsCategorizedSymbolRenderer
QgsRendererCategory = _QgsRendererCategory
QgsLegendRenderer = _QgsLegendRenderer
QgsLegendStyle = _QgsLegendStyle
QgsMapLayer = _QgsMapLayer
QgsApplication = _QgsApplication
QgsSymbolLayerUtils = _QgsSymbolLayerUtils
QgsLayoutMeasurement = _QgsLayoutMeasurement
QgsLayoutUtils = _QgsLayoutUtils
SELECT_BEHAVIOR_SET = _QgisVT.SelectBehavior.SetSelection
SELECT_RELATION_INTERSECT = _QgisVT.SelectGeometryRelationship.Intersect
PROPERTY_TYPE_EXPRESSION = getattr(getattr(_QgsProperty, "Type", None), "ExpressionBasedProperty", None)
if PROPERTY_TYPE_EXPRESSION is None:
PROPERTY_TYPE_EXPRESSION = getattr(_QgsProperty, "ExpressionBasedProperty", None)
LEGEND_STYLE_HIDDEN = getattr(getattr(_QgsLegendStyle, "Style", _QgsLegendStyle), "Hidden", None)
LEGEND_STYLE_SUBGROUP = getattr(getattr(_QgsLegendStyle, "Style", _QgsLegendStyle), "Subgroup", None)
_kategorie = getattr(_QgsMapLayer, "StyleCategory", _QgsMapLayer)
STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG = _kategorie.Symbology | _kategorie.Labeling
VECTOR_TILES_AVAILABLE = True
except Exception:
VECTOR_TILES_AVAILABLE = False
class _MockQgsVectorTileLayer:
"""Platzhalter: Vektorkachel-Layer gibt es im Mock-Modus nicht."""
class _MockQgsSelectionContext:
def __init__(self):
self._scale = 0.0
def setScale(self, scale: float) -> None:
self._scale = scale
def scale(self) -> float:
return self._scale
class _MockQgsTileXYZ:
def __init__(self, column: int = 0, row: int = 0, zoom: int = 0):
self._column, self._row, self._zoom = column, row, zoom
def column(self) -> int:
return self._column
def row(self) -> int:
return self._row
def zoomLevel(self) -> int:
return self._zoom
class _MockQgsExpression:
def __init__(self, text: str = ""):
self._text = text
def prepare(self, context: Any) -> bool:
return True
def evaluate(self, context: Any = None) -> Any:
return True
class _MockQgsExpressionContextScope:
def __init__(self, name: str = ""):
self._variables: dict[str, Any] = {}
def setVariable(self, name: str, value: Any, *args: Any) -> None:
self._variables[name] = value
def variable(self, name: str) -> Any:
return self._variables.get(name)
class _MockQgsExpressionContext:
def __init__(self):
self._scopes: list[Any] = []
self._feature: Any = None
def appendScope(self, scope: Any) -> None:
self._scopes.append(scope)
def setFeature(self, feature: Any) -> None:
self._feature = feature
class _MockQgsProperty:
def __init__(self, value: Any = None):
self._value = value
@staticmethod
def fromValue(value: Any) -> "_MockQgsProperty":
return _MockQgsProperty(value)
class _MockQgsRendererCategory:
def __init__(self, value: Any = None, symbol: Any = None, label: str = ""):
self._value, self._symbol, self._label = value, symbol, label
def value(self) -> Any:
return self._value
def symbol(self) -> Any:
return self._symbol
def label(self) -> str:
return self._label
class _MockQgsCategorizedSymbolRenderer:
def __init__(self, attribute: str = "", categories: list | None = None):
self._attribute = attribute
self._categories = list(categories or [])
def categories(self) -> list:
return list(self._categories)
class _MockQgsLegendStyle:
Hidden = 0
Group = 1
Subgroup = 2
class _MockQgsLegendRenderer:
@staticmethod
def setNodeLegendStyle(node: Any, style: Any) -> None:
setattr(node, "_legend_style", style)
class _MockQgsMapLayer:
class StyleCategory:
Symbology = 1
Labeling = 2
class _MockQgsApplication:
@staticmethod
def qgisSettingsDirPath() -> str:
return ""
class _MockQgsSymbolLayerUtils:
@staticmethod
def symbolPreviewIcon(symbol: Any, size: Any, *args: Any) -> Any:
return None
QgsVectorTileLayer = _MockQgsVectorTileLayer
QgsSelectionContext = _MockQgsSelectionContext
QgsTileXYZ = _MockQgsTileXYZ
QgsExpression = _MockQgsExpression
QgsExpressionContext = _MockQgsExpressionContext
QgsExpressionContextScope = _MockQgsExpressionContextScope
QgsProperty = _MockQgsProperty
QgsCategorizedSymbolRenderer = _MockQgsCategorizedSymbolRenderer
QgsRendererCategory = _MockQgsRendererCategory
QgsLegendRenderer = _MockQgsLegendRenderer
QgsLegendStyle = _MockQgsLegendStyle
QgsMapLayer = _MockQgsMapLayer
QgsApplication = _MockQgsApplication
QgsSymbolLayerUtils = _MockQgsSymbolLayerUtils
class _MockQgsLayoutMeasurement:
def __init__(self, length: float = 0.0, units: Any = None):
self._length = length
def length(self) -> float:
return self._length
QgsLayoutMeasurement = _MockQgsLayoutMeasurement
class _MockQgsLayoutUtils:
@staticmethod
def createRenderContextForLayout(layout: Any, painter: Any, dpi: float = -1) -> Any:
return None
QgsLayoutUtils = _MockQgsLayoutUtils
# QgsRectangle hatte im allgemeinen Mock-Block keinen Ersatz (nur Typannotation).
if "QgsRectangle" not in globals():
class _MockQgsRectangle:
def __init__(self, x_min: float = 0.0, y_min: float = 0.0, x_max: float = 0.0, y_max: float = 0.0):
self._werte = (x_min, y_min, x_max, y_max)
def xMinimum(self) -> float:
return self._werte[0]
def yMinimum(self) -> float:
return self._werte[1]
def xMaximum(self) -> float:
return self._werte[2]
def yMaximum(self) -> float:
return self._werte[3]
def width(self) -> float:
return self._werte[2] - self._werte[0]
def height(self) -> float:
return self._werte[3] - self._werte[1]
QgsRectangle = _MockQgsRectangle
SELECT_BEHAVIOR_SET = 0
SELECT_RELATION_INTERSECT = 0
PROPERTY_TYPE_EXPRESSION = 2
LEGEND_STYLE_HIDDEN = _MockQgsLegendStyle.Hidden
LEGEND_STYLE_SUBGROUP = _MockQgsLegendStyle.Subgroup
STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG = 3
# ---------------------------------------------------------
# Netzwerk
# ---------------------------------------------------------
+28
View File
@@ -55,6 +55,7 @@ QTableWidget: Type[Any] = object
QTableWidgetItem: Type[Any] = object
QTableWidgetSelectionRange: Type[Any] = object
QIcon: Type[Any] = object
QSize: Type[Any] = object
def exec_dialog(dialog: Any) -> Any:
@@ -125,6 +126,7 @@ try:
Qt as _Qt,
QVariant as _QVariant,
QSettings as _QSettings,
QSize as _QSize,
)
from qgis.PyQt.QtNetwork import (
QNetworkRequest as _QNetworkRequest,
@@ -175,6 +177,7 @@ try:
QTableWidgetItem = _QTableWidgetItem
QTableWidgetSelectionRange = _QTableWidgetSelectionRange
QIcon = _QIcon
QSize = _QSize
# ✅ QT6 ENUMS
YES = QMessageBox.StandardButton.Yes
NO = QMessageBox.StandardButton.No
@@ -200,6 +203,11 @@ try:
SelectRows = QAbstractItemView.SelectionBehavior.SelectRows
NoEditTriggers = QAbstractItemView.EditTrigger.NoEditTriggers
AscendingOrder = Qt.SortOrder.AscendingOrder
ItemIsUserCheckable = Qt.ItemFlag.ItemIsUserCheckable
ItemIsEnabled = Qt.ItemFlag.ItemIsEnabled
CheckStateChecked = Qt.CheckState.Checked
CheckStateUnchecked = Qt.CheckState.Unchecked
UserRole = Qt.ItemDataRole.UserRole
def exec_dialog(dialog: Any) -> Any:
return dialog.exec()
@@ -249,6 +257,7 @@ except (ImportError, AttributeError):
Qt as _Qt,
QVariant as _QVariant,
QSettings as _QSettings,
QSize as _QSize,
)
from PyQt5.QtNetwork import (
QNetworkRequest as _QNetworkRequest,
@@ -297,6 +306,7 @@ except (ImportError, AttributeError):
QTableWidgetItem = _QTableWidgetItem
QTableWidgetSelectionRange = _QTableWidgetSelectionRange
QIcon = _QIcon
QSize = _QSize
# ✅ PYQT5 ENUMS
YES = QMessageBox.Yes
@@ -324,6 +334,11 @@ except (ImportError, AttributeError):
SelectRows = QAbstractItemView.SelectRows
NoEditTriggers = QAbstractItemView.NoEditTriggers
AscendingOrder = Qt.AscendingOrder
ItemIsUserCheckable = Qt.ItemIsUserCheckable
ItemIsEnabled = Qt.ItemIsEnabled
CheckStateChecked = Qt.Checked
CheckStateUnchecked = Qt.Unchecked
UserRole = Qt.UserRole
def exec_dialog(dialog: Any) -> Any:
return dialog.exec_()
@@ -687,6 +702,19 @@ except (ImportError, AttributeError):
SelectRows = 1
NoEditTriggers = 0
AscendingOrder = 0
ItemIsUserCheckable = 16
ItemIsEnabled = 32
CheckStateChecked = 2
CheckStateUnchecked = 0
UserRole = 256
class _MockQSize:
def __init__(self, width: int = 0, height: int = 0):
self._width, self._height = width, height
def width(self) -> int: return self._width
def height(self) -> int: return self._height
QSize = _MockQSize
class _MockQDialog(_MockWidget):
DialogCode = type("DialogCode", (), {"Accepted": DialogAccepted, "Rejected": 0})
+192
View File
@@ -0,0 +1,192 @@
"""
sn_basis/modules/basemap_kacheln.py – Kacheln des Basemap-Layers auswerten
Liest die Renderer-Styles eines Vektorkachel-Layers, rechnet Maßstab in Zoomstufen um wie der
QGIS-Renderer und sammelt die Objekte eines Auswertebereichs kachelweise über
``QgsVectorTileLayer.selectByGeometry``. Teil der Basemap-Legende (``basemap_legende``).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from sn_basis.functions.qgiscore_wrapper import (
QgsExpression,
QgsExpressionContext,
QgsExpressionContextScope,
QgsGeometry,
QgsRectangle,
QgsSelectionContext,
QgsTileXYZ,
SELECT_BEHAVIOR_SET,
SELECT_RELATION_INTERSECT,
)
_KACHELFELDER = ("tile_zoom", "tile_layer")
def _aufruf(objekt: Any, methode: str, standard: Any = None) -> Any:
funktion = getattr(objekt, methode, None)
if not callable(funktion):
return standard
try:
return funktion()
except Exception:
return standard
@dataclass(frozen=True)
class StilInfo:
"""Renderer-Style des Basemap-Layers in QGIS-unabhängiger Form."""
name: str
layer_name: str
geometrie: int # 0 Punkt, 1 Linie, 2 Fläche
min_zoom: int = -1
max_zoom: int = -1
aktiv: bool = True
filter: str = ""
@dataclass
class ObjektInfo:
"""Ein (entdoppeltes) Objekt aus den Kacheln des Auswertebereichs."""
layer_name: str
geometrie: int
attribute: dict
merkmal: Any = None # QgsFeature für die Auswertung der Filterausdrücke in QGIS
def _enum_int(wert: Any) -> int:
try:
return int(wert)
except (TypeError, ValueError):
return int(getattr(wert, "value", -1))
def stile_aus_layer(layer: Any) -> list[StilInfo]:
"""Liest die Renderer-Styles eines Vektorkachel-Layers."""
renderer = _aufruf(layer, "renderer")
stile = _aufruf(renderer, "styles", []) or []
return [
StilInfo(
name=s.styleName(),
layer_name=s.layerName(),
geometrie=_enum_int(s.geometryType()),
min_zoom=s.minZoomLevel(),
max_zoom=s.maxZoomLevel(),
aktiv=s.isEnabled(),
filter=s.filterExpression(),
)
for s in stile
]
def zoomstufen(layer: Any, massstab: float) -> tuple[int, int, float]:
"""Kachel-, Darstellungs- und gebrochene Zoomstufe wie im QGIS-Vektorkachel-Renderer.
``zoom_level`` = ``scaleToZoomLevel(maßstab, False)`` (nicht auf die Kachelstufen begrenzt),
``vector_tile_zoom`` = ``scaleToZoom(maßstab)``. Für Karten in EPSG:25833 entspricht der
Kachelmaßstab dem Kartenmaßstab (geprüft mit ``calculateTileScaleForMap``, QGIS 3.44).
"""
matrix = layer.tileMatrixSet()
return (
matrix.scaleToZoomLevel(massstab),
matrix.scaleToZoomLevel(massstab, False),
matrix.scaleToZoom(massstab),
)
def qgis_filterpruefer(vector_tile_zoom: float, zoom_level: int) -> Callable[[str, ObjektInfo], bool]:
"""Liefert eine Filterprüfung, die QGIS-Ausdrücke wie der Renderer auswertet."""
kontext = QgsExpressionContext()
scope = QgsExpressionContextScope()
scope.setVariable("vector_tile_zoom", vector_tile_zoom)
scope.setVariable("zoom_level", zoom_level)
kontext.appendScope(scope)
ausdruecke: dict[str, Any] = {}
def pruefe(filter_text: str, objekt: ObjektInfo) -> bool:
ausdruck = ausdruecke.get(filter_text)
if ausdruck is None:
ausdruck = QgsExpression(filter_text)
ausdruck.prepare(kontext)
ausdruecke[filter_text] = ausdruck
kontext.setFeature(objekt.merkmal)
return bool(ausdruck.evaluate(kontext))
return pruefe
def _objekt_schluessel(merkmal: Any) -> tuple:
werte = []
for feld in merkmal.fields():
name = feld.name()
if name not in _KACHELFELDER:
werte.append((name, str(merkmal[name])))
return merkmal["tile_layer"], _enum_int(merkmal.geometry().type()), tuple(sorted(werte))
def sammle_objekte(
layer: Any,
geometrie: Any,
massstab: float,
*,
fortschritt: Callable[[int, int], bool] | None = None,
) -> list[ObjektInfo] | None:
"""Wertet die Kacheln des Basemap-Layers im Auswertebereich aus.
``geometrie`` liegt im KBS des Layers (EPSG:3857). Ausgewertet wird kachelweise über
``selectByGeometry``, damit Fortschritt und Abbruch möglich sind. Gleiche Objekte (gleicher
Quell-Layer, Geometrietyp und Attribute) werden entdoppelt. Eine vorhandene Auswahl auf dem
Basemap-Layer wird dabei aufgehoben.
Returns
-------
list[ObjektInfo] | None
``None``, wenn der Nutzer abgebrochen hat.
"""
matrix_set = layer.tileMatrixSet()
zoom = matrix_set.scaleToZoomLevel(massstab)
matrix = matrix_set.tileMatrix(zoom)
bereich = matrix.tileRangeFromExtent(geometrie.boundingBox())
kacheln = [
(spalte, zeile)
for spalte in range(bereich.startColumn(), bereich.endColumn() + 1)
for zeile in range(bereich.startRow(), bereich.endRow() + 1)
]
objekte: dict[tuple, ObjektInfo] = {}
try:
for nummer, (spalte, zeile) in enumerate(kacheln, start=1):
if fortschritt is not None and not fortschritt(nummer, len(kacheln)):
return None
rechteck = matrix.tileExtent(QgsTileXYZ(spalte, zeile, zoom))
rand = rechteck.width() * 1e-6
innen = QgsRectangle(rechteck.xMinimum() + rand, rechteck.yMinimum() + rand,
rechteck.xMaximum() - rand, rechteck.yMaximum() - rand)
teil = geometrie.intersection(QgsGeometry.fromRect(innen))
if teil is None or teil.isEmpty():
continue
kontext = QgsSelectionContext()
kontext.setScale(massstab)
layer.selectByGeometry(teil, kontext, SELECT_BEHAVIOR_SET, SELECT_RELATION_INTERSECT)
for merkmal in layer.selectedFeatures():
schluessel = _objekt_schluessel(merkmal)
if schluessel not in objekte:
objekte[schluessel] = ObjektInfo(
layer_name=schluessel[0],
geometrie=schluessel[1],
attribute=dict(schluessel[2]),
merkmal=merkmal,
)
finally:
_aufruf(layer, "removeSelection")
return list(objekte.values())
def anzahl_kacheln(layer: Any, geometrie: Any, massstab: float) -> int:
matrix_set = layer.tileMatrixSet()
zoom = matrix_set.scaleToZoomLevel(massstab)
bereich = matrix_set.tileMatrix(zoom).tileRangeFromExtent(geometrie.boundingBox())
return max(0, bereich.endColumn() - bereich.startColumn() + 1) * max(0, bereich.endRow() - bereich.startRow() + 1)
+418
View File
@@ -0,0 +1,418 @@
"""
sn_basis/modules/basemap_katalog.py – Legendenkatalog für basemap.de erzeugen
Erzeugt aus der Style-JSON von basemap.de (``bm_web_col.json``) den Legendenkatalog
``data/basemap_legende.json``. Der Katalog ordnet jede Style-ID genau einem
Legendeneintrag zu (z. B. gehören ``Kontur_Kreisstr``, ``Decker_Kreisstr`` und
``Bruecke_Kreisstr_Decker`` zum Eintrag „Kreisstraße“) und legt fest, welche
Styles zusammen das Legendensymbol bilden.
Das Modul nutzt nur die Standardbibliothek und lässt sich auch eigenständig
aufrufen, um den Katalog nach einer neuen Basemap-Stilversion zu erneuern::
python modules/basemap_katalog.py # lädt die Style-JSON und schreibt data/basemap_legende.json
python modules/basemap_katalog.py --stil datei.json
Bestehende fachliche Nachbearbeitung (Bezeichnungen, Gruppen, Reihenfolge,
Zuordnung einzelner Style-IDs) bleibt bei einem erneuten Lauf erhalten.
"""
from __future__ import annotations
import json
import os
import re
import sys
from typing import Any
STIL_URL = "https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/styles/bm_web_col.json"
# Style-IDs, die nie in die Legende gehören (Flächenfüllung des Kartenhintergrunds).
NIE_IN_LEGENDE = ["Hintergrund"]
# Präfixe von Style-IDs, die nie in die Legende gehören: Beschriftungen und Straßennummernschilder
# (``Nummer_*`` kommen als Symbol mit Schildhintergrund im Renderer an).
NIE_IN_LEGENDE_PRAEFIXE = ["Name_", "Nummer_"]
# Bedingungen, die einen eigenen Legendeneintrag begründen. Alle anderen Filterbedingungen
# (bauwerk, fahrbahn, anzahl, fahrbahnachse …) sind Darstellungsvarianten desselben Eintrags.
BEDEUTUNGSTRAGENDE_ATTRIBUTE = ("klasse", "zustand", "merkmal", "zone", "art")
# Teile einer Style-ID, die nur eine Darstellungsvariante kennzeichnen.
VARIANTEN_KENNUNGEN = (
"Bruecke", "Bruecken", "Tunnel", "Staumauer", "Staudamm", "getrennt", "getr", "Fahrbahnachse",
"FSZ1", "einblenden", "Kontur", "Decker", "Fuellung", "ohne", "Linie", "Punkte", "Kreise", "Enden",
"Versatz", "Symbol", "L", "F", "P",
)
# Rollen von Style-Layern, die zusammen ein Legendensymbol bilden (je Rolle ein Style).
SYMBOL_ROLLEN = ("Kontur", "Decker", "Fuellung", "Linie", "Punkte", "Kreise", "Enden", "Versatz")
# Objektfamilie je Quell-Layer: Fläche, Linie und Punkt derselben Objektart bilden einen Eintrag.
_FAMILIE_JE_QUELL_LAYER = {
"Bauwerksflaeche": "Bauwerk", "Bauwerkslinie": "Bauwerk", "Bauwerkspunkt": "Bauwerk",
"Gewaesserflaeche": "Gewaesser", "Gewaesserlinie": "Gewaesser", "Gewaesserpunkt": "Gewaesser",
"Reliefflaeche": "Relief", "Relieflinie": "Relief", "Reliefpunkt": "Relief",
"Historische_Flaeche": "Historisch", "Historische_Linie": "Historisch", "Historischer_Punkt": "Historisch",
"Besondere_Flaeche": "Besonders", "Besondere_Linie": "Besonders", "Besonderer_Punkt": "Besonders",
"Vegetationsflaeche": "Vegetation", "Vegetationslinie": "Vegetation", "Vegetationspunkt": "Vegetation",
"Verkehrsflaeche": "Verkehr", "Verkehrslinie": "Verkehr", "Verkehrspunkt": "Verkehr",
"Gebaeudeflaeche": "Gebaeude", "Gebaeudepunkt": "Gebaeude",
"Grenze_Flaeche": "Grenze", "Grenze_Linie": "Grenze", "Grenze_Punkt": "Grenze",
"Siedlungsflaeche": "Siedlung", "Weitere_Nutzung_Flaeche": "Siedlung",
"Versorgungslinie": "Versorgung", "Versorgungspunkt": "Versorgung",
}
GRUPPEN = [
"Verkehr",
"Gewässer",
"Vegetation",
"Siedlung und Nutzung",
"Gebäude",
"Bauwerke",
"Relief",
"Grenzen und Schutzgebiete",
"Historisches",
"Versorgung",
"Sonstiges",
]
_GRUPPE_JE_QUELL_LAYER = {
"Verkehrslinie": "Verkehr",
"Verkehrsflaeche": "Verkehr",
"Verkehrspunkt": "Verkehr",
"Gewaesserlinie": "Gewässer",
"Gewaesserflaeche": "Gewässer",
"Gewaesserpunkt": "Gewässer",
"Vegetationsflaeche": "Vegetation",
"Vegetationslinie": "Vegetation",
"Vegetationspunkt": "Vegetation",
"Siedlungsflaeche": "Siedlung und Nutzung",
"Weitere_Nutzung_Flaeche": "Siedlung und Nutzung",
"Gebaeudeflaeche": "Gebäude",
"Gebaeudepunkt": "Gebäude",
"Bauwerksflaeche": "Bauwerke",
"Bauwerkslinie": "Bauwerke",
"Bauwerkspunkt": "Bauwerke",
"Barrierenlinie": "Bauwerke",
"Reliefflaeche": "Relief",
"Relieflinie": "Relief",
"Reliefpunkt": "Relief",
"Grenze_Flaeche": "Grenzen und Schutzgebiete",
"Grenze_Linie": "Grenzen und Schutzgebiete",
"Grenze_Punkt": "Grenzen und Schutzgebiete",
"Historische_Flaeche": "Historisches",
"Historische_Linie": "Historisches",
"Historischer_Punkt": "Historisches",
"Besondere_Flaeche": "Historisches",
"Besondere_Linie": "Historisches",
"Besonderer_Punkt": "Historisches",
"Versorgungslinie": "Versorgung",
"Versorgungspunkt": "Versorgung",
}
# Ersetzungen, um aus ASCII-Style-IDs lesbare deutsche Bezeichnungen zu bilden.
_UMLAUT_ERSETZUNGEN = (
("Strasse", "Straße"), ("Str", "Straße"), ("strasse", "straße"), ("Fuss", "Fuß"), ("fuss", "fuß"),
("Gewaesser", "Gewässer"), ("gewaesser", "gewässer"), ("Flaeche", "Fläche"), ("flaeche", "fläche"),
("Gruen", "Grün"), ("gruen", "grün"), ("Gehoelz", "Gehölz"), ("Bruecke", "Brücke"),
("Uebung", "Übung"), ("uebung", "übung"), ("Foerder", "Förder"), ("foerder", "förder"),
("Behaelter", "Behälter"), ("Schiess", "Schieß"), ("oeffentlich", "öffentlich"),
("Daemme", "Dämme"), ("Fliess", "Fließ"), ("Geroell", "Geröll"), ("Dueker", "Düker"),
("Gaenger", "Gänger"), ("gaenger", "gänger"), ("Uebergang", "Übergang"), ("Hoehe", "Höhe"),
("hoehe", "höhe"), ("Schleuse", "Schleuse"), ("Faehr", "Fähr"), ("Muehle", "Mühle"),
("Sumpf", "Sumpf"), ("Huegel", "Hügel"), ("huegel", "hügel"), ("Aussichts", "Aussichts"),
("Schoepf", "Schöpf"), ("Gelaende", "Gelände"), ("gelaende", "gelände"), ("Oe", "Ö"),
)
_ZUSTAND_KURZ = {
"Im Bau": "im Bau",
"Außer Betrieb, stillgelegt, verlassen": "außer Betrieb",
}
def lade_json(pfad: str) -> dict:
with open(pfad, encoding="utf-8") as datei:
return json.load(datei)
def _sammle_bedingungen(filter_ausdruck: Any, bedingungen: dict[str, set[str]]) -> None:
"""Sammelt ``==``/``in``-Bedingungen eines Mapbox-Filters (rekursiv über ``all``/``any``)."""
if not isinstance(filter_ausdruck, list) or not filter_ausdruck:
return
operator = filter_ausdruck[0]
if operator in ("==", "in") and len(filter_ausdruck) >= 3 and isinstance(filter_ausdruck[1], str):
bedingungen.setdefault(filter_ausdruck[1], set()).update(str(w) for w in filter_ausdruck[2:])
elif operator in ("all", "any"):
for teil in filter_ausdruck[1:]:
_sammle_bedingungen(teil, bedingungen)
def ist_renderer_stil(stil: dict) -> bool:
"""Ob ein Style-Layer in QGIS als Renderer-Style (und nicht als Beschriftung) ankommt.
Reine Beschriftungen (``symbol`` mit ``text-field`` ohne ``icon-image``) landen beim
Import in ``QgsVectorTileBasicLabeling``; ``fill-extrusion`` (3D-Gebäude) übernimmt QGIS
nicht. Beides erscheint nie in der Legende. Geprüft gegen QGIS 3.44 (02.10.2026).
"""
typ = stil.get("type")
if typ in ("fill", "line", "circle"):
return True
if typ == "symbol":
return "icon-image" in (stil.get("layout") or {})
return False
def ist_ausgeschlossen(stil_id: str, nie_in_legende: list[str], praefixe: list[str]) -> bool:
"""Ob eine Style-ID nie in die Legende gehört (Ausschlussliste oder Präfix)."""
return stil_id in nie_in_legende or any(stil_id.startswith(p) for p in praefixe)
def normalisiere_stil_id(stil_id: str) -> str:
"""Entfernt Variantenkennungen (Kontur, Decker, Brücke …) aus einer Style-ID."""
teile = [t for t in stil_id.split("_")
if t and t not in VARIANTEN_KENNUNGEN and not re.fullmatch(r"\d+m", t)]
return "_".join(teile) or stil_id
def _slug(text: str) -> str:
text = text.lower()
for alt, neu in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
text = text.replace(alt, neu)
return re.sub(r"[^a-z0-9]+", "_", text).strip("_")
def _bezeichnung_aus_id(stil_id: str) -> str:
name = normalisiere_stil_id(stil_id)
teile = name.split("_")
# Präfixe wie "SiedlungF", "VerkehrsL", "Symbol", "BauwerkP" tragen keine Bedeutung für die Legende.
while len(teile) > 1 and (teile[0] == "Symbol" or re.fullmatch(r"[A-Z][a-z]+[FLP]", teile[0])
or teile[0] in ("Gebaeude2D", "Gebaeudepunkt", "Verkehrsflaeche", "Gewaesser",
"IstWeitereNutzung", "Grenzen")):
teile = teile[1:]
woerter = []
for teil in teile:
for alt, neu in _UMLAUT_ERSETZUNGEN:
if teil == alt or teil.startswith(alt):
teil = neu + teil[len(alt):]
break
woerter.append(teil)
return " ".join(woerter)
def _bezeichnung(stil_id: str, bedingungen: dict[str, set[str]]) -> str:
klassen = sorted(bedingungen.get("klasse", set()))
if 1 <= len(klassen) <= 2:
text = " / ".join(klassen)
else:
text = _bezeichnung_aus_id(stil_id)
if "merkmal" in bedingungen:
text += ", nicht ständig wasserführend" if any("ständig" in m for m in bedingungen["merkmal"]) else ""
zustaende = sorted(_ZUSTAND_KURZ.get(z, z) for z in bedingungen.get("zustand", set()))
if zustaende:
text += f" ({' / '.join(zustaende)})"
return text
def eintrags_schluessel(stil: dict) -> tuple[str, str]:
"""Bildet Schlüssel und Bezeichnung des Legendeneintrags, zu dem ein Style-Layer gehört."""
bedingungen: dict[str, set[str]] = {}
_sammle_bedingungen(stil.get("filter"), bedingungen)
quell_layer = stil.get("source-layer", "")
familie = _FAMILIE_JE_QUELL_LAYER.get(quell_layer, quell_layer)
bedeutung = {k: v for k, v in bedingungen.items() if k in BEDEUTUNGSTRAGENDE_ATTRIBUTE}
if "klasse" in bedeutung:
teile = [familie] + [f"{k}={'|'.join(sorted(bedeutung[k]))}" for k in sorted(bedeutung)]
else:
teile = [familie, normalisiere_stil_id(stil.get("id", ""))] + [
f"{k}={'|'.join(sorted(bedeutung[k]))}" for k in sorted(bedeutung)]
schluessel = _slug("_".join(teile))
if len(schluessel) > 60:
# Lange Klassenlisten kürzen, Eindeutigkeit über die normalisierte Style-ID.
schluessel = _slug(f"{familie}_{normalisiere_stil_id(stil.get('id', ''))}")
return schluessel, _bezeichnung(stil.get("id", ""), bedingungen)
def _ist_grundvariante(stil_id: str) -> bool:
"""Ob eine Style-ID die Normaldarstellung (keine Brücke, kein Tunnel, keine Sonderfahrbahn) zeigt."""
teile = set(stil_id.split("_"))
return not teile & {"Bruecke", "Bruecken", "Tunnel", "Staumauer", "getrennt", "getr", "Fahrbahnachse",
"FSZ1", "einblenden"}
def waehle_symbol_stile(treffer_stile: list[str]) -> list[str]:
"""Wählt die Styles, die zusammen das Legendensymbol bilden.
Aus den Grundvarianten (keine Brücke, kein Tunnel, keine Sonderfahrbahn) wird je Rolle
(Kontur, Decker, Füllung …) der erste Style genommen, z. B. ``Kontur_Kreisstr`` +
``Decker_Kreisstr``. Ohne Grundvariante gelten alle Treffer-Styles als Ausgangsmenge.
"""
kandidaten = [s for s in treffer_stile if _ist_grundvariante(s)] or list(treffer_stile)
gewaehlt: list[str] = []
rollen: set[str] = set()
for stil_id in kandidaten:
teile = stil_id.split("_")
rolle = next((r for r in SYMBOL_ROLLEN if r in teile), "basis")
if rolle in rollen:
continue
rollen.add(rolle)
gewaehlt.append(stil_id)
return gewaehlt
def erzeuge_katalog(stil_json: dict, bisher: dict | None = None) -> tuple[dict, list[str]]:
"""Erzeugt den Legendenkatalog aus der Style-JSON.
``bisher`` ist ein bereits vorhandener (ggf. fachlich nachbearbeiteter) Katalog. Seine
Zuordnung Style-ID → Eintrag, Bezeichnungen, Gruppen und Reihenfolge bleiben erhalten;
neue Style-IDs werden ergänzt, entfallene entfernt.
Returns
-------
(katalog, meldungen)
"""
meldungen: list[str] = []
bisher = bisher or {}
bisherige_eintraege = {e["schluessel"]: e for e in bisher.get("eintraege", [])}
bisherige_zuordnung = {
stil_id: e["schluessel"] for e in bisher.get("eintraege", []) for stil_id in e.get("treffer_stile", [])
}
nie = list(dict.fromkeys(list(bisher.get("nie_in_legende", [])) + NIE_IN_LEGENDE))
praefixe = list(dict.fromkeys(list(bisher.get("nie_in_legende_praefixe", [])) + NIE_IN_LEGENDE_PRAEFIXE))
eintraege: dict[str, dict] = {}
reihenfolge_neu: list[str] = []
gueltige_ids: set[str] = set()
for stil in stil_json.get("layers", []):
stil_id = stil.get("id", "")
if not stil_id or ist_ausgeschlossen(stil_id, nie, praefixe) or not ist_renderer_stil(stil):
continue
gueltige_ids.add(stil_id)
schluessel, bezeichnung = eintrags_schluessel(stil)
schluessel = bisherige_zuordnung.get(stil_id, schluessel)
if schluessel not in eintraege:
vorlage = bisherige_eintraege.get(schluessel, {})
eintraege[schluessel] = {
"schluessel": schluessel,
"bezeichnung": vorlage.get("bezeichnung", bezeichnung),
"gruppe": vorlage.get("gruppe", _GRUPPE_JE_QUELL_LAYER.get(stil.get("source-layer", ""), "Sonstiges")),
"treffer_stile": [],
"symbol_stile": [],
}
reihenfolge_neu.append(schluessel)
if schluessel not in bisherige_eintraege and bisher:
meldungen.append(f"Neuer Eintrag: {schluessel} ({bezeichnung})")
eintraege[schluessel]["treffer_stile"].append(stil_id)
for eintrag in eintraege.values():
vorlage = bisherige_eintraege.get(eintrag["schluessel"], {})
symbol_bisher = [s for s in vorlage.get("symbol_stile", []) if s in gueltige_ids]
if symbol_bisher:
eintrag["symbol_stile"] = symbol_bisher
else:
eintrag["symbol_stile"] = waehle_symbol_stile(eintrag["treffer_stile"])
for stil_id, schluessel in bisherige_zuordnung.items():
if stil_id not in gueltige_ids:
meldungen.append(f"Style-ID entfallen: {stil_id} (war {schluessel})")
# Reihenfolge: bisherige Reihenfolge zuerst, neue Einträge in Reihenfolge der Style-JSON dahinter,
# innerhalb der Gruppenreihenfolge.
alte_reihenfolge = [e["schluessel"] for e in bisher.get("eintraege", []) if e["schluessel"] in eintraege]
neue = [s for s in reihenfolge_neu if s not in alte_reihenfolge]
gruppen = list(bisher.get("gruppen", GRUPPEN))
for eintrag in eintraege.values():
if eintrag["gruppe"] not in gruppen:
gruppen.append(eintrag["gruppe"])
if alte_reihenfolge:
sortiert = alte_reihenfolge + neue
else:
sortiert = sorted(neue, key=lambda s: gruppen.index(eintraege[s]["gruppe"]))
katalog = {
"beschreibung": (
"Legendenkatalog für basemap.de Web Vektor. Ordnet jede Style-ID genau einem Legendeneintrag zu. "
"Erzeugt mit modules/basemap_katalog.py, danach fachlich nachbearbeitet."
),
"quelle": {
"url": STIL_URL,
"name": stil_json.get("name", ""),
"version": stil_json.get("version", ""),
},
"gruppen": gruppen,
"nie_in_legende": nie,
"nie_in_legende_praefixe": praefixe,
"eintraege": [eintraege[s] for s in sortiert],
}
for name in [n for n in pruefe_katalog(katalog)]:
meldungen.append(f"Katalogfehler: {name}")
return katalog, meldungen
def pruefe_katalog(katalog: dict) -> list[str]:
"""Prüft die Konsistenz eines Katalogs und liefert eine Liste von Fehlermeldungen."""
fehler: list[str] = []
gesehen: dict[str, str] = {}
schluessel_gesehen: set[str] = set()
alle_stile = {s for e in katalog.get("eintraege", []) for s in e.get("treffer_stile", [])}
for eintrag in katalog.get("eintraege", []):
schluessel = eintrag.get("schluessel", "")
if not schluessel:
fehler.append("Eintrag ohne Schlüssel")
continue
if schluessel in schluessel_gesehen:
fehler.append(f"Schlüssel doppelt: {schluessel}")
schluessel_gesehen.add(schluessel)
if eintrag.get("gruppe") not in katalog.get("gruppen", []):
fehler.append(f"{schluessel}: Gruppe '{eintrag.get('gruppe')}' nicht in der Gruppenliste")
for stil_id in eintrag.get("treffer_stile", []):
if stil_id in gesehen:
fehler.append(f"Style-ID {stil_id} in {gesehen[stil_id]} und {schluessel}")
gesehen[stil_id] = schluessel
# symbol_stile dürfen auch Styles anderer Einträge nutzen (gemeinsame Kontur von
# „Straße im Bau“ und „Straße außer Betrieb“), müssen aber im Katalog vorkommen.
unbekannt = set(eintrag.get("symbol_stile", [])) - alle_stile
if unbekannt:
fehler.append(f"{schluessel}: symbol_stile nicht im Katalog: {', '.join(sorted(unbekannt))}")
if not eintrag.get("symbol_stile"):
fehler.append(f"{schluessel}: keine symbol_stile")
return fehler
def _standard_zielpfad() -> str:
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "basemap_legende.json")
def main(argv: list[str] | None = None) -> int:
"""Kommandozeile: Style-JSON laden, Katalog erzeugen bzw. aktualisieren und schreiben."""
import argparse
import urllib.request
parser = argparse.ArgumentParser(description="Legendenkatalog für basemap.de erzeugen oder aktualisieren.")
parser.add_argument("--stil", help="lokale Style-JSON statt Download von basemap.de")
parser.add_argument("--ziel", default=_standard_zielpfad(), help="Zieldatei (Standard: data/basemap_legende.json)")
parser.add_argument("--neu", action="store_true", help="bestehenden Katalog ignorieren")
args = parser.parse_args(argv)
if args.stil:
stil_json = lade_json(args.stil)
else:
with urllib.request.urlopen(STIL_URL, timeout=60) as antwort:
stil_json = json.loads(antwort.read().decode("utf-8"))
bisher = None
if not args.neu and os.path.exists(args.ziel):
bisher = lade_json(args.ziel)
katalog, meldungen = erzeuge_katalog(stil_json, bisher)
with open(args.ziel, "w", encoding="utf-8", newline="\n") as datei:
json.dump(katalog, datei, ensure_ascii=False, indent=1)
datei.write("\n")
for meldung in meldungen:
print(meldung)
print(f"{len(katalog['eintraege'])} Einträge geschrieben: {args.ziel}")
return 1 if any(m.startswith("Katalogfehler") for m in meldungen) else 0
if __name__ == "__main__":
sys.exit(main())
+471
View File
@@ -0,0 +1,471 @@
"""
sn_basis/modules/basemap_legende.py – Legende für den basemap.de-Vektorkachel-Layer
QGIS erzeugt für Vektorkachel-Layer keine Legende. Dieses Modul wertet aus, welche
Objekte des Basemap-Layers im Auswertebereich (Verfahrensgebiet oder Druckbereich)
tatsächlich dargestellt werden, lässt den Nutzer Kategorien abwählen und schreibt die
gewählten Kategorien mit der Signatur des Projektlayers in ein eigenes Legendenelement
des Drucklayouts (``basemap_legende_layout``).
Ablauf und Festlegungen: Konzept ``docs/konzept/10_basemap_legende.md`` im Projektwissen,
Ticket Plugin_SN_Basis #88.
Aufbau:
- reine Logik ohne QGIS (Katalog, Zuordnung Style → Legendeneintrag, Vorauswahl) – im
Mock-Modus getestet,
- QGIS-Anbindung: Layer-Erkennung und Auswertebereich hier, Kachelauswertung über
``selectByGeometry`` in ``basemap_kacheln``,
- ``BasemapLegende.aktualisiere(layout)`` als Einstieg für Druck-Tab und Vorlagenerstellung.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable
from sn_basis.functions.qgiscore_wrapper import QgsCoordinateTransform, QgsGeometry, QgsProject
from sn_basis.functions.variable_wrapper import get_variable, set_variable
from sn_basis.modules.basemap_kacheln import ( # noqa: F401 – StilInfo/ObjektInfo gehören zur Schnittstelle
ObjektInfo,
StilInfo,
anzahl_kacheln,
qgis_filterpruefer,
sammle_objekte,
stile_aus_layer,
zoomstufen,
)
# ---------------------------------------------------------
# Konstanten und Schnittstellen
# ---------------------------------------------------------
BASEMAP_URL_KENNUNG = "gdz_basemapde_vektor"
BASEMAP_NAME_RUECKFALL = "basemap.de"
AUSWERTEBEREICH_VERFAHRENSGEBIET = "verfahrensgebiet"
AUSWERTEBEREICH_DRUCKBEREICH = "druckbereich"
AUSWERTEBEREICHE = {
AUSWERTEBEREICH_VERFAHRENSGEBIET: "Verfahrensgebiet",
AUSWERTEBEREICH_DRUCKBEREICH: "Druckbereich (Kartenausschnitt der Vorlage)",
}
# Projektvariablen (ohne Präfix sn_, siehe variable_wrapper)
AUSWERTEBEREICH_VAR = "basemap_auswertebereich"
LEGENDE_ERZEUGEN_VAR = "basemap_legende_erzeugen"
AUSWAHL_VAR = "basemap_legende_auswahl"
GRUPPE_SONSTIGES = "Sonstiges"
# Ab dieser Kachelzahl wird vor der Auswertung nachgefragt (je Kachel ein Netzabruf).
KACHELN_NACHFRAGEN_AB = 400
_STANDARD_KATALOG = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "basemap_legende.json")
# ---------------------------------------------------------
# Datenklassen
# ---------------------------------------------------------
@dataclass
class LegendenEintrag:
"""Ein Eintrag der Basemap-Legende."""
schluessel: str
bezeichnung: str
gruppe: str
symbol_stile: list[str]
treffer: list[str] = field(default_factory=list)
bekannt: bool = True
# ---------------------------------------------------------
# Katalog und Zuordnung (ohne QGIS)
# ---------------------------------------------------------
class Katalog:
"""Legendenkatalog aus ``data/basemap_legende.json`` mit Index Style-ID → Eintrag."""
def __init__(self, daten: dict):
self.daten = daten
self.gruppen: list[str] = list(daten.get("gruppen", []))
if GRUPPE_SONSTIGES not in self.gruppen:
self.gruppen.append(GRUPPE_SONSTIGES)
self.eintraege: list[dict] = list(daten.get("eintraege", []))
self._nie = set(daten.get("nie_in_legende", []))
self._praefixe = tuple(daten.get("nie_in_legende_praefixe", []))
self._index: dict[str, dict] = {}
self._reihenfolge: dict[str, int] = {}
for position, eintrag in enumerate(self.eintraege):
self._reihenfolge[eintrag["schluessel"]] = position
for stil_id in eintrag.get("treffer_stile", []):
self._index[stil_id] = eintrag
@classmethod
def laden(cls, pfad: str | None = None) -> "Katalog":
with open(pfad or _STANDARD_KATALOG, encoding="utf-8") as datei:
return cls(json.load(datei))
def ist_ausgeschlossen(self, stil_name: str) -> bool:
return stil_name in self._nie or (bool(self._praefixe) and stil_name.startswith(self._praefixe))
def eintrag_fuer(self, stil_name: str) -> dict | None:
return self._index.get(stil_name)
def sortierschluessel(self, eintrag: LegendenEintrag) -> tuple[int, int, str]:
gruppe = self.gruppen.index(eintrag.gruppe) if eintrag.gruppe in self.gruppen else len(self.gruppen)
return gruppe, self._reihenfolge.get(eintrag.schluessel, len(self._reihenfolge)), eintrag.bezeichnung
def stil_ist_aktiv(stil: StilInfo, zoom: int) -> bool:
"""Wie ``QgsVectorTileBasicRendererStyle.isActive``: eingeschaltet und Zoomstufe im Bereich."""
return (
stil.aktiv
and (stil.min_zoom < 0 or zoom >= stil.min_zoom)
and (stil.max_zoom < 0 or zoom <= stil.max_zoom)
)
def ermittle_getroffene_stile(
objekte: Iterable[ObjektInfo],
stile: Iterable[StilInfo],
zoom: int,
filter_trifft: Callable[[str, ObjektInfo], bool],
) -> dict[str, int]:
"""Ermittelt, welche Renderer-Styles die Objekte bei der Zoomstufe tatsächlich zeichnen.
Ein Style zeichnet ein Objekt, wenn er aktiv ist, zum Quell-Layer passt (leerer
Layername = alle Layer), denselben Geometrietyp hat und sein Filterausdruck zutrifft.
Returns
-------
dict[str, int]
Style-Name → Anzahl der gezeichneten (entdoppelten) Objekte.
"""
je_layer: dict[str, list[StilInfo]] = {}
for stil in stile:
if stil_ist_aktiv(stil, zoom):
je_layer.setdefault(stil.layer_name, []).append(stil)
treffer: dict[str, int] = {}
for objekt in objekte:
for stil in je_layer.get(objekt.layer_name, []) + je_layer.get("", []):
if stil.geometrie != objekt.geometrie:
continue
if stil.filter and not filter_trifft(stil.filter, objekt):
continue
treffer[stil.name] = treffer.get(stil.name, 0) + 1
return treffer
def bilde_eintraege(getroffene_stile: Iterable[str], katalog: Katalog) -> list[LegendenEintrag]:
"""Fasst getroffene Styles zu Legendeneinträgen zusammen, sortiert nach Gruppe und Katalog.
Ausgeschlossene Styles (Beschriftungen, Hintergrund) entfallen. Styles, die der Katalog
nicht kennt (eigene Ergänzungen, neue Basemap-Version), bilden je einen Eintrag in der
Gruppe „Sonstiges“.
"""
eintraege: dict[str, LegendenEintrag] = {}
for stil_name in getroffene_stile:
if katalog.ist_ausgeschlossen(stil_name):
continue
daten = katalog.eintrag_fuer(stil_name)
if daten is None:
schluessel = f"stil:{stil_name}"
eintrag = eintraege.setdefault(schluessel, LegendenEintrag(
schluessel=schluessel,
bezeichnung=stil_name.replace("_", " "),
gruppe=GRUPPE_SONSTIGES,
symbol_stile=[stil_name],
bekannt=False,
))
else:
eintrag = eintraege.setdefault(daten["schluessel"], LegendenEintrag(
schluessel=daten["schluessel"],
bezeichnung=daten.get("bezeichnung", daten["schluessel"]),
gruppe=daten.get("gruppe", GRUPPE_SONSTIGES),
symbol_stile=list(daten.get("symbol_stile", [])),
))
eintrag.treffer.append(stil_name)
return sorted(eintraege.values(), key=katalog.sortierschluessel)
def lies_auswahl() -> dict[str, list[str]]:
"""Liest die zuletzt im Dialog getroffene Auswahl aus der Projektvariable."""
roh = get_variable(AUSWAHL_VAR, scope="project") or ""
try:
daten = json.loads(roh) if roh else {}
except (TypeError, ValueError):
daten = {}
if not isinstance(daten, dict):
daten = {}
return {
"ausgeblendet": [str(s) for s in daten.get("ausgeblendet", [])],
"eingeblendet": [str(s) for s in daten.get("eingeblendet", [])],
}
def vorauswahl(eintraege: Iterable[LegendenEintrag], gespeichert: dict[str, list[str]] | None = None) -> set[str]:
"""Bestimmt die vorausgewählten Einträge für den Dialog.
Zuletzt abgewählte Einträge bleiben abgewählt, zuletzt gewählte bleiben gewählt. Sonst gilt:
Katalogeinträge sind gewählt, unbekannte Styles („Sonstiges“) abgewählt.
"""
gespeichert = gespeichert or {"ausgeblendet": [], "eingeblendet": []}
aus = set(gespeichert.get("ausgeblendet", []))
ein = set(gespeichert.get("eingeblendet", []))
auswahl = set()
for eintrag in eintraege:
if eintrag.schluessel in aus:
continue
if eintrag.bekannt or eintrag.schluessel in ein:
auswahl.add(eintrag.schluessel)
return auswahl
def merke_auswahl(eintraege: Iterable[LegendenEintrag], gewaehlt: Iterable[str]) -> None:
"""Speichert die Dialogauswahl in der Projektvariable (ergänzt die bisherige Auswahl)."""
gewaehlt = set(gewaehlt)
bisher = lies_auswahl()
aus = set(bisher["ausgeblendet"])
ein = set(bisher["eingeblendet"])
for eintrag in eintraege:
if eintrag.schluessel in gewaehlt:
aus.discard(eintrag.schluessel)
ein.add(eintrag.schluessel)
else:
ein.discard(eintrag.schluessel)
aus.add(eintrag.schluessel)
set_variable(AUSWAHL_VAR, json.dumps({"ausgeblendet": sorted(aus), "eingeblendet": sorted(ein)}),
scope="project")
def lies_auswertebereich() -> str:
wert = (get_variable(AUSWERTEBEREICH_VAR, scope="project") or "").strip()
return wert if wert in AUSWERTEBEREICHE else AUSWERTEBEREICH_VERFAHRENSGEBIET
def setze_auswertebereich(bereich: str) -> None:
if bereich not in AUSWERTEBEREICHE:
raise ValueError(f"Unbekannter Auswertebereich: {bereich}")
set_variable(AUSWERTEBEREICH_VAR, bereich, scope="project")
def legende_erzeugen_aktiv() -> bool:
"""Ob beim Erstellen einer Vorlage eine Basemap-Legende erzeugt wird (Standard: ja)."""
return (get_variable(LEGENDE_ERZEUGEN_VAR, scope="project") or "1").strip() != "0"
def setze_legende_erzeugen(aktiv: bool) -> None:
set_variable(LEGENDE_ERZEUGEN_VAR, "1" if aktiv else "0", scope="project")
# ---------------------------------------------------------
# Basemap-Layer erkennen
# ---------------------------------------------------------
def _aufruf(objekt: Any, methode: str, standard: Any = None) -> Any:
funktion = getattr(objekt, methode, None)
if not callable(funktion):
return standard
try:
return funktion()
except Exception:
return standard
def ist_basemap_layer(layer: Any) -> bool:
"""Erkennt den basemap.de-Vektorkachel-Layer.
Maßgeblich ist ein Vektorkachel-Layer (hat ``tileMatrixSet``), dessen Quelle die
basemap.de-Kennung enthält; der Layername dient nur als Rückfall, weil Nutzer Layer
umbenennen.
"""
if layer is None or not callable(getattr(layer, "tileMatrixSet", None)):
return False
quelle = str(_aufruf(layer, "source", "") or "").lower()
if BASEMAP_URL_KENNUNG in quelle:
return True
return BASEMAP_NAME_RUECKFALL in str(_aufruf(layer, "name", "") or "").lower()
def sichtbare_layer_der_karte(map_item: Any, project: Any) -> list[Any]:
"""Liefert die Layer, die eine Layoutkarte darstellt (Kartenthema, feste Layerliste oder Layerbaum)."""
if _aufruf(map_item, "followVisibilityPreset", False):
thema = _aufruf(map_item, "followVisibilityPresetName", "")
sammlung = _aufruf(project, "mapThemeCollection")
if thema and sammlung is not None:
try:
return list(sammlung.mapThemeVisibleLayers(thema))
except Exception:
pass
if _aufruf(map_item, "keepLayerSet", False):
layer = _aufruf(map_item, "layers", []) or []
if layer:
return list(layer)
wurzel = _aufruf(project, "layerTreeRoot")
knoten = _aufruf(wurzel, "findLayers", []) or []
return [k.layer() for k in knoten if _aufruf(k, "isVisible", False) and _aufruf(k, "layer") is not None]
def finde_basemap_layer(layer: Iterable[Any]) -> list[Any]:
return [l for l in layer if ist_basemap_layer(l)]
# ---------------------------------------------------------
# Auswertebereich
# ---------------------------------------------------------
def auswertegeometrie(
bereich: str,
map_item: Any,
project: Any,
ziel_crs: Any,
) -> tuple[Any | None, str]:
"""Bestimmt die Auswertegeometrie im Ziel-KBS.
Returns
-------
(geometrie, tatsächlicher Bereich)
Ohne Verfahrensgebiet wird auf den Druckbereich zurückgefallen.
"""
from sn_basis.functions.verfahrensgebiet_manager import get_verfahrensgebiet_layer_id
geometrie, quell_crs, genutzt = None, None, AUSWERTEBEREICH_DRUCKBEREICH
if bereich == AUSWERTEBEREICH_VERFAHRENSGEBIET:
layer_id = get_verfahrensgebiet_layer_id()
vg_layer = project.mapLayer(layer_id) if layer_id else None
if vg_layer is not None:
teile = [f.geometry() for f in vg_layer.getFeatures() if f.hasGeometry()]
if teile:
geometrie = QgsGeometry.unaryUnion(teile)
quell_crs = vg_layer.crs()
genutzt = AUSWERTEBEREICH_VERFAHRENSGEBIET
if geometrie is None:
geometrie = QgsGeometry.fromRect(map_item.extent())
quell_crs = map_item.crs()
if geometrie is None or geometrie.isEmpty():
return None, genutzt
geometrie = QgsGeometry(geometrie)
geometrie.transform(QgsCoordinateTransform(quell_crs, ziel_crs, project))
return geometrie, genutzt
# ---------------------------------------------------------
# Ablauf
# ---------------------------------------------------------
class BasemapLegende:
"""Erzeugt bzw. aktualisiert die Basemap-Legende eines Drucklayouts.
Dialog, Fortschritt und Rückfragen werden injiziert, damit der Ablauf ohne QGIS-Oberfläche
testbar bleibt.
"""
def __init__(
self,
project: Any | None = None,
katalog: Katalog | None = None,
dialog: Callable[..., list[str] | None] | None = None,
fortschritt_fabrik: Callable[..., Any] | None = None,
bestaetigen: Callable[[str, str], bool] | None = None,
melden: Callable[[str, str], None] | None = None,
) -> None:
from sn_basis.functions.dialog_wrapper import ask_mehrfachauswahl, ask_yes_no, create_progress_dialog
from sn_basis.functions.message_wrapper import info
self.project = project or QgsProject.instance()
self.katalog = katalog or Katalog.laden()
self.dialog = dialog or ask_mehrfachauswahl
self.fortschritt_fabrik = fortschritt_fabrik or create_progress_dialog
self.bestaetigen = bestaetigen or (lambda titel, text: ask_yes_no(titel, text, default=False))
self.melden = melden or (lambda titel, text: info(titel, text))
def ermittle_eintraege(self, layer: Any, geometrie: Any, massstab: float) -> list[LegendenEintrag] | None:
"""Wertet die Kacheln aus und bildet die Legendeneinträge (``None`` bei Abbruch)."""
kachelzahl = anzahl_kacheln(layer, geometrie, massstab)
if kachelzahl >= KACHELN_NACHFRAGEN_AB and not self.bestaetigen(
"Basemap-Legende",
f"Für die Basemap-Legende müssen {kachelzahl} Kacheln ausgewertet werden. "
"Das kann einige Minuten dauern. Fortfahren?",
):
return None
fortschritt = self.fortschritt_fabrik(kachelzahl, "Basemap-Legende", "Kacheln werden ausgewertet …")
def melde_fortschritt(nummer: int, gesamt: int) -> bool:
fortschritt.set_value(nummer)
return not fortschritt.is_canceled()
try:
objekte = sammle_objekte(layer, geometrie, massstab, fortschritt=melde_fortschritt)
finally:
fortschritt.close()
if objekte is None:
return None
_, darstellungszoom, kachelzoom_bruch = zoomstufen(layer, massstab)
getroffen = ermittle_getroffene_stile(
objekte, stile_aus_layer(layer), darstellungszoom,
qgis_filterpruefer(kachelzoom_bruch, darstellungszoom),
)
return bilde_eintraege(getroffen, self.katalog)
def aktualisiere(self, layout: Any, bereich: str | None = None) -> dict:
"""Erzeugt bzw. ersetzt die Basemap-Legende im Layout.
Returns
-------
dict
``status`` (``ok``, ``kein_layer``, ``keine_karte``, ``keine_eintraege``, ``abgebrochen``),
``meldung`` und ``anzahl`` (gewählte Einträge).
"""
from sn_basis.modules import basemap_legende_layout as ausgabe
map_item = ausgabe.hauptkarte(layout)
if map_item is None:
return {"status": "keine_karte", "meldung": "Das Layout enthält keine Karte.", "anzahl": 0}
basemap = finde_basemap_layer(sichtbare_layer_der_karte(map_item, self.project))
if not basemap:
return {"status": "kein_layer", "meldung": "Kein sichtbarer Basemap-Layer in der Karte.", "anzahl": 0}
layer = basemap[0]
if len(basemap) > 1:
self.melden("Basemap-Legende", f"Mehrere Basemap-Layer sichtbar – ausgewertet wird „{layer.name()}“.")
bereich = bereich or lies_auswertebereich()
geometrie, genutzt = auswertegeometrie(bereich, map_item, self.project, layer.crs())
if geometrie is None:
return {"status": "keine_eintraege", "meldung": "Kein Auswertebereich vorhanden.", "anzahl": 0}
if genutzt != bereich:
self.melden("Basemap-Legende", "Kein Verfahrensgebiet gesetzt – ausgewertet wird der Druckbereich.")
massstab = map_item.scale()
eintraege = self.ermittle_eintraege(layer, geometrie, massstab)
if eintraege is None:
return {"status": "abgebrochen", "meldung": "Auswertung abgebrochen.", "anzahl": 0}
if not eintraege:
return {"status": "keine_eintraege", "meldung": "Im Auswertebereich wurden keine Basemap-Objekte gefunden.",
"anzahl": 0}
_, darstellungszoom, kachelzoom_bruch = zoomstufen(layer, massstab)
symbole = ausgabe.baue_symbole(layer, eintraege, darstellungszoom, kachelzoom_bruch)
dialog_eintraege = [
{"schluessel": e.schluessel, "text": e.bezeichnung, "gruppe": e.gruppe,
"icon": ausgabe.vorschau_icon(symbole.get(e.schluessel))}
for e in eintraege
]
gewaehlt = self.dialog(
"Basemap-Legende",
f"Im {AUSWERTEBEREICHE[genutzt].split(' (')[0]} kommen diese Kategorien der Basemap vor.\n"
"Abgewählte Kategorien erscheinen nicht in der Legende.",
dialog_eintraege,
vorauswahl(eintraege, lies_auswahl()),
)
if gewaehlt is None:
return {"status": "abgebrochen", "meldung": "Auswahl abgebrochen.", "anzahl": 0}
merke_auswahl(eintraege, gewaehlt)
gewaehlte_eintraege = [e for e in eintraege if e.schluessel in set(gewaehlt)]
hinweis = ausgabe.schreibe_legende(layout, map_item, gewaehlte_eintraege, symbole, self.project,
bereich=genutzt, massstab=massstab)
if hinweis:
self.melden("Basemap-Legende", hinweis)
anzahl = len(gewaehlte_eintraege)
return {"status": "ok", "meldung": f"{anzahl} Einträge in der Basemap-Legende.", "anzahl": anzahl}
+396
View File
@@ -0,0 +1,396 @@
"""
sn_basis/modules/basemap_legende_layout.py – Basemap-Legende im Drucklayout ausgeben
Baut aus den gewählten Legendeneinträgen (``basemap_legende``) die Legendensymbole aus den
Renderer-Styles des Projektlayers, legt dafür leere Hilfslayer an und schreibt sie in ein
eigenes Legendenelement „Legende Basemap“ unterhalb der Hauptlegende.
Warum ein eigenes Legendenelement: Die Hauptlegende filtert nach Karteninhalt
(``setLegendFilterByMapEnabled``) und würde die Hilfslayer ausblenden, weil sie nicht in der
Karte liegen (Spike 02.10.2026, QGIS 3.44). Das Basemap-Element ist ohne Kartenfilter und ohne
automatische Aktualisierung; die Hilfslayer stehen nicht im Layerbaum, bleiben aber mit dem
Projekt gespeichert. Leere Speicherlayer lösen beim Schließen des Projekts keine Rückfrage aus.
"""
from __future__ import annotations
import json
import re
from typing import Any
from sn_basis.functions.qgiscore_wrapper import (
LEGEND_STYLE_HIDDEN,
LEGEND_STYLE_SUBGROUP,
PROPERTY_TYPE_EXPRESSION,
QgsCategorizedSymbolRenderer,
QgsExpressionContext,
QgsExpressionContextScope,
QgsLayoutItem,
QgsLayoutItemLegend,
QgsLayoutItemMap,
QgsLayoutMeasurement,
QgsLayoutPoint,
QgsLayoutSize,
QgsLayoutUtils,
QgsLegendRenderer,
QgsProperty,
QgsRendererCategory,
QgsSymbolLayerUtils,
QgsUnitTypes,
QgsVectorLayer,
)
from sn_basis.functions.qt_wrapper import QSize
MM = QgsUnitTypes.LayoutMillimeters
LEGENDE_ID = "Legende Basemap"
LEGENDE_TITEL = "Kartengrundlage (basemap.de)"
HAUPTKARTE_ID = "Hauptkarte"
HAUPTLEGENDE_ID = "Legende"
QUELLENANGABE_ID = "Quellenangabe"
ABSTAND_MM = 4.0
# Mindestplatz, der über der Basemap-Legende für die Hauptlegende frei bleiben soll.
PLATZ_HAUPTLEGENDE_MM = 40.0
RAHMENSTAERKE_MM = 0.5
# Layout-Eigenschaften
LAYOUT_KENNZEICHEN = "sn_basemap_legende"
LAYOUT_BEREICH = "sn_basemap_bereich"
LAYOUT_MASSSTAB = "sn_basemap_massstab"
LAYOUT_EINTRAEGE = "sn_basemap_eintraege"
LAYOUT_HILFSLAYER = "sn_basemap_hilfslayer"
# Eigenschaft der Hilfslayer (Wert: Layoutname)
HILFSLAYER_KENNUNG = "sn_basemap_legende"
_GEOMETRIE_JE_SYMBOLTYP = {0: "Point", 1: "LineString", 2: "Polygon"}
_ZOOM_VARIABLEN = {"vector_tile_zoom", "zoom_level"}
_VARIABLE = re.compile(r"@([A-Za-z_][A-Za-z0-9_]*)")
def _enum_int(wert: Any) -> int:
try:
return int(wert)
except (TypeError, ValueError):
return int(getattr(wert, "value", -1))
def _aufruf(objekt: Any, methode: str, *args: Any, standard: Any = None) -> Any:
funktion = getattr(objekt, methode, None)
if not callable(funktion):
return standard
try:
return funktion(*args)
except Exception:
return standard
# ---------------------------------------------------------
# Layout-Kennzeichen
# ---------------------------------------------------------
def markiere_layout(layout: Any) -> None:
"""Kennzeichnet ein Layout als Nutzer der Basemap-Legende."""
layout.setCustomProperty(LAYOUT_KENNZEICHEN, 1)
def nutzt_basemap_legende(layout: Any) -> bool:
return str(_aufruf(layout, "customProperty", LAYOUT_KENNZEICHEN, 0, standard=0)).lower() in ("1", "true")
def layouts_mit_basemap_legende(project: Any) -> list[str]:
"""Namen der Drucklayouts des Projekts, die die Basemap-Legende nutzen."""
verwaltung = _aufruf(project, "layoutManager")
layouts = _aufruf(verwaltung, "printLayouts", standard=[]) or []
return [l.name() for l in layouts if nutzt_basemap_legende(l)]
def hauptkarte(layout: Any) -> Any | None:
"""Die Karte, auf die sich die Legende bezieht (ID „Hauptkarte“, sonst die erste Karte)."""
karte = _aufruf(layout, "itemById", HAUPTKARTE_ID)
if karte is not None:
return karte
for element in _aufruf(layout, "items", standard=[]) or []:
if isinstance(element, QgsLayoutItemMap):
return element
return None
# ---------------------------------------------------------
# Symbole
# ---------------------------------------------------------
def friere_zoom_ein(symbol: Any, zoom_level: int, vector_tile_zoom: float) -> None:
"""Schreibt zoomabhängige Werte (Strichbreite, Deckkraft …) für die Druck-Zoomstufe fest.
Die Basemap-Styles nutzen ``@vector_tile_zoom``; diese Variable gibt es in der Legende nicht.
Ohne Festschreiben bleiben z. B. gestrichelte Linien in der Legende unsichtbar, weil ihr
Strichmuster zoomabhängig berechnet wird. ``@symbol_color`` wird mit der Farbe der jeweiligen
Symbolebene belegt; lokale Variablen wie ``@element`` (``array_foreach``) wertet QGIS selbst aus.
"""
for symbol_layer in symbol.symbolLayers():
kontext = QgsExpressionContext()
scope = QgsExpressionContextScope()
scope.setVariable("vector_tile_zoom", vector_tile_zoom)
scope.setVariable("zoom_level", zoom_level)
farbe = _aufruf(symbol_layer, "color")
if farbe is not None:
scope.setVariable("symbol_color", farbe)
kontext.appendScope(scope)
eigenschaften = symbol_layer.dataDefinedProperties()
for schluessel in eigenschaften.propertyKeys():
eigenschaft = eigenschaften.property(schluessel)
if eigenschaft.propertyType() != PROPERTY_TYPE_EXPRESSION:
continue
if not set(_VARIABLE.findall(eigenschaft.expressionString())) & _ZOOM_VARIABLEN:
continue
ergebnis = eigenschaft.value(kontext, None)
wert, ok = ergebnis if isinstance(ergebnis, tuple) else (ergebnis, ergebnis is not None)
if ok and wert is not None:
eigenschaften.setProperty(schluessel, QgsProperty.fromValue(wert))
unter = symbol_layer.subSymbol()
if unter is not None:
friere_zoom_ein(unter, zoom_level, vector_tile_zoom)
def waehle_symbolnamen(eintrag: Any, aktive: dict[str, int]) -> list[str]:
"""Style-Namen für das Legendensymbol, in Zeichenreihenfolge des Renderers.
Bevorzugt die ``symbol_stile`` des Katalogs, soweit sie bei der Zoomstufe aktiv sind; sonst
den ersten tatsächlich getroffenen Style.
"""
namen = [n for n in eintrag.symbol_stile if n in aktive]
if not namen:
namen = [n for n in eintrag.treffer if n in aktive][:1]
return sorted(dict.fromkeys(namen), key=lambda n: aktive[n])
def baue_symbole(layer: Any, eintraege: list, zoom_level: int, vector_tile_zoom: float) -> dict[str, Any]:
"""Setzt je Legendeneintrag ein Symbol aus den Renderer-Styles des Projektlayers zusammen."""
stile = layer.renderer().styles()
aktive = {s.styleName(): i for i, s in enumerate(stile) if s.isActive(zoom_level) and s.symbol() is not None}
je_name = {s.styleName(): s for s in stile}
symbole: dict[str, Any] = {}
for eintrag in eintraege:
basis = None
for name in waehle_symbolnamen(eintrag, aktive):
symbol = je_name[name].symbol().clone()
if basis is None:
basis = symbol
continue
for symbol_layer in symbol.symbolLayers():
# Unverträgliche Ebenen (z. B. Punktsymbol in einer Füllung) lehnt QGIS ab.
basis.appendSymbolLayer(symbol_layer.clone())
if basis is not None:
friere_zoom_ein(basis, zoom_level, vector_tile_zoom)
symbole[eintrag.schluessel] = basis
return symbole
def vorschau_icon(symbol: Any) -> Any:
if symbol is None:
return None
return _aufruf(QgsSymbolLayerUtils, "symbolPreviewIcon", symbol, QSize(16, 16))
# ---------------------------------------------------------
# Hilfslayer
# ---------------------------------------------------------
def entferne_hilfslayer(project: Any, layout: Any) -> None:
"""Entfernt die Hilfslayer einer früheren Basemap-Legende dieses Layouts aus dem Projekt."""
roh = str(_aufruf(layout, "customProperty", LAYOUT_HILFSLAYER, "", standard="") or "")
for layer_id in [i for i in roh.split(",") if i]:
if _aufruf(project, "mapLayer", layer_id) is not None:
project.removeMapLayer(layer_id)
def erzeuge_hilfslayer(project: Any, layout_name: str, crs_authid: str, eintraege: list,
symbole: dict[str, Any]) -> list[tuple[str, list[Any]]]:
"""Legt je Gruppe und Geometrietyp einen leeren, kategorisierten Speicherlayer an.
Returns
-------
list[tuple[str, list]]
Gruppen in Legendenreihenfolge mit ihren Hilfslayern.
"""
gruppen: dict[str, dict[int, list]] = {}
for eintrag in eintraege:
symbol = symbole.get(eintrag.schluessel)
if symbol is None:
continue
typ = _enum_int(symbol.type())
if typ not in _GEOMETRIE_JE_SYMBOLTYP:
continue
gruppen.setdefault(eintrag.gruppe, {}).setdefault(typ, []).append(
QgsRendererCategory(eintrag.schluessel, symbol, eintrag.bezeichnung))
ergebnis: list[tuple[str, list[Any]]] = []
for gruppe, je_typ in gruppen.items():
layer_liste = []
for typ in sorted(je_typ, reverse=True): # Flächen, Linien, Punkte
layer = QgsVectorLayer(f"{_GEOMETRIE_JE_SYMBOLTYP[typ]}?crs={crs_authid}&field=eintrag:string",
f"Basemap-Legende {layout_name}: {gruppe}", "memory")
layer.setRenderer(QgsCategorizedSymbolRenderer("eintrag", je_typ[typ]))
layer.setCustomProperty("skipMemoryLayersCheck", 1)
layer.setCustomProperty(HILFSLAYER_KENNUNG, layout_name)
project.addMapLayer(layer, False)
layer_liste.append(layer)
ergebnis.append((gruppe, layer_liste))
return ergebnis
# ---------------------------------------------------------
# Legendenelement
# ---------------------------------------------------------
def _rechteck_mm(element: Any) -> tuple[float, float, float, float] | None:
"""Links, oben, Breite, Höhe eines Layoutelements in mm – unabhängig von seinem Bezugspunkt."""
rechteck = _aufruf(element, "sceneBoundingRect")
if rechteck is not None:
return rechteck.x(), rechteck.y(), rechteck.width(), rechteck.height()
position = _aufruf(element, "positionWithUnits")
groesse = _aufruf(element, "sizeWithUnits")
if position is None or groesse is None:
return None
return position.x(), position.y(), groesse.width(), groesse.height()
def _neues_legendenelement(layout: Any) -> Any:
legende = QgsLayoutItemLegend(layout)
legende.setId(LEGENDE_ID)
_aufruf(legende, "setObjectName", LEGENDE_ID)
_aufruf(legende, "setFrameEnabled", True)
_aufruf(legende, "setBackgroundEnabled", False)
_aufruf(legende, "setFrameStrokeWidth", _layout_messung(RAHMENSTAERKE_MM))
layout.addLayoutItem(legende)
return legende
def _layout_messung(wert_mm: float) -> Any:
return QgsLayoutMeasurement(wert_mm, MM)
def mindestgroesse(legende: Any, layout: Any, spalten: int) -> tuple[float, float] | None:
"""Platzbedarf der Legende in mm bei ``spalten`` Spalten, ohne sie zu zeichnen.
``adjustBoxSize`` wirkt in QGIS erst, nachdem das Element einmal gezeichnet wurde; für die
Platzierung beim Anlegen wird der Bedarf deshalb über ``QgsLegendRenderer`` berechnet.
"""
einstellungen = _aufruf(legende, "legendSettings")
kontext = _aufruf(QgsLayoutUtils, "createRenderContextForLayout", layout, None)
if einstellungen is None or kontext is None:
return None
einstellungen.setColumnCount(spalten)
einstellungen.setSplitLayer(True)
einstellungen.setEqualColumnWidth(True)
try:
groesse = QgsLegendRenderer(legende.model(), einstellungen).minimumSize(kontext)
except Exception:
return None
return groesse.width(), groesse.height()
def platziere(layout: Any, legende: Any) -> str:
"""Setzt die Basemap-Legende direkt über die Quellenangabe, in die Spalte der Hauptlegende.
Die Legende ist unten verankert und wächst nach oben. Unter der Hauptlegende zu verankern geht
nicht: deren Höhe steht erst nach dem Rendern fest, weil sie nach Karteninhalt filtert. Bis zur
Oberkante der Hauptlegende bleibt ``PLATZ_HAUPTLEGENDE_MM`` frei; reicht der Platz nicht, wird
die Legende zwei- oder dreispaltig, solange sie nicht über den Seitenrand hinausragt.
Returns
-------
str
Hinweis, wenn der Platz auch dreispaltig nicht reicht, sonst leer.
"""
haupt = _rechteck_mm(_aufruf(layout, "itemById", HAUPTLEGENDE_ID))
karte = _rechteck_mm(hauptkarte(layout))
quelle = _rechteck_mm(_aufruf(layout, "itemById", QUELLENANGABE_ID))
if haupt is not None:
x, oben = haupt[0], haupt[1]
elif karte is not None:
x, oben = karte[0] + karte[2] + 10.0, karte[1]
else:
x, oben = 10.0, 10.0
if quelle is not None:
unten = quelle[1] - ABSTAND_MM
elif karte is not None:
unten = karte[1] + karte[3]
else:
unten = oben + 200.0
verfuegbar = unten - oben - PLATZ_HAUPTLEGENDE_MM
seite = _aufruf(_aufruf(_aufruf(layout, "pageCollection"), "page", 0), "pageSize")
max_breite = (seite.width() - x - 10.0) if seite is not None else float("inf")
# Wenigste Spalten, mit denen die Höhe passt – aber nie breiter als bis zum Seitenrand.
spalten, groesse = 1, mindestgroesse(legende, layout, 1)
while groesse is not None and groesse[1] > verfuegbar and spalten < 3:
breiter = mindestgroesse(legende, layout, spalten + 1)
if breiter is None or breiter[0] > max_breite:
break
spalten, groesse = spalten + 1, breiter
_aufruf(legende, "setColumnCount", spalten)
_aufruf(legende, "setSplitLayer", True)
_aufruf(legende, "setEqualColumnWidth", True)
unten_links = getattr(getattr(QgsLayoutItem, "ReferencePoint", QgsLayoutItem), "LowerLeft", None)
if unten_links is not None:
_aufruf(legende, "setReferencePoint", unten_links)
breite, hoehe = groesse if groesse is not None else (180.0, 20.0)
legende.attemptMove(QgsLayoutPoint(x, unten, MM))
legende.attemptResize(QgsLayoutSize(breite, hoehe, MM))
if groesse is not None and hoehe > verfuegbar:
return ("Die Basemap-Legende braucht mehr Platz, als neben der Karte frei ist. Bitte im Layout "
"anpassen oder weniger Kategorien wählen.")
return ""
def schreibe_legende(layout: Any, map_item: Any, eintraege: list, symbole: dict[str, Any], project: Any,
*, bereich: str, massstab: float) -> str:
"""Schreibt bzw. ersetzt die Basemap-Legende des Layouts.
Ohne gewählte Einträge wird ein vorhandenes Basemap-Legendenelement entfernt.
Returns
-------
str
Hinweis für den Nutzer oder leer.
"""
entferne_hilfslayer(project, layout)
legende = _aufruf(layout, "itemById", LEGENDE_ID)
markiere_layout(layout)
layout.setCustomProperty(LAYOUT_BEREICH, bereich)
layout.setCustomProperty(LAYOUT_MASSSTAB, float(massstab))
layout.setCustomProperty(LAYOUT_EINTRAEGE, json.dumps([e.schluessel for e in eintraege]))
if not eintraege:
if legende is not None:
layout.removeLayoutItem(legende)
layout.setCustomProperty(LAYOUT_HILFSLAYER, "")
return ""
crs = _aufruf(map_item, "crs")
gruppen = erzeuge_hilfslayer(project, layout.name(), _aufruf(crs, "authid", standard="") or "EPSG:25833",
eintraege, symbole)
layout.setCustomProperty(LAYOUT_HILFSLAYER, ",".join(l.id() for _, ls in gruppen for l in ls))
neu = legende is None
if neu:
legende = _neues_legendenelement(layout)
_aufruf(legende, "setLocked", False)
legende.setLinkedMap(map_item)
legende.setAutoUpdateModel(False)
legende.setLegendFilterByMapEnabled(False)
legende.setTitle(LEGENDE_TITEL)
wurzel = legende.model().rootGroup()
wurzel.removeAllChildren()
for gruppe, layer_liste in gruppen:
for nummer, layer in enumerate(layer_liste):
knoten = wurzel.addLayer(layer)
_aufruf(knoten, "setName", gruppe)
QgsLegendRenderer.setNodeLegendStyle(knoten, LEGEND_STYLE_SUBGROUP if nummer == 0 else LEGEND_STYLE_HIDDEN)
_aufruf(legende, "refresh")
hinweis = platziere(layout, legende) if neu else ""
if not neu:
_aufruf(legende, "adjustBoxSize")
_aufruf(legende, "setLocked", True)
return hinweis
+181
View File
@@ -0,0 +1,181 @@
"""
sn_basis/modules/basemap_stilvorlagen.py – lokale Stilvorlagen für den Basemap-Layer
Speichert die Darstellung des Basemap-Layers (Symbologie und Beschriftung, also auch
abgeschaltete Styles wie Hausnummern) als benannte Stilvorlage im QGIS-Profil und lädt sie
wieder. Mehrere Varianten sind möglich; eine davon kann als Standard markiert werden.
Ablage: ``<QGIS-Profil>/sn_basis/basemap_stile/<name>.qml`` plus ``<name>.json`` mit
Anzeigename, Erstellungszeit, Style-Namen der Vorlage und – optional – der Legendenauswahl.
Der Standard steht in ``QSettings`` unter ``sn_basis/basemap_standardstil``.
Die Vorlagen sind lokal (je Rechner und Profil) und werden nicht geteilt.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from sn_basis.functions.qgiscore_wrapper import QgsApplication, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG
from sn_basis.functions.qt_wrapper import QSettings
from sn_basis.functions.text_wrapper import sanitize_bezeichner
STANDARD_SCHLUESSEL = "sn_basis/basemap_standardstil"
@dataclass
class Stilvorlage:
name: str # Dateiname ohne Endung
anzeigename: str
pfad: str # QML-Datei
erstellt: str = ""
standard: bool = False
def standard_ordner() -> str:
"""Ordner der Stilvorlagen im aktiven QGIS-Profil (außerhalb von QGIS im Benutzerordner)."""
try:
basis = QgsApplication.qgisSettingsDirPath() or ""
except Exception:
basis = ""
if not basis:
basis = os.path.join(os.path.expanduser("~"), ".sn_basis")
return os.path.join(basis, "sn_basis", "basemap_stile")
def _stilnamen(layer: Any) -> list[str]:
try:
return [s.styleName() for s in layer.renderer().styles()]
except Exception:
return []
class BasemapStilvorlagen:
"""Verwaltet die lokalen Stilvorlagen des Basemap-Layers."""
def __init__(self, ordner: str | None = None, einstellungen: Any | None = None) -> None:
self.ordner = ordner or standard_ordner()
self.einstellungen = einstellungen if einstellungen is not None else QSettings()
# --- Abfragen -------------------------------------------------------
def _pfade(self, name: str) -> tuple[str, str]:
basis = os.path.join(self.ordner, name)
return basis + ".qml", basis + ".json"
def _metadaten(self, name: str) -> dict:
_, json_pfad = self._pfade(name)
try:
with open(json_pfad, encoding="utf-8") as datei:
daten = json.load(datei)
return daten if isinstance(daten, dict) else {}
except (OSError, ValueError):
return {}
@staticmethod
def dateiname(anzeigename: str) -> str:
return sanitize_bezeichner(anzeigename.strip())
def standard(self) -> str:
return str(self.einstellungen.value(STANDARD_SCHLUESSEL, "") or "")
def liste(self) -> list[Stilvorlage]:
"""Alle Stilvorlagen, alphabetisch nach Anzeigename. Fehlende Begleitdatei wird toleriert."""
if not os.path.isdir(self.ordner):
return []
standard = self.standard()
vorlagen = []
for datei in os.listdir(self.ordner):
if not datei.lower().endswith(".qml"):
continue
name = datei[:-4]
meta = self._metadaten(name)
vorlagen.append(Stilvorlage(
name=name,
anzeigename=str(meta.get("anzeigename") or name),
pfad=os.path.join(self.ordner, datei),
erstellt=str(meta.get("erstellt", "")),
standard=(name == standard),
))
return sorted(vorlagen, key=lambda v: v.anzeigename.lower())
def vorhanden(self, anzeigename: str) -> bool:
return os.path.exists(self._pfade(self.dateiname(anzeigename))[0])
def legendenauswahl(self, name: str) -> dict | None:
"""In der Vorlage gespeicherte Legendenauswahl (``ausgeblendet``/``eingeblendet``) oder ``None``."""
auswahl = self._metadaten(name).get("legende_auswahl")
return auswahl if isinstance(auswahl, dict) else None
def pruefe_passung(self, layer: Any, name: str) -> list[str]:
"""Style-Namen der Vorlage, die der Layer nicht kennt (z. B. Vorlage aus „Grau“ für „Farbe“)."""
vorlage = self._metadaten(name).get("stile")
if not isinstance(vorlage, list):
return []
vorhanden = set(_stilnamen(layer))
return [s for s in vorlage if s not in vorhanden]
# --- Ändern ---------------------------------------------------------
def speichern(self, layer: Any, anzeigename: str, legende_auswahl: dict | None = None) -> Stilvorlage:
"""Speichert die aktuelle Darstellung des Layers; eine gleichnamige Vorlage wird ersetzt.
Raises
------
ValueError
Bei leerem Namen oder wenn QGIS die Datei nicht schreiben konnte.
"""
name = self.dateiname(anzeigename)
if not name:
raise ValueError("Bitte einen Namen für die Stilvorlage angeben.")
os.makedirs(self.ordner, exist_ok=True)
qml_pfad, json_pfad = self._pfade(name)
ergebnis = layer.saveNamedStyle(qml_pfad, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG)
meldung, ok = ergebnis if isinstance(ergebnis, tuple) else ("", bool(ergebnis))
if not ok:
raise ValueError(f"Die Stilvorlage konnte nicht gespeichert werden: {meldung}")
erstellt = datetime.now().isoformat(timespec="seconds")
meta = {
"anzeigename": anzeigename.strip(),
"erstellt": erstellt,
"layer": layer.name(),
"stile": _stilnamen(layer),
}
if legende_auswahl:
meta["legende_auswahl"] = legende_auswahl
with open(json_pfad, "w", encoding="utf-8") as datei:
json.dump(meta, datei, ensure_ascii=False, indent=1)
return Stilvorlage(name, anzeigename.strip(), qml_pfad, erstellt, name == self.standard())
def laden(self, layer: Any, name: str) -> None:
"""Ersetzt Symbologie und Beschriftung des Layers durch die Vorlage.
Raises
------
ValueError
Wenn die Vorlage fehlt oder QGIS sie nicht laden konnte.
"""
qml_pfad, _ = self._pfade(name)
if not os.path.exists(qml_pfad):
raise ValueError(f"Die Stilvorlage „{name}“ gibt es nicht mehr.")
ergebnis = layer.loadNamedStyle(qml_pfad, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG)
meldung, ok = ergebnis if isinstance(ergebnis, tuple) else ("", bool(ergebnis))
if not ok:
raise ValueError(f"Die Stilvorlage konnte nicht geladen werden: {meldung}")
nachzeichnen = getattr(layer, "triggerRepaint", None)
if callable(nachzeichnen):
nachzeichnen()
def loeschen(self, name: str) -> None:
for pfad in self._pfade(name):
if os.path.exists(pfad):
os.remove(pfad)
if self.standard() == name:
self.einstellungen.remove(STANDARD_SCHLUESSEL)
def setze_standard(self, name: str) -> None:
if not os.path.exists(self._pfade(name)[0]):
raise ValueError(f"Die Stilvorlage „{name}“ gibt es nicht.")
self.einstellungen.setValue(STANDARD_SCHLUESSEL, name)
+23
View File
@@ -981,6 +981,7 @@ class PrintLayout:
kartenname_auswahl: str = "",
thema: str = "",
fit_extent_to_map: bool = True,
basemap_legende: bool = False,
) -> Any:
layout_manager = self.project.layoutManager()
existing_layout = layout_manager.layoutByName(name)
@@ -1082,7 +1083,29 @@ class PrintLayout:
kartenname_auswahl,
)
if basemap_legende:
self._erzeuge_basemap_legende(layout)
layout_manager.addLayout(layout)
self._current_layout = None
open_layout_designer(layout)
return layout
def _erzeuge_basemap_legende(self, layout: Any) -> None:
"""Kennzeichnet das Layout und erzeugt die Basemap-Legende (Basis #88).
Ein Fehler bei der Basemap-Legende darf das Anlegen der Vorlage nicht verhindern; er wird
als Warnung gemeldet.
"""
from sn_basis.functions.message_wrapper import warning
from sn_basis.modules.basemap_legende import BasemapLegende
from sn_basis.modules.basemap_legende_layout import markiere_layout
markiere_layout(layout)
try:
ergebnis = BasemapLegende(self.project).aktualisiere(layout)
except Exception as fehler:
warning("Basemap-Legende", f"Die Basemap-Legende konnte nicht erzeugt werden: {fehler}")
return
if ergebnis.get("status") == "keine_eintraege":
warning("Basemap-Legende", ergebnis.get("meldung", ""))
+9
View File
@@ -69,6 +69,8 @@ DRUCKRECHTECK_LAYER_NAME = "Druckrechteck"
KARTENNAME_38 = "§38"
KARTENNAME_41 = "§41"
KARTENNAME_BAUFREIGABE = "Anlage zur Baufreigabe"
# Layouts, die beim Anlegen eine Basemap-Legende erhalten (Basis #88). Neue Layouts hier aufnehmen.
LAYOUTS_MIT_BASEMAP_LEGENDE = frozenset({KARTENNAME_38, KARTENNAME_41})
MASSSTAB_WIE_KARTENFENSTER = "Wie Kartenfenster"
THEMA_WIE_KARTENFENSTER = "wie kartenfenster"
@@ -1188,6 +1190,12 @@ class PrintLogic:
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 _basemap_legende_erzeugen(self, kartenname_auswahl: str) -> bool:
"""Ob die neue Vorlage eine Basemap-Legende bekommt (Layoutart und Schalter im Druck-Tab)."""
from sn_basis.modules.basemap_legende import legende_erzeugen_aktiv
return kartenname_auswahl in LAYOUTS_MIT_BASEMAP_LEGENDE and legende_erzeugen_aktiv()
def druckvorlage_anlegen(
self,
layer: object | None,
@@ -1326,6 +1334,7 @@ class PrintLogic:
kartenname_auswahl=kartenname_auswahl,
thema=thema,
fit_extent_to_map=(druckbereich_auswahl != DRUCKBEREICH_VERFAHRENSGEBIET) or force_fit_extent_for_min_a4,
basemap_legende=self._basemap_legende_erzeugen(kartenname_auswahl),
)
except ValueError as exc:
self.pruefmanager.zeige_hinweis("Vorlage anlegen", str(exc))
+174
View File
@@ -0,0 +1,174 @@
"""
Tests für die Anbindung der Basemap-Legende an Druckablauf, Dialog und Druck-Tab (Basis #88):
Mehrfachauswahl-Dialog, PrintLogic/PrintLayout und Basemap-Bereich im Druck-Tab.
"""
import json
import unittest
from unittest.mock import MagicMock, patch
from sn_basis.functions.dialog_wrapper import ask_mehrfachauswahl
from sn_basis.functions.variable_wrapper import get_variable, set_variable
from sn_basis.modules import basemap_legende as bl
from sn_basis.modules.print_layout import PrintLayout
from sn_basis.modules.print_logic import (
KARTENNAME_38,
KARTENNAME_41,
KARTENNAME_BAUFREIGABE,
LAYOUTS_MIT_BASEMAP_LEGENDE,
PrintLogic,
)
from sn_basis.ui.tabs.print_tab import PrintTab
from sn_basis.ui.widgets.basemap_bereich import BasemapBereich
class TestMehrfachauswahl(unittest.TestCase):
def test_mock_modus_liefert_vorauswahl_in_listenreihenfolge(self):
"""Prüft, dass der Mehrfachauswahl-Dialog im Mock-Modus die vorausgewählten Schlüssel in Listenreihenfolge liefert."""
eintraege = [{"schluessel": s, "text": s.upper()} for s in ("a", "b", "c")]
self.assertEqual(ask_mehrfachauswahl("T", "H", eintraege, {"c", "a"}), ["a", "c"])
class TestDruckanbindung(unittest.TestCase):
def tearDown(self):
set_variable(bl.LEGENDE_ERZEUGEN_VAR, "", scope="project")
def test_basemap_legende_nur_fuer_38_und_41_und_bei_eingeschaltetem_schalter(self):
"""Prüft, dass nur §-38- und §-41-Vorlagen eine Basemap-Legende bekommen und nur, wenn der Schalter im Druck-Tab an ist."""
logik = PrintLogic(pruefmanager=MagicMock())
self.assertEqual(LAYOUTS_MIT_BASEMAP_LEGENDE, {KARTENNAME_38, KARTENNAME_41})
self.assertTrue(logik._basemap_legende_erzeugen(KARTENNAME_41))
self.assertFalse(logik._basemap_legende_erzeugen(KARTENNAME_BAUFREIGABE))
bl.setze_legende_erzeugen(False)
self.assertFalse(logik._basemap_legende_erzeugen(KARTENNAME_38))
def test_layout_wird_markiert_und_legende_erzeugt(self):
"""Prüft, dass PrintLayout das Layout als Basemap-Nutzer kennzeichnet und die Basemap-Legende erzeugen lässt."""
layout = MagicMock()
with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse:
klasse.return_value.aktualisiere.return_value = {"status": "ok"}
PrintLayout(project=MagicMock())._erzeuge_basemap_legende(layout)
layout.setCustomProperty.assert_any_call("sn_basemap_legende", 1)
klasse.return_value.aktualisiere.assert_called_once_with(layout)
def test_fehler_der_basemap_legende_verhindert_die_vorlage_nicht(self):
"""Prüft, dass ein Fehler beim Erzeugen der Basemap-Legende nur als Warnung gemeldet wird und keine Ausnahme durchreicht."""
with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse, \
patch("sn_basis.functions.message_wrapper.warning") as warnung:
klasse.return_value.aktualisiere.side_effect = RuntimeError("Netz weg")
PrintLayout(project=MagicMock())._erzeuge_basemap_legende(MagicMock())
warnung.assert_called_once()
self.assertIn("Netz weg", warnung.call_args[0][1])
def test_druck_tab_aktualisiert_basemap_bereich_falls_vorhanden(self):
"""Prüft, dass der Druck-Tab den Basemap-Bereich neu einliest und ohne Bereich nichts tut."""
tab = PrintTab.__new__(PrintTab)
tab._basemap_bereich_aktualisieren() # ohne Bereich: keine Ausnahme
tab._basemap_bereich = MagicMock()
tab._basemap_bereich_aktualisieren()
tab._basemap_bereich.aktualisieren.assert_called_once()
def _bereich(stil_name="Ohne_Hausnummern", layer=None, layout_name="§41"):
bereich = BasemapBereich.__new__(BasemapBereich)
bereich._project = MagicMock()
bereich._stilvorlagen = MagicMock()
bereich._stil_combo = MagicMock()
bereich._stil_combo.currentData.return_value = stil_name
bereich._stil_combo.currentText.return_value = stil_name
bereich._layout_combo = MagicMock()
bereich._layout_combo.currentText.return_value = layout_name
bereich._bereich_combo = MagicMock()
bereich.basemap_layer = MagicMock(return_value=layer)
bereich.aktualisieren = MagicMock()
return bereich
class TestBasemapBereich(unittest.TestCase):
def tearDown(self):
for schluessel in (bl.AUSWAHL_VAR, bl.AUSWERTEBEREICH_VAR):
set_variable(schluessel, "", scope="project")
def test_auswertebereich_wird_gespeichert(self):
"""Prüft, dass die Wahl des Auswertebereichs in der Projektvariable landet."""
bereich = _bereich()
bereich._bereich_combo.currentData.return_value = bl.AUSWERTEBEREICH_DRUCKBEREICH
bereich._on_bereich_geaendert(1)
self.assertEqual(bl.lies_auswertebereich(), bl.AUSWERTEBEREICH_DRUCKBEREICH)
@patch("sn_basis.ui.widgets.basemap_bereich.warning")
def test_aktualisieren_ohne_vorlage_warnt(self, warnung):
"""Prüft, dass „Basemap-Legende aktualisieren“ ohne vorhandene Vorlage warnt und nichts auswertet."""
bereich = _bereich()
bereich._project.layoutManager.return_value.layoutByName.return_value = None
with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse:
bereich._on_legende_aktualisieren()
klasse.assert_not_called()
warnung.assert_called_once()
@patch("sn_basis.ui.widgets.basemap_bereich.success")
def test_aktualisieren_wertet_gewaehlte_vorlage_aus(self, erfolg):
"""Prüft, dass „Basemap-Legende aktualisieren“ die gewählte Vorlage auswertet und den Erfolg meldet."""
bereich = _bereich()
layout = bereich._project.layoutManager.return_value.layoutByName.return_value
with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse:
klasse.return_value.aktualisiere.return_value = {"status": "ok", "meldung": "3 Einträge"}
bereich._on_legende_aktualisieren()
klasse.return_value.aktualisiere.assert_called_once_with(layout)
erfolg.assert_called_once()
@patch("sn_basis.ui.widgets.basemap_bereich.ask_yes_no", return_value=True)
@patch("sn_basis.ui.widgets.basemap_bereich.success")
def test_stil_laden_uebernimmt_legendenauswahl(self, _erfolg, _frage):
"""Prüft, dass das Laden einer Stilvorlage den Layerstil ersetzt und ihre Legendenauswahl in das Projekt übernimmt."""
layer = MagicMock()
bereich = _bereich(layer=layer)
bereich._stilvorlagen.pruefe_passung.return_value = []
bereich._stilvorlagen.legendenauswahl.return_value = {"ausgeblendet": ["wald"], "eingeblendet": []}
bereich._on_stil_laden()
bereich._stilvorlagen.laden.assert_called_once_with(layer, "Ohne_Hausnummern")
self.assertEqual(json.loads(get_variable(bl.AUSWAHL_VAR, scope="project"))["ausgeblendet"], ["wald"])
@patch("sn_basis.ui.widgets.basemap_bereich.ask_yes_no", side_effect=[True, False])
def test_stil_laden_bricht_bei_fremder_vorlage_ab(self, _frage):
"""Prüft, dass eine Vorlage mit unbekannten Styles nur nach Bestätigung geladen wird."""
bereich = _bereich(layer=MagicMock())
bereich._stilvorlagen.pruefe_passung.return_value = ["Fremder_Style"]
bereich._on_stil_laden()
bereich._stilvorlagen.laden.assert_not_called()
@patch("sn_basis.ui.widgets.basemap_bereich.ask_text", return_value=("Ohne Hausnummern", True))
@patch("sn_basis.ui.widgets.basemap_bereich.ask_yes_no", return_value=False)
def test_vorhandene_vorlage_wird_nur_nach_rueckfrage_ersetzt(self, _frage, _text):
"""Prüft, dass eine gleichnamige Stilvorlage ohne Bestätigung nicht überschrieben wird."""
bereich = _bereich(layer=MagicMock())
bereich._stilvorlagen.vorhanden.return_value = True
bereich._on_stil_speichern()
bereich._stilvorlagen.speichern.assert_not_called()
@patch("sn_basis.ui.widgets.basemap_bereich.ask_text", return_value=("Neu", True))
@patch("sn_basis.ui.widgets.basemap_bereich.success")
def test_speichern_mit_aktueller_legendenauswahl(self, _erfolg, _text):
"""Prüft, dass eine neue Stilvorlage zusammen mit der aktuellen Legendenauswahl gespeichert wird."""
layer = MagicMock()
bereich = _bereich(layer=layer)
bereich._stilvorlagen.vorhanden.return_value = False
bl.merke_auswahl([bl.LegendenEintrag("wald", "Wald", "Vegetation", [])], [])
bereich._on_stil_speichern()
bereich._stilvorlagen.speichern.assert_called_once_with(
layer, "Neu", {"ausgeblendet": ["wald"], "eingeblendet": []})
bereich.aktualisieren.assert_called_once()
@patch("sn_basis.ui.widgets.basemap_bereich.warning")
def test_stil_speichern_ohne_basemap_layer_warnt(self, warnung):
"""Prüft, dass ohne Basemap-Layer keine Stilvorlage gespeichert, sondern gewarnt wird."""
bereich = _bereich(layer=None)
bereich._on_stil_speichern()
bereich._stilvorlagen.speichern.assert_not_called()
warnung.assert_called_once()
if __name__ == "__main__":
unittest.main()
+186
View File
@@ -0,0 +1,186 @@
"""
Tests für modules/basemap_kacheln.py – kachelweise Auswertung des Basemap-Layers (Basis #88).
Layer, Kachelmatrix und Geometrie sind Fakes; ``selectByGeometry`` liefert je Kachel vorbereitete
Objekte. Die echte Auswertung gegen basemap.de ist im QGIS-Test geprüft.
"""
import unittest
from unittest.mock import patch
from sn_basis.modules.basemap_kacheln import anzahl_kacheln, sammle_objekte, stile_aus_layer, zoomstufen
class _Rechteck:
def __init__(self, x0, y0, x1, y1):
self.werte = (x0, y0, x1, y1)
def xMinimum(self): return self.werte[0]
def yMinimum(self): return self.werte[1]
def xMaximum(self): return self.werte[2]
def yMaximum(self): return self.werte[3]
def width(self): return self.werte[2] - self.werte[0]
class _Bereich:
def __init__(self, spalten, zeilen):
self._spalten, self._zeilen = spalten, zeilen
def startColumn(self): return self._spalten[0]
def endColumn(self): return self._spalten[1]
def startRow(self): return self._zeilen[0]
def endRow(self): return self._zeilen[1]
class _Matrix:
def __init__(self, bereich):
self._bereich = bereich
def tileRangeFromExtent(self, ausdehnung):
return self._bereich
def tileExtent(self, kachel):
return _Rechteck(kachel.column() * 10, kachel.row() * 10, kachel.column() * 10 + 10, kachel.row() * 10 + 10)
class _MatrixSet:
def __init__(self, bereich):
self._bereich = bereich
self.gefragt = []
def scaleToZoomLevel(self, massstab, begrenzen=True):
self.gefragt.append((massstab, begrenzen))
return 15 if begrenzen else 16
def scaleToZoom(self, massstab):
return 15.8
def tileMatrix(self, zoom):
return _Matrix(self._bereich)
class _Geometrie:
def __init__(self, leer=False):
self._leer = leer
def boundingBox(self):
return _Rechteck(0, 0, 20, 10)
def intersection(self, andere):
return _Geometrie(self._leer)
def isEmpty(self):
return self._leer
class _Feld:
def __init__(self, name): self._name = name
def name(self): return self._name
class _Merkmal:
def __init__(self, layer, geometrietyp, **attribute):
self._werte = dict(attribute, tile_layer=layer, tile_zoom=15)
self._typ = geometrietyp
def fields(self): return [_Feld(n) for n in self._werte]
def __getitem__(self, name): return self._werte[name]
def geometry(self): return type("G", (), {"type": lambda s: self._typ})()
class _Layer:
def __init__(self, bereich, merkmale_je_aufruf):
self.matrix = _MatrixSet(bereich)
self._merkmale = list(merkmale_je_aufruf)
self._aktuell = []
self.auswahl_aufgehoben = 0
self.aufrufe = 0
def tileMatrixSet(self): return self.matrix
def selectByGeometry(self, geometrie, kontext, verhalten, beziehung):
self.aufrufe += 1
self._aktuell = self._merkmale.pop(0) if self._merkmale else []
def selectedFeatures(self): return self._aktuell
def removeSelection(self): self.auswahl_aufgehoben += 1
class _GeometrieFabrik:
"""Ersatz für QgsGeometry.fromRect (fehlt im Mock-Modus)."""
@staticmethod
def fromRect(rechteck):
return rechteck
@patch("sn_basis.modules.basemap_kacheln.QgsGeometry", _GeometrieFabrik)
class TestSammleObjekte(unittest.TestCase):
def test_gleiche_objekte_aus_mehreren_kacheln_werden_entdoppelt(self):
"""Prüft, dass Objekte mit gleichem Quell-Layer, Geometrietyp und Attributen über Kachelgrenzen hinweg nur einmal gezählt werden."""
strasse = _Merkmal("Verkehrslinie", 1, klasse="Kreisstraße")
layer = _Layer(_Bereich((0, 1), (0, 0)), [
[strasse, _Merkmal("Vegetationsflaeche", 2, klasse="Wald")],
[_Merkmal("Verkehrslinie", 1, klasse="Kreisstraße")],
])
objekte = sammle_objekte(layer, _Geometrie(), 5000.0)
self.assertEqual(layer.aufrufe, 2)
self.assertEqual(sorted(o.layer_name for o in objekte), ["Vegetationsflaeche", "Verkehrslinie"])
kreis = next(o for o in objekte if o.layer_name == "Verkehrslinie")
self.assertEqual(kreis.attribute, {"klasse": "Kreisstraße"})
self.assertIs(kreis.merkmal, strasse)
self.assertEqual(layer.auswahl_aufgehoben, 1)
def test_kacheln_ausserhalb_der_geometrie_werden_uebersprungen(self):
"""Prüft, dass Kacheln ohne Schnitt mit dem Auswertebereich nicht abgefragt werden."""
layer = _Layer(_Bereich((0, 2), (0, 1)), [])
self.assertEqual(sammle_objekte(layer, _Geometrie(leer=True), 5000.0), [])
self.assertEqual(layer.aufrufe, 0)
def test_abbruch_liefert_none_und_hebt_auswahl_auf(self):
"""Prüft, dass ein Abbruch über den Fortschritt None liefert und die Auswahl auf dem Basemap-Layer trotzdem aufgehoben wird."""
layer = _Layer(_Bereich((0, 3), (0, 0)), [[], [], [], []])
fortschritt = []
def melde(nummer, gesamt):
fortschritt.append((nummer, gesamt))
return nummer < 2
self.assertIsNone(sammle_objekte(layer, _Geometrie(), 5000.0, fortschritt=melde))
self.assertEqual(fortschritt, [(1, 4), (2, 4)])
self.assertEqual(layer.aufrufe, 1)
self.assertEqual(layer.auswahl_aufgehoben, 1)
def test_anzahl_kacheln_aus_kachelbereich(self):
"""Prüft, dass die Kachelzahl aus dem Kachelbereich der Geometrie berechnet wird."""
self.assertEqual(anzahl_kacheln(_Layer(_Bereich((3, 5), (7, 10)), []), _Geometrie(), 5000.0), 12)
class TestZoomUndStile(unittest.TestCase):
def test_zoomstufen_wie_im_qgis_renderer(self):
"""Prüft, dass Kachelzoom begrenzt, Darstellungszoom unbegrenzt und der gebrochene Zoom aus dem Maßstab bestimmt werden."""
layer = _Layer(_Bereich((0, 0), (0, 0)), [])
self.assertEqual(zoomstufen(layer, 5000.0), (15, 16, 15.8))
self.assertEqual(layer.matrix.gefragt, [(5000.0, True), (5000.0, False)])
def test_stile_aus_layer_liest_renderer_styles(self):
"""Prüft, dass Name, Quell-Layer, Geometrietyp, Zoombereich, Schalter und Filter der Renderer-Styles übernommen werden."""
class Stil:
def styleName(self): return "Decker_Kreisstr"
def layerName(self): return "Verkehrslinie"
def geometryType(self): return 1
def minZoomLevel(self): return 9
def maxZoomLevel(self): return 21
def isEnabled(self): return False
def filterExpression(self): return "\"klasse\" IS 'Kreisstraße'"
layer = type("L", (), {"renderer": lambda s: type("R", (), {"styles": lambda s: [Stil()]})()})()
stil = stile_aus_layer(layer)[0]
self.assertEqual((stil.name, stil.layer_name, stil.geometrie, stil.min_zoom, stil.max_zoom, stil.aktiv),
("Decker_Kreisstr", "Verkehrslinie", 1, 9, 21, False))
self.assertIn("Kreisstraße", stil.filter)
if __name__ == "__main__":
unittest.main()
+174
View File
@@ -0,0 +1,174 @@
"""
Tests für modules/basemap_katalog.py – Legendenkatalog aus der basemap.de-Style-JSON (Basis #88).
Die Style-JSON wird nicht geladen; die Tests arbeiten mit einem kleinen Ausschnitt mit den
typischen Mustern (Kontur/Decker, Brücke, Beschriftung, Symbol, Hintergrund).
"""
import copy
import unittest
from sn_basis.modules.basemap_katalog import (
erzeuge_katalog,
ist_ausgeschlossen,
ist_renderer_stil,
lade_json,
normalisiere_stil_id,
pruefe_katalog,
waehle_symbol_stile,
_standard_zielpfad,
)
_KREIS = ["==", "klasse", "Kreisstraße"]
STIL_AUSSCHNITT = {
"name": "Ausschnitt",
"version": 8,
"layers": [
{"id": "Hintergrund", "type": "fill", "source-layer": "Hintergrund"},
{"id": "VegetationsF_Wald", "type": "fill", "source-layer": "Vegetationsflaeche",
"filter": ["==", "klasse", "Wald"]},
{"id": "Kontur_Kreisstr", "type": "line", "source-layer": "Verkehrslinie",
"filter": ["all", _KREIS, ["!has", "bauwerk"]]},
{"id": "Kontur_Kreisstr_getrennt", "type": "line", "source-layer": "Verkehrslinie",
"filter": ["all", _KREIS, ["==", "fahrbahn", "Getrennt"]]},
{"id": "Decker_Kreisstr", "type": "line", "source-layer": "Verkehrslinie",
"filter": ["all", _KREIS, ["!has", "bauwerk"]]},
{"id": "Bruecke_Kreisstr_Decker", "type": "line", "source-layer": "Verkehrslinie",
"filter": ["all", _KREIS, ["==", "bauwerk", "Brücke"]]},
{"id": "ImBau_Decker_Kreisstr", "type": "line", "source-layer": "Verkehrslinie",
"filter": ["all", _KREIS, ["==", "zustand", "Im Bau"]]},
{"id": "Name_Kreisstr", "type": "symbol", "source-layer": "Verkehrslinie",
"layout": {"text-field": "{name}"}, "filter": _KREIS},
{"id": "Nummer_Kreisstr", "type": "symbol", "source-layer": "Verkehrslinie",
"layout": {"text-field": "{nummer}", "icon-image": "schild"}, "filter": _KREIS},
{"id": "Symbol_BauwerkP_Kran", "type": "symbol", "source-layer": "Bauwerkspunkt",
"layout": {"icon-image": "kran"}, "filter": ["==", "klasse", "Kran"]},
{"id": "Symbol_BauwerkF_Kran", "type": "symbol", "source-layer": "Bauwerksflaeche",
"layout": {"icon-image": "kran"}, "filter": ["==", "klasse", "Kran"]},
{"id": "Gebaeude3D", "type": "fill-extrusion", "source-layer": "Gebaeudeflaeche"},
],
}
def _eintrag(katalog, stil_id):
return next(e for e in katalog["eintraege"] if stil_id in e["treffer_stile"])
class TestRendererStil(unittest.TestCase):
def test_beschriftung_und_3d_sind_keine_renderer_stile(self):
"""Prüft, dass reine Beschriftungen und 3D-Gebäude nicht als Renderer-Style gelten, Icon-Symbole und Flächen schon."""
stile = {s["id"]: s for s in STIL_AUSSCHNITT["layers"]}
self.assertFalse(ist_renderer_stil(stile["Name_Kreisstr"]))
self.assertFalse(ist_renderer_stil(stile["Gebaeude3D"]))
self.assertTrue(ist_renderer_stil(stile["Symbol_BauwerkP_Kran"]))
self.assertTrue(ist_renderer_stil(stile["VegetationsF_Wald"]))
def test_ausschluss_ueber_liste_und_praefix(self):
"""Prüft, dass Style-IDs über die Ausschlussliste und über Präfixe (Nummernschilder) ausgeschlossen werden."""
self.assertTrue(ist_ausgeschlossen("Hintergrund", ["Hintergrund"], ["Nummer_"]))
self.assertTrue(ist_ausgeschlossen("Nummer_Kreisstr", [], ["Nummer_"]))
self.assertFalse(ist_ausgeschlossen("Decker_Kreisstr", ["Hintergrund"], ["Nummer_"]))
def test_normalisierung_entfernt_variantenkennungen(self):
"""Prüft, dass Variantenkennungen wie Kontur, Brücke oder Breitenangaben beim Normalisieren einer Style-ID entfallen."""
self.assertEqual(normalisiere_stil_id("Bruecke_Kreisstr_getrennt_Decker"), "Kreisstr")
self.assertEqual(normalisiere_stil_id("Gewaesser_L_Breite_12m"), "Gewaesser_Breite")
class TestErzeugeKatalog(unittest.TestCase):
def test_kontur_decker_und_bruecke_bilden_einen_eintrag(self):
"""Prüft, dass Kontur-, Decker-, Fahrbahn- und Brückenvarianten derselben Straßenklasse genau einen Legendeneintrag bilden."""
katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT)
kreis = _eintrag(katalog, "Kontur_Kreisstr")
self.assertEqual(kreis["bezeichnung"], "Kreisstraße")
self.assertEqual(set(kreis["treffer_stile"]),
{"Kontur_Kreisstr", "Kontur_Kreisstr_getrennt", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"})
self.assertEqual(kreis["symbol_stile"], ["Kontur_Kreisstr", "Decker_Kreisstr"])
def test_zustand_im_bau_wird_eigener_eintrag(self):
"""Prüft, dass „im Bau“ einen eigenen Legendeneintrag mit Zustandsangabe in der Bezeichnung bekommt."""
katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT)
im_bau = _eintrag(katalog, "ImBau_Decker_Kreisstr")
self.assertNotIn("Kontur_Kreisstr", im_bau["treffer_stile"])
self.assertIn("im Bau", im_bau["bezeichnung"])
def test_punkt_und_flaeche_derselben_objektart_werden_zusammengefasst(self):
"""Prüft, dass das Kran-Symbol aus Bauwerkspunkt und Bauwerksfläche in einem Eintrag landet."""
katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT)
kran = _eintrag(katalog, "Symbol_BauwerkP_Kran")
self.assertIn("Symbol_BauwerkF_Kran", kran["treffer_stile"])
def test_ausgeschlossene_stile_fehlen_im_katalog(self):
"""Prüft, dass Hintergrund, Beschriftungen, Nummernschilder und 3D-Gebäude keinem Eintrag zugeordnet werden."""
katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT)
alle = {s for e in katalog["eintraege"] for s in e["treffer_stile"]}
for stil_id in ("Hintergrund", "Name_Kreisstr", "Nummer_Kreisstr", "Gebaeude3D"):
self.assertNotIn(stil_id, alle)
self.assertEqual(pruefe_katalog(katalog), [])
def test_fachliche_nachbearbeitung_bleibt_beim_erneuten_lauf_erhalten(self):
"""Prüft, dass Bezeichnung, Gruppe, Zusammenführung und Symbolwahl eines bestehenden Katalogs bei einem erneuten Lauf erhalten bleiben."""
katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT)
bearbeitet = copy.deepcopy(katalog)
kreis = _eintrag(bearbeitet, "Kontur_Kreisstr")
im_bau = _eintrag(bearbeitet, "ImBau_Decker_Kreisstr")
kreis["bezeichnung"] = "Kreisstraße (fachlich)"
kreis["treffer_stile"] += im_bau["treffer_stile"]
kreis["symbol_stile"] = ["Decker_Kreisstr"]
bearbeitet["eintraege"].remove(im_bau)
erneut, meldungen = erzeuge_katalog(STIL_AUSSCHNITT, bearbeitet)
kreis_neu = _eintrag(erneut, "Kontur_Kreisstr")
self.assertEqual(kreis_neu["bezeichnung"], "Kreisstraße (fachlich)")
self.assertIn("ImBau_Decker_Kreisstr", kreis_neu["treffer_stile"])
self.assertEqual(kreis_neu["symbol_stile"], ["Decker_Kreisstr"])
self.assertEqual(meldungen, [])
def test_entfallene_style_ids_werden_gemeldet(self):
"""Prüft, dass eine Style-ID, die in der neuen Style-JSON fehlt, als entfallen gemeldet und aus dem Katalog entfernt wird."""
katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT)
verkleinert = copy.deepcopy(STIL_AUSSCHNITT)
verkleinert["layers"] = [l for l in verkleinert["layers"] if l["id"] != "Bruecke_Kreisstr_Decker"]
erneut, meldungen = erzeuge_katalog(verkleinert, katalog)
self.assertTrue(any("Bruecke_Kreisstr_Decker" in m for m in meldungen))
self.assertNotIn("Bruecke_Kreisstr_Decker", _eintrag(erneut, "Kontur_Kreisstr")["treffer_stile"])
class TestSymbolwahlUndPruefung(unittest.TestCase):
def test_symbol_stile_je_rolle_nur_grundvariante(self):
"""Prüft, dass für das Legendensymbol je Rolle (Kontur, Decker) nur die erste Grundvariante gewählt wird."""
gewaehlt = waehle_symbol_stile(
["Kontur_Kreisstr_FSZ1", "Kontur_Kreisstr", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"])
self.assertEqual(gewaehlt, ["Kontur_Kreisstr", "Decker_Kreisstr"])
def test_pruefung_findet_doppelte_zuordnung_und_unbekannte_symbole(self):
"""Prüft, dass die Katalogprüfung doppelt zugeordnete Style-IDs und Symbol-Styles außerhalb des Katalogs meldet."""
katalog = {
"gruppen": ["Verkehr"],
"eintraege": [
{"schluessel": "a", "gruppe": "Verkehr", "treffer_stile": ["X"], "symbol_stile": ["X"]},
{"schluessel": "b", "gruppe": "Verkehr", "treffer_stile": ["X"], "symbol_stile": ["Y"]},
],
}
fehler = pruefe_katalog(katalog)
self.assertTrue(any("Style-ID X" in f for f in fehler))
self.assertTrue(any("nicht im Katalog: Y" in f for f in fehler))
def test_ausgelieferter_katalog_ist_konsistent(self):
"""Prüft, dass der ausgelieferte Katalog data/basemap_legende.json ohne Fehler geprüft wird und Kreisstraße mit Kontur und Decker enthält."""
katalog = lade_json(_standard_zielpfad())
self.assertEqual(pruefe_katalog(katalog), [])
kreis = _eintrag(katalog, "Decker_Kreisstr")
self.assertEqual(kreis["bezeichnung"], "Kreisstraße")
self.assertEqual(kreis["symbol_stile"], ["Kontur_Kreisstr", "Decker_Kreisstr"])
self.assertIn("Hintergrund", katalog["nie_in_legende"])
if __name__ == "__main__":
unittest.main()
+327
View File
@@ -0,0 +1,327 @@
"""
Tests für modules/basemap_legende.py – Zuordnung, Auswahl und Ablauf der Basemap-Legende (Basis #88).
Die Kachelauswertung und die Layoutausgabe werden durch Fakes ersetzt; die echte Auswertung
gegen basemap.de ist im QGIS-Test geprüft (siehe Pull Request).
"""
import json
import unittest
from unittest.mock import patch
from sn_basis.functions.variable_wrapper import get_variable, set_variable
from sn_basis.modules import basemap_legende as bl
from sn_basis.modules.basemap_legende import (
AUSWAHL_VAR,
AUSWERTEBEREICH_DRUCKBEREICH,
AUSWERTEBEREICH_VAR,
AUSWERTEBEREICH_VERFAHRENSGEBIET,
BasemapLegende,
Katalog,
LEGENDE_ERZEUGEN_VAR,
LegendenEintrag,
ObjektInfo,
StilInfo,
bilde_eintraege,
ermittle_getroffene_stile,
ist_basemap_layer,
legende_erzeugen_aktiv,
lies_auswahl,
lies_auswertebereich,
merke_auswahl,
setze_auswertebereich,
sichtbare_layer_der_karte,
stil_ist_aktiv,
vorauswahl,
)
KATALOG = Katalog({
"gruppen": ["Verkehr", "Vegetation"],
"nie_in_legende": ["Hintergrund"],
"nie_in_legende_praefixe": ["Nummer_"],
"eintraege": [
{"schluessel": "kreis", "bezeichnung": "Kreisstraße", "gruppe": "Verkehr",
"treffer_stile": ["Kontur_Kreisstr", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"],
"symbol_stile": ["Kontur_Kreisstr", "Decker_Kreisstr"]},
{"schluessel": "wald", "bezeichnung": "Wald", "gruppe": "Vegetation",
"treffer_stile": ["VegetationsF_Wald"], "symbol_stile": ["VegetationsF_Wald"]},
{"schluessel": "weg", "bezeichnung": "Wirtschaftsweg", "gruppe": "Verkehr",
"treffer_stile": ["Kontur_Wirtschaftsweg"], "symbol_stile": ["Kontur_Wirtschaftsweg"]},
],
})
def _filter_ueber_attribute(filter_text, objekt):
"""Einfache Filterprüfung für Tests: Filter der Form klasse=Wert."""
feld, wert = filter_text.split("=")
return objekt.attribute.get(feld) == wert
def _leere_variablen():
for schluessel in (AUSWAHL_VAR, AUSWERTEBEREICH_VAR, LEGENDE_ERZEUGEN_VAR):
set_variable(schluessel, "", scope="project")
class TestStileUndEintraege(unittest.TestCase):
def test_stil_aktiv_nur_eingeschaltet_und_im_zoombereich(self):
"""Prüft, dass ein Style nur aktiv ist, wenn er eingeschaltet ist und die Zoomstufe im Bereich liegt (-1 = unbegrenzt)."""
stil = StilInfo("A", "L", 1, min_zoom=11, max_zoom=14)
self.assertTrue(stil_ist_aktiv(stil, 11))
self.assertFalse(stil_ist_aktiv(stil, 15))
self.assertFalse(stil_ist_aktiv(StilInfo("A", "L", 1, aktiv=False), 12))
self.assertTrue(stil_ist_aktiv(StilInfo("A", "L", 1), 20))
def test_getroffene_stile_pruefen_layer_geometrie_zoom_und_filter(self):
"""Prüft, dass ein Style nur Objekte seines Quell-Layers und Geometrietyps zählt, deren Filter zutrifft, und nur bei passender Zoomstufe."""
stile = [
StilInfo("Decker_Kreisstr", "Verkehrslinie", 1, filter="klasse=Kreisstraße"),
StilInfo("Decker_Bundesstr", "Verkehrslinie", 1, filter="klasse=Bundesstraße"),
StilInfo("VegetationsF_Wald", "Vegetationsflaeche", 2, filter="klasse=Wald"),
StilInfo("Nur_klein", "Verkehrslinie", 1, max_zoom=10),
StilInfo("Alle_Layer", "", 2),
]
objekte = [
ObjektInfo("Verkehrslinie", 1, {"klasse": "Kreisstraße"}),
ObjektInfo("Vegetationsflaeche", 2, {"klasse": "Wald"}),
ObjektInfo("Vegetationsflaeche", 1, {"klasse": "Wald"}), # Linie: passt nicht zum Flächenstyle
]
getroffen = ermittle_getroffene_stile(objekte, stile, 15, _filter_ueber_attribute)
self.assertEqual(getroffen, {"Decker_Kreisstr": 1, "VegetationsF_Wald": 1, "Alle_Layer": 1})
def test_eintraege_fassen_styles_zusammen_und_sortieren_nach_gruppe(self):
"""Prüft, dass mehrere Styles eines Katalogeintrags einen Eintrag ergeben und die Einträge nach Gruppen- und Katalogreihenfolge sortiert sind."""
eintraege = bilde_eintraege(
["VegetationsF_Wald", "Kontur_Wirtschaftsweg", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"], KATALOG)
self.assertEqual([e.schluessel for e in eintraege], ["kreis", "weg", "wald"])
self.assertEqual(eintraege[0].treffer, ["Decker_Kreisstr", "Bruecke_Kreisstr_Decker"])
self.assertEqual(eintraege[0].symbol_stile, ["Kontur_Kreisstr", "Decker_Kreisstr"])
def test_ausgeschlossene_und_unbekannte_styles(self):
"""Prüft, dass ausgeschlossene Styles entfallen und unbekannte Styles als nicht bekannter Eintrag in „Sonstiges“ landen."""
eintraege = bilde_eintraege(["Hintergrund", "Nummer_B6", "Eigener_Style"], KATALOG)
self.assertEqual(len(eintraege), 1)
self.assertEqual(eintraege[0].schluessel, "stil:Eigener_Style")
self.assertEqual(eintraege[0].gruppe, "Sonstiges")
self.assertFalse(eintraege[0].bekannt)
class TestAuswahlUndEinstellungen(unittest.TestCase):
def setUp(self):
_leere_variablen()
def tearDown(self):
_leere_variablen()
def test_vorauswahl_bekannte_an_unbekannte_aus(self):
"""Prüft, dass ohne gespeicherte Auswahl Katalogeinträge vorausgewählt und unbekannte Styles abgewählt sind."""
eintraege = [LegendenEintrag("kreis", "Kreisstraße", "Verkehr", []),
LegendenEintrag("stil:X", "X", "Sonstiges", [], bekannt=False)]
self.assertEqual(vorauswahl(eintraege), {"kreis"})
def test_auswahl_wird_gemerkt_und_vorbelegt(self):
"""Prüft, dass ab- und angewählte Einträge in der Projektvariable gespeichert und beim nächsten Mal so vorbelegt werden."""
eintraege = [LegendenEintrag("kreis", "Kreisstraße", "Verkehr", []),
LegendenEintrag("stil:X", "X", "Sonstiges", [], bekannt=False)]
merke_auswahl(eintraege, ["stil:X"])
self.assertEqual(lies_auswahl(), {"ausgeblendet": ["kreis"], "eingeblendet": ["stil:X"]})
self.assertEqual(vorauswahl(eintraege, lies_auswahl()), {"stil:X"})
def test_merken_ergaenzt_die_bisherige_auswahl(self):
"""Prüft, dass das Merken die Auswahl nicht angezeigter Einträge aus früheren Läufen behält."""
merke_auswahl([LegendenEintrag("wald", "Wald", "Vegetation", [])], [])
merke_auswahl([LegendenEintrag("kreis", "Kreisstraße", "Verkehr", [])], ["kreis"])
self.assertEqual(lies_auswahl()["ausgeblendet"], ["wald"])
def test_kaputte_projektvariable_ergibt_leere_auswahl(self):
"""Prüft, dass eine nicht lesbare Projektvariable als leere Auswahl gilt."""
set_variable(AUSWAHL_VAR, "{kein json", scope="project")
self.assertEqual(lies_auswahl(), {"ausgeblendet": [], "eingeblendet": []})
def test_auswertebereich_standard_und_umstellung(self):
"""Prüft, dass der Auswertebereich standardmäßig das Verfahrensgebiet ist, umgestellt werden kann und unbekannte Werte abgewiesen werden."""
self.assertEqual(lies_auswertebereich(), AUSWERTEBEREICH_VERFAHRENSGEBIET)
setze_auswertebereich(AUSWERTEBEREICH_DRUCKBEREICH)
self.assertEqual(lies_auswertebereich(), AUSWERTEBEREICH_DRUCKBEREICH)
with self.assertRaises(ValueError):
setze_auswertebereich("irgendwo")
def test_legende_erzeugen_standard_an(self):
"""Prüft, dass das Erzeugen der Basemap-Legende standardmäßig eingeschaltet ist und abgeschaltet werden kann."""
self.assertTrue(legende_erzeugen_aktiv())
bl.setze_legende_erzeugen(False)
self.assertFalse(legende_erzeugen_aktiv())
class _FakeVektorkachelLayer:
def __init__(self, quelle="", name="Layer"):
self._quelle, self._name = quelle, name
def tileMatrixSet(self):
return None
def source(self):
return self._quelle
def name(self):
return self._name
def crs(self):
return "EPSG:3857"
class _FakeVektorLayer:
def source(self):
return "https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/wms"
def name(self):
return "Basemap.de WMS"
class TestLayerErkennung(unittest.TestCase):
def test_erkennung_ueber_quelle_und_namen(self):
"""Prüft, dass der Basemap-Layer an Vektorkacheln mit basemap.de-Quelle erkannt wird, der Name nur als Rückfall dient und andere Layertypen nicht zählen."""
self.assertTrue(ist_basemap_layer(_FakeVektorkachelLayer(
"type=xyz&url=https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/tiles/v2/{z}/{x}/{y}.pbf")))
self.assertTrue(ist_basemap_layer(_FakeVektorkachelLayer("type=mbtiles", "Basemap.de - Farbe")))
self.assertFalse(ist_basemap_layer(_FakeVektorkachelLayer("type=xyz&url=https://andere.de", "OSM")))
self.assertFalse(ist_basemap_layer(_FakeVektorLayer()))
self.assertFalse(ist_basemap_layer(None))
def test_sichtbare_layer_aus_thema_feste_liste_oder_layerbaum(self):
"""Prüft, dass die Layer der Karte aus dem Kartenthema, sonst aus der festen Layerliste, sonst aus dem sichtbaren Layerbaum kommen."""
class Karte:
def __init__(self, thema="", feste=None):
self._thema, self._feste = thema, feste or []
def followVisibilityPreset(self): return bool(self._thema)
def followVisibilityPresetName(self): return self._thema
def keepLayerSet(self): return bool(self._feste)
def layers(self): return self._feste
class Knoten:
def __init__(self, layer, sichtbar): self._layer, self._sichtbar = layer, sichtbar
def layer(self): return self._layer
def isVisible(self): return self._sichtbar
class Projekt:
def mapThemeCollection(self):
return type("T", (), {"mapThemeVisibleLayers": lambda s, t: ["thema-" + t]})()
def layerTreeRoot(self):
return type("W", (), {"findLayers": lambda s: [Knoten("a", True), Knoten("b", False)]})()
self.assertEqual(sichtbare_layer_der_karte(Karte(thema="Plan"), Projekt()), ["thema-Plan"])
self.assertEqual(sichtbare_layer_der_karte(Karte(feste=["x"]), Projekt()), ["x"])
self.assertEqual(sichtbare_layer_der_karte(Karte(), Projekt()), ["a"])
class _FakeAusgabe:
"""Ersatz für basemap_legende_layout im Ablauftest."""
def __init__(self, karte):
self.karte = karte
self.geschrieben = None
def hauptkarte(self, layout):
return self.karte
def baue_symbole(self, layer, eintraege, zoom, vtz):
return {e.schluessel: f"symbol-{e.schluessel}" for e in eintraege}
def vorschau_icon(self, symbol):
return None
def schreibe_legende(self, layout, karte, eintraege, symbole, projekt, *, bereich, massstab):
self.geschrieben = {"eintraege": [e.schluessel for e in eintraege], "bereich": bereich, "massstab": massstab}
return ""
class _FakeKarte:
def scale(self):
return 5000.0
class TestAblauf(unittest.TestCase):
def setUp(self):
_leere_variablen()
self.dialog_aufrufe = []
self.meldungen = []
def tearDown(self):
_leere_variablen()
def _legende(self, dialog_ergebnis=("kreis",)):
def dialog(titel, hinweis, eintraege, vorauswahl):
self.dialog_aufrufe.append((eintraege, set(vorauswahl)))
return None if dialog_ergebnis is None else list(dialog_ergebnis)
return BasemapLegende(project=object(), katalog=KATALOG, dialog=dialog,
fortschritt_fabrik=lambda *a: None, bestaetigen=lambda *a: True,
melden=lambda titel, text: self.meldungen.append(text))
def _starte(self, legende, basemap=True, genutzt=AUSWERTEBEREICH_VERFAHRENSGEBIET, eintraege=None):
ausgabe = _FakeAusgabe(_FakeKarte())
layer = [_FakeVektorkachelLayer("gdz_basemapde_vektor", "Basemap.de - Farbe")] if basemap else []
eintraege = eintraege if eintraege is not None else bilde_eintraege(
["Decker_Kreisstr", "VegetationsF_Wald"], KATALOG)
with patch.dict("sys.modules", {"sn_basis.modules.basemap_legende_layout": ausgabe}), \
patch("sn_basis.modules.basemap_legende.sichtbare_layer_der_karte", return_value=layer), \
patch("sn_basis.modules.basemap_legende.auswertegeometrie", return_value=("geometrie", genutzt)), \
patch("sn_basis.modules.basemap_legende.zoomstufen", return_value=(15, 16, 15.8)), \
patch.object(BasemapLegende, "ermittle_eintraege", return_value=eintraege):
import sn_basis.modules as paket
with patch.object(paket, "basemap_legende_layout", ausgabe, create=True):
ergebnis = legende.aktualisiere(layout="layout")
return ergebnis, ausgabe
def test_ohne_basemap_layer_passiert_nichts(self):
"""Prüft, dass ohne sichtbaren Basemap-Layer weder Dialog noch Legende entstehen."""
ergebnis, ausgabe = self._starte(self._legende(), basemap=False)
self.assertEqual(ergebnis["status"], "kein_layer")
self.assertEqual(self.dialog_aufrufe, [])
self.assertIsNone(ausgabe.geschrieben)
def test_gewaehlte_eintraege_werden_geschrieben_und_gemerkt(self):
"""Prüft, dass nur die im Dialog gewählten Einträge in die Legende geschrieben und die Auswahl gespeichert wird."""
ergebnis, ausgabe = self._starte(self._legende(dialog_ergebnis=("kreis",)))
self.assertEqual(ergebnis["status"], "ok")
self.assertEqual(ausgabe.geschrieben["eintraege"], ["kreis"])
self.assertEqual(ausgabe.geschrieben["massstab"], 5000.0)
eintraege, vorbelegt = self.dialog_aufrufe[0]
self.assertEqual([e["gruppe"] for e in eintraege], ["Verkehr", "Vegetation"])
self.assertEqual(vorbelegt, {"kreis", "wald"})
self.assertEqual(json.loads(get_variable(AUSWAHL_VAR, scope="project"))["ausgeblendet"], ["wald"])
def test_abbruch_im_dialog_aendert_nichts(self):
"""Prüft, dass ein Abbruch im Auswahldialog die Legende nicht verändert und keine Auswahl speichert."""
ergebnis, ausgabe = self._starte(self._legende(dialog_ergebnis=None))
self.assertEqual(ergebnis["status"], "abgebrochen")
self.assertIsNone(ausgabe.geschrieben)
self.assertFalse(get_variable(AUSWAHL_VAR, scope="project"))
def test_rueckfall_auf_druckbereich_wird_gemeldet(self):
"""Prüft, dass ohne Verfahrensgebiet auf den Druckbereich zurückgefallen und das gemeldet wird."""
ergebnis, ausgabe = self._starte(self._legende(), genutzt=AUSWERTEBEREICH_DRUCKBEREICH)
self.assertEqual(ergebnis["status"], "ok")
self.assertEqual(ausgabe.geschrieben["bereich"], AUSWERTEBEREICH_DRUCKBEREICH)
self.assertTrue(any("Druckbereich" in m for m in self.meldungen))
def test_ohne_objekte_keine_legende(self):
"""Prüft, dass ohne Basemap-Objekte im Auswertebereich weder Dialog noch Legende entstehen."""
ergebnis, ausgabe = self._starte(self._legende(), eintraege=[])
self.assertEqual(ergebnis["status"], "keine_eintraege")
self.assertEqual(self.dialog_aufrufe, [])
self.assertIsNone(ausgabe.geschrieben)
def test_viele_kacheln_werden_vorher_bestaetigt(self):
"""Prüft, dass vor einer Auswertung mit sehr vielen Kacheln nachgefragt wird und eine Ablehnung die Auswertung abbricht."""
legende = BasemapLegende(project=object(), katalog=KATALOG, dialog=lambda *a: [],
fortschritt_fabrik=lambda *a: self.fail("keine Auswertung erwartet"),
bestaetigen=lambda *a: False, melden=lambda *a: None)
with patch("sn_basis.modules.basemap_legende.anzahl_kacheln", return_value=bl.KACHELN_NACHFRAGEN_AB):
self.assertIsNone(legende.ermittle_eintraege(object(), object(), 5000.0))
if __name__ == "__main__":
unittest.main()
+226
View File
@@ -0,0 +1,226 @@
"""
Tests für modules/basemap_legende_layout.py – Basemap-Legende im Drucklayout (Basis #88).
Layout, Legende und Projekt sind Fakes. Aussehen der Symbole und Platzierung im echten Layout
sind im QGIS-Test geprüft (siehe Pull Request).
"""
import json
import unittest
from unittest.mock import patch
from sn_basis.modules import basemap_legende_layout as bll
from sn_basis.modules.basemap_legende import LegendenEintrag
def _wert(objekt, name):
"""Liest x/y/width/height – im Mock-Modus Attribute, in QGIS Methoden."""
wert = getattr(objekt, name)
return wert() if callable(wert) else wert
class _Rechteck:
def __init__(self, x, y, b, h): self.w = (x, y, b, h)
def x(self): return self.w[0]
def y(self): return self.w[1]
def width(self): return self.w[2]
def height(self): return self.w[3]
class _Element:
def __init__(self, x=0.0, y=0.0, b=0.0, h=0.0):
self.rechteck = _Rechteck(x, y, b, h)
def sceneBoundingRect(self): return self.rechteck
class _Seite:
def __init__(self, breite): self._breite = breite
def pageSize(self): return type("S", (), {"width": lambda s: self._breite})()
class _Layout:
def __init__(self, elemente=None, breite=841.0, name="Plan"):
self.elemente = dict(elemente or {})
self.eigenschaften = {}
self.entfernt = []
self._breite, self._name = breite, name
def itemById(self, element_id): return self.elemente.get(element_id)
def items(self): return list(self.elemente.values())
def setCustomProperty(self, k, v): self.eigenschaften[k] = v
def customProperty(self, k, standard=None): return self.eigenschaften.get(k, standard)
def removeLayoutItem(self, element): self.entfernt.append(element)
def name(self): return self._name
def pageCollection(self): return type("P", (), {"page": lambda s, i: _Seite(self._breite)})()
class _Legende(_Element):
"""Legende, deren Platzbedarf je Spaltenzahl vorgegeben ist."""
def __init__(self):
super().__init__()
self.spalten = 1
self.bewegt = None
self.groesse = None
def setColumnCount(self, n): self.spalten = n
def setSplitLayer(self, wert): pass
def setEqualColumnWidth(self, wert): pass
def setReferencePoint(self, punkt): pass
def attemptMove(self, punkt): self.bewegt = punkt
def attemptResize(self, groesse): self.groesse = groesse
class TestKennzeichenUndKarte(unittest.TestCase):
def test_layout_kennzeichen_setzen_und_erkennen(self):
"""Prüft, dass ein markiertes Layout als Nutzer der Basemap-Legende erkannt und in der Projektliste geführt wird."""
markiert, normal = _Layout(name="§41 Plan"), _Layout(name="Baufreigabe")
bll.markiere_layout(markiert)
self.assertTrue(bll.nutzt_basemap_legende(markiert))
self.assertFalse(bll.nutzt_basemap_legende(normal))
projekt = type("P", (), {"layoutManager": lambda s: type("M", (), {
"printLayouts": lambda s: [markiert, normal]})()})()
self.assertEqual(bll.layouts_mit_basemap_legende(projekt), ["§41 Plan"])
def test_hauptkarte_ueber_id(self):
"""Prüft, dass die Karte mit der ID „Hauptkarte“ gefunden wird."""
karte = _Element()
self.assertIs(bll.hauptkarte(_Layout({"Hauptkarte": karte})), karte)
self.assertIsNone(bll.hauptkarte(_Layout()))
class TestSymbolwahl(unittest.TestCase):
def test_symbolnamen_aus_aktiven_symbol_stilen_in_renderer_reihenfolge(self):
"""Prüft, dass die Symbol-Styles des Katalogs in Zeichenreihenfolge genommen und inaktive übergangen werden."""
eintrag = LegendenEintrag("kreis", "Kreisstraße", "Verkehr", ["Decker_Kreisstr", "Kontur_Kreisstr"],
treffer=["Bruecke_Kreisstr_Decker"])
aktive = {"Kontur_Kreisstr": 3, "Decker_Kreisstr": 7, "Bruecke_Kreisstr_Decker": 9}
self.assertEqual(bll.waehle_symbolnamen(eintrag, aktive), ["Kontur_Kreisstr", "Decker_Kreisstr"])
def test_ohne_aktive_symbol_stile_erster_getroffener_style(self):
"""Prüft, dass ohne aktiven Symbol-Style der erste tatsächlich getroffene Style das Legendensymbol liefert."""
eintrag = LegendenEintrag("kreis", "Kreisstraße", "Verkehr", ["Decker_Kreisstr"],
treffer=["Bruecke_Kreisstr_Decker", "Tunnel_Kreisstr"])
aktive = {"Bruecke_Kreisstr_Decker": 9, "Tunnel_Kreisstr": 2}
self.assertEqual(bll.waehle_symbolnamen(eintrag, aktive), ["Bruecke_Kreisstr_Decker"])
class _Symbol:
def __init__(self, typ): self._typ = typ
def type(self): return self._typ
class _Projekt:
def __init__(self):
self.layer = {}
def addMapLayer(self, layer, in_baum=True):
layer._in_baum = in_baum
self.layer[layer.id()] = layer
def mapLayer(self, layer_id): return self.layer.get(layer_id)
def removeMapLayer(self, layer_id): self.layer.pop(layer_id, None)
class _Speicherlayer:
zaehler = 0
def __init__(self, uri, name, provider):
_Speicherlayer.zaehler += 1
self.uri, self._name, self._id = uri, name, f"hl{_Speicherlayer.zaehler}"
self.eigenschaften = {}
self.renderer = None
def id(self): return self._id
def setRenderer(self, renderer): self.renderer = renderer
def setCustomProperty(self, k, v): self.eigenschaften[k] = v
class TestHilfslayer(unittest.TestCase):
@patch("sn_basis.modules.basemap_legende_layout.QgsVectorLayer", _Speicherlayer)
def test_je_gruppe_und_geometrietyp_ein_leerer_hilfslayer(self):
"""Prüft, dass je Gruppe und Geometrietyp ein Hilfslayer außerhalb des Layerbaums entsteht, Flächen vor Linien, ohne Rückfrage beim Schließen."""
eintraege = [LegendenEintrag("kreis", "Kreisstraße", "Verkehr", []),
LegendenEintrag("platz", "Platz", "Verkehr", []),
LegendenEintrag("wald", "Wald", "Vegetation", []),
LegendenEintrag("ohne", "Ohne Symbol", "Vegetation", [])]
symbole = {"kreis": _Symbol(1), "platz": _Symbol(2), "wald": _Symbol(2)}
projekt = _Projekt()
gruppen = bll.erzeuge_hilfslayer(projekt, "Plan", "EPSG:25833", eintraege, symbole)
self.assertEqual([g for g, _ in gruppen], ["Verkehr", "Vegetation"])
verkehr = gruppen[0][1]
self.assertTrue(verkehr[0].uri.startswith("Polygon?crs=EPSG:25833"))
self.assertTrue(verkehr[1].uri.startswith("LineString?"))
self.assertEqual([k.label() for k in verkehr[1].renderer.categories()], ["Kreisstraße"])
self.assertEqual(len(gruppen[1][1]), 1)
for layer in projekt.layer.values():
self.assertFalse(layer._in_baum)
self.assertEqual(layer.eigenschaften["skipMemoryLayersCheck"], 1)
self.assertEqual(layer.eigenschaften[bll.HILFSLAYER_KENNUNG], "Plan")
def test_alte_hilfslayer_werden_entfernt(self):
"""Prüft, dass die Hilfslayer einer früheren Basemap-Legende des Layouts aus dem Projekt entfernt werden."""
projekt = _Projekt()
alt = _Speicherlayer("Point", "alt", "memory")
projekt.addMapLayer(alt, False)
layout = _Layout()
layout.setCustomProperty(bll.LAYOUT_HILFSLAYER, f"{alt.id()},gibt-es-nicht")
bll.entferne_hilfslayer(projekt, layout)
self.assertEqual(projekt.layer, {})
def test_ohne_eintraege_wird_vorhandene_basemap_legende_entfernt(self):
"""Prüft, dass ohne gewählte Einträge eine vorhandene Basemap-Legende entfernt und die Auswahl am Layout vermerkt wird."""
vorhandene = _Legende()
layout = _Layout({bll.LEGENDE_ID: vorhandene})
hinweis = bll.schreibe_legende(layout, _Element(), [], {}, _Projekt(), bereich="druckbereich", massstab=2500)
self.assertEqual(hinweis, "")
self.assertEqual(layout.entfernt, [vorhandene])
self.assertEqual(layout.eigenschaften[bll.LAYOUT_BEREICH], "druckbereich")
self.assertEqual(json.loads(layout.eigenschaften[bll.LAYOUT_EINTRAEGE]), [])
self.assertTrue(bll.nutzt_basemap_legende(layout))
class TestPlatzierung(unittest.TestCase):
def _layout(self, breite=841.0):
return _Layout({
bll.HAUPTLEGENDE_ID: _Element(380, 10, 180, 50),
bll.QUELLENANGABE_ID: _Element(380, 198, 180, 20),
bll.HAUPTKARTE_ID: _Element(10, 10, 360, 310),
}, breite=breite)
def _platziere(self, layout, bedarf):
legende = _Legende()
with patch.object(bll, "mindestgroesse", side_effect=lambda l, lay, n: bedarf[n]):
hinweis = bll.platziere(layout, legende)
return legende, hinweis
def test_einspaltig_ueber_der_quellenangabe(self):
"""Prüft, dass die Basemap-Legende einspaltig unten an der Quellenangabe verankert wird, wenn der Platz reicht."""
legende, hinweis = self._platziere(self._layout(), {1: (100.0, 120.0)})
self.assertEqual(hinweis, "")
self.assertEqual(legende.spalten, 1)
self.assertEqual((_wert(legende.bewegt, "x"), _wert(legende.bewegt, "y")), (380, 198 - bll.ABSTAND_MM))
self.assertEqual((_wert(legende.groesse, "width"), _wert(legende.groesse, "height")), (100.0, 120.0))
def test_mehrspaltig_wenn_hoehe_nicht_reicht(self):
"""Prüft, dass die Legende zwei- oder dreispaltig wird, wenn sie einspaltig nicht zwischen Hauptlegende und Quellenangabe passt."""
legende, hinweis = self._platziere(self._layout(), {1: (100.0, 300.0), 2: (200.0, 150.0), 3: (300.0, 100.0)})
self.assertEqual(hinweis, "")
self.assertEqual(legende.spalten, 3)
def test_nie_breiter_als_der_seitenrand_mit_hinweis(self):
"""Prüft, dass keine Spaltenzahl gewählt wird, die über den Seitenrand ragt, und dann ein Hinweis kommt."""
legende, hinweis = self._platziere(self._layout(breite=600.0),
{1: (100.0, 300.0), 2: (200.0, 160.0), 3: (300.0, 100.0)})
self.assertEqual(legende.spalten, 2)
self.assertIn("mehr Platz", hinweis)
if __name__ == "__main__":
unittest.main()
+128
View File
@@ -0,0 +1,128 @@
"""
Tests für modules/basemap_stilvorlagen.py – lokale Stilvorlagen des Basemap-Layers (Basis #88).
Gespeichert wird in einem temporären Ordner; der Layer schreibt statt einer echten QML-Datei
eine kleine Textdatei. Laden und Speichern am echten Vektorkachel-Layer ist im QGIS-Test geprüft.
"""
import os
import shutil
import tempfile
import unittest
from sn_basis.modules.basemap_stilvorlagen import STANDARD_SCHLUESSEL, BasemapStilvorlagen
class _Einstellungen:
"""Ersatz für QSettings – die Tests dürfen keine echten Benutzereinstellungen verändern."""
def __init__(self): self.daten = {}
def value(self, k, standard=""): return self.daten.get(k, standard)
def setValue(self, k, v): self.daten[k] = v
def remove(self, k): self.daten.pop(k, None)
class _Stil:
def __init__(self, name): self._name = name
def styleName(self): return self._name
class _Layer:
def __init__(self, stile=("Kontur_Kreisstr", "Decker_Kreisstr"), speichern_ok=True):
self.stile = list(stile)
self.geladen = None
self.neu_gezeichnet = False
self._speichern_ok = speichern_ok
def name(self): return "Basemap.de - Farbe"
def renderer(self): return type("R", (), {"styles": lambda s: [_Stil(n) for n in self.stile]})()
def saveNamedStyle(self, pfad, kategorien):
if not self._speichern_ok:
return "kein Schreibrecht", False
with open(pfad, "w", encoding="utf-8") as datei:
datei.write("<qgis/>")
return "", True
def loadNamedStyle(self, pfad, kategorien):
self.geladen = pfad
return "", True
def triggerRepaint(self): self.neu_gezeichnet = True
class TestStilvorlagen(unittest.TestCase):
def setUp(self):
self.ordner = tempfile.mkdtemp()
self.einstellungen = _Einstellungen()
self.vorlagen = BasemapStilvorlagen(os.path.join(self.ordner, "stile"), self.einstellungen)
def tearDown(self):
shutil.rmtree(self.ordner, ignore_errors=True)
def test_speichern_und_auflisten(self):
"""Prüft, dass eine gespeicherte Vorlage mit Anzeigenamen, QML-Datei und Begleitdatei aufgelistet wird."""
self.vorlagen.speichern(_Layer(), "Ohne Hausnummern", {"ausgeblendet": ["wald"], "eingeblendet": []})
liste = self.vorlagen.liste()
self.assertEqual([v.anzeigename for v in liste], ["Ohne Hausnummern"])
self.assertEqual(liste[0].name, "Ohne_Hausnummern")
self.assertTrue(os.path.exists(liste[0].pfad))
self.assertEqual(self.vorlagen.legendenauswahl("Ohne_Hausnummern")["ausgeblendet"], ["wald"])
def test_mehrere_varianten_alphabetisch(self):
"""Prüft, dass mehrere Varianten nebeneinander bestehen und alphabetisch nach Anzeigename sortiert werden."""
for name in ("Zeichnung", "Abstimmung", "mit Gebäuden"):
self.vorlagen.speichern(_Layer(), name)
self.assertEqual([v.anzeigename for v in self.vorlagen.liste()], ["Abstimmung", "mit Gebäuden", "Zeichnung"])
def test_leerer_name_oder_fehler_beim_schreiben(self):
"""Prüft, dass ein leerer Name und ein Schreibfehler von QGIS als ValueError gemeldet werden."""
with self.assertRaises(ValueError):
self.vorlagen.speichern(_Layer(), " ")
with self.assertRaises(ValueError):
self.vorlagen.speichern(_Layer(speichern_ok=False), "Test")
def test_laden_setzt_stil_und_zeichnet_neu(self):
"""Prüft, dass das Laden die QML-Datei der Vorlage in den Layer lädt und neu zeichnen lässt."""
self.vorlagen.speichern(_Layer(), "Standard LNO")
layer = _Layer()
self.vorlagen.laden(layer, "Standard_LNO")
self.assertTrue(layer.geladen.endswith("Standard_LNO.qml"))
self.assertTrue(layer.neu_gezeichnet)
with self.assertRaises(ValueError):
self.vorlagen.laden(layer, "gibt_es_nicht")
def test_passung_meldet_fremde_styles(self):
"""Prüft, dass Style-Namen der Vorlage gemeldet werden, die der Ziel-Layer nicht kennt."""
self.vorlagen.speichern(_Layer(stile=("A", "B", "C")), "Grau")
self.assertEqual(self.vorlagen.pruefe_passung(_Layer(stile=("A",)), "Grau"), ["B", "C"])
self.assertEqual(self.vorlagen.pruefe_passung(_Layer(stile=("A", "B", "C")), "Grau"), [])
def test_standard_setzen_und_beim_loeschen_entfernen(self):
"""Prüft, dass eine Vorlage als Standard markiert wird und die Markierung beim Löschen der Vorlage entfällt."""
self.vorlagen.speichern(_Layer(), "Standard LNO")
self.vorlagen.setze_standard("Standard_LNO")
self.assertTrue(self.vorlagen.liste()[0].standard)
self.vorlagen.loeschen("Standard_LNO")
self.assertEqual(self.vorlagen.liste(), [])
self.assertEqual(self.vorlagen.standard(), "")
self.assertNotIn(STANDARD_SCHLUESSEL, self.einstellungen.daten)
with self.assertRaises(ValueError):
self.vorlagen.setze_standard("Standard_LNO")
def test_fehlende_begleitdatei_wird_toleriert(self):
"""Prüft, dass eine QML-Datei ohne Begleitdatei mit ihrem Dateinamen aufgelistet wird."""
os.makedirs(self.vorlagen.ordner, exist_ok=True)
with open(os.path.join(self.vorlagen.ordner, "Alt.qml"), "w", encoding="utf-8") as datei:
datei.write("<qgis/>")
self.assertEqual([v.anzeigename for v in self.vorlagen.liste()], ["Alt"])
self.assertIsNone(self.vorlagen.legendenauswahl("Alt"))
self.assertEqual(self.vorlagen.pruefe_passung(_Layer(), "Alt"), [])
def test_ohne_ordner_leere_liste(self):
"""Prüft, dass ohne angelegten Vorlagenordner eine leere Liste zurückkommt."""
self.assertEqual(self.vorlagen.liste(), [])
if __name__ == "__main__":
unittest.main()
+13
View File
@@ -22,6 +22,7 @@ from sn_basis.functions.qgiscore_wrapper import QgsProject, QgsMapLayerProxyMode
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.ui.widgets.basemap_bereich import BasemapBereich
from sn_basis.modules.DataGrabber import DataGrabber
from sn_basis.modules.print_logic import (
PrintLogic,
@@ -257,6 +258,10 @@ class PrintTab(QWidget):
self._atlas_content = atlas_content
main_layout.addWidget(atlas_content)
# Basemap-Bereich (einklappbar): Basemap-Legende und Stilvorlagen (Basis #88)
self._basemap_bereich = BasemapBereich(self)
main_layout.addWidget(self._basemap_bereich)
btn_vorlage_erstellen = QPushButton("Vorlage erstellen", self)
btn_vorlage_erstellen.clicked.connect(self._on_vorlage_erstellen)
self._btn_vorlage_erstellen = btn_vorlage_erstellen
@@ -445,6 +450,13 @@ class PrintTab(QWidget):
formfaktor=self._endlosrolle_cb.isChecked() if self._endlosrolle_cb else False,
druckbereich_auswahl=self._druckbereich_combo.currentText() if self._druckbereich_combo else DRUCKBEREICH_VERFAHRENSGEBIET,
)
self._basemap_bereich_aktualisieren()
def _basemap_bereich_aktualisieren(self) -> None:
"""Liest den Basemap-Bereich neu ein (neue Vorlage, Projektwechsel)."""
bereich = getattr(self, "_basemap_bereich", None)
if bereich is not None:
bereich.aktualisieren()
def _disconnect_project_signals(self) -> None:
try:
@@ -494,6 +506,7 @@ class PrintTab(QWidget):
self._theme_signal_connected = False
self._connect_theme_collection_signals()
self._restore_state()
self._basemap_bereich_aktualisieren()
def _toggle_atlas(self, checked: bool) -> None:
if self._atlas_button:
+1
View File
@@ -0,0 +1 @@
"""sn_basis/ui/widgets – wiederverwendbare Bereiche für Tabs."""
+271
View File
@@ -0,0 +1,271 @@
"""
sn_basis/ui/widgets/basemap_bereich.py – einklappbarer Bereich „Basemap“ im Druck-Tab
Einstellungen der Basemap-Legende (Auswertebereich, Erzeugen beim Anlegen der Vorlage),
„Basemap-Legende aktualisieren“ für bestehende Vorlagen und lokale Stilvorlagen des
Basemap-Layers (laden, speichern, als Standard markieren, löschen). Basis #88.
Die Handler enthalten nur Ablauf und Rückfragen; die Logik liegt in
``modules/basemap_legende`` und ``modules/basemap_stilvorlagen``.
"""
from __future__ import annotations
from typing import Any, Optional
from sn_basis.functions.dialog_wrapper import ask_text, ask_yes_no
from sn_basis.functions.message_wrapper import info, success, warning
from sn_basis.functions.qgiscore_wrapper import QgsProject
from sn_basis.functions.qt_wrapper import (
ArrowDown,
ArrowRight,
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QPushButton,
QToolButton,
QVBoxLayout,
QWidget,
ToolButtonTextBesideIcon,
)
from sn_basis.modules import basemap_legende as legende
from sn_basis.modules.basemap_legende_layout import layouts_mit_basemap_legende
from sn_basis.modules.basemap_stilvorlagen import BasemapStilvorlagen
TITEL = "Basemap"
KEIN_LAYER_TEXT = "Kein Basemap-Layer im Projekt"
class BasemapBereich(QWidget):
"""Einklappbarer Basemap-Bereich; standardmäßig eingeklappt."""
def __init__(self, parent: Optional[Any] = None, project: Any | None = None,
stilvorlagen: BasemapStilvorlagen | None = None) -> None:
super().__init__(parent)
self._project = project
self._stilvorlagen = stilvorlagen
self._build_ui()
self.aktualisieren()
# --- Aufbau ---------------------------------------------------------
def _build_ui(self) -> None:
"""von Test abgesehen, weil reiner Widget-Aufbau; im QGIS-Test geprüft"""
haupt = QVBoxLayout()
haupt.setSpacing(4)
haupt.setContentsMargins(0, 0, 0, 0)
knopf = QToolButton()
knopf.setText(TITEL)
knopf.setCheckable(True)
knopf.setChecked(False)
knopf.setToolButtonStyle(ToolButtonTextBesideIcon)
knopf.setArrowType(ArrowRight)
knopf.setStyleSheet("font-weight: bold; margin-top: 6px;")
knopf.toggled.connect(self._umschalten)
self._knopf = knopf
haupt.addWidget(knopf)
inhalt = QWidget()
spalte = QVBoxLayout()
spalte.setSpacing(4)
spalte.setContentsMargins(10, 4, 4, 4)
self._status = QLabel(KEIN_LAYER_TEXT)
self._status.setWordWrap(True)
spalte.addWidget(self._status)
spalte.addWidget(self._ueberschrift("Legende"))
spalte.addWidget(QLabel("Auswertebereich"))
self._bereich_combo = QComboBox(self)
for schluessel, text in legende.AUSWERTEBEREICHE.items():
self._bereich_combo.addItem(text, schluessel)
self._bereich_combo.currentIndexChanged.connect(self._on_bereich_geaendert)
spalte.addWidget(self._bereich_combo)
self._erzeugen_cb = QCheckBox("Basemap-Legende beim Erstellen der Vorlage erzeugen (§ 38, § 41)", self)
self._erzeugen_cb.toggled.connect(self._on_erzeugen_geaendert)
spalte.addWidget(self._erzeugen_cb)
spalte.addWidget(QLabel("Vorlage"))
self._layout_combo = QComboBox(self)
spalte.addWidget(self._layout_combo)
self._aktualisieren_btn = QPushButton("Basemap-Legende aktualisieren", self)
self._aktualisieren_btn.clicked.connect(self._on_legende_aktualisieren)
spalte.addWidget(self._aktualisieren_btn)
spalte.addWidget(self._ueberschrift("Stilvorlage"))
self._stil_combo = QComboBox(self)
spalte.addWidget(self._stil_combo)
zeile = QHBoxLayout()
for text, handler in (
("Laden", self._on_stil_laden),
("Speichern unter …", self._on_stil_speichern),
("Als Standard", self._on_stil_standard),
("Löschen", self._on_stil_loeschen),
):
knopf_stil = QPushButton(text, self)
knopf_stil.clicked.connect(lambda *_, h=handler: h())
zeile.addWidget(knopf_stil)
spalte.addLayout(zeile)
inhalt.setLayout(spalte)
inhalt.setVisible(False)
self._inhalt = inhalt
haupt.addWidget(inhalt)
self.setLayout(haupt)
@staticmethod
def _ueberschrift(text: str) -> Any:
beschriftung = QLabel(text)
beschriftung.setStyleSheet("font-weight: bold; margin-top: 4px;")
return beschriftung
# --- Zustand --------------------------------------------------------
def _projekt(self) -> Any:
return getattr(self, "_project", None) or QgsProject.instance()
def _vorlagen(self) -> BasemapStilvorlagen:
if getattr(self, "_stilvorlagen", None) is None:
self._stilvorlagen = BasemapStilvorlagen()
return self._stilvorlagen
def basemap_layer(self) -> Any | None:
"""Erster Basemap-Layer des Projekts (sichtbar oder nicht)."""
try:
layer = list(self._projekt().mapLayers().values())
except Exception:
return None
gefunden = legende.finde_basemap_layer(layer)
return gefunden[0] if gefunden else None
def _umschalten(self, offen: bool) -> None:
self._knopf.setArrowType(ArrowDown if offen else ArrowRight)
self._inhalt.setVisible(offen)
if offen:
self.aktualisieren()
def aktualisieren(self) -> None:
"""Liest Projektvariablen, Layouts und Stilvorlagen neu ein (z. B. nach Projektwechsel)."""
layer = self.basemap_layer()
self._status.setText(f"Basemap-Layer: {layer.name()}" if layer is not None else KEIN_LAYER_TEXT)
bereich = legende.lies_auswertebereich()
self._bereich_combo.blockSignals(True)
self._bereich_combo.setCurrentIndex(max(0, self._bereich_combo.findData(bereich)))
self._bereich_combo.blockSignals(False)
self._erzeugen_cb.blockSignals(True)
self._erzeugen_cb.setChecked(legende.legende_erzeugen_aktiv())
self._erzeugen_cb.blockSignals(False)
self._layout_combo.clear()
self._layout_combo.addItems(layouts_mit_basemap_legende(self._projekt()))
self._aktualisieren_btn.setEnabled(layer is not None and self._layout_combo.count() > 0)
self._stil_combo.clear()
for vorlage in self._vorlagen().liste():
self._stil_combo.addItem(f"{vorlage.anzeigename}{' ★' if vorlage.standard else ''}", vorlage.name)
standard = self._vorlagen().standard()
if standard:
self._stil_combo.setCurrentIndex(max(0, self._stil_combo.findData(standard)))
# --- Handler: Legende -----------------------------------------------
def _on_bereich_geaendert(self, *_: Any) -> None:
bereich = self._bereich_combo.currentData()
if bereich in legende.AUSWERTEBEREICHE:
legende.setze_auswertebereich(bereich)
def _on_erzeugen_geaendert(self, aktiv: bool) -> None:
legende.setze_legende_erzeugen(bool(aktiv))
def _on_legende_aktualisieren(self) -> None:
name = self._layout_combo.currentText()
layout = self._projekt().layoutManager().layoutByName(name) if name else None
if layout is None:
warning(TITEL, "Bitte eine Vorlage mit Basemap-Legende wählen.")
return
try:
ergebnis = legende.BasemapLegende(self._projekt()).aktualisiere(layout)
except Exception as fehler:
warning(TITEL, f"Die Basemap-Legende konnte nicht aktualisiert werden: {fehler}")
return
if ergebnis["status"] == "ok":
success(TITEL, ergebnis["meldung"])
elif ergebnis["status"] != "abgebrochen":
info(TITEL, ergebnis["meldung"])
# --- Handler: Stilvorlagen ------------------------------------------
def _gewaehlte_vorlage(self) -> str:
return str(self._stil_combo.currentData() or "")
def _on_stil_laden(self) -> None:
layer = self.basemap_layer()
name = self._gewaehlte_vorlage()
if layer is None or not name:
warning(TITEL, "Kein Basemap-Layer oder keine Stilvorlage gewählt.")
return
if not ask_yes_no("Stilvorlage laden",
f"Darstellung von „{layer.name()}“ durch die Stilvorlage ersetzen?", default=False):
return
unbekannt = self._vorlagen().pruefe_passung(layer, name)
if unbekannt and not ask_yes_no(
"Stilvorlage laden",
f"{len(unbekannt)} Styles der Vorlage gibt es in diesem Layer nicht – die Vorlage stammt "
"vermutlich von einer anderen Basemap-Variante. Trotzdem laden?",
default=False,
):
return
try:
self._vorlagen().laden(layer, name)
except ValueError as fehler:
warning(TITEL, str(fehler))
return
auswahl = self._vorlagen().legendenauswahl(name)
if auswahl is not None:
import json
from sn_basis.functions.variable_wrapper import set_variable
set_variable(legende.AUSWAHL_VAR, json.dumps(auswahl), scope="project")
success(TITEL, "Stilvorlage geladen.")
def _on_stil_speichern(self) -> None:
layer = self.basemap_layer()
if layer is None:
warning(TITEL, KEIN_LAYER_TEXT)
return
anzeigename, ok = ask_text("Stilvorlage speichern", "Name der Stilvorlage:",
default_text=self._stil_combo.currentText().replace(" ★", ""))
if not ok or not anzeigename.strip():
return
if self._vorlagen().vorhanden(anzeigename) and not ask_yes_no(
"Stilvorlage speichern", f"Die Stilvorlage „{anzeigename}“ gibt es schon. Ersetzen?", default=False
):
return
try:
self._vorlagen().speichern(layer, anzeigename, legende.lies_auswahl())
except ValueError as fehler:
warning(TITEL, str(fehler))
return
self.aktualisieren()
success(TITEL, f"Stilvorlage „{anzeigename.strip()}“ gespeichert.")
def _on_stil_standard(self) -> None:
name = self._gewaehlte_vorlage()
if not name:
return
try:
self._vorlagen().setze_standard(name)
except ValueError as fehler:
warning(TITEL, str(fehler))
return
self.aktualisieren()
def _on_stil_loeschen(self) -> None:
name = self._gewaehlte_vorlage()
if not name or not ask_yes_no("Stilvorlage löschen",
f"Stilvorlage „{self._stil_combo.currentText()}“ löschen?", default=False):
return
self._vorlagen().loeschen(name)
self.aktualisieren()