""" sn_basis/modules/print_kartenrand.py – Kartenrand neben der Karte überlappungsfrei anordnen (Basis #93) Im Standardlayout (§ 38, § 41) stehen rechts neben der Karte Hauptlegende, Basemap-Legende, Quellenangabe und Kartenschild. Bei kleinen Verfahren oder großem Maßstab ist die Karte niedriger als diese Elemente zusammen, und sie überlappten sich. ``ordne_kartenrand_an`` läuft nach dem Aufbau des Layouts: 1. Die Legenden werden für 1, 2 und 3 Spalten vermessen. Die Hauptlegende filtert nach Karteninhalt; ihre Höhe steht erst nach einmaligem Zeichnen fest, deshalb wird der Legendenbereich vorher einmal gerendert. 2. Gewählt wird die kleinste Spaltenzahl, bei der alles neben die Karte passt; der Kartenrand wird dafür so weit verbreitert, wie die Legenden es brauchen. 3. Reicht es auch dreispaltig nicht, wird die Seite höher; Quellenangabe und Kartenschild rutschen nach unten. Größe und Lage der Karte bleiben unverändert. """ from __future__ import annotations from dataclasses import dataclass from typing import Any, Callable 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 # 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 = ( QUELLENANGABE_ID, "Kartenschild", "Kartenschild_Linie_1", "Kartenschild_Linie_2", "Kartenschild_Linie_3", "Kartenschild_Linie_4", "Kartenschild_Linie_5", "Kartenschild_Linie_6", "Herausgeber", "Bezeichnung_Flurbereinigung", "Name_Flurbereinigung", "Stadt_Gemeinde", "Landkreis", "Kartenname", "Gefertigt", "Geprüft", "Massstab", "Ausgabedatum", "Aktenzeichen", ) @dataclass class Anordnung: spalten: int spaltenbreite: float seite_breite: float seite_hoehe: float verschiebung_y: float # um so viel rutschen Quellenangabe und Kartenschild nach unten def berechne_anordnung( karte: tuple[float, float, float, float], seite: tuple[float, float], legenden_je_spalten: dict[int, list[tuple[float, float]]], ) -> Anordnung: """Berechnet Spaltenzahl, Seitengröße und Verschiebung des Kartenschilds. Parameters ---------- karte : (links, oben, Breite, Höhe) der Karte in mm seite : (Breite, Höhe) der Seite in mm legenden_je_spalten : je Spaltenzahl 1–3 die Größen (Breite, Höhe) aller Legenden, von oben nach unten """ links, oben, breite, hoehe = karte spalte_links = links + breite + RAND_MM fest = QUELLE_HOEHE_MM + ABSTAND_QUELLE_SCHILD_MM + SCHILD_HOEHE_MM def bedarf(spalten: int) -> tuple[float, float]: groessen = legenden_je_spalten.get(spalten, []) benoetigt = sum(h + ABSTAND_LEGENDEN_MM for _, h in groessen) + fest spaltenbreite = max([MIN_SPALTENBREITE_MM] + [b for b, _ in groessen]) return benoetigt, spaltenbreite # Kleinste Spaltenzahl, die neben die Karte passt; passt keine, die mit dem geringsten Platzbedarf # (bei Gleichstand weniger Spalten – mehr Spalten ohne Gewinn verbreitern nur den Rand). kandidaten = [(n, *bedarf(n)) for n in range(1, MAX_SPALTEN + 1)] passend = [k for k in kandidaten if k[1] <= hoehe] spalten, benoetigt, spaltenbreite = passend[0] if passend else min(kandidaten, key=lambda k: (k[1], k[0])) verschiebung = max(0.0, benoetigt - hoehe) return Anordnung( spalten=spalten, spaltenbreite=spaltenbreite, seite_breite=max(seite[0], spalte_links + spaltenbreite + RAND_MM), seite_hoehe=max(seite[1], oben + benoetigt + RAND_MM) if verschiebung else seite[1], verschiebung_y=verschiebung, ) # --------------------------------------------------------- # Anwendung auf ein Layout # --------------------------------------------------------- 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 def _wert(objekt: Any, name: str) -> float: wert = getattr(objekt, name) return float(wert() if callable(wert) else wert) def _rechteck(element: Any) -> tuple[float, float, float, float]: r = element.sceneBoundingRect() return _wert(r, "x"), _wert(r, "y"), _wert(r, "width"), _wert(r, "height") def _legende_groesse(legende: Any, spalten: int) -> tuple[float, float]: _aufruf(legende, "setColumnCount", spalten) _aufruf(legende, "setSplitLayer", True) _aufruf(legende, "setEqualColumnWidth", False) _aufruf(legende, "setAutoWrapLinesAfter", TEXTUMBRUCH_MM) _aufruf(legende, "adjustBoxSize") groesse = legende.sizeWithUnits() return _wert(groesse, "width"), _wert(groesse, "height") def _rendere_legenden(layout: Any, legenden: list[Any]) -> None: """Zeichnet die Legenden einmal mit geringer Auflösung, damit Kartenfilter und Größe feststehen.""" exporter = QgsLayoutExporter(layout) for legende in legenden: _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)) def ordne_kartenrand_an(layout: Any, rendern: Callable[[Any, list], None] | None = None) -> Anordnung | None: """Ordnet den Kartenrand eines Standardlayouts überlappungsfrei an (Seite ggf. breiter/höher). Returns ------- Anordnung | None Die angewandte Anordnung oder ``None``, wenn das Layout keine Hauptkarte hat. """ karte = _aufruf(layout, "itemById", HAUPTKARTE_ID) if karte is None: return 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)} seite = layout.pageCollection().page(0) seitengroesse = seite.pageSize() anordnung = berechne_anordnung(_rechteck(karte), (_wert(seitengroesse, "width"), _wert(seitengroesse, "height")), groessen) for legende in legenden: _legende_groesse(legende, anordnung.spalten) if (anordnung.seite_breite, anordnung.seite_hoehe) != (_wert(seitengroesse, "width"), _wert(seitengroesse, "height")): seite.setPageSize(QgsLayoutSize(anordnung.seite_breite, anordnung.seite_hoehe, MM)) if anordnung.verschiebung_y > 0: quelle = _aufruf(layout, "itemById", QUELLENANGABE_ID) quelle_oben = _rechteck(quelle)[1] if quelle is not None else None for element_id in UNTERE_ELEMENTE: element = _aufruf(layout, "itemById", element_id) if element is not None: _verschiebe(element, anordnung.verschiebung_y) basemap = _aufruf(layout, "itemById", BASEMAP_LEGENDE_ID) if basemap is not None and quelle_oben is not None: # unten verankert (siehe basemap_legende_layout.platziere): direkt über die verschobene Quellenangabe position = basemap.positionWithUnits() basemap.attemptMove(QgsLayoutPoint(_wert(position, "x"), quelle_oben + anordnung.verschiebung_y - ABSTAND_LEGENDEN_MM, MM)) for legende in legenden: _aufruf(legende, "setLocked", True) return anordnung