Files
Plugin_SN_Basis/modules/print_kartenrand.py
T

285 lines
11 KiB
Python
Raw Normal View History

2026-10-02 13:06:35 +02:00
"""
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,
)
2026-10-02 13:06:35 +02:00
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
2026-10-02 13:06:35 +02:00
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
2026-10-02 13:20:15 +02:00
# 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
2026-10-02 13:06:35 +02:00
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"
2026-10-02 13:06:35 +02:00
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)
2026-10-02 13:20:15 +02:00
_aufruf(legende, "setEqualColumnWidth", False)
_aufruf(legende, "setAutoWrapLinesAfter", TEXTUMBRUCH_MM)
2026-10-02 13:06:35 +02:00
_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
2026-10-02 13:06:35 +02:00
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)
2026-10-02 13:06:35 +02:00
(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