Coverage for gws-app/gws/plugin/qgis/caps.py: 72%

418 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 12:46 +0200

1"""QGIS project xml parser.""" 

2 

3from typing import Optional, Any 

4 

5import math 

6import re 

7 

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 

16 

17 

18class PrintTemplateElement(gws.Data): 

19 type: str 

20 uuid: str 

21 attributes: dict 

22 position: gws.UomPoint 

23 size: gws.UomSize 

24 

25 

26class PrintTemplate(gws.Data): 

27 title: str 

28 index: int 

29 attributes: dict 

30 elements: list[PrintTemplateElement] 

31 

32 

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]] 

43 

44 

45def parse(xml: str) -> Caps: 

46 el = gws.lib.xmlx.from_string(xml) 

47 return parse_element(el) 

48 

49 

50def parse_element(root_el: gws.XmlElement) -> Caps: 

51 caps = Caps() 

52 

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) 

58 

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 

64 

65 ext = _extent_from_tag(root_el.find('properties/WMSExtent')) 

66 if ext: 

67 caps.projectBounds = gws.lib.bounds.from_extent(ext, caps.projectCrs) 

68 

69 ext = _extent_from_tag(root_el.find('mapcanvas/extent')) 

70 if ext: 

71 caps.projectCanvasBounds = gws.lib.bounds.from_extent(ext, caps.projectCrs) 

72 

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 []) 

76 

77 return caps 

78 

79 

80## 

81 

82 

83def _project_metadata(root_el) -> gws.Metadata: 

84 md = gws.base.metadata.new() 

85 

86 el = root_el.find('projectMetadata') 

87 if el: 

88 _collect_metadata(el, md) 

89 

90 # @TODO supplementary metadata 

91 return gws.base.metadata.normalize(md) 

92 

93 

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] 

104 

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] 

120 

121 

122def _add_dict(dst, src, mapping): 

123 for dkey, skey in mapping: 

124 if skey in src: 

125 setattr(dst, dkey, src[skey]) 

126 

127 

128def _collect_metadata(el: gws.XmlElement, md: gws.Metadata): 

129 # extract metadata from projectMetadata/resourceMetadata 

130 

131 _add_dict(md, el.textdict(), _meta_mapping) 

132 

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) 

140 

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 

147 

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 ) 

161 

162 for e in el.findall('constraints'): 

163 md.accessConstraints = e.text 

164 md.accessConstraintsType = e.get('type') 

165 

166 for e in el.findall('license'): 

167 md.license = e.text 

168 break 

169 

170 e = el.find('extent/temporal') 

171 if e: 

172 md.temporalBegin = e.textof('period/start') 

173 md.temporalEnd = e.textof('period/end') 

174 

175 

176# see QGIS/src/core/layout/qgslayoutitemregistry.h 

177 

178_QGraphicsItem_UserType = 65536 # https://doc.qt.io/qtforpython/PySide2/QtWidgets/QGraphicsItem.html 

179 

180_LT0 = _QGraphicsItem_UserType + 100 

181 

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} 

202 

203 

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 

216 

217 

218def _print_templates(root_el: gws.XmlElement): 

219 templates = [] 

220 

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 ) 

228 

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)) 

232 

233 tpl.elements.extend(gws.u.compact(_layout_element(c) for c in layout_el)) 

234 

235 templates.append(tpl) 

236 

237 return templates 

238 

239 

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 ) 

251 

252 

253## 

254 

255 

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) 

259 

260 layers_dct = {} 

261 

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 

270 

271 return layers_dct 

272 

273 

274def _map_layer(layer_el: gws.XmlElement, caps: Caps, use_layer_ids: bool) -> gws.SourceLayer: 

275 sl = gws.SourceLayer() 

276 

277 sl.metadata = _map_layer_metadata(layer_el) 

278 

279 if layer_el.get('hasScaleBasedVisibilityFlag') == '1': 

280 # in qgis, maxScale < minScale 

281 a = _parse_float(layer_el.get('maxScale')) 

282 z = _parse_float(layer_el.get('minScale')) 

283 if z > a: 

284 sl.scaleRange = [a, z] 

285 

286 sl.dataSource = _map_layer_datasource(layer_el) 

287 sl.opacity = _parse_float(layer_el.textof('layerOpacity') or '1') 

288 sl.isQueryable = layer_el.textof('flags/Identifiable') == '1' 

289 sl.properties = _parse_properties(layer_el.find('customproperties')) 

290 

291 uid = layer_el.textof('id') 

