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