From 7465904121c9c5d093976fecbce968d7a03b2509 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 2 Oct 2026 12:49:01 +0200 Subject: [PATCH] =?UTF-8?q?Basemap-Legende:=20Rand=20f=C3=BCr=20wei=C3=9Fe?= =?UTF-8?q?=20Fl=C3=A4chen=20ohne=20Kontur=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- changelog.txt | 1 + functions/qgiscore_wrapper.py | 16 +++++ modules/basemap_legende_layout.py | 69 +++++++++++++++++++ tests/test_basemap_legende_rand.py | 102 +++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 tests/test_basemap_legende_rand.py diff --git a/changelog.txt b/changelog.txt index 51c7b07..ea6380c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,4 @@ +- Bugfix: Weiße Basemap-Flächen ohne Kontur (z. B. „Platz“) waren in der Basemap-Legende auf weißem Grund unsichtbar; ihr Legendensymbol bekommt jetzt einen dünnen grauen Rand (0,15 mm), die Karte bleibt unverändert (#92) - Bugfix: Punktlinien des Basemap-Layers (Baumreihe, Hecke, Durchlass, Seilbahnen u. a., 12 Styles) wurden in Karte und Legende nicht gezeichnet – basemap.de nutzt Strichlänge 0 mit runder Kappe, Qt zeichnet Striche der Länge 0 nicht. Neuer Knopf „Punktlinien korrigieren“ im Basemap-Bereich des Druck-Tabs ersetzt die Strichlänge 0 durch 0,01 (festes Muster und zoomabhängiger Ausdruck); die Korrektur lässt sich als Stilvorlage sichern (#90) - 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) diff --git a/functions/qgiscore_wrapper.py b/functions/qgiscore_wrapper.py index e85057a..c336054 100644 --- a/functions/qgiscore_wrapper.py +++ b/functions/qgiscore_wrapper.py @@ -893,6 +893,7 @@ QgsApplication: Type[Any] QgsSymbolLayerUtils: Type[Any] QgsLayoutMeasurement: Type[Any] QgsLayoutUtils: Type[Any] +QgsSimpleLineSymbolLayer: Type[Any] SELECT_BEHAVIOR_SET: Any = None SELECT_RELATION_INTERSECT: Any = None PROPERTY_TYPE_EXPRESSION: Any = None @@ -921,6 +922,7 @@ try: QgsLayoutMeasurement as _QgsLayoutMeasurement, QgsLayoutUtils as _QgsLayoutUtils, QgsSymbolLayer as _QgsSymbolLayer, + QgsSimpleLineSymbolLayer as _QgsSimpleLineSymbolLayer, Qgis as _QgisVT, ) @@ -940,6 +942,7 @@ try: QgsSymbolLayerUtils = _QgsSymbolLayerUtils QgsLayoutMeasurement = _QgsLayoutMeasurement QgsLayoutUtils = _QgsLayoutUtils + QgsSimpleLineSymbolLayer = _QgsSimpleLineSymbolLayer SELECT_BEHAVIOR_SET = _QgisVT.SelectBehavior.SetSelection SELECT_RELATION_INTERSECT = _QgisVT.SelectGeometryRelationship.Intersect @@ -1112,6 +1115,19 @@ except Exception: QgsLayoutUtils = _MockQgsLayoutUtils + class _MockQgsSimpleLineSymbolLayer: + def __init__(self, eigenschaften: dict | None = None): + self.eigenschaften = dict(eigenschaften or {}) + + @staticmethod + def create(eigenschaften: dict) -> "_MockQgsSimpleLineSymbolLayer": + return _MockQgsSimpleLineSymbolLayer(eigenschaften) + + def layerType(self) -> str: + return "SimpleLine" + + QgsSimpleLineSymbolLayer = _MockQgsSimpleLineSymbolLayer + # QgsRectangle hatte im allgemeinen Mock-Block keinen Ersatz (nur Typannotation). if "QgsRectangle" not in globals(): class _MockQgsRectangle: diff --git a/modules/basemap_legende_layout.py b/modules/basemap_legende_layout.py index 52bc518..4a68918 100644 --- a/modules/basemap_legende_layout.py +++ b/modules/basemap_legende_layout.py @@ -34,6 +34,7 @@ from sn_basis.functions.qgiscore_wrapper import ( QgsLegendRenderer, QgsProperty, QgsRendererCategory, + QgsSimpleLineSymbolLayer, QgsSymbolLayerUtils, QgsUnitTypes, QgsVectorLayer, @@ -62,6 +63,13 @@ LAYOUT_HILFSLAYER = "sn_basemap_hilfslayer" 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_]*)") @@ -181,10 +189,71 @@ def baue_symbole(layer: Any, eintraege: list, zoom_level: int, vector_tile_zoom: 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 diff --git a/tests/test_basemap_legende_rand.py b/tests/test_basemap_legende_rand.py new file mode 100644 index 0000000..7003ce2 --- /dev/null +++ b/tests/test_basemap_legende_rand.py @@ -0,0 +1,102 @@ +""" +Tests für den Legendenrand weißer Basemap-Flächen (Basis #92). + +Weiße Flächen ohne Umrandung (z. B. „Platz“) verschwinden auf dem weißen Legendenhintergrund. +Das Legendensymbol bekommt dann einen dünnen grauen Rand; die Karte bleibt unverändert. +Das Aussehen in der Legende ist im QGIS-Test geprüft. +""" +import unittest +from unittest.mock import patch + +from sn_basis.modules import basemap_legende_layout as bll +from sn_basis.modules.basemap_legende_layout import braucht_legendenrand, ergaenze_legendenrand + +WEISS = (255, 255, 255, 255) +GRUEN = (200, 230, 160, 255) +GRAU = (120, 120, 120, 255) + + +class TestBrauchtLegendenrand(unittest.TestCase): + + def test_weisse_flaeche_ohne_rand(self): + """Prüft, dass eine weiße Fläche ohne Umrandung einen Legendenrand braucht.""" + self.assertTrue(braucht_legendenrand([{"art": "fuellung", "farbe": WEISS, "rand": None}])) + + def test_farbige_flaeche_oder_flaeche_mit_kontur(self): + """Prüft, dass farbige Flächen und weiße Flächen mit dunkler Kontur oder eigener Konturlinie keinen Rand bekommen.""" + self.assertFalse(braucht_legendenrand([{"art": "fuellung", "farbe": GRUEN, "rand": None}])) + self.assertFalse(braucht_legendenrand([{"art": "fuellung", "farbe": WEISS, "rand": GRAU}])) + self.assertFalse(braucht_legendenrand([{"art": "fuellung", "farbe": WEISS, "rand": None}, + {"art": "linie", "farbe": GRAU}])) + + def test_heller_rand_und_fast_weiss_zaehlen_als_unsichtbar(self): + """Prüft, dass fast weiße Füllungen und helle oder durchsichtige Ränder als unsichtbar gelten.""" + self.assertTrue(braucht_legendenrand([{"art": "fuellung", "farbe": (250, 248, 245, 255), + "rand": (255, 255, 255, 255)}])) + self.assertTrue(braucht_legendenrand([{"art": "fuellung", "farbe": WEISS, "rand": (0, 0, 0, 0)}])) + + def test_andere_ebenen_und_leeres_symbol(self): + """Prüft, dass Symbole mit anderen Ebenen (Muster, Punkte) und leere Symbole keinen Rand bekommen.""" + self.assertFalse(braucht_legendenrand([{"art": "sonst", "farbe": WEISS}])) + self.assertFalse(braucht_legendenrand([])) + + +class _Farbe: + def __init__(self, r, g, b, a=255): self.w = (r, g, b, a) + def red(self): return self.w[0] + def green(self): return self.w[1] + def blue(self): return self.w[2] + def alpha(self): return self.w[3] + + +class _Fuellung: + def __init__(self, farbe, rand_stil=0, rand=(0, 0, 0, 255)): + self._farbe, self._rand_stil, self._rand = _Farbe(*farbe), rand_stil, _Farbe(*rand) + + def layerType(self): return "SimpleFill" + def color(self): return self._farbe + def strokeStyle(self): return self._rand_stil + def strokeColor(self): return self._rand + + +class _Symbol: + def __init__(self, typ, *ebenen): + self._typ, self.ebenen = typ, list(ebenen) + + def type(self): return self._typ + def symbolLayers(self): return list(self.ebenen) + def appendSymbolLayer(self, ebene): self.ebenen.append(ebene) + + +class _Linienebene: + erzeugt = [] + + @classmethod + def create(cls, eigenschaften): + cls.erzeugt.append(eigenschaften) + return ("Rand", eigenschaften) + + +@patch.object(bll, "QgsSimpleLineSymbolLayer", _Linienebene) +class TestErgaenzeLegendenrand(unittest.TestCase): + + def test_weisse_flaeche_bekommt_grauen_rand(self): + """Prüft, dass eine weiße Fläche ohne Kontur eine graue Randlinie als oberste Ebene bekommt.""" + symbol = _Symbol(2, _Fuellung(WEISS)) + self.assertTrue(ergaenze_legendenrand(symbol)) + self.assertEqual(len(symbol.ebenen), 2) + eigenschaften = symbol.ebenen[-1][1] + self.assertEqual(eigenschaften["line_width"], str(bll.LEGENDENRAND_BREITE_MM)) + self.assertEqual(eigenschaften["line_color"], bll.LEGENDENRAND_FARBE) + + def test_farbige_flaeche_und_linien_bleiben_unveraendert(self): + """Prüft, dass farbige Flächen, Flächen mit Kontur und Liniensymbole keinen Rand bekommen.""" + for symbol in (_Symbol(2, _Fuellung(GRUEN)), _Symbol(2, _Fuellung(WEISS, rand_stil=1, rand=GRAU)), + _Symbol(1, _Fuellung(WEISS))): + anzahl = len(symbol.ebenen) + self.assertFalse(ergaenze_legendenrand(symbol)) + self.assertEqual(len(symbol.ebenen), anzahl) + + +if __name__ == "__main__": + unittest.main()