From d79dff109368b881e509dc6270292a7f5af637c3 Mon Sep 17 00:00:00 2001 From: KAI Date: Mon, 21 Sep 2026 11:17:45 +0200 Subject: [PATCH] Widmungen und Verkehrszeichen laden, Schilder lokal ablegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Server liefert seit heute die Sammlung widmung--punkt|-linie mit einem Stil, der die Schilder selbst malt, dazu blattzeichen- mit den neuen Zeichen am Weg. Das Plugin kannte beides nicht -- die Gruppenzuordnung fiel auf "Weitere", und die Seitenleiste filterte das heraus. * GRUPPEN kennt jetzt widmung ("Widmungen & Verkehrszeichen", eigener Haken in der Seitenleiste) und blattzeichen (laeuft in der Gruppe der Maßnahmenblaetter mit). * Neues Modul zeichen_katalog.py: holt /v2/zeichen/katalog.json (oeffentlich, wird neben den Grafiken abgelegt, damit die Palette auch ohne Netz oeffnet), laedt fehlende Schilder nach /svg/vln/.svg, traegt den Ordner in die SVG-Suchpfade ein und schreibt im Stil das Adressen-Praefix auf "vln/" um. Fehlt eine Datei, bleibt die Serveradresse stehen -- so zeichnet QGIS sie weiter aus dem Netz, im Druck und offline stimmt es trotzdem. * Beim Laden eines Zeichen-Layers werden die Grafiken bereitgestellt, BEVOR der Stil greift; sonst zeigt der Ausdruck auf eine Datei, die es lokal noch nicht gibt. * api_client.roh() holt eine Datei roh und ohne Anmeldung; Fehler geben b"" zurueck, ein fehlendes Schild darf nichts abbrechen. Headless gemessen (QGIS 4.2, offscreen): Katalog 890 Zeichen, drei Grafiken geholt, ein unbekannter Code sauber als fehlend gemeldet, Suchpfad eingetragen, Stil lokalisiert. Co-Authored-By: Claude Opus 5 --- vln_karten/api_client.py | 22 ++++ vln_karten/ogc_layers.py | 38 +++++- vln_karten/panel.py | 4 +- vln_karten/plugin.py | 4 +- vln_karten/zeichen_katalog.py | 210 ++++++++++++++++++++++++++++++++++ 5 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 vln_karten/zeichen_katalog.py diff --git a/vln_karten/api_client.py b/vln_karten/api_client.py index ea5cd8f..afb183e 100644 --- a/vln_karten/api_client.py +++ b/vln_karten/api_client.py @@ -142,6 +142,28 @@ class KartenApiClient: "Antwort von %s ist keine GeoJSON FeatureCollection." % path ) + def roh(self, path, with_auth=False): + """Eine Datei roh holen (Bytes) — fuer die Schildgrafiken und den Katalog. + + Anders als :meth:`_request` wird hier nichts als JSON gelesen und keine Anmeldung + verlangt: ``/v2/zeichen/...`` ist oeffentlich, damit der Stil auch ohne Schluessel + zeichnet. Fehler geben ``b""`` zurueck — ein fehlendes Schild darf nichts abbrechen. + """ + from qgis.PyQt.QtCore import QUrl + from qgis.PyQt.QtNetwork import QNetworkRequest + + request = QNetworkRequest(QUrl(self.base_url + path)) + if with_auth and self.api_key: + request.setRawHeader(b"X-API-Key", self.api_key.encode("utf-8")) + blocking = QgsBlockingNetworkRequest() + if blocking.get(request) != QgsBlockingNetworkRequest.NoError: + return b"" + antwort = blocking.reply() + status = antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) + if status is not None and int(status) >= 400: + return b"" + return bytes(antwort.content()) + def _request(self, method, path, payload=None, with_auth=True, frisch=False): """Ein Aufruf gegen die v2-API. diff --git a/vln_karten/ogc_layers.py b/vln_karten/ogc_layers.py index d032dc7..eaad7b0 100644 --- a/vln_karten/ogc_layers.py +++ b/vln_karten/ogc_layers.py @@ -51,6 +51,9 @@ SETTINGS_AUTHCFG = "ogc_authcfg" GRUPPEN = [ ("vorerhebung", "Vorerhebung"), ("massnahmeblatt", "Maßnahmenblätter"), + # Die neuen Zeichen am Weg gehoeren zum Blatt, also in dessen Gruppe. + ("blattzeichen", "Maßnahmenblätter"), + ("widmung", "Widmungen & Verkehrszeichen"), ("umringe", "Verfahren"), ("p41", "Verfahren"), ("ngg", "Verfahren"), @@ -135,9 +138,16 @@ def stil_anwenden(layer, base_url, cid, api_key=None, authcfg=None): if antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) != 200: return False, "HTTP %s" % antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) quelle = bytes(antwort.rawHeader(b"X-Stil-Quelle")).decode() or "?" + roh = bytes(antwort.content()).decode("utf-8", "replace") + # Schildgrafiken aus der lokalen Ablage nehmen, wo sie liegt (s. zeichen_katalog). + try: + from . import zeichen_katalog as zk + roh, _ = zk.stil_lokalisieren(roh, base_url) + except Exception: # noqa: BLE001 + pass pfad = os.path.join(tempfile.gettempdir(), "vln_%s.qml" % cid.replace("*", "_")) - with open(pfad, "wb") as fh: - fh.write(bytes(antwort.content())) + with open(pfad, "w", encoding="utf-8") as fh: + fh.write(roh) meldung, ok = layer.loadNamedStyle(pfad) if ok: layer.triggerRepaint() @@ -260,7 +270,25 @@ def vorwaermen(base_url, collections, api_key=None, authcfg=None, gleichzeitig=8 return geholt -def layer_hinzufuegen(iface, base_url, vkz, vkz_name, collections, authcfg=None, api_key=None): +def _schilder_bereitstellen(client, layer): + """Die Schildgrafiken der geladenen Zeichen lokal ablegen (best effort). + + Ohne lokale Dateien zeichnet QGIS die Schilder aus dem Netz — im Druck und offline + bleibt die Karte dann leer (Erik, 21.09.2026: „lokal ablegen, URL als Rückfall"). + """ + try: + from . import zeichen_katalog as zk + codes = zk.codes_im_layer(layer) + if not codes: + return + zk.pfad_registrieren() + zk.sichere_svgs(client, codes) + except Exception: # noqa: BLE001 + pass # Beiwerk, kein Abbruch + + +def layer_hinzufuegen(iface, base_url, vkz, vkz_name, collections, authcfg=None, api_key=None, + client=None): """Alle Sammlungen als OAPIF-Layer in die Gruppe „VLN " hängen — **schrittweise**. Warum nicht in einer Schleife: der OAPIF-Provider stellt beim Anlegen jedes Layers mehrere @@ -317,6 +345,10 @@ def layer_hinzufuegen(iface, base_url, vkz, vkz_name, collections, authcfg=None, layer = QgsVectorLayer(layer_uri(base_url, cid, authcfg, coll.get("storageCrs"), api_key), titel, "OAPIF") if layer.isValid(): + # Verkehrszeichen brauchen ihre Grafiken, BEVOR der Stil greift — sonst zeigt der + # Ausdruck auf eine Datei, die es lokal nicht gibt, und QGIS malt ein Fragezeichen. + if cid.startswith(("widmung-", "blattzeichen-")) and client is not None: + _schilder_bereitstellen(client, layer) stil_anwenden(layer, base_url, cid, api_key, authcfg) # Darstellung wie im Web-GIS projekt.addMapLayer(layer, False) untergruppen[gruppen_name].addLayer(layer) diff --git a/vln_karten/panel.py b/vln_karten/panel.py index 8579353..86c9410 100644 --- a/vln_karten/panel.py +++ b/vln_karten/panel.py @@ -148,7 +148,9 @@ class VlnPanel(QDockWidget): self.gruppen_haken = {} for schluessel, beschriftung in (("Verfahren", "Verfahren (Umring, Plan 41, KAS …)"), ("Vorerhebung", "Vorerhebung"), - ("Maßnahmenblätter", "Maßnahmenblätter")): + ("Maßnahmenblätter", "Maßnahmenblätter (mit Zeichen am Weg)"), + ("Widmungen & Verkehrszeichen", + "Widmungen & Verkehrszeichen")): haken = QCheckBox(beschriftung) haken.setChecked(True) lage.addWidget(haken) diff --git a/vln_karten/plugin.py b/vln_karten/plugin.py index b5f1a5b..49dc79b 100644 --- a/vln_karten/plugin.py +++ b/vln_karten/plugin.py @@ -251,14 +251,14 @@ class VlnKartenPlugin: if not collections: self._show_error("Nichts ausgewählt", "Bitte oben mindestens eine Gruppe anhaken (Verfahren, " - "Vorerhebung oder Maßnahmenblätter).") + "Vorerhebung, Maßnahmenblätter oder Widmungen).") return # Schrittweise: je Ereignisschleifen-Durchlauf ein Layer, Fortschritt in der # Meldungsleiste. Fehler kommen dort am Ende gesammelt, nicht als Modal-Dialog. self.panel.set_status("Lade %d Sammlungen …" % len(collections)) ogc_layers.layer_hinzufuegen( self.iface, self.client.base_url, vkz, self.panel.aktueller_text(), collections, - authcfg, api_key=None if authcfg else self.client.api_key) + authcfg, api_key=None if authcfg else self.client.api_key, client=self.client) def run_save_edits(self): """Offene Bearbeitungssitzungen aller geladenen VLN-Layer schließen. diff --git a/vln_karten/zeichen_katalog.py b/vln_karten/zeichen_katalog.py new file mode 100644 index 0000000..0af762a --- /dev/null +++ b/vln_karten/zeichen_katalog.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +"""Zeichenkatalog und Schildgrafiken — vom Server holen, lokal ablegen. + +Der Server liefert den Stil für die Widmungs-Sammlungen mit Schildgrafiken; ihre Adresse +zeigt auf die API (``…/v2/zeichen/svg/.svg``). Das reicht zum Ansehen, nicht zum +Arbeiten: QGIS lädt entfernte SVG zwar und puffert sie, im Druck und ohne Netz bleibt die +Karte aber leer. Erik, 21.09.2026: „lokal ablegen, URL als Rückfall." + +Darum legt dieses Modul die gebrauchten Dateien einmal in den QGIS-Profilordner +(``/svg/vln/.svg``), trägt diesen Ordner in die SVG-Suchpfade ein und schreibt +im Stil das Adressen-Präfix auf den kurzen Namen ``vln/`` um. Fehlt eine Datei, bleibt die +Serveradresse stehen — dann zeichnet QGIS sie wie bisher aus dem Netz. + +Der Katalog (Code, Name, Kategorie, Maße; rund 890 Einträge) kommt als JSON von +``/v2/zeichen/katalog.json`` und wird neben den Grafiken abgelegt, damit die Palette auch +ohne Netz öffnet. +""" +import json +import os + +from qgis.core import QgsApplication, QgsSettings + +from .api_client import ApiError + +#: Unterordner im QGIS-Profil — kurz, damit der Stil ``vln/.svg`` sagen kann. +ORDNER = "vln" +#: Wie der Server die Grafiken adressiert (Präfix, das beim Umschreiben ersetzt wird). +SERVER_PFAD = "/v2/zeichen/svg/" + +_KATALOG = {} # Prozess-Puffer: {basis: daten} + + +def svg_ordner(): + """``/svg/vln`` — wird angelegt, wenn er fehlt.""" + wurzel = os.path.join(QgsApplication.qgisSettingsDirPath(), "svg") + ziel = os.path.join(wurzel, ORDNER) + os.makedirs(ziel, exist_ok=True) + return ziel + + +def pfad_registrieren(): + """Den Ordner in die SVG-Suchpfade von QGIS eintragen (einmalig, idempotent). + + Ohne diesen Eintrag findet QGIS den relativen Namen ``vln/250.svg`` nicht und zeichnet + statt des Schildes ein Fragezeichen. + """ + wurzel = os.path.join(QgsApplication.qgisSettingsDirPath(), "svg") + s = QgsSettings() + pfade = s.value("svg/searchPathsForSVG", []) + if isinstance(pfade, str): + pfade = [p for p in pfade.split("|") if p] + pfade = list(pfade or []) + if wurzel not in pfade: + pfade.append(wurzel) + s.setValue("svg/searchPathsForSVG", pfade) + return wurzel + + +def _katalog_datei(): + return os.path.join(svg_ordner(), "katalog.json") + + +def katalog(client, frisch=False): + """Den Zeichenkatalog holen — vom Server, sonst aus der lokalen Ablage. + + :param client: :class:`~vln_karten.api_client.KartenApiClient` + :param frisch: vorhandene Ablage übergehen + :returns: ``{"stand", "kategorien": [...], "zeichen": [...]}``; ``{}``, wenn beides fehlt + """ + basis = client.base_url + if not frisch and basis in _KATALOG: + return _KATALOG[basis] + daten = {} + try: + daten = client._request("GET", "/zeichen/katalog.json", with_auth=False) or {} + except (ApiError, Exception): # noqa: BLE001 + daten = {} + if daten.get("zeichen"): + try: + with open(_katalog_datei(), "w", encoding="utf-8") as fh: + json.dump(daten, fh, ensure_ascii=False) + except OSError: + pass # ohne Ablage geht es auch + else: + try: + with open(_katalog_datei(), "r", encoding="utf-8") as fh: + daten = json.load(fh) + except (OSError, ValueError): + daten = {} + _KATALOG[basis] = daten + return daten + + +def eintrag(daten, code): + """Katalogeintrag zu einem Code (oder ``None``).""" + for z in (daten or {}).get("zeichen", []): + if str(z.get("code")) == str(code): + return z + return None + + +def _holen(client, pfad, ziel): + """Eine Datei vom Server in die lokale Ablage schreiben; ``True`` bei Erfolg.""" + try: + roh = client.roh(pfad) + except Exception: # noqa: BLE001 + return False + if not roh: + return False + try: + with open(ziel, "wb") as fh: + fh.write(roh) + except OSError: + return False + return True + + +def sichere_svgs(client, codes): + """Fehlende Schildgrafiken nachladen. Gibt zurück, wie viele geholt wurden und was fehlt. + + :param codes: iterierbare Zeichencodes (``"250"``, ``"1020-30"`` …) + :returns: ``(geholt, fehlend)`` + """ + ordner = svg_ordner() + geholt, fehlend = 0, [] + for code in sorted({str(c).strip() for c in codes if str(c or "").strip()}): + ziel = os.path.join(ordner, code + ".svg") + if os.path.isfile(ziel): + continue + if _holen(client, "/zeichen/svg/%s.svg" % code, ziel): + geholt += 1 + else: + fehlend.append(code) + return geholt, fehlend + + +def sichere_pngs(client, codes): + """Dasselbe für die Vorschaubilder der Palette (96 px).""" + ordner = os.path.join(svg_ordner(), "png") + os.makedirs(ordner, exist_ok=True) + geholt = 0 + for code in sorted({str(c).strip() for c in codes if str(c or "").strip()}): + ziel = os.path.join(ordner, code + ".png") + if os.path.isfile(ziel): + continue + if _holen(client, "/zeichen/png/%s.png" % code, ziel): + geholt += 1 + return geholt + + +def png_pfad(code): + """Lokaler Pfad des Vorschaubildes (kann fehlen).""" + return os.path.join(svg_ordner(), "png", str(code) + ".png") + + +def codes_im_layer(layer, feld="vz", zusatzfeld="zusatz"): + """Alle Zeichencodes, die in einem Layer vorkommen — Haupt- und Zusatzzeichen.""" + codes = set() + if layer is None: + return codes + felder = [f.name() for f in layer.fields()] + if feld not in felder: + return codes + for f in layer.getFeatures(): + wert = f[feld] + if wert: + codes.add(str(wert).strip()) + if zusatzfeld in felder: + for c in _liste(f[zusatzfeld]): + codes.add(c) + return {c for c in codes if c and c != "NULL"} + + +def _liste(wert): + """``zusatz`` kann Liste oder Text sein — QGIS führt das Array als Textfeld.""" + if isinstance(wert, (list, tuple)): + return [str(x).strip() for x in wert if x is not None and str(x).strip()] + text = str(wert or "").strip() + if not text or text in ("NULL", "None"): + return [] + if text.startswith("["): + try: + roh = json.loads(text) + except ValueError: + roh = None + if isinstance(roh, list): + return [str(x).strip() for x in roh if x is not None and str(x).strip()] + return [t for t in (x.strip().strip("[]\"' ") for x in text.split(",")) if t] + + +def stil_lokalisieren(qml, basis_url): + """Im Stil die Serveradresse durch den lokalen Namen ersetzen — nur wo die Datei liegt. + + Der Stil baut die Adresse im Ausdruck zusammen (``'' || "vz" || '.svg'``). Ersetzt + wird deshalb das Präfix, nicht der ganze Name: aus ``'https://…/v2/zeichen/svg/'`` wird + ``'vln/'``. Fehlt der Ordner oder ist er leer, bleibt alles beim Server. + + :returns: ``(qml, ersetzt)`` + """ + praefix = basis_url.rstrip("/") + SERVER_PFAD + if praefix not in qml: + return qml, False + ordner = svg_ordner() + try: + vorhanden = any(n.endswith(".svg") for n in os.listdir(ordner)) + except OSError: + vorhanden = False + if not vorhanden: + return qml, False + return qml.replace(praefix, ORDNER + "/"), True