Files
Plugin_SN_Basis/modules/basemap_legende_layout.py
T

472 lines
19 KiB
Python
Raw Normal View History

"""
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,
QgsSimpleLineSymbolLayer,
QgsSymbolLayerUtils,
QgsUnitTypes,
QgsVectorLayer,
)
from sn_basis.functions.qt_wrapper import QSize
from sn_basis.modules.print_kartenrand import MIN_SPALTENBREITE_MM, TEXTUMBRUCH_MM
MM = QgsUnitTypes.LayoutMillimeters
LEGENDE_ID = "Legende Basemap"
2026-10-02 13:20:15 +02:00
# Zweizeilig, damit der Titel nicht die Mindestbreite der Legende bestimmt (Basis #96)
LEGENDE_TITEL = "Kartengrundlage\n(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"}
# Legendenrand für weiße Flächen ohne Kontur (Basis #92)
LEGENDENRAND_BREITE_MM = 0.15
LEGENDENRAND_FARBE = "150,150,150,255"
# Ab diesem Wert je Farbkanal gilt eine Farbe auf weißem Legendenhintergrund als unsichtbar.
HELL_AB = 240
DURCHSICHTIG_BIS = 25
_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)
ergaenze_legendenrand(basis)
symbole[eintrag.schluessel] = basis
return symbole
def _ist_unsichtbar(farbe: tuple) -> bool:
"""Ob eine RGBA-Farbe auf weißem Legendenhintergrund praktisch nicht zu sehen ist."""
rot, gruen, blau, alpha = farbe
return alpha <= DURCHSICHTIG_BIS or min(rot, gruen, blau) >= HELL_AB
def braucht_legendenrand(ebenen: list[dict]) -> bool:
"""Ob ein Flächensymbol in der Legende einen Rand braucht.
``ebenen`` beschreibt die Symbolebenen: ``art`` (``fuellung``, ``linie``, ``sonst``), ``farbe``
(RGBA) und bei Füllungen ``rand`` (RGBA oder ``None`` ohne Kontur). Ein Rand ist nötig, wenn
alle Ebenen auf weißem Hintergrund unsichtbar sind – z. B. die weiße Fläche „Platz“.
"""
if not ebenen:
return False
for ebene in ebenen:
if ebene.get("art") not in ("fuellung", "linie") or not _ist_unsichtbar(ebene["farbe"]):
return False
if ebene.get("rand") is not None and not _ist_unsichtbar(ebene["rand"]):
return False
return True
def _rgba(farbe: Any) -> tuple:
return farbe.red(), farbe.green(), farbe.blue(), farbe.alpha()
def _ebenen_beschreiben(symbol: Any) -> list[dict]:
ebenen = []
for ebene in symbol.symbolLayers():
art = ebene.layerType()
if art == "SimpleFill":
ohne_kontur = _enum_int(ebene.strokeStyle()) == 0 # Qt.NoPen
ebenen.append({"art": "fuellung", "farbe": _rgba(ebene.color()),
"rand": None if ohne_kontur else _rgba(ebene.strokeColor())})
elif art == "SimpleLine":
ebenen.append({"art": "linie", "farbe": _rgba(ebene.color())})
else:
ebenen.append({"art": "sonst"})
return ebenen
def ergaenze_legendenrand(symbol: Any) -> bool:
"""Gibt einem praktisch weißen Flächensymbol ohne Kontur einen dünnen grauen Rand (nur Legende).
Returns
-------
bool
Ob ein Rand ergänzt wurde.
"""
if _enum_int(symbol.type()) != 2 or not braucht_legendenrand(_ebenen_beschreiben(symbol)):
return False
symbol.appendSymbolLayer(QgsSimpleLineSymbolLayer.create({
"line_color": LEGENDENRAND_FARBE,
"line_width": str(LEGENDENRAND_BREITE_MM),
"line_width_unit": "MM",
}))
return True
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))
# Mindestens so breit wie das Kartenschild (Basis #96)
_aufruf(legende, "setMinimumSize", QgsLayoutSize(MIN_SPALTENBREITE_MM, 0.0, 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)
2026-10-02 13:20:15 +02:00
einstellungen.setEqualColumnWidth(False)
einstellungen.setAutoWrapLinesAfter(TEXTUMBRUCH_MM)
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)
2026-10-02 13:20:15 +02:00
_aufruf(legende, "setEqualColumnWidth", False)
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)
2026-10-02 13:20:15 +02:00
_aufruf(legende, "setAutoWrapLinesAfter", TEXTUMBRUCH_MM)
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