Coverage for gws-app/gws/plugin/qgis/caps.py: 73%
433 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +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 = _map_layer_opacity(layer_el)
336 sl.isQueryable = layer_el.textof('flags/Identifiable') == '1'
337 sl.isReadOnly = layer_el.get('readOnly') == '1'
338 sl.properties = _parse_properties(layer_el.find('customproperties'))
340 uid = layer_el.textof('id')
341 if use_layer_ids:
342 layer_name = uid
343 else:
344 layer_name = layer_el.textof('shortname') or layer_el.textof('layername')
346 sl.title = layer_el.textof('layername') or sl.metadata.get('title') or ''
347 sl.name = layer_name
348 sl.sourceId = uid
350 crs = gws.lib.crs.get(layer_el.textof('srs/spatialrefsys/authid'))
351 if crs:
352 sl.supportedCrs = [crs]
354 ext = qgis_extent(layer_el, crs or caps.projectCrs)
355 if ext:
356 sl.wgsExtent = ext
358 return sl
361def _map_layer_opacity(layer_el: gws.XmlElement) -> float:
362 """Read the layer opacity, for raster layers from the renderer."""
363 s = layer_el.textof('layerOpacity')
364 if not s:
365 el = layer_el.find('pipe/rasterrenderer')
366 if el is not None:
367 s = el.get('opacity')
368 return _parse_float(s or '1')
371def _map_layer_metadata(layer_el) -> gws.Metadata:
372 # Layer metadata is either Layer->Properties->Metadata (stored in maplayer/resourceMetadata),
373 # or Layer->Properties->QGIS Server (stored directly under maplayer/abstract, maplayer/keywordList and so on.
375 md = gws.base.metadata.new()
377 el = layer_el.find('resourceMetadata')
378 if el:
379 _collect_metadata(el, md)
381 # fill in missing props from direct metadata
383 d = layer_el.textdict()
385 md.title = md.title or d.get('title') or d.get('shortname') or d.get('layername')
386 md.abstract = md.abstract or d.get('abstract')
388 if not md.attribution and d.get('attribution'):
389 md.attribution = d.get('attribution')
391 if not md.keywords:
392 md.keywords = layer_el.textlist('keywordList/value')
394 if not md.metaLinks:
395 md.metaLinks = []
396 for e in layer_el.findall('metadataUrls/metadataUrl'):
397 md.metaLinks.append(
398 gws.MetadataLink(
399 type=e.get('type'),
400 format=e.get('format'),
401 title=e.text,
402 )
403 )
405 return gws.base.metadata.normalize(md)
408def _map_layer_datasource(layer_el: gws.XmlElement) -> dict:
409 prov = layer_el.textof('provider')
410 ds_text = layer_el.textof('datasource')
412 if ds_text:
413 return parse_datasource((prov or '').lower(), ds_text)
414 if prov:
415 return {'provider': prov.lower()}
416 return {}
419def qgis_extent(layer_el: gws.XmlElement, layer_crs: gws.Crs):
420 """Compute the WGS extent of a map layer.
422 The extent is taken from the first valid source of the following: the
423 explicit extent in the layer metadata, the ``wgs84extent`` element, and
424 the ``extent`` element, which is assumed to be in the layer CRS.
426 Args:
427 layer_el: ``maplayer`` element.
428 layer_crs: CRS of the layer.
430 Returns:
431 WGS extent, or ``None`` if no valid extent is found.
432 """
433 uid = layer_el.textof('id')
435 # extent explicitly defined in metadata (Layer Props -> Metadata -> Extent)
436 el = layer_el.find('resourceMetadata/extent/spatial')
437 if el:
438 ext = _extent_from_tag(el)
439 crs = gws.lib.crs.get(el.get('crs'))
440 if ext and crs:
441 ext = gws.lib.extent.transform(ext, crs, gws.lib.crs.WGS84)
442 if gws.lib.extent.is_valid_wgs(ext):
443 gws.log.debug(f'qgis_extent: {uid}: spatial: {ext}')
444 return ext
446 # extent in <maplayer>/<wgs84extent>
447 el = layer_el.find('wgs84extent')
448 if el:
449 ext = _extent_from_tag(el)
450 if ext and gws.lib.extent.is_valid_wgs(ext):
451 gws.log.debug(f'qgis_extent: {uid}: wgs84extent: {ext}')
452 return ext
454 # extent in <maplayer>/<extent>, assume the layer CRS
455 el = layer_el.find('extent')
456 if el:
457 ext = _extent_from_tag(el)
458 if ext:
459 ext = gws.lib.extent.transform(ext, layer_crs, gws.lib.crs.WGS84)
460 if ext and gws.lib.extent.is_valid_wgs(ext):
461 gws.log.debug(f'qgis_extent: {uid}: extent: {ext}')
462 return ext
464 gws.log.warning(f'qgis_extent: {uid}: no extent defined')
467# layer trees:
469# <layer-tree-group>
470# <layer-tree-group checked="Qt::Checked" expanded="1" name="...">
471# <layer-tree-layer ... checked="Qt::Checked" expanded="1" id="...">
472# ...
475def _layer_tree(el: Optional[gws.XmlElement], layers_dct):
476 if not el:
477 return
479 visible = el.get('checked') != 'Qt::Unchecked'
480 expanded = el.get('expanded') == '1'
482 if el.tag == 'layer-tree-group':
483 title = el.get('name')
484 # qgis doesn't write 'id' for groups but our generators might
485 name = el.get('id') or title
487 return gws.SourceLayer(
488 title=title,
489 name=name,
490 metadata=gws.Metadata(title=title, name=name),
491 isVisible=visible,
492 isExpanded=expanded,
493 isGroup=True,
494 isQueryable=False,
495 isImage=False,
496 layers=gws.u.compact(_layer_tree(c, layers_dct) for c in el),
497 )
499 if el.tag == 'layer-tree-layer':
500 sl = layers_dct.get(el.get('id'))
501 if sl:
502 sl.isVisible = visible
503 sl.isExpanded = expanded
504 sl.isGroup = False
505 sl.isImage = True
506 return sl
509##
512def _visibility_presets(root_el: gws.XmlElement):
513 """Map each visibility preset (map theme) name to the IDs of its visible layers."""
515 d = {}
517 for el in root_el.findall('visibility-presets/visibility-preset'):
518 ls = []
519 for la in el.findall('layer'):
520 if la.get('visible') == '1':
521 ls.append(la.get('id'))
522 d[el.get('name')] = ls
524 return d
527##
530def parse_datasource(prov, text):
531 """Parse a QGIS layer data source string.
533 Common formats (ampersand-delimited, space-delimited, plain URL,
534 pipe-delimited path) are parsed into a dict with lower-case keys,
535 anything else is returned as ``{'text': text}``. The ``provider`` key
536 is always set; WMS sources with a tile matrix set are classified as
537 ``wmts``, and WMS sources of type ``xyz`` as ``xyz``.
539 Args:
540 prov: Data provider name from the project, e.g. ``postgres``.
541 text: Data source string.
543 Returns:
544 Data source properties.
546 Raises:
547 ``ValueError``: If a space- or pipe-delimited string is malformed.
548 """
549 ds = gws.u.to_lower_dict(_parse_datasource(text) or {})
550 ds['provider'] = (ds.get('provider') or prov).lower()
552 if ds['provider'] == 'wms' and 'tilematrixset' in ds:
553 ds['provider'] = 'wmts'
554 elif ds['provider'] == 'wms' and ds.get('type') == 'xyz':
555 ds['provider'] = 'xyz'
557 # @TODO classify ogr's based on a file extension
559 return ds
562def _parse_datasource(text):
563 # Datasources are very versatile and the format depends on the provider.
564 # For some hints see `decodedSource` in qgsvectorlayer.cpp/qgsrasterlayer.cpp.
565 # We don't have ambition to parse them all, just do some ad-hoc parsing
566 # of the most common flavors, and return the rest as `{'text': text}`.
568 text = text.strip()
570 if re.match(r'^\w+=[^&]*&', text):
571 # key=value, amp-separated, uri-encoded
572 # used for WMS, e.g.
573 # contextualWMSLegend=0&crs=EPSG:31468&...&url=...?SERVICE%3DWMTS%26REQUEST%3DGetCapabilities
574 return _datasource_amp_delimited(text)
576 if re.match(r'^\w+=\S+ ', text):
577 # key=value, space separated
578 # used for postgres/WFS, e.g.
579 # dbname='...' host=... port=...
580 # pagingEnabled='...' preferCoordinatesForWfsT11=...
581 return _datasource_space_delimited(text)
583 if text.startswith(('http://', 'https://')):
584 # just an url
585 return {'url': text}
587 if text.startswith(('.', '/')):
588 # path or path|options
589 # used for Geojson, GPKG, e.g.
590 # ../rel/path/test.gpkg|layername=name|subset=... etc
591 return _datasource_pipe_delimited(text)
593 return {'text': text}
596def _datasource_amp_delimited(text):
597 ds = {}
599 for p in text.split('&'):
600 if '=' not in p:
601 continue
602 k, v = p.split('=', maxsplit=1)
604 v = gws.lib.net.unquote(v)
606 if k in {'layers', 'styles'}:
607 ds.setdefault(k, []).append(v)
608 else:
609 ds[k] = v
611 if 'url' not in ds:
612 return ds
614 # extract params from the url
616 url, params = gws.lib.net.extract_params(ds['url'])
617 params = gws.u.to_lower_dict(params)
619 if 'typename' in params:
620 ds['typename'] = params.pop('typename')
621 if 'layers' in params:
622 ds.setdefault('layers', []).extend(params.pop('layers').split(','))
623 if 'styles' in params:
624 ds.setdefault('styles', []).extend(params.pop('styles').split(','))
626 params.pop('service', None)
627 params.pop('request', None)
629 ds['params'] = params
631 # {x} placeholders shouldn't be encoded
632 url = url.replace('%7B', '{')
633 url = url.replace('%7D', '}')
635 ds['url'] = url
637 return ds
640def _datasource_space_delimited(text):
641 key_re = r'^\w+\s*=\s*'
643 value_re = r"""(?x)
644 " (?: \\. | [^"])* " |
645 ' (?: \\. | [^'])* ' |
646 \S+
647 """
649 parens_re = r'\(.*?\)'
651 def _cut(u, rx):
652 m = re.match(rx, u)
653 if not m:
654 raise ValueError(f'datasource uri error, expected {rx!r}, found {u[:25]!r}')
655 v = m.group(0)
656 return v, u[len(v) :].strip()
658 def _unesc(s):
659 return re.sub(r'\\(.)', r'\1', s)
661 def _mid(s):
662 return s[1:-1].strip()
664 def _value(v):
665 if v.startswith(("'", '"')):
666 return _unesc(_mid(v))
667 return v
669 ds = {}
671 while text:
672 # keyword=
673 key, text = _cut(text, key_re)
674 key = key.strip('= ')
676 if key == 'sql':
677 # 'sql=' is special and can contain whatever, it's always the last one
678 ds[key] = text
679 break
681 elif key == 'table':
682 # 'table=' is special, it can be `table="foo"` or `table="foo"."bar"` or table=`"foo"."bar" (geom)`
683 v, text = _cut(text, value_re)
684 parts = [_value(v)]
686 if text.startswith('.'):
687 v, text = _cut(text[1:], value_re)
688 parts.append(_value(v))
690 if len(parts) == 1 and (parts[0].startswith('(') or parts[0].upper().startswith('SELECT ')):
691 ds['table'] = parts[0]
692 else:
693 ds['table'] = '.'.join(_quote_table_name_part(p) for p in parts)
695 if text.startswith('('):
696 v, text = _cut(text, parens_re)
697 ds['geometryColumn'] = _mid(v)
699 else:
700 # just param=val
701 v, text = _cut(text, value_re)
702 ds[key] = _value(v)
704 return ds
707def _quote_table_name_part(name):
708 if re.match(r'^\w+$', name):
709 return name
710 return '"' + name.replace('"', '""') + '"'
713def _datasource_pipe_delimited(text):
714 if '|' not in text:
715 return {'path': text}
717 path, rest = text.split('|', maxsplit=1)
719 if '=' not in rest:
720 return {'path': path, 'options': rest}
722 ds = {'path': path}
724 for p in rest.split('|'):
725 k, v = p.split('=', maxsplit=1)
726 ds[k] = v
728 return ds
731##
734def _parse_properties(el: Optional[gws.XmlElement]) -> dict:
735 """Parse a QGIS property block (scalars, dicts or option maps) into a dict."""
737 if not el:
738 return {}
740 _, val = _parse_property_tag(el)
741 return val
744def _parse_property_tag(el: gws.XmlElement) -> tuple[str, Any]:
745 typ = el.get('type')
746 name = el.tag
747 is_opt = el.tag == 'Option'
749 if is_opt and el.get('name'):
750 name = el.get('name') or ''
752 if not typ or typ == 'Map':
753 d = {}
755 for c in el:
756 k, v = _parse_property_tag(c)
757 if k:
758 d[k] = v
760 if len(d) == 1 and 'Option' in d:
761 return name, d['Option']
763 return name, d
765 if typ == 'List':
766 ls = []
767 for c in el:
768 _, v = _parse_property_tag(c)
769 ls.append(v)
770 return name, ls
772 if typ == 'QStringList':
773 val = [c.text for c in el.findall('value')]
774 return name, val
776 if typ == 'QString':
777 val = el.get('value') if is_opt else el.text
778 return name, val
780 if typ == 'bool':
781 val = el.get('value') if is_opt else el.text
782 return name, (val or '').lower() == 'true'
784 if typ == 'int':
785 val = el.get('value') if is_opt else el.text
786 return name, _parse_int(val)
788 if typ == 'double':
789 val = el.get('value') if is_opt else el.text
790 return name, _parse_float(val)
792 return '', None
795##
798def _extent_from_tag(el: Optional[gws.XmlElement]) -> Optional[gws.Extent]:
799 if not el:
800 return
802 ext = _extent_from_tag2(el)
803 if not ext:
804 return
806 # work around qgis bug with absurdly high values in extent tags
807 if any(abs(c) > 1e10 for c in ext):
808 gws.log.warning(f'qgis_extent: ignoring invalid extent {ext}')
809 return
811 # ignore all-zero extents
812 if all(abs(c) < 1e-10 for c in ext):
813 return
815 # avoid single-point extents
816 min_size = 0.0001 # in metric projections, this is about 1cm
817 if ext[2] - ext[0] < min_size:
818 ext = gws.Extent([ext[0] - min_size, ext[1], ext[2] + min_size, ext[3]])
819 if ext[3] - ext[1] < min_size:
820 ext = gws.Extent([ext[0], ext[1] - min_size, ext[2], ext[3] + min_size])
822 return ext
825def _extent_from_tag2(el: gws.XmlElement):
826 # <spatial dimensions="2" miny="0" maxz="0" maxx="0" crs="EPSG:25832" minx="0" minz="0" maxy="0"/>
827 if el.get('minx'):
828 return _extent_from_list(
829 [
830 el.get('minx'),
831 el.get('miny'),
832 el.get('maxx'),
833 el.get('maxy'),
834 ]
835 )
837 # <WMSExtent type="QStringList">
838 # <value>1</value>
839 # <value>2</value>
840 # <value>3</value>
841 # <value>4</value>
842 # </WMSExtent>
843 #
844 if el.get('type') == 'QStringList':
845 return _extent_from_list([v.text for v in el.children()])
847 # <wgs84extent>
848 # <xmin>1</xmin>
849 # <ymin>2</ymin>
850 # <xmax>3</xmax>
851 # <ymax>4</ymax>
852 # </wgs84extent>
854 if el.find('xmin'):
855 return _extent_from_list(
856 [
857 el.textof('xmin'),
858 el.textof('ymin'),
859 el.textof('xmax'),
860 el.textof('ymax'),
861 ]
862 )
865def _extent_from_list(ls):
866 if len(ls) != 4:
867 return
868 try:
869 ext = [float(p) for p in ls]
870 except ValueError:
871 return
872 if not all(math.isfinite(p) for p in ext):
873 return
874 return gws.Extent([
875 min(ext[0], ext[2]),
876 min(ext[1], ext[3]),
877 max(ext[0], ext[2]),
878 max(ext[1], ext[3]),
879 ])
882def _parse_msize(s):
883 # e.g. 'position': '228.477,27.8455,mm'
884 try:
885 x, y, u = s.split(',')
886 return _parse_float(x), _parse_float(y), u
887 except Exception:
888 return None
891def _parse_int(s) -> int:
892 try:
893 return int(s)
894 except Exception:
895 return 0
898def _parse_float(s) -> float:
899 try:
900 x = float(s)
901 except Exception:
902 return 0
903 if math.isnan(x) or math.isinf(x):
904 return 0
905 return x