Files
Plugin_SN_Basis/modules/basemap_legende_layout.py
T
DanielandClaude Opus 5.5 7465904121 Basemap-Legende: Rand für weiße Flächen ohne Kontur (#92)
Die Basemap-Styles „Platz“ (Verkehrsfläche, weitere Nutzung) haben eine rein weiße Füllung
ohne Umrandung und waren auf dem weißen Legendenhintergrund unsichtbar. baue_symbole gibt
solchen Flächensymbolen jetzt in der Legende eine dünne graue Randlinie (0,15 mm). Erkannt
wird das an Füllfarbe und fehlender Kontur (braucht_legendenrand), nicht an Style-Namen; der
Basemap-Layer in der Karte bleibt unverändert. Wrapper: QgsSimpleLineSymbolLayer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:49:01 +02:00

466 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
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"}
# 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))
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