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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 <Verfahren>" 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)
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user