292 if use_layer_ids: 

293 layer_name = uid 

294 else: 

295 layer_name = layer_el.textof('shortname') or layer_el.textof('layername') 

296 

297 sl.title = layer_el.textof('layername') or sl.metadata.get('title') or '' 

298 sl.name = layer_name 

299 sl.sourceId = uid 

300 

301 crs = gws.lib.crs.get(layer_el.textof('srs/spatialrefsys/authid')) 

302 if crs: 

303 sl.supportedCrs = [crs] 

304 

305 ext = qgis_extent(layer_el, crs or caps.projectCrs) 

306 if ext: 

307 sl.wgsExtent = ext 

308 

309 return sl 

310 

311 

312def _map_layer_metadata(layer_el) -> gws.Metadata: 

313 # Layer metadata is either Layer->Properties->Metadata (stored in maplayer/resourceMetadata), 

314 # or Layer->Properties->QGIS Server (stored directly under maplayer/abstract, maplayer/keywordList and so on. 

315 

316 md = gws.base.metadata.new() 

317 

318 el = layer_el.find('resourceMetadata') 

319 if el: 

320 _collect_metadata(el, md) 

321 

322 # fill in missing props from direct metadata 

323 

324 d = layer_el.textdict() 

325 

326 md.title = md.title or d.get('title') or d.get('shortname') or d.get('layername') 

327 md.abstract = md.abstract or d.get('abstract') 

328 

329 if not md.attribution and d.get('attribution'): 

330 md.attribution = d.get('attribution') 

331 

332 if not md.keywords: 

333 md.keywords = layer_el.textlist('keywordList/value') 

334 

335 if not md.metaLinks: 

336 md.metaLinks = [] 

337 for e in layer_el.findall('metadataUrls/metadataUrl'): 

338 md.metaLinks.append( 

339 gws.MetadataLink( 

340 type=e.get('type'), 

341 format=e.get('format'), 

342 title=e.text, 

343 ) 

344 ) 

345 

346 return gws.base.metadata.normalize(md) 

347 

348 

349def _map_layer_datasource(layer_el: gws.XmlElement) -> dict: 

350 prov = layer_el.textof('provider') 

351 ds_text = layer_el.textof('datasource') 

352 

353 if ds_text: 

354 return parse_datasource((prov or '').lower(), ds_text) 

355 if prov: 

356 return {'provider': prov.lower()} 

357 return {} 

358 

359 

360def qgis_extent(layer_el: gws.XmlElement, layer_crs: gws.Crs): 

361 uid = layer_el.textof('id') 

362 

363 # extent explicitly defined in metadata (Layer Props -> Metadata -> Extent) 

364 el = layer_el.find('resourceMetadata/extent/spatial') 

365 if el: 

366 ext = _extent_from_tag(el) 

367 crs = gws.lib.crs.get(el.get('crs')) 

368 if ext and crs: 

369 ext = gws.lib.extent.transform(ext, crs, gws.lib.crs.WGS84) 

370 if gws.lib.extent.is_valid_wgs(ext): 

371 gws.log.debug(f'qgis_extent: {uid}: spatial: {ext}') 

372 return ext 

373 

374 # extent in <maplayer>/<wgs84extent> 

375 el = layer_el.find('wgs84extent') 

376 if el: 

377 ext = _extent_from_tag(el) 

378 if ext and gws.lib.extent.is_valid_wgs(ext): 

379 gws.log.debug(f'qgis_extent: {uid}: wgs84extent: {ext}') 

380 return ext 

381 

382 # extent in <maplayer>/<extent>, assume the layer CRS 

383 el = layer_el.find('extent') 

384 if el: 

385 ext = _extent_from_tag(el) 

386 if ext: 

387 ext = gws.lib.extent.transform(ext, layer_crs, gws.lib.crs.WGS84) 

388 if ext and gws.lib.extent.is_valid_wgs(ext): 

389 gws.log.debug(f'qgis_extent: {uid}: extent: {ext}') 

390 return ext 

391 

392 gws.log.warning(f'qgis_extent: {uid}: NOT FOUND') 

393 

394 

395# layer trees: 

396 

397# <layer-tree-group> 

398# <layer-tree-group checked="Qt::Checked" expanded="1" name="..."> 

399# <layer-tree-layer ... checked="Qt::Checked" expanded="1" id="..."> 

400# ... 

401 

402 

403def _layer_tree(el: Optional[gws.XmlElement], layers_dct): 

404 if not el: 

405 return 

406 

