Coverage for gws-app/gws/plugin/qgis/caps.py: 72%
418 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +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 """An item of a QGIS print layout."""
21 type: str
22 """Item type, e.g. ``map``, ``label`` or ``page``."""
23 uuid: str
24 """Item UUID."""
25 attributes: dict
26 """XML attributes of the item element."""
27 position: gws.UomPoint
28 """Position of the item on the page."""
29 size: gws.UomSize
30 """Size of the item."""
33class PrintTemplate(gws.Data):
34 """A QGIS print layout."""
36 title: str
37 """Layout name."""
38 index: int
39 """Position of the layout in the project, starting with 0."""
40 attributes: dict
41 """XML attributes of the layout element."""
42 elements: list[PrintTemplateElement]
43 """Pages and items of the layout."""
46class Caps(gws.Data):
47 """Capabilities of a QGIS project, as parsed from the project XML."""
49 metadata: gws.Metadata
50 """Project metadata."""
51 printTemplates: list[PrintTemplate]
52 """Print layouts."""
53 projectCrs: gws.Crs
54 """Project CRS, EPSG:4326 if the project has none."""
55 projectBounds: Optional[gws.Bounds]
56 """Explicit WMS extent of the project, if set."""
57 projectCanvasBounds: Optional[gws.Bounds]
58 """Map canvas extent of the project, if set."""
59 properties: dict
60 """Project properties."""
61 sourceLayers: list[gws.SourceLayer]
62 """Top-level layers and groups of the project layer tree."""
63 version: str
64 """QGIS version that wrote the project."""
65 visibilityPresets: dict[str, list[str]]
66 """Map themes, mapping a theme name to the IDs of visible layers."""
69def parse(xml: str) -> Caps:
70 """Parse a QGIS project XML string.
72 Args:
73 xml: Project XML.
75 Returns:
76 Project capabilities.
78 Raises:
79 ``gws.Error``: If the project CRS is invalid.
80 """
81 el = gws.lib.xmlx.from_string(xml)
82 return parse_element(el)
85def parse_element(root_el: gws.XmlElement) -> Caps:
86 """Parse a QGIS project XML element.
88 Layers listed as restricted in the project WMS settings are skipped.
90 Args:
91 root_el: Root element of the project XML.
93 Returns:
94 Project capabilities.
96 Raises:
97 ``gws.Error``: If the project CRS is invalid.
98 """
99 caps = Caps()
101 caps.version = str(root_el.get('version') or '')
102 caps.properties = _parse_properties(root_el.find('properties'))
103 caps.metadata = _project_metadata(root_el)
104 caps.printTemplates = _print_templates(root_el)
105 caps.visibilityPresets = _visibility_presets(root_el)
107 srid = root_el.textof('projectCrs/spatialrefsys/authid') or '4326'
108 crs = gws.lib.crs.get(srid)
109 if not crs:
110 raise gws.Error(f'invalid CRS {srid=} in qgis project')
111 caps.projectCrs = crs
113 ext = _extent_from_tag(root_el.find('properties/WMSExtent'))
114 if ext:
115 caps.projectBounds = gws.lib.bounds.from_extent(ext, caps.projectCrs)
117 ext = _extent_from_tag(root_el.find('mapcanvas/extent'))
118 if ext:
119 caps.projectCanvasBounds = gws.lib.bounds.from_extent(ext, caps.projectCrs)
121 layers_dct = _map_layers(root_el, caps)
122 root_group = _layer_tree(root_el.find('layer-tree-group'), layers_dct)
123 caps.sourceLayers = gws.gis.source.check_layers(root_group.layers if root_group else [])
125 return caps
128##
131def _project_metadata(root_el) -> gws.Metadata:
132 md = gws.base.metadata.new()
134 el = root_el.find('projectMetadata')
135 if el:
136 _collect_metadata(el, md)
138 # @TODO supplementary metadata
139 return gws.base.metadata.normalize(md)
142_meta_mapping = [
143 ('authorityIdentifier', 'identifier'),
144 ('parentIdentifier', 'parentidentifier'),
145 ('language', 'language'),
146 ('type', 'type'),
147 ('title', 'title'),
148 ('abstract', 'abstract'),
149 ('dateCreated', 'creation'),
150 ('fees', 'fees'),
151]
153_contact_mapping = [
154 ('contactEmail', 'email'),
155 ('contactFax', 'fax'),
156 ('contactOrganization', 'organization'),
157 ('contactPerson', 'name'),
158 ('contactPhone', 'voice'),
159 ('contactPosition', 'position'),
160 ('contactRole', 'role'),
161 ('contactAddress', 'address'),
162 ('contactAddressType', 'type'),
163 ('contactArea', 'administrativearea'),
164 ('contactCity', 'city'),
165 ('contactCountry', 'country'),
166 ('contactZip', 'postalcode'),
167]
170def _add_dict(dst, src, mapping):
171 for dkey, skey in mapping:
172 if skey in src:
173 setattr(dst, dkey, src[skey])
176def _collect_metadata(el: gws.XmlElement, md: gws.Metadata):
177 # extract metadata from projectMetadata/resourceMetadata
179 _add_dict(md, el.textdict(), _meta_mapping)
181 md.keywords = []
182 for kw in el.findall('keywords'):
183 keywords = kw.textlist('keyword')
184 if kw.get('vocabulary') == 'gmd:topicCategory':
185 md.isoTopicCategories = keywords
186 else:
187 md.keywords.extend(keywords)
189 contact_el = el.find('contact')
190 if contact_el:
191 _add_dict(md, contact_el.textdict(), _contact_mapping)
192 for e in contact_el.findall('contactAddress'):
193 _add_dict(md, e.textdict(), _contact_mapping)
194 break # NB we only support one contact address
196 md.metaLinks = []
197 for e in el.findall('links/link'):
198 # @TODO clarify
199 md.metaLinks.append(
200 gws.MetadataLink(
201 url=e.get('url'),
202 description=e.get('description'),
203 mimeType=e.get('mimeType'),
204 format=e.get('format'),
205 title=e.get('name'),
206 scheme=e.get('type'),
207 )
208 )
210 for e in el.findall('constraints'):
211 md.accessConstraints = e.text
212 md.accessConstraintsType = e.get('type')
214 for e in el.findall('license'):
215 md.license = e.text
216 break
218 e = el.find('extent/temporal')
219 if e:
220 md.temporalBegin = e.textof('period/start')
221 md.temporalEnd = e.textof('period/end')
224# see QGIS/src/core/layout/qgslayoutitemregistry.h
226_QGraphicsItem_UserType = 65536 # https://doc.qt.io/qtforpython/PySide2/QtWidgets/QGraphicsItem.html
228_LT0 = _QGraphicsItem_UserType + 100
230_LAYOUT_TYPES = {
231 _LT0 + 0: 'item', # LayoutItem
232 _LT0 + 1: 'group', # LayoutGroup
233 _LT0 + 2: 'page', # LayoutPage
234 _LT0 + 3: 'map', # LayoutMap
235 _LT0 + 4: 'picture', # LayoutPicture
236 _LT0 + 5: 'label', # LayoutLabel
237 _LT0 + 6: 'legend', # LayoutLegend
238 _LT0 + 7: 'shape', # LayoutShape
239 _LT0 + 8: 'polygon', # LayoutPolygon
240 _LT0 + 9: 'polyline', # LayoutPolyline
241 _LT0 + 10: 'scalebar', # LayoutScaleBar
242 _LT0 + 11: 'frame', # LayoutFrame
243 _LT0 + 12: 'html', # LayoutHtml
244 _LT0 + 13: 'attributetable', # LayoutAttributeTable
245 _LT0 + 14: 'texttable', # LayoutTextTable
246 _LT0 + 15: '3dmap', # Layout3DMap
247 _LT0 + 16: 'manualtable', # LayoutManualTable
248 _LT0 + 17: 'marker', # LayoutMarker
249}
252# print templates in qgis-3:
253#
254# <Layouts>
255# <Layout name="..." <- template 1
256# <PageCollection
257# <LayoutItem <- pages
258# <LayoutItem type="<int, see below>" ...
259# <LayoutMultiFrame type="<int>" ...
260# <Layout??? <- evtl. other item tags
261#
262# <Layout name="..." <- template 2
263# etc
266def _print_templates(root_el: gws.XmlElement):
267 templates = []
269 for layout_el in root_el.findall('Layouts/Layout'):
270 tpl = PrintTemplate(
271 title=layout_el.get('name', ''),
272 attributes=layout_el.attrib,
273 index=len(templates),
274 elements=[],
275 )
277 pc_el = layout_el.find('PageCollection')
278 if pc_el:
279 tpl.elements.extend(gws.u.compact(_layout_element(c) for c in pc_el))
281 tpl.elements.extend(gws.u.compact(_layout_element(c) for c in layout_el))
283 templates.append(tpl)
285 return templates
288def _layout_element(item_el: gws.XmlElement):
289 type = _LAYOUT_TYPES.get(_parse_int(item_el.get('type')))
290 uuid = item_el.get('uuid')
291 if type and uuid:
292 return PrintTemplateElement(
293 type=type,
294 uuid=uuid,
295 attributes=item_el.attrib,
296 position=_parse_msize(item_el.get('position')),
297 size=_parse_msize(item_el.get('size')),
298 )
301##
304def _map_layers(root_el: gws.XmlElement, caps: Caps) -> dict[str, gws.SourceLayer]:
305 no_wms_layers = set(caps.properties.get('WMSRestrictedLayers', []))
306 use_layer_ids = caps.properties.get('WMSUseLayerIDs', False)
308 layers_dct = {}
310 for el in root_el.findall('projectlayers/maplayer'):
311 sl = _map_layer(el, caps, use_layer_ids)
312 if not sl:
313 continue
314 # no_wms_layers always contains titles, not ids (=names)
315 if sl.title in no_wms_layers:
316 continue
317 layers_dct[sl.sourceId] = sl
319 return layers_dct
322def _map_layer(layer_el: gws.XmlElement, caps: Caps, use_layer_ids: bool) -> gws.SourceLayer:
323 sl = gws.SourceLayer()
325 sl.metadata = _map_layer_metadata(layer_el)
327 if layer_el.get('hasScaleBasedVisibilityFlag') == '1':
328 # in qgis, maxScale < minScale
329 a = _parse_float(layer_el.get('maxScale'))
330 z = _parse_float(layer_el.get('minScale'))
331 if z > a:
332 sl.scaleRange = [a, z]
334 sl.dataSource = _map_layer_datasource(layer_el)
335 sl.opacity = _parse_float(layer_el.textof('layerOpacity') or '1')
336 sl.isQueryable = layer_el.textof('flags/Identifiable') == '1'
337 sl.properties = _parse_properties(layer_el.find('customproperties'))
339 uid = layer_el.textof('id')
340 if use_layer_ids:
341 layer_name = uid
342 else:
343 layer_name = layer_el.textof('shortname') or layer_el.textof('layername')
345 sl.title = layer_el.textof('layername') or sl.metadata.get('title') or ''
346 sl.name = layer_name
347 sl.sourceId = uid
349 crs = gws.lib.crs.get(layer_el.textof('srs/spatialrefsys/authid'))
350 if crs:
351 sl.supportedCrs = [crs]
353 ext = qgis_extent(layer_el, crs or caps.projectCrs)
354 if ext:
355 sl.wgsExtent = ext
357 return sl
360def _map_layer_metadata(layer_el) -> gws.Metadata:
361 # Layer metadata is either Layer->Properties->Metadata (stored in maplayer/resourceMetadata),
362 # or Layer->Properties->QGIS Server (stored directly under maplayer/abstract, maplayer/keywordList and so on.
364 md = gws.base.metadata.new()
366 el = layer_el.find('resourceMetadata')
367 if el:
368 _collect_metadata(el, md)
370 # fill in missing props from direct metadata
372 d = layer_el.textdict()
374 md.title = md.title or d.get('title') or d.get('shortname') or d.get('layername')
375 md.abstract = md.abstract or d.get('abstract')
377 if not md.attribution and d.get('attribution'):
378 md.attribution = d.get('attribution')
380 if not md.keywords:
381 md.keywords = layer_el.textlist('keywordList/value')
383 if not md.metaLinks:
384 md.metaLinks = []
385 for e in layer_el.findall('metadataUrls/metadataUrl'):
386 md.metaLinks.append(
387 gws.MetadataLink(
388 type=e.get('type'),
389 format=e.get('format'),
390 title=e.text,
391 )
392 )
394 return gws.base.metadata.normalize(md)
397def _map_layer_datasource(layer_el: gws.XmlElement) -> dict:
398 prov = layer_el.textof('provider')
399 ds_text = layer_el.textof('datasource')
401 if ds_text:
402 return parse_datasource((prov or '').lower(), ds_text)
403 if prov:
404 return {'provider': prov.lower()}
405 return {}
408def qgis_extent(layer_el: gws.XmlElement, layer_crs: gws.Crs):
409 """Compute the WGS extent of a map layer.
411 The extent is taken from the first valid source of the following: the
412 explicit extent in the layer metadata, the ``wgs84extent`` element, and
413 the ``extent`` element, which is assumed to be in the layer CRS.
415 Args:
416 layer_el: ``maplayer`` element.
417 layer_crs: CRS of the layer.
419 Returns:
420 WGS extent, or ``None`` if no valid extent is found.
421 """
422 uid = layer_el.textof('id')
424 # extent explicitly defined in metadata (Layer Props -> Metadata -> Extent)
425 el = layer_el.find('resourceMetadata/extent/spatial')
426 if el:
427 ext = _extent_from_tag(el)
428 crs = gws.lib.crs.get(el.get('crs'))
429 if ext and crs:
430 ext = gws.lib.extent.transform(ext, crs, gws.lib.crs.WGS84)
431 if gws.lib.extent.is_valid_wgs(ext):
432 gws.log.debug(f'qgis_extent: {uid}: spatial: {ext}')
433 return ext
435 # extent in <maplayer>/<wgs84extent>
436 el = layer_el.find('wgs84extent')
437 if el:
438 ext = _extent_from_tag(el)
439 if ext and gws.lib.extent.is_valid_wgs(ext):
440 gws.log.debug(f'qgis_extent: {uid}: wgs84extent: {ext}')
441 return ext
443 # extent in <maplayer>/<extent>, assume the layer CRS
444 el = layer_el.find('extent')
445 if el:
446 ext = _extent_from_tag(el)
447 if ext:
448 ext = gws.lib.extent.transform(ext, layer_crs, gws.lib.crs.WGS84)
449 if ext and gws.lib.extent.is_valid_wgs(ext):
450 gws.log.debug(f'qgis_extent: {uid}: extent: {ext}')
451 return ext
453 gws.log.warning(f'qgis_extent: {uid}: no extent defined')
456# layer trees:
458# <layer-tree-group>
459# <layer-tree-group checked="Qt::Checked" expanded="1" name="...">
460# <layer-tree-layer ... checked="Qt::Checked" expanded="1" id="...">
461# ...
464def _layer_tree(el: Optional[gws.XmlElement], layers_dct):
465 if not el:
466 return
468 visible = el.get('checked') != 'Qt::Unchecked'
469 expanded = el.get('expanded') == '1'
471 if el.tag == 'layer-tree-group':
472 title = el.get('name')
473 # qgis doesn't write 'id' for groups but our generators might
474 name = el.get('id') or title
476 return gws.SourceLayer(
477 title=title,
478 name=name,
479 metadata=gws.Metadata(title=title, name=name),
480 isVisible=visible,
481 isExpanded=expanded,
482 isGroup=True,
483 isQueryable=False,
484 isImage=False,
485 layers=gws.u.compact(_layer_tree(c, layers_dct) for c in el),
486 )
488 if el.tag == 'layer-tree-layer':
489 sl = layers_dct.get(el.get('id'))
490 if sl:
491 sl.isVisible = visible
492 sl.isExpanded = expanded
493 sl.isGroup = False
494 sl.isImage = True
495 return sl
498##
501def _visibility_presets(root_el: gws.XmlElement):
502 """Map each visibility preset (map theme) name to the IDs of its visible layers."""
504 d = {}
506 for el in root_el.findall('visibility-presets/visibility-preset'):
507 ls = []
508 for la in el.findall('layer'):
509 if la.get('visible') == '1':
510 ls.append(la.get('id'))
511 d[el.get('name')] = ls
513 return d
516##
519def parse_datasource(prov, text):
520 """Parse a QGIS layer data source string.
522 Common formats (ampersand-delimited, space-delimited, plain URL,
523 pipe-delimited path) are parsed into a dict with lower-case keys,
524 anything else is returned as ``{'text': text}``. The ``provider`` key
525 is always set; WMS sources with a tile matrix set are classified as
526 ``wmts``, and WMS sources of type ``xyz`` as ``xyz``.
528 Args:
529 prov: Data provider name from the project, e.g. ``postgres``.
530 text: Data source string.
532 Returns:
533 Data source properties.
535 Raises:
536 ``ValueError``: If a space- or pipe-delimited string is malformed.
537 """
538 ds = gws.u.to_lower_dict(_parse_datasource(text) or {})
539 ds['provider'] = (ds.get('provider') or prov).lower()
541 if ds['provider'] == 'wms' and 'tilematrixset' in ds:
542 ds['provider'] = 'wmts'
543 elif ds['provider'] == 'wms' and ds.get('type') == 'xyz':
544 ds['provider'] = 'xyz'
546 # @TODO classify ogr's based on a file extension
548 return ds
551def _parse_datasource(text):
552 # Datasources are very versatile and the format depends on the provider.
553 # For some hints see `decodedSource` in qgsvectorlayer.cpp/qgsrasterlayer.cpp.
554 # We don't have ambition to parse them all, just do some ad-hoc parsing
555 # of the most common flavors, and return the rest as `{'text': text}`.
557 text = text.strip()
559 if re.match(r'^\w+=[^&]*&', text):
560 # key=value, amp-separated, uri-encoded
561 # used for WMS, e.g.
562 # contextualWMSLegend=0&crs=EPSG:31468&...&url=...?SERVICE%3DWMTS%26REQUEST%3DGetCapabilities
563 return _datasource_amp_delimited(text)
565 if re.match(r'^\w+=\S+ ', text):
566 # key=value, space separated
567 # used for postgres/WFS, e.g.
568 # dbname='...' host=... port=...
569 # pagingEnabled='...' preferCoordinatesForWfsT11=...
570 return _datasource_space_delimited(text)
572 if text.startswith(('http://', 'https://')):
573 # just an url
574 return {'url': text}
576 if text.startswith(('.', '/')):
577 # path or path|options
578 # used for Geojson, GPKG, e.g.
579 # ../rel/path/test.gpkg|layername=name|subset=... etc
580 return _datasource_pipe_delimited(text)
582 return {'text': text}
585def _datasource_amp_delimited(text):
586 ds = {}
588 for p in text.split('&'):
589 if '=' not in p:
590 continue
591 k, v = p.split('=', maxsplit=1)
593 v = gws.lib.net.unquote(v)
595 if k in {'layers', 'styles'}:
596 ds.setdefault(k, []).append(v)
597 else:
598 ds[k] = v
600 if 'url' not in ds:
601 return ds
603 # extract params from the url
605 url, params = gws.lib.net.extract_params(ds['url'])
606 params = gws.u.to_lower_dict(params)
608 if 'typename' in params:
609 ds['typename'] = params.pop('typename')
610 if 'layers' in params:
611 ds.setdefault('layers', []).extend(params.pop('layers').split(','))
612 if 'styles' in params:
613 ds.setdefault('styles', []).extend(params.pop('styles').split(','))
615 params.pop('service', None)
616 params.pop('request', None)
618 ds['params'] = params
620 # {x} placeholders shouldn't be encoded
621 url = url.replace('%7B', '{')
622 url = url.replace('%7D', '}')
624 ds['url'] = url
626 return ds
629def _datasource_space_delimited(text):
630 key_re = r'^\w+\s*=\s*'
632 value_re = r"""(?x)
633 " (?: \\. | [^"])* " |
634 ' (?: \\. | [^'])* ' |
635 \S+
636 """
638 parens_re = r'\(.*?\)'
640 def _cut(u, rx):
641 m = re.match(rx, u)
642 if not m:
643 raise ValueError(f'datasource uri error, expected {rx!r}, found {u[:25]!r}')
644 v = m.group(0)
645 return v, u[len(v) :].strip()
647 def _unesc(s):
648 return re.sub(r'\\(.)', '\1', s)
650 def _mid(s):
651 return s[1:-1].strip()
653 def _value(v):
654 if v.startswith(("'", '"')):
655 return _unesc(_mid(v))
656 return v
658 ds = {}
660 while text:
661 # keyword=
662 key, text = _cut(text, key_re)
663 key = key.strip('= ')
665 if key == 'sql':
666 # 'sql=' is special and can contain whatever, it's always the last one
667 ds[key] = text
668 break
670 elif key == 'table':
671 # 'table=' is special, it can be `table="foo"` or `table="foo"."bar"` or table=`"foo"."bar" (geom)`
672 v, text = _cut(text, value_re)
673 ds['table'] = _value(v)
675 if text.startswith('.'):
676 v, text = _cut(text[1:], value_re)
677 ds['table'] += '.' + _value(v)
679 if text.startswith('('):
680 v, text = _cut(text, parens_re)
681 ds['geometryColumn'] = _mid(v)
683 else:
684 # just param=val
685 v, text = _cut(text, value_re)
686 ds[key] = _value(v)
688 return ds
691def _datasource_pipe_delimited(text):
692 if '|' not in text:
693 return {'path': text}
695 path, rest = text.split('|', maxsplit=1)
697 if '=' not in rest:
698 return {'path': path, 'options': rest}
700 ds = {'path': path}
702 for p in rest.split('|'):
703 k, v = p.split('=', maxsplit=1)
704 ds[k] = v
706 return ds
709##
712def _parse_properties(el: Optional[gws.XmlElement]) -> dict:
713 """Parse a QGIS property block (scalars, dicts or option maps) into a dict."""
715 if not el:
716 return {}
718 _, val = _parse_property_tag(el)
719 return val
722def _parse_property_tag(el: gws.XmlElement) -> tuple[str, Any]:
723 typ = el.get('type')
724 name = el.tag
725 is_opt = el.tag == 'Option'
727 if is_opt and el.get('name'):
728 name = el.get('name') or ''
730 if not typ or typ == 'Map':
731 d = {}
733 for c in el:
734 k, v = _parse_property_tag(c)
735 if k:
736 d[k] = v
738 if len(d) == 1 and 'Option' in d:
739 return name, d['Option']
741 return name, d
743 if typ == 'List':
744 ls = []
745 for c in el:
746 _, v = _parse_property_tag(c)
747 ls.append(v)
748 return name, ls
750 if typ == 'QStringList':
751 val = [c.text for c in el.findall('value')]
752 return name, val
754 if typ == 'QString':
755 val = el.get('value') if is_opt else el.text
756 return name, val
758 if typ == 'bool':
759 val = el.get('value') if is_opt else el.text
760 return name, (val or '').lower() == 'true'
762 if typ == 'int':
763 val = el.get('value') if is_opt else el.text
764 return name, _parse_int(val)
766 if typ == 'double':
767 val = el.get('value') if is_opt else el.text
768 return name, _parse_float(val)
770 return '', None
773##
776def _extent_from_tag(el: Optional[gws.XmlElement]) -> Optional[gws.Extent]:
777 if not el:
778 return
780 ext = _extent_from_tag2(el)
781 if not ext:
782 return
784 # work around qgis bug with absurdly high values in extent tags
785 if any(abs(c) > 1e10 for c in ext):
786 gws.log.warning(f'qgis_extent: ignoring invalid extent {ext}')
787 return
789 # ignore all-zero extents
790 if all(abs(c) < 1e-10 for c in ext):
791 return
793 # avoid single-point extents
794 min_size = 0.0001 # in metric projections, this is about 1cm
795 if ext[2] - ext[0] < min_size:
796 ext = gws.Extent([ext[0] - min_size, ext[1], ext[2] + min_size, ext[3]])
797 if ext[3] - ext[1] < min_size:
798 ext = gws.Extent([ext[0], ext[1] - min_size, ext[2], ext[3] + min_size])
800 return ext
803def _extent_from_tag2(el: gws.XmlElement):
804 # <spatial dimensions="2" miny="0" maxz="0" maxx="0" crs="EPSG:25832" minx="0" minz="0" maxy="0"/>
805 if el.get('minx'):
806 return _extent_from_list(
807 [
808 el.get('minx'),
809 el.get('miny'),
810 el.get('maxx'),
811 el.get('maxy'),
812 ]
813 )
815 # <WMSExtent type="QStringList">
816 # <value>1</value>
817 # <value>2</value>
818 # <value>3</value>
819 # <value>4</value>
820 # </WMSExtent>
821 #
822 if el.get('type') == 'QStringList':
823 return _extent_from_list([v.text for v in el.children()])
825 # <wgs84extent>
826 # <xmin>1</xmin>
827 # <ymin>2</ymin>
828 # <xmax>3</xmax>
829 # <ymax>4</ymax>
830 # </wgs84extent>
832 if el.find('xmin'):
833 return _extent_from_list(
834 [
835 el.textof('xmin'),
836 el.textof('ymin'),
837 el.textof('xmax'),
838 el.textof('ymax'),
839 ]
840 )
843def _extent_from_list(ls):
844 if len(ls) != 4:
845 return
846 try:
847 ext = [float(p) for p in ls]
848 except ValueError:
849 return
850 if not all(math.isfinite(p) for p in ext):
851 return
852 return gws.Extent([
853 min(ext[0], ext[2]),
854 min(ext[1], ext[3]),
855 max(ext[0], ext[2]),
856 max(ext[1], ext[3]),
857 ])
860def _parse_msize(s):
861 # e.g. 'position': '228.477,27.8455,mm'
862 try:
863 x, y, u = s.split(',')
864 return _parse_float(x), _parse_float(y), u
865 except Exception:
866 return None
869def _parse_int(s) -> int:
870 try:
871 return int(s)
872 except Exception:
873 return 0
876def _parse_float(s) -> float:
877 try:
878 x = float(s)
879 except Exception:
880 return 0
881 if math.isnan(x) or math.isinf(x):
882 return 0
883 return x