Widmungen und Verkehrszeichen laden, Schilder lokal ablegen

Der Server liefert seit heute die Sammlung widmung-<vkz>-punkt|-linie mit einem Stil, der
die Schilder selbst malt, dazu blattzeichen-<vkz> 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
  <Profil>/svg/vln/<code>.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 <noreply@anthropic.com>
This commit is contained in:
KAI
2026-09-21 11:17:45 +02:00
co-authored by Claude Opus 5
parent 3a9075f635
commit d79dff1093
5 changed files with 272 additions and 6 deletions
+22
View File
@@ -142,6 +142,28 @@ class KartenApiClient:
"Antwort von %s ist keine GeoJSON FeatureCollection." % path "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): def _request(self, method, path, payload=None, with_auth=True, frisch=False):
"""Ein Aufruf gegen die v2-API. """Ein Aufruf gegen die v2-API.
+35 -3
View File
@@ -51,6 +51,9 @@ SETTINGS_AUTHCFG = "ogc_authcfg"
GRUPPEN = [ GRUPPEN = [
("vorerhebung", "Vorerhebung"), ("vorerhebung", "Vorerhebung"),
("massnahmeblatt", "Maßnahmenblätter"), ("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"), ("umringe", "Verfahren"),
("p41", "Verfahren"), ("p41", "Verfahren"),
("ngg", "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: if antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) != 200:
return False, "HTTP %s" % antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) return False, "HTTP %s" % antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
quelle = bytes(antwort.rawHeader(b"X-Stil-Quelle")).decode() or "?" 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("*", "_")) pfad = os.path.join(tempfile.gettempdir(), "vln_%s.qml" % cid.replace("*", "_"))
with open(pfad, "wb") as fh: with open(pfad, "w", encoding="utf-8") as fh:
fh.write(bytes(antwort.content())) fh.write(roh)
meldung, ok = layer.loadNamedStyle(pfad) meldung, ok = layer.loadNamedStyle(pfad)
if ok: if ok:
layer.triggerRepaint() layer.triggerRepaint()
@@ -260,7 +270,25 @@ def vorwaermen(base_url, collections, api_key=None, authcfg=None, gleichzeitig=8
return geholt 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 <Verfahren>" hängen — **schrittweise**. """Alle Sammlungen als OAPIF-Layer in die Gruppe „VLN <Verfahren>" hängen — **schrittweise**.
Warum nicht in einer Schleife: der OAPIF-Provider stellt beim Anlegen jedes Layers mehrere 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), layer = QgsVectorLayer(layer_uri(base_url, cid, authcfg, coll.get("storageCrs"), api_key),
titel, "OAPIF") titel, "OAPIF")
if layer.isValid(): 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 stil_anwenden(layer, base_url, cid, api_key, authcfg) # Darstellung wie im Web-GIS
projekt.addMapLayer(layer, False) projekt.addMapLayer(layer, False)
untergruppen[gruppen_name].addLayer(layer) untergruppen[gruppen_name].addLayer(layer)
+3 -1
View File
@@ -148,7 +148,9 @@ class VlnPanel(QDockWidget):
self.gruppen_haken = {} self.gruppen_haken = {}
for schluessel, beschriftung in (("Verfahren", "Verfahren (Umring, Plan 41, KAS …)"), for schluessel, beschriftung in (("Verfahren", "Verfahren (Umring, Plan 41, KAS …)"),
("Vorerhebung", "Vorerhebung"), ("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 = QCheckBox(beschriftung)
haken.setChecked(True) haken.setChecked(True)
lage.addWidget(haken) lage.addWidget(haken)
+2 -2
View File
@@ -251,14 +251,14 @@ class VlnKartenPlugin:
if not collections: if not collections:
self._show_error("Nichts ausgewählt", self._show_error("Nichts ausgewählt",
"Bitte oben mindestens eine Gruppe anhaken (Verfahren, " "Bitte oben mindestens eine Gruppe anhaken (Verfahren, "
"Vorerhebung oder Maßnahmenblätter).") "Vorerhebung, Maßnahmenblätter oder Widmungen).")
return return
# Schrittweise: je Ereignisschleifen-Durchlauf ein Layer, Fortschritt in der # Schrittweise: je Ereignisschleifen-Durchlauf ein Layer, Fortschritt in der
# Meldungsleiste. Fehler kommen dort am Ende gesammelt, nicht als Modal-Dialog. # Meldungsleiste. Fehler kommen dort am Ende gesammelt, nicht als Modal-Dialog.
self.panel.set_status("Lade %d Sammlungen …" % len(collections)) self.panel.set_status("Lade %d Sammlungen …" % len(collections))
ogc_layers.layer_hinzufuegen( ogc_layers.layer_hinzufuegen(
self.iface, self.client.base_url, vkz, self.panel.aktueller_text(), collections, 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): def run_save_edits(self):
"""Offene Bearbeitungssitzungen aller geladenen VLN-Layer schließen. """Offene Bearbeitungssitzungen aller geladenen VLN-Layer schließen.
+210
View File
@@ -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/<code>.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
(``<Profil>/svg/vln/<code>.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/<code>.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():
"""``<QGIS-Profil>/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 (``'<basis>' || "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