407 visible = el.get('checked') != 'Qt::Unchecked' 

408 expanded = el.get('expanded') == '1' 

409 

410 if el.tag == 'layer-tree-group': 

411 title = el.get('name') 

412 # qgis doesn't write 'id' for groups but our generators might 

413 name = el.get('id') or title 

414 

415 return gws.SourceLayer( 

416 title=title, 

417 name=name, 

418 metadata=gws.Metadata(title=title, name=name), 

419 isVisible=visible, 

420 isExpanded=expanded, 

421 isGroup=True, 

422 isQueryable=False, 

423 isImage=False, 

424 layers=gws.u.compact(_layer_tree(c, layers_dct) for c in el), 

425 ) 

426 

427 if el.tag == 'layer-tree-layer': 

428 sl = layers_dct.get(el.get('id')) 

429 if sl: 

430 sl.isVisible = visible 

431 sl.isExpanded = expanded 

432 sl.isGroup = False 

433 sl.isImage = True 

434 return sl 

435 

436 

437## 

438 

439 

440def _visibility_presets(root_el: gws.XmlElement): 

441 """Parse the global ``visibility-presets`` block. 

442 

443 We're only interested in which layers are visible. 

444 

445 Overall structure:: 

446 

447 <visibility-presets> 

448 <visibility-preset .... name="..."> 

449 <layer id="..." visible="1" ... /> 

450 <layer id="..." visible="1" ... /> 

451 <visibility-preset .... name="..."> 

452 <layer id="..." visible="1" ... /> 

453 <layer id="..." visible="1" ... /> 

454 

455 """ 

456 

457 d = {} 

458 

459 for el in root_el.findall('visibility-presets/visibility-preset'): 

460 ls = [] 

461 for la in el.findall('layer'): 

462 if la.attr('visible') == '1': 

463 ls.append(la.attr('id')) 

464 d[el.attr('name')] = ls 

465 

466 return d 

467 

468 

469## 

470 

471 

472def parse_datasource(prov, text): 

473 ds = gws.u.to_lower_dict(_parse_datasource(text) or {}) 

474 ds['provider'] = (ds.get('provider') or prov).lower() 

475 

476 if ds['provider'] == 'wms' and 'tilematrixset' in ds: 

477 ds['provider'] = 'wmts' 

478 elif ds['provider'] == 'wms' and ds.get('type') == 'xyz': 

479 ds['provider'] = 'xyz' 

480 

481 # @TODO classify ogr's based on a file extension 

482 

483 return ds 

484 

485 

486def _parse_datasource(text): 

487 # Datasources are very versatile and the format depends on the provider. 

488 # For some hints see `decodedSource` in qgsvectorlayer.cpp/qgsrasterlayer.cpp. 

489 # We don't have ambition to parse them all, just do some ad-hoc parsing 

490 # of the most common flavors, and return the rest as `{'text': text}`. 

491 

492 text = text.strip() 

493 

494 if re.match(r'^\w+=[^&]*&', text): 

495 # key=value, amp-separated, uri-encoded 

496 # used for WMS, e.g. 

497 # contextualWMSLegend=0&crs=EPSG:31468&...&url=...?SERVICE%3DWMTS%26REQUEST%3DGetCapabilities 

498 return _datasource_amp_delimited(text) 

499 

500 if re.match(r'^\w+=\S+ ', text): 

501 # key=value, space separated 

502 # used for postgres/WFS, e.g. 

503 # dbname='...' host=... port=... 

504 # pagingEnabled='...' preferCoordinatesForWfsT11=... 

505 return _datasource_space_delimited(text) 

506 

507 if text.startswith(('http://', 'https://')): 

508 # just an url 

509 return {'url': text} 

510 

511 if text.startswith(('.', '/')): 

512 # path or path|options 

513 # used for Geojson, GPKG, e.g. 

514 # ../rel/path/test.gpkg|layername=name|subset=... etc 

515 return _datasource_pipe_delimited(text) 

516 

517 return {'text': text} 

518 

519 

520def _datasource_amp_delimited(text): 

521 ds = {} 

522 

523 for p in text.split('&'): 

524 if '=' not in p: 

525 continue 

526 k, v = p.split('=', maxsplit=1) 

527 

528 v = gws.lib.net.unquote(v) 

529 

530 if k in {'layers', 'styles'}: 

531 ds.setdefault(k, []).append(v) 

532 else: 

533 ds[k] = v 

534 

535 if 'url' not in ds: 

536 return ds 

537 

538 # extract params from the url 

539 

