Coverage for gws-app/gws/plugin/qgis/caps.py: 0%
411 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
1"""QGIS project xml parser."""
3from typing import Optional, Any
5import math
6import re
8import gws
9import gws.lib.bounds
10import gws.lib.extent
11import gws.lib.crs
12import gws.gis.source
13import gws.base.metadata
14import gws.lib.net
15import gws.lib.xmlx
18class PrintTemplateElement(gws.Data):
19 type: str
20 uuid: str
21 attributes: dict
22 position: gws.UomPoint
23 size: gws.UomSize
26class PrintTemplate(gws.Data):
27 title: str
28 index: int
29 attributes: dict
30 elements: list[PrintTemplateElement]
33class Caps(gws.Data):
34 metadata: gws.Metadata
35 printTemplates: list[PrintTemplate]
36 projectCrs: gws.Crs
37 projectBounds: Optional[gws.Bounds]
38 projectCanvasBounds: Optional[gws.Bounds]
39 properties: dict
40 sourceLayers: list[gws.SourceLayer]
41 version: str
42 visibilityPresets: dict[str, list[str]]
45def parse(xml: str) -> Caps:
46 el = gws.lib.xmlx.from_string(xml)
47 return parse_element(el)
50def parse_element(root_el: gws.XmlElement) -> Caps:
51 caps = Caps()
53 caps.version = str(root_el.get('version') or '')
54 caps.properties = _parse_properties(root_el.find('properties'))
55 caps.metadata = _project_metadata(root_el)
56 caps.printTemplates = _print_templates(root_el)
57 caps.visibilityPresets = _visibility_presets(root_el)
59 srid = root_el.textof('projectCrs/spatialrefsys/authid') or '4326'
60 crs = gws.lib.crs.get(srid)
61 if not crs:
62 raise gws.Error(f'invalid CRS {srid=} in qgis project')
63 caps.projectCrs = crs
65 ext = _extent_from_tag(root_el.find('properties/WMSExtent'))
66 if ext:
67 caps.projectBounds = gws.lib.bounds.from_extent(ext, caps.projectCrs)
69 ext = _extent_from_tag(root_el.find('mapcanvas/extent'))
70 if ext:
71 caps.projectCanvasBounds = gws.lib.bounds.from_extent(ext, caps.projectCrs)
73 layers_dct = _map_layers(root_el, caps)
74 root_group = _layer_tree(root_el.find('layer-tree-group'), layers_dct)
75 caps.sourceLayers = gws.gis.source.check_layers(root_group.layers if root_group else [])
77 return caps
80##
83def _project_metadata(root_el) -> gws.Metadata:
84 md = gws.base.metadata.new()
86 el = root_el.find('projectMetadata')
87 if el:
88 _collect_metadata(el, md)
90 # @TODO supplementary metadata
91 return gws.base.metadata.normalize(md)
94_meta_mapping = [
95 ('authorityIdentifier', 'identifier'),
96 ('parentIdentifier', 'parentidentifier'),
97 ('language', 'language'),
98 ('type', 'type'),
99 ('title', 'title'),
100 ('abstract', 'abstract'),
101 ('dateCreated', 'creation'),
102 ('fees', 'fees'),
103]
105_contact_mapping = [
106 ('contactEmail', 'email'),
107 ('contactFax', 'fax'),
108 ('contactOrganization', 'organization'),
109 ('contactPerson', 'name'),
110 ('contactPhone', 'voice'),
111 ('contactPosition', 'position'),
112 ('contactRole', 'role'),
113 ('contactAddress', 'address'),
114 ('contactAddressType', 'type'),
115 ('contactArea', 'administrativearea'),
116 ('contactCity', 'city'),
117 ('contactCountry', 'country'),
118 ('contactZip', 'postalcode'),
119]
122def _add_dict(dst, src, mapping):
123 for dkey, skey in mapping:
124 if skey in src:
125 setattr(dst, dkey, src[skey])
128def _collect_metadata(el: gws.XmlElement, md: gws.Metadata):
129 # extract metadata from projectMetadata/resourceMetadata
131 _add_dict(md, el.textdict(), _meta_mapping)
133 md.keywords = []
134 for kw in el.findall('keywords'):
135 keywords = kw.textlist('keyword')
136 if kw.get('vocabulary') == 'gmd:topicCategory':
137 md.isoTopicCategories = keywords
138 else:
139 md.keywords.extend(keywords)
141 contact_el = el.find('contact')
142 if contact_el:
143 _add_dict(md, contact_el.textdict(), _contact_mapping)
144 for e in contact_el.findall('contactAddress'):
145 _add_dict(md, e.textdict(), _contact_mapping)
146 break # NB we only support one contact address
148 md.metaLinks = []
149 for e in el.findall('links/link'):
150 # @TODO clarify
151 md.metaLinks.append(
152 gws.MetadataLink(
153 url=e.get('url'),
154 description=e.get('description'),
155 mimeType=e.get('mimeType'),
156 format=e.get('format'),
157 title=e.get('name'),
158 scheme=e.get('type'),
159 )
160 )
162 for e in el.findall('constraints'):
163 md.accessConstraints = e.text
164 md.accessConstraintsType = e.get('type')
166 for e in el.findall('license'):
167 md.license = e.text
168 break
170 e = el.find('extent/temporal')
171 if e:
172 md.temporalBegin = e.textof('period/start')
173 md.temporalEnd = e.textof('period/end')
176# see QGIS/src/core/layout/qgslayoutitemregistry.h
178_QGraphicsItem_UserType = 65536 # https://doc.qt.io/qtforpython/PySide2/QtWidgets/QGraphicsItem.html
180_LT0 = _QGraphicsItem_UserType + 100
182_LAYOUT_TYPES = {
183 _LT0 + 0: 'item', # LayoutItem
184 _LT0 + 1: 'group', # LayoutGroup
185 _LT0 + 2: 'page', # LayoutPage
186 _LT0 + 3: 'map', # LayoutMap
187 _LT0 + 4: 'picture', # LayoutPicture
188 _LT0 + 5: 'label', # LayoutLabel
189 _LT0 + 6: 'legend', # LayoutLegend
190 _LT0 + 7: 'shape', # LayoutShape
191 _LT0 + 8: 'polygon', # LayoutPolygon
192 _LT0 + 9: 'polyline', # LayoutPolyline
193 _LT0 + 10: 'scalebar', # LayoutScaleBar
194 _LT0 + 11: 'frame', # LayoutFrame
195 _LT0 + 12: 'html', # LayoutHtml
196 _LT0 + 13: 'attributetable', # LayoutAttributeTable
197 _LT0 + 14: 'texttable', # LayoutTextTable
198 _LT0 + 15: '3dmap', # Layout3DMap
199 _LT0 + 16: 'manualtable', # LayoutManualTable
200 _LT0 + 17: 'marker', # LayoutMarker
201}
204# print templates in qgis-3:
205#
206# <Layouts>
207# <Layout name="..." <- template 1
208# <PageCollection
209# <LayoutItem <- pages
210# <LayoutItem type="<int, see below>" ...
211# <LayoutMultiFrame type="<int>" ...
212# <Layout??? <- evtl. other item tags
213#
214# <Layout name="..." <- template 2
215# etc
218def _print_templates(root_el: gws.XmlElement):
219 templates = []
221 for layout_el in root_el.findall('Layouts/Layout'):
222 tpl = PrintTemplate(
223 title=layout_el.get('name', ''),
224 attributes=layout_el.attrib,
225 index=len(templates),
226 elements=[],
227 )
229 pc_el = layout_el.find('PageCollection')
230 if pc_el:
231 tpl.elements.extend(gws.u.compact(_layout_element(c) for c in pc_el))
233 tpl.elements.extend(gws.u.compact(_layout_element(c) for c in layout_el))
235 templates.append(tpl)
237 return templates
240def _layout_element(item_el: gws.XmlElement):
241 type = _LAYOUT_TYPES.get(_parse_int(item_el.get('type')))
242 uuid = item_el.get('uuid')
243 if type and uuid:
244 return PrintTemplateElement(
245 type=type,
246 uuid=uuid,
247 attributes=item_el.attrib,
248 position=_parse_msize(item_el.get('position')),
249 size=_parse_msize(item_el.get('size')),
250 )
253##
256def _map_layers(root_el: gws.XmlElement, caps: Caps) -> dict[str, gws.SourceLayer]:
257 no_wms_layers = set(caps.properties.get('WMSRestrictedLayers', []))
258 use_layer_ids = caps.properties.get('WMSUseLayerIDs', False)
260 layers_dct = {}
262 for el in root_el.findall('projectlayers/maplayer'):
263 sl = _map_layer(el, caps, use_layer_ids)
264 if not sl:
265 continue
266 # no_wms_layers always contains titles, not ids (=names)
267 if sl.title in no_wms_layers:
268 continue
269 layers_dct[sl.sourceId] = sl
271 return layers_dct
274def _map_layer(layer_el: gws.XmlElement, caps: Caps, use_layer_ids: bool) -> gws.SourceLayer:
275 sl = gws.SourceLayer(
276 supportedCrs=[],
277 )
279 sl.metadata = _map_layer_metadata(layer_el)
281 crs = gws.lib.crs.get(layer_el.textof('srs/spatialrefsys/authid'))
282 if crs:
283 sl.supportedCrs.append(crs)
285 if layer_el.get('hasScaleBasedVisibilityFlag') == '1':
286 # in qgis, maxScale < minScale
287 a = _parse_float(layer_el.get('maxScale'))
288 z = _parse_float(layer_el.get('minScale'))
289 if z > a:
290 sl.scaleRange = [a, z]
292 sl.dataSource = _map_layer_datasource(layer_el)
293 sl.opacity = _parse_float(layer_el.textof('layerOpacity') or '1')
294 sl.isQueryable = layer_el.textof('flags/Identifiable') == '1'
295 sl.properties = _parse_properties(layer_el.find('customproperties'))
297 uid = layer_el.textof('id')
298 if use_layer_ids:
299 layer_name = uid
300 else:
301 layer_name = layer_el.textof('shortname') or layer_el.textof('layername')
303 sl.title = layer_el.textof('layername') or sl.metadata.get('title') or ''
304 sl.name = layer_name
305 sl.sourceId = uid
307 ext = _map_layer_wgs_extent(layer_el, caps.projectCrs)
308 if ext:
309 sl.wgsExtent = ext
311 return sl
314def _map_layer_metadata(layer_el) -> gws.Metadata:
315 # Layer metadata is either Layer->Properties->Metadata (stored in maplayer/resourceMetadata),
316 # or Layer->Properties->QGIS Server (stored directly under maplayer/abstract, maplayer/keywordList and so on.
318 md = gws.base.metadata.new()
320 el = layer_el.find('resourceMetadata')
321 if el:
322 _collect_metadata(el, md)
324 # fill in missing props from direct metadata
326 d = layer_el.textdict()
328 md.title = md.title or d.get('title') or d.get('shortname') or d.get('layername')
329 md.abstract = md.abstract or d.get('abstract')
331 if not md.attribution and d.get('attribution'):
332 md.attribution = d.get('attribution')
334 if not md.keywords:
335 md.keywords = layer_el.textlist('keywordList/value')
337 if not md.metaLinks:
338 md.metaLinks = []
339 for e in layer_el.findall('metadataUrls/metadataUrl'):
340 md.metaLinks.append(
341 gws.MetadataLink(
342 type=e.get('type'),
343 format=e.get('format'),
344 title=e.text,
345 )
346 )
348 return gws.base.metadata.normalize(md)
351def _map_layer_datasource(layer_el: gws.XmlElement) -> dict:
352 prov = layer_el.textof('provider')
353 ds_text = layer_el.textof('datasource')
355 if ds_text:
356 return parse_datasource((prov or '').lower(), ds_text)
357 if prov:
358 return {'provider': prov.lower()}
359 return {}
362def _map_layer_wgs_extent(layer_el: gws.XmlElement, project_crs: gws.Crs):
363 uid = layer_el.textof('id')
364 min_size = 0.000001
366 # extent explicitly defined in metadata (Layer Props -> Metadata -> Extent)
367 el = layer_el.find('resourceMetadata/extent/spatial')
368 if el:
369 ext = _extent_from_tag(el)
370 crs = gws.lib.crs.get(el.get('crs'))
371 if ext and crs:
372 ext = gws.lib.extent.transform(ext, crs, gws.lib.crs.WGS84)
373 if gws.lib.extent.is_valid_wgs(ext, min_size):
374 gws.log.debug(f'_map_layer_wgs_extent: {uid}: spatial: {ext}')
375 return ext
377 # extent in <maplayer>/<wgs84extent>
378 el = layer_el.find('wgs84extent')
379 if el:
380 ext = _extent_from_tag(el)
381 if ext and gws.lib.extent.is_valid_wgs(ext, min_size):
382 gws.log.debug(f'_map_layer_wgs_extent: {uid}: wgs84extent: {ext}')
383 return ext
385 # extent in <maplayer>/<extent>, assume the project CRS
386 el = layer_el.find('extent')
387 if el:
388 ext = _extent_from_tag(el)
389 if ext:
390 ext = gws.lib.extent.transform(ext, project_crs, gws.lib.crs.WGS84)
391 if ext and gws.lib.extent.is_valid_wgs(ext, min_size):
392 gws.log.debug(f'_map_layer_wgs_extent: {uid}: extent: {ext}')
393 return ext
395 gws.log.warning(f'_map_layer_wgs_extent: {uid}: NOT FOUND')
398# layer trees:
400# <layer-tree-group>
401# <layer-tree-group checked="Qt::Checked" expanded="1" name="...">
402# <layer-tree-layer ... checked="Qt::Checked" expanded="1" id="...">
403# ...
406def _layer_tree(el: Optional[gws.XmlElement], layers_dct):
407 if not el:
408 return
410 visible = el.get('checked') != 'Qt::Unchecked'
411 expanded = el.get('expanded') == '1'
413 if el.tag == 'layer-tree-group':
414 title = el.get('name')
415 # qgis doesn't write 'id' for groups but our generators might
416 name = el.get('id') or title
418 return gws.SourceLayer(
419 title=title,
420 name=name,
421 metadata=gws.Metadata(title=title, name=name),
422 isVisible=visible,
423 isExpanded=expanded,
424 isGroup=True,
425 isQueryable=False,
426 isImage=False,
427 layers=gws.u.compact(_layer_tree(c, layers_dct) for c in el),
428 )
430 if el.tag == 'layer-tree-layer':
431 sl = layers_dct.get(el.get('id'))
432 if sl:
433 sl.isVisible = visible
434 sl.isExpanded = expanded
435 sl.isGroup = False
436 sl.isImage = True
437 return sl
440##
443def _visibility_presets(root_el: gws.XmlElement):
444 """Parse the global ``visibility-presets`` block.
446 We're only interested in which layers are visible.
448 Overall structure::
450 <visibility-presets>
451 <visibility-preset .... name="...">
452 <layer id="..." visible="1" ... />
453 <layer id="..." visible="1" ... />
454 <visibility-preset .... name="...">
455 <layer id="..." visible="1" ... />
456 <layer id="..." visible="1" ... />
458 """
460 d = {}
462 for el in root_el.findall('visibility-presets/visibility-preset'):
463 ls = []
464 for la in el.findall('layer'):
465 if la.attr('visible') == '1':
466 ls.append(la.attr('id'))
467 d[el.attr('name')] = ls
469 return d
472##
475def parse_datasource(prov, text):
476 ds = gws.u.to_lower_dict(_parse_datasource(text) or {})
477 ds['provider'] = (ds.get('provider') or prov).lower()
479 if ds['provider'] == 'wms' and 'tilematrixset' in ds:
480 ds['provider'] = 'wmts'
481 elif ds['provider'] == 'wms' and ds.get('type') == 'xyz':
482 ds['provider'] = 'xyz'
484 # @TODO classify ogr's based on a file extension
486 return ds
489def _parse_datasource(text):
490 # Datasources are very versatile and the format depends on the provider.
491 # For some hints see `decodedSource` in qgsvectorlayer.cpp/qgsrasterlayer.cpp.
492 # We don't have ambition to parse them all, just do some ad-hoc parsing
493 # of the most common flavors, and return the rest as `{'text': text}`.
495 text = text.strip()
497 if re.match(r'^\w+=[^&]*&', text):
498 # key=value, amp-separated, uri-encoded
499 # used for WMS, e.g.
500 # contextualWMSLegend=0&crs=EPSG:31468&...&url=...?SERVICE%3DWMTS%26REQUEST%3DGetCapabilities
501 return _datasource_amp_delimited(text)
503 if re.match(r'^\w+=\S+ ', text):
504 # key=value, space separated
505 # used for postgres/WFS, e.g.
506 # dbname='...' host=... port=...
507 # pagingEnabled='...' preferCoordinatesForWfsT11=...
508 return _datasource_space_delimited(text)
510 if text.startswith(('http://', 'https://')):
511 # just an url
512 return {'url': text}
514 if text.startswith(('.', '/')):
515 # path or path|options
516 # used for Geojson, GPKG, e.g.
517 # ../rel/path/test.gpkg|layername=name|subset=... etc
518 return _datasource_pipe_delimited(text)
520 return {'text': text}
523def _datasource_amp_delimited(text):
524 ds = {}
526 for p in text.split('&'):
527 if '=' not in p:
528 continue
529 k, v = p.split('=', maxsplit=1)
531 v = gws.lib.net.unquote(v)
533 if k in {'layers', 'styles'}:
534 ds.setdefault(k, []).append(v)
535 else:
536 ds[k] = v
538 if 'url' not in ds:
539 return ds
541 # extract params from the url
543 url, params = gws.lib.net.extract_params(ds['url'])
545 if 'typename' in params:
546 ds['typename'] = params.pop('typename')
547 if 'layers' in params:
548 ds.setdefault('layers', []).extend(params.pop('layers').split(','))
549 if 'styles' in params:
550 ds.setdefault('styles', []).extend(params.pop('styles').split(','))
552 params.pop('service', None)
553 params.pop('request', None)
555 ds['params'] = params
557 # {x} placeholders shouldn't be encoded
558 url = url.replace('%7B', '{')
559 url = url.replace('%7D', '}')
561 ds['url'] = url
563 return ds
566def _datasource_space_delimited(text):
567 key_re = r'^\w+\s*=\s*'
569 value_re = r"""(?x)
570 " (?: \\. | [^"])* " |
571 ' (?: \\. | [^'])* ' |
572 \S+
573 """
575 parens_re = r'\(.*?\)'
577 def _cut(u, rx):
578 m = re.match(rx, u)
579 if not m:
580 raise ValueError(f'datasource uri error, expected {rx!r}, found {u[:25]!r}')
581 v = m.group(0)
582 return v, u[len(v) :].strip()
584 def _unesc(s):
585 return re.sub(r'\\(.)', '\1', s)
587 def _mid(s):
588 return s[1:-1].strip()
590 def _value(v):
591 if v.startswith(("'", '"')):
592 return _unesc(_mid(v))
593 return v
595 ds = {}
597 while text:
598 # keyword=
599 key, text = _cut(text, key_re)
600 key = key.strip('= ')
602 if key == 'sql':
603 # 'sql=' is special and can contain whatever, it's always the last one
604 ds[key] = text
605 break
607 elif key == 'table':
608 # 'table=' is special, it can be `table="foo"` or `table="foo"."bar"` or table=`"foo"."bar" (geom)`
609 v, text = _cut(text, value_re)
610 ds['table'] = _value(v)
612 if text.startswith('.'):
613 v, text = _cut(text[1:], value_re)
614 ds['table'] += '.' + _value(v)
616 if text.startswith('('):
617 v, text = _cut(text, parens_re)
618 ds['geometryColumn'] = _mid(v)
620 else:
621 # just param=val
622 v, text = _cut(text, value_re)
623 ds[key] = _value(v)
625 return ds
628def _datasource_pipe_delimited(text):
629 if '|' not in text:
630 return {'path': text}
632 path, rest = text.split('|', maxsplit=1)
634 if '=' not in rest:
635 return {'path': path, 'options': rest}
637 ds = {'path': path}
639 for p in rest.split('|'):
640 k, v = p.split('=', maxsplit=1)
641 ds[k] = v
643 return ds
646##
649def _parse_properties(el: Optional[gws.XmlElement]) -> dict:
650 """Parse qgis property blocks.
652 There are following forms:
654 Scalar property::
656 <WMSContactPhone type="QString">...
658 Dict::
660 <QFieldSync>
661 <dirsToCopy type="QString">...
662 <exportDirectoryProject type="QString">...
663 </QFieldSync>
665 Option map::
667 <data-defined-properties>
668 <Option type="Map">
669 <Option type="QString" name="..." value="..."/>
670 <Option name="properties"/>
671 </Option>
672 </data-defined-properties>
675 """
677 if not el:
678 return {}
680 _, val = _parse_property_tag(el)
681 return val
684def _parse_property_tag(el: gws.XmlElement) -> tuple[str, Any]:
685 typ = el.get('type')
686 name = el.tag
687 is_opt = el.tag == 'Option'
689 if is_opt and el.attr('name'):
690 name = el.attr('name') or ''
692 if not typ or typ == 'Map':
693 d = {}
695 for c in el:
696 k, v = _parse_property_tag(c)
697 if k:
698 d[k] = v
700 if len(d) == 1 and 'Option' in d:
701 return name, d['Option']
703 return name, d
705 if typ == 'List':
706 ls = []
707 for c in el:
708 _, v = _parse_property_tag(c)
709 ls.append(v)
710 return name, ls
712 if typ == 'QStringList':
713 val = [c.text for c in el.findall('value')]
714 return name, val
716 if typ == 'QString':
717 val = el.attr('value') if is_opt else el.text
718 return name, val
720 if typ == 'bool':
721 val = el.attr('value') if is_opt else el.text
722 return name, (val or '').lower() == 'true'
724 if typ == 'int':
725 val = el.attr('value') if is_opt else el.text
726 return name, _parse_int(val)
728 if typ == 'double':
729 val = el.attr('value') if is_opt else el.text
730 return name, _parse_float(val)
732 return '', None
735##
738def _extent_from_tag(el: Optional[gws.XmlElement]):
739 if not el:
740 return
742 # <spatial dimensions="2" miny="0" maxz="0" maxx="0" crs="EPSG:25832" minx="0" minz="0" maxy="0"/>
743 if el.get('minx'):
744 return _extent_from_list(
745 [
746 el.get('minx'),
747 el.get('miny'),
748 el.get('maxx'),
749 el.get('maxy'),
750 ]
751 )
753 # <WMSExtent type="QStringList">
754 # <value>1</value>
755 # <value>2</value>
756 # <value>3</value>
757 # <value>4</value>
758 # </WMSExtent>
759 #
760 if el.get('type') == 'QStringList':
761 return _extent_from_list([v.text for v in el.children()])
763 # <wgs84extent>
764 # <xmin>1</xmin>
765 # <ymin>2</ymin>
766 # <xmax>3</xmax>
767 # <ymax>4</ymax>
768 # </wgs84extent>
770 if el.find('xmin'):
771 return _extent_from_list(
772 [
773 el.textof('xmin'),
774 el.textof('ymin'),
775 el.textof('xmax'),
776 el.textof('ymax'),
777 ]
778 )
781def _extent_from_list(ls):
782 if len(ls) != 4:
783 return
784 try:
785 e = [float(p) for p in ls]
786 except ValueError:
787 return
788 if not all(math.isfinite(p) for p in e):
789 return
790 e = [
791 min(e[0], e[2]),
792 min(e[1], e[3]),
793 max(e[0], e[2]),
794 max(e[1], e[3]),
795 ]
796 # for single-point extents, add 0.0001 (~10 m)
797 c = 0.0001
798 if e[0] == e[2]:
799 e[0] -= c
800 e[2] += c
801 if e[1] == e[3]:
802 e[1] -= c
803 e[3] += c
804 return gws.Extent(e)
807def _parse_msize(s):
808 # e.g. 'position': '228.477,27.8455,mm'
809 try:
810 x, y, u = s.split(',')
811 return _parse_float(x), _parse_float(y), u
812 except Exception:
813 return None
816def _parse_int(s) -> int:
817 try:
818 return int(s)
819 except Exception:
820 return 0
823def _parse_float(s) -> float:
824 try:
825 x = float(s)
826 except Exception:
827 return 0
828 if math.isnan(x) or math.isinf(x):
829 return 0
830 return x