diff --git a/changelog.txt b/changelog.txt index 39dd0c8..d795f23 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,4 @@ +- Bugfix: Mehrspaltige Legenden waren sehr breit, weil alle Spalten so breit wie der längste Text waren. Legendentext bricht jetzt nach 60 mm um, Spalten sind ungleich breit, der Titel der Basemap-Legende ist zweizeilig – im Test wurde die dreispaltige Basemap-Legende 226 statt 431 mm breit. Standard- und Mindestbreite der Legenden ist die Breite des Kartenschilds (180 mm); sie wird am Layout vermerkt und beim Laden des Projekts wieder gesetzt, weil QGIS sie nicht speichert (#96) - Bugfix: Bei kleinen Verfahren oder großem Maßstab überlappten sich Legenden, Quellenangabe und Kartenschild, weil sie zusammen höher als die Karte waren. Beim Anlegen einer §38-/§41-Vorlage werden die Legenden jetzt bei Bedarf zwei- oder dreispaltig gesetzt (Kartenrand wird dafür breiter); reicht auch das nicht, wird die Seite höher und Quellenangabe und Kartenschild rutschen nach unten. Größe und Lage der Karte bleiben gleich (#93) - 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) diff --git a/main.py b/main.py index cdace26..429b13b 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,10 @@ from sn_basis.functions.verfahrensgebiet_manager import ( setup_verfahrensgebiet_listener, teardown_verfahrensgebiet_listener, ) +from sn_basis.modules.print_kartenrand import ( + setup_mindestbreiten_listener, + teardown_mindestbreiten_listener, +) from sn_basis.ui.navigation import Navigation @@ -41,6 +45,8 @@ class BasisPlugin: # Zentrale Verwaltung des Verfahrensgebiet-Layers starten setup_verfahrensgebiet_listener() + # Mindestbreite der Drucklegenden nach dem Laden eines Projekts wieder setzen (Basis #96) + setup_mindestbreiten_listener() for name, plugin in plugins.items(): if name.startswith("sn_") and name != "sn_basis": @@ -57,6 +63,7 @@ class BasisPlugin: Räumt UI-Komponenten sauber auf. """ teardown_verfahrensgebiet_listener() + teardown_mindestbreiten_listener() if self.ui: self.ui.remove_all() diff --git a/modules/basemap_legende_layout.py b/modules/basemap_legende_layout.py index 4a68918..d37d7da 100644 --- a/modules/basemap_legende_layout.py +++ b/modules/basemap_legende_layout.py @@ -40,11 +40,13 @@ from sn_basis.functions.qgiscore_wrapper import ( 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" -LEGENDE_TITEL = "Kartengrundlage (basemap.de)" +# 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" @@ -330,6 +332,8 @@ def _neues_legendenelement(layout: Any) -> Any: _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 @@ -350,7 +354,8 @@ def mindestgroesse(legende: Any, layout: Any, spalten: int) -> tuple[float, floa return None einstellungen.setColumnCount(spalten) einstellungen.setSplitLayer(True) - einstellungen.setEqualColumnWidth(True) + einstellungen.setEqualColumnWidth(False) + einstellungen.setAutoWrapLinesAfter(TEXTUMBRUCH_MM) try: groesse = QgsLegendRenderer(legende.model(), einstellungen).minimumSize(kontext) except Exception: @@ -399,7 +404,7 @@ def platziere(layout: Any, legende: Any) -> str: spalten, groesse = spalten + 1, breiter _aufruf(legende, "setColumnCount", spalten) _aufruf(legende, "setSplitLayer", True) - _aufruf(legende, "setEqualColumnWidth", True) + _aufruf(legende, "setEqualColumnWidth", False) unten_links = getattr(getattr(QgsLayoutItem, "ReferencePoint", QgsLayoutItem), "LowerLeft", None) if unten_links is not None: @@ -450,6 +455,7 @@ def schreibe_legende(layout: Any, map_item: Any, eintraege: list, symbole: dict[ legende.setAutoUpdateModel(False) legende.setLegendFilterByMapEnabled(False) legende.setTitle(LEGENDE_TITEL) + _aufruf(legende, "setAutoWrapLinesAfter", TEXTUMBRUCH_MM) wurzel = legende.model().rootGroup() wurzel.removeAllChildren() for gruppe, layer_liste in gruppen: diff --git a/modules/print_kartenrand.py b/modules/print_kartenrand.py index aa6dc3d..d39dbf5 100644 --- a/modules/print_kartenrand.py +++ b/modules/print_kartenrand.py @@ -21,23 +21,38 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any, Callable -from sn_basis.functions.qgiscore_wrapper import QgsLayoutExporter, QgsLayoutPoint, QgsLayoutSize, QgsUnitTypes +from sn_basis.functions.qgiscore_wrapper import ( + QGIS_AVAILABLE, + QgsLayoutExporter, + QgsLayoutPoint, + QgsLayoutSize, + QgsProject, + QgsUnitTypes, +) from sn_basis.functions.qt_wrapper import QSize MM = QgsUnitTypes.LayoutMillimeters RAND_MM = 10.0 # Seitenrand und Abstand Karte → Kartenrand -MIN_SPALTENBREITE_MM = 180.0 # Breite von Kartenschild und Quellenangabe +# Breite von Kartenschild und Quellenangabe (PrintLayout._LEGEND_WIDTH_MM) – zugleich Standard- und +# Mindestbreite der Legenden, damit sie optisch mit dem Kartenschild übereinstimmen. +MIN_SPALTENBREITE_MM = 180.0 ABSTAND_LEGENDEN_MM = 4.0 # unter jeder Legende QUELLE_HOEHE_MM = 20.0 ABSTAND_QUELLE_SCHILD_MM = 10.0 SCHILD_HOEHE_MM = 92.0 MAX_SPALTEN = 3 VERMESSUNG_DPI = 25 # Auflösung des einmaligen Zeichnens zum Vermessen +# Legendentext bricht nach dieser Breite um, Spalten sind ungleich breit – sonst bestimmt der längste +# Text die Breite aller Spalten (dreispaltige Basemap-Legende 431 statt 226 mm, Basis #96). +TEXTUMBRUCH_MM = 60.0 HAUPTKARTE_ID = "Hauptkarte" LEGENDEN_IDS = ("Legende", "Legende Basemap") # von oben nach unten BASEMAP_LEGENDE_ID = "Legende Basemap" +# Layout-Eigenschaft: Mindestbreite der Legenden. QGIS speichert setMinimumSize nicht im Projekt; +# der Vermerk erlaubt, sie beim Laden wieder zu setzen (wende_mindestbreiten_an). +LAYOUT_MINDESTBREITE = "sn_legende_mindestbreite" QUELLENANGABE_ID = "Quellenangabe" # Elemente unterhalb der Legenden, die bei höherer Seite gemeinsam nach unten rutschen UNTERE_ELEMENTE = ( @@ -124,7 +139,8 @@ def _rechteck(element: Any) -> tuple[float, float, float, float]: def _legende_groesse(legende: Any, spalten: int) -> tuple[float, float]: _aufruf(legende, "setColumnCount", spalten) _aufruf(legende, "setSplitLayer", True) - _aufruf(legende, "setEqualColumnWidth", True) + _aufruf(legende, "setEqualColumnWidth", False) + _aufruf(legende, "setAutoWrapLinesAfter", TEXTUMBRUCH_MM) _aufruf(legende, "adjustBoxSize") groesse = legende.sizeWithUnits() return _wert(groesse, "width"), _wert(groesse, "height") @@ -137,6 +153,84 @@ def _rendere_legenden(layout: Any, legenden: list[Any]) -> None: _aufruf(exporter, "renderRegionToImage", legende.sceneBoundingRect(), QSize(), VERMESSUNG_DPI) +def setze_legenden_mindestbreite(layout: Any, breite: float = MIN_SPALTENBREITE_MM, merken: bool = True) -> int: + """Gibt den Legenden des Layouts eine Mindestbreite (Standard: Breite des Kartenschilds). + + Die Legenden passen sich weiter ihrem Inhalt an, werden aber nie schmaler. Mit ``merken`` wird die + Breite am Layout vermerkt, damit sie nach dem Laden des Projekts wieder gesetzt werden kann. + + Returns + ------- + int + Anzahl der Legenden, die eine Mindestbreite bekommen haben. + """ + anzahl = 0 + for legenden_id in LEGENDEN_IDS: + legende = _aufruf(layout, "itemById", legenden_id) + if legende is not None: + legende.setMinimumSize(QgsLayoutSize(breite, 0.0, MM)) + anzahl += 1 + if merken: + layout.setCustomProperty(LAYOUT_MINDESTBREITE, float(breite)) + return anzahl + + +def wende_mindestbreiten_an(project: Any | None = None) -> int: + """Setzt die vermerkten Mindestbreiten der Legenden aller Drucklayouts erneut (nach dem Laden). + + Returns + ------- + int + Anzahl der Layouts, an denen eine Mindestbreite gesetzt wurde. + """ + project = project or QgsProject.instance() + verwaltung = _aufruf(project, "layoutManager") + anzahl = 0 + for layout in _aufruf(verwaltung, "printLayouts", standard=[]) or []: + wert = _aufruf(layout, "customProperty", LAYOUT_MINDESTBREITE, None) + try: + breite = float(wert) + except (TypeError, ValueError): + continue + if breite > 0 and setze_legenden_mindestbreite(layout, breite, merken=False): + anzahl += 1 + return anzahl + + +_listener_aktiv = False + + +def _on_projekt_gelesen(*_: Any) -> None: + wende_mindestbreiten_an() + + +def setup_mindestbreiten_listener() -> None: + """Setzt die Mindestbreiten bei jedem Laden eines Projekts neu (einmal beim Plugin-Start aufrufen).""" + global _listener_aktiv + if _listener_aktiv or not QGIS_AVAILABLE: + return + try: + project = QgsProject.instance() + project.readProject.connect(_on_projekt_gelesen) + _listener_aktiv = True + wende_mindestbreiten_an(project) # bereits geöffnetes Projekt + except Exception: + pass + + +def teardown_mindestbreiten_listener() -> None: + """Trennt den Listener wieder (beim Entladen des Plugins; idempotent).""" + global _listener_aktiv + if not _listener_aktiv: + return + try: + QgsProject.instance().readProject.disconnect(_on_projekt_gelesen) + except Exception: + pass + finally: + _listener_aktiv = False + + def _verschiebe(element: Any, dy: float) -> None: position = element.positionWithUnits() element.attemptMove(QgsLayoutPoint(_wert(position, "x"), _wert(position, "y") + dy, MM)) @@ -156,6 +250,7 @@ def ordne_kartenrand_an(layout: Any, rendern: Callable[[Any, list], None] | None legenden = [l for l in (_aufruf(layout, "itemById", i) for i in LEGENDEN_IDS) if l is not None] for legende in legenden: _aufruf(legende, "setLocked", False) + setze_legenden_mindestbreite(layout) (rendern or _rendere_legenden)(layout, legenden) groessen = {n: [_legende_groesse(l, n) for l in legenden] for n in range(1, MAX_SPALTEN + 1)} diff --git a/tests/test_basemap_legende_layout.py b/tests/test_basemap_legende_layout.py index 33f2af2..3d678d1 100644 --- a/tests/test_basemap_legende_layout.py +++ b/tests/test_basemap_legende_layout.py @@ -62,10 +62,11 @@ class _Legende(_Element): self.spalten = 1 self.bewegt = None self.groesse = None + self.gleiche_breite = True def setColumnCount(self, n): self.spalten = n def setSplitLayer(self, wert): pass - def setEqualColumnWidth(self, wert): pass + def setEqualColumnWidth(self, wert): self.gleiche_breite = wert def setReferencePoint(self, punkt): pass def attemptMove(self, punkt): self.bewegt = punkt def attemptResize(self, groesse): self.groesse = groesse @@ -214,6 +215,43 @@ class TestPlatzierung(unittest.TestCase): self.assertEqual(hinweis, "") self.assertEqual(legende.spalten, 3) + def test_spalten_ungleich_breit(self): + """Prüft, dass die Basemap-Legende ungleich breite Spalten bekommt (#96).""" + legende, _ = self._platziere(self._layout(), {1: (100.0, 300.0), 2: (200.0, 150.0), 3: (300.0, 100.0)}) + self.assertFalse(legende.gleiche_breite) + + def test_mindestgroesse_mit_ungleichen_spalten_und_umbruch(self): + """Prüft, dass der Platzbedarf mit ungleich breiten Spalten und Textumbruch berechnet wird (#96).""" + class Einstellungen: + def __init__(self): self.werte = {} + def __getattr__(self, name): return lambda wert: self.werte.__setitem__(name, wert) + + einstellungen = Einstellungen() + legende = type("L", (), {"legendSettings": lambda s: einstellungen, "model": lambda s: None})() + renderer = type("R", (), {"minimumSize": lambda s, k: type("G", (), {"width": lambda s: 80.0, "height": lambda s: 40.0})()}) + with patch.object(bll, "QgsLayoutUtils", type("U", (), {"createRenderContextForLayout": staticmethod(lambda l, p: object())})), patch.object(bll, "QgsLegendRenderer", lambda modell, einst: renderer()): + self.assertEqual(bll.mindestgroesse(legende, object(), 2), (80.0, 40.0)) + self.assertEqual(einstellungen.werte["setColumnCount"], 2) + self.assertFalse(einstellungen.werte["setEqualColumnWidth"]) + self.assertEqual(einstellungen.werte["setAutoWrapLinesAfter"], bll.TEXTUMBRUCH_MM) + + def test_neue_basemap_legende_mindestens_so_breit_wie_kartenschild(self): + """Prüft, dass eine neu angelegte Basemap-Legende die Breite des Kartenschilds als Mindestbreite bekommt.""" + class Element: + def __init__(self, layout): self.mindestbreite = None + def __getattr__(self, name): return lambda *a: None + def setMinimumSize(self, groesse): + self.mindestbreite = groesse.width() if callable(groesse.width) else groesse.width + + layout = type("L", (), {"addLayoutItem": lambda s, e: None})() + with patch.object(bll, "QgsLayoutItemLegend", Element): + legende = bll._neues_legendenelement(layout) + self.assertEqual(legende.mindestbreite, 180.0) + + def test_titel_zweizeilig(self): + """Prüft, dass der Titel der Basemap-Legende zweizeilig ist, damit er die Mindestbreite nicht bestimmt (#96).""" + self.assertEqual(bll.LEGENDE_TITEL.splitlines(), ["Kartengrundlage", "(basemap.de)"]) + 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), diff --git a/tests/test_print_kartenrand.py b/tests/test_print_kartenrand.py index a2ca1db..f584d9e 100644 --- a/tests/test_print_kartenrand.py +++ b/tests/test_print_kartenrand.py @@ -96,10 +96,15 @@ class _Legende(_Element): def __init__(self, x, y, groessen, unten_verankert=False): super().__init__(x, y, groessen[1][0], groessen[1][1]) self.groessen, self.spalten, self.unten = groessen, 1, unten_verankert + self.gleiche_breite, self.umbruch = True, 0 + self.mindestbreite = None def setColumnCount(self, n): self.spalten = n def setSplitLayer(self, wert): pass - def setEqualColumnWidth(self, wert): pass + def setEqualColumnWidth(self, wert): self.gleiche_breite = wert + def setAutoWrapLinesAfter(self, mm): self.umbruch = mm + def setMinimumSize(self, groesse): + self.mindestbreite = groesse.width() if callable(groesse.width) else groesse.width def setLocked(self, wert): pass def adjustBoxSize(self): self.b, self.h = self.groessen[self.spalten] @@ -119,6 +124,10 @@ class _Seite: class _Layout: def __init__(self, elemente, seite): self.elemente, self.seite = elemente, seite + self.eigenschaften = {} + + def setCustomProperty(self, k, v): self.eigenschaften[k] = v + def customProperty(self, k, standard=None): return self.eigenschaften.get(k, standard) def itemById(self, element_id): return self.elemente.get(element_id) def pageCollection(self): return type("P", (), {"page": lambda s, i: self.seite})() @@ -151,6 +160,21 @@ class TestOrdneKartenrandAn(unittest.TestCase): self.assertEqual(elemente["Hauptkarte"].verschoben, []) self.assertEqual(layout.seite.b, 180 + 450 + kr.RAND_MM) + def test_spalten_ungleich_breit_und_text_umgebrochen(self): + """Prüft, dass die Legenden ungleich breite Spalten und einen automatischen Textumbruch bekommen, damit mehrspaltige Legenden nicht unnötig breit werden (#96).""" + layout, elemente = self._layout({1: (150, 300), 2: (300, 160), 3: (450, 110)}) + ordne_kartenrand_an(layout, rendern=lambda *a: None) + self.assertFalse(elemente["Legende"].gleiche_breite) + self.assertEqual(elemente["Legende"].umbruch, kr.TEXTUMBRUCH_MM) + + def test_legenden_mindestens_so_breit_wie_kartenschild(self): + """Prüft, dass die Legenden die Breite des Kartenschilds als Mindestbreite bekommen und das Layout sie für das Neuladen vermerkt.""" + layout, elemente = self._layout({1: (60, 300), 2: (120, 160), 3: (175, 110)}) + ordne_kartenrand_an(layout, rendern=lambda *a: None) + self.assertEqual(elemente["Legende"].mindestbreite, kr.MIN_SPALTENBREITE_MM) + self.assertEqual(kr.MIN_SPALTENBREITE_MM, 180.0) # Breite von Kartenschild und Quellenangabe + self.assertEqual(float(layout.customProperty(kr.LAYOUT_MINDESTBREITE)), kr.MIN_SPALTENBREITE_MM) + def test_passt_nichts_wird_verschoben(self): """Prüft, dass bei ausreichend hoher Karte weder Seite noch Kartenschild verändert werden.""" layout, elemente = self._layout({1: (100, 10), 2: (200, 6), 3: (300, 4)}) @@ -165,6 +189,44 @@ class TestOrdneKartenrandAn(unittest.TestCase): self.assertIsNone(ordne_kartenrand_an(_Layout({}, _Seite(100, 100)), rendern=lambda *a: None)) +class TestMindestbreiteBeimLaden(unittest.TestCase): + + def _projekt(self, *layouts): + verwaltung = type("V", (), {"printLayouts": lambda s: list(layouts)})() + return type("P", (), {"layoutManager": lambda s: verwaltung})() + + def test_mindestbreite_nur_fuer_vermerkte_layouts(self): + """Prüft, dass beim Laden die Mindestbreite nur an Layouts mit Vermerk wieder gesetzt wird.""" + mit = _Layout({"Legende": _Legende(0, 0, {1: (60, 10)}), "Legende Basemap": _Legende(0, 0, {1: (90, 10)})}, _Seite(1, 1)) + mit.setCustomProperty(kr.LAYOUT_MINDESTBREITE, "180.0") + ohne = _Layout({"Legende": _Legende(0, 0, {1: (60, 10)})}, _Seite(1, 1)) + self.assertEqual(kr.wende_mindestbreiten_an(self._projekt(mit, ohne)), 1) + self.assertEqual(mit.elemente["Legende"].mindestbreite, 180.0) + self.assertEqual(mit.elemente["Legende Basemap"].mindestbreite, 180.0) + self.assertIsNone(ohne.elemente["Legende"].mindestbreite) + + def test_listener_verbinden_und_trennen(self): + """Prüft, dass der Listener readProject einmal verbindet, beim Trennen wieder löst und beides mehrfach aufgerufen werden darf.""" + signal = MagicMock() + projekt = MagicMock() + projekt.readProject = signal + projekt.layoutManager.return_value.printLayouts.return_value = [] + with patch.object(kr, "QGIS_AVAILABLE", True), patch.object(kr, "QgsProject") as qgs_projekt: + qgs_projekt.instance.return_value = projekt + kr.setup_mindestbreiten_listener() + kr.setup_mindestbreiten_listener() + self.assertEqual(signal.connect.call_count, 1) + kr.teardown_mindestbreiten_listener() + kr.teardown_mindestbreiten_listener() + self.assertEqual(signal.disconnect.call_count, 1) + + def test_ohne_qgis_kein_listener(self): + """Prüft, dass im Mock-Modus kein Listener verbunden wird.""" + with patch.object(kr, "QGIS_AVAILABLE", False), patch.object(kr, "QgsProject") as qgs_projekt: + kr.setup_mindestbreiten_listener() + qgs_projekt.instance.assert_not_called() + + class TestAnbindungPrintLayout(unittest.TestCase): def test_vorlage_ordnet_kartenrand_an(self):