540 url, params = gws.lib.net.extract_params(ds['url']) 

541 params = gws.u.to_lower_dict(params) 

542 

543 if 'typename' in params: 

544 ds['typename'] = params.pop('typename') 

545 if 'layers' in params: 

546 ds.setdefault('layers', []).extend(params.pop('layers').split(',')) 

547 if 'styles' in params: 

548 ds.setdefault('styles', []).extend(params.pop('styles').split(',')) 

549 

550 params.pop('service', None) 

551 params.pop('request', None) 

552 

553 ds['params'] = params 

554 

555 # {x} placeholders shouldn't be encoded 

556 url = url.replace('%7B', '{') 

557 url = url.replace('%7D', '}') 

558 

559 ds['url'] = url 

560 

561 return ds 

562 

563 

564def _datasource_space_delimited(text): 

565 key_re = r'^\w+\s*=\s*' 

566 

567 value_re = r"""(?x) 

568 " (?: \\. | [^"])* " | 

569 ' (?: \\. | [^'])* ' | 

570 \S+ 

571 """ 

572 

573 parens_re = r'\(.*?\)' 

574 

575 def _cut(u, rx): 

576 m = re.match(rx, u) 

577 if not m: 

578 raise ValueError(f'datasource uri error, expected {rx!r}, found {u[:25]!r}') 

579 v = m.group(0) 

580 return v, u[len(v) :].strip() 

581 

582 def _unesc(s): 

583 return re.sub(r'\\(.)', '\1', s) 

584 

585 def _mid(s): 

586 return s[1:-1].strip() 

587 

588 def _value(v): 

589 if v.startswith(("'", '"')): 

590 return _unesc(_mid(v)) 

591 return v 

592 

593 ds = {} 

594 

595 while text: 

596 # keyword= 

597 key, text = _cut(text, key_re) 

598 key = key.strip('= ') 

599 

600 if key == 'sql': 

601 # 'sql=' is special and can contain whatever, it's always the last one 

602 ds[key] = text 

603 break 

604 

605 elif key == 'table': 

606 # 'table=' is special, it can be `table="foo"` or `table="foo"."bar"` or table=`"foo"."bar" (geom)` 

607 v, text = _cut(text, value_re) 

608 ds['table'] = _value(v) 

609 

610 if text.startswith('.'): 

611 v, text = _cut(text[1:], value_re) 

612 ds['table'] += '.' + _value(v) 

613 

614 if text.startswith('('): 

615 v, text = _cut(text, parens_re) 

616 ds['geometryColumn'] = _mid(v) 

617 

618 else: 

619 # just param=val 

620 v, text = _cut(text, value_re) 

621 ds[key] = _value(v) 

622 

623 return ds 

624 

625 

626def _datasource_pipe_delimited(text): 

627 if '|' not in text: 

628 return {'path': text} 

629 

630 path, rest = text.split('|', maxsplit=1) 

631 

632 if '=' not in rest: 

633 return {'path': path, 'options': rest} 

634 

635 ds = {'path': path} 

636 

637 for p in rest.split('|'): 

638 k, v = p.split('=', maxsplit=1) 

639 ds[k] = v 

640 

641 return ds 

642 

643 

644## 

645 

646 

647def _parse_properties(el: Optional[gws.XmlElement]) -> dict: 

648 """Parse qgis property blocks. 

649 

650 There are following forms: 

651 

652 Scalar property:: 

653 

654 <WMSContactPhone type="QString">... 

655 

656 Dict:: 

657 

658 <QFieldSync> 

659 <dirsToCopy type="QString">... 

660 <exportDirectoryProject type="QString">... 

661 </QFieldSync> 

662 

663 Option map:: 

664 

665 <data-defined-properties> 

666 <Option type="Map"> 

667 <Option type="QString" name="..." value="..."/> 

668 <Option name="properties"/> 

669 </Option> 

670 </data-defined-properties> 

671 

672 

673 """ 

674 

675 if not el: 

676 return {} 

677 

678 _, val = _parse_property_tag(el) 

679 return val 

680 

681 

682def _parse_property_tag(el: gws.XmlElement) -> tuple[str, Any]: 

683 typ = el.get('type') 

684 name = el.tag 

685 is_opt = el.tag == 'Option' 

686 

687 if is_opt and el.attr('name'): 

688 name = el.attr('name') or '' 

689 

690 if not typ or typ == 'Map': 

691 d = {} 

692 

693 for c in el: 

694 k, v = _parse_property_tag(c) 

695 if k: 

696 d[k] = v 

697 

698 if len(d) == 1 and 'Option' in d: 

699 return name, d['Option'] 

700 

701 return name, d 

702 

703 if typ == 'List': 

704 ls = [] 

705 for c in el: 

706 _, v = _parse_property_tag(c) 

707 ls.append(v) 

708 return name, ls 

709 

710 if typ == 'QStringList': 

711 val = [c.text for c in el.findall('value')] 

712 return name, val 

713 

714 if typ == 'QString': 

715 val = el.attr('value') if is_opt else el.text 

716 return name, val 

717 

718 if typ == 'bool': 

719 val = el.attr('value') if is_opt else el.text 

720 return name, (val or '').lower() == 'true' 

721 

722 if typ == 'int': 

723 val = el.attr('value') if is_opt else el.text 

724 return name, _parse_int(val) 

725 

726 if typ == 'double': 

727 val = el.attr('value') if is_opt else el.text 

728 return name, _parse_float(val) 

729 

730 return '', None 

731 

732 

733## 

734 

735 

736def _extent_from_tag(el: Optional[gws.XmlElement]) -> Optional[gws.Extent]: 

737 if not el: 

738 return 

739 

740 ext = _extent_from_tag2(el) 

741 if not ext: 

742 return 

743 

744 # work around qgis bug with absurdly high values in extent tags 

745 if any(abs(c) > 1e10 for c in ext): 

746 gws.log.warning(f'qgis_extent: ignoring invalid extent {ext}') 

747 return 

748 

749 # ignore all-zero extents 

750 if all(abs(c) < 1e-10 for c in ext): 

751 return 

752 

753 # avoid single-point extents 

754 min_size = 0.0001 # in metric projections, this is about 1cm 

755 if ext[2] - ext[0] < min_size: 

756 ext = gws.Extent([ext[0] - min_size, ext[1], ext[2] + min_size, ext[3]]) 

757 if ext[3] - ext[1] < min_size: 

758 ext = gws.Extent([ext[0], ext[1] - min_size, ext[2], ext[3] + min_size]) 

759 

760 return ext 

761 

762 

763def _extent_from_tag2(el: gws.XmlElement): 

764 # <spatial dimensions="2" miny="0" maxz="0" maxx="0" crs="EPSG:25832" minx="0" minz="0" maxy="0"/> 

765 if el.get('minx'): 

766 return _extent_from_list( 

767 [ 

768 el.get('minx'), 

769 el.get('miny'), 

770 el.get('maxx'), 

771 el.get('maxy'), 

772 ] 

773 ) 

774 

775 # <WMSExtent type="QStringList"> 

776 # <value>1</value> 

777 # <value>2</value> 

778 # <value>3</value> 

779 # <value>4</value> 

780 # </WMSExtent> 

781 # 

782 if el.get('type') == 'QStringList': 

783 return _extent_from_list([v.text for v in el.children()]) 

784 

785 # <wgs84extent> 

786 # <xmin>1</xmin> 

787 # <ymin>2</ymin> 

788 # <xmax>3</xmax> 

789 # <ymax>4</ymax> 

790 # </wgs84extent> 

791 

792 if el.find('xmin'): 

793 return _extent_from_list( 

794 [ 

795 el.textof('xmin'), 

796 el.textof('ymin'), 

797 el.textof('xmax'), 

798 el.textof('ymax'), 

799 ] 

800 ) 

801 

802 

803def _extent_from_list(ls): 

804 if len(ls) != 4: 

805 return 

806 try: 

807 ext = [float(p) for p in ls] 

808 except ValueError: 

809 return 

810 if not all(math.isfinite(p) for p in ext): 

811 return 

812 return gws.Extent([ 

813 min(ext[0], ext[2]), 

814 min(ext[1], ext[3]), 

815 max(ext[0], ext[2]), 

816 max(ext[1], ext[3]), 

817 ]) 

818 

819 

820def _parse_msize(s): 

821 # e.g. 'position': '228.477,27.8455,mm' 

822 try: 

823 x, y, u = s.split(',') 

824 return _parse_float(x), _parse_float(y), u 

825 except Exception: 

826 return None 

827 

828 

829def _parse_int(s) -> int: 

830 try: 

831 return int(s) 

832 except Exception: 

833 return 0 

834 

835 

836def _parse_float(s) -> float: 

837 try: 

838 x = float(s) 

839 except Exception: 

840 return 0 

841 if math.isnan(x) or math.isinf(x): 

842 return 0 

843 return x