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 22:59 +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 supportedCrs=[], 

277 ) 

278 

279 sl.metadata = _map_layer_metadata(layer_el) 

280 

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

282 if crs: 

283 sl.supportedCrs.append(crs) 

284 

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] 

291 

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

296 

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

302 

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

304 sl.name = layer_name 

305 sl.sourceId = uid 

306 

307 ext = _map_layer_wgs_extent(layer_el, caps.projectCrs) 

308 if ext: 

309 sl.wgsExtent = ext 

310 

311 return sl 

312 

313 

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. 

317 

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

319 

320 el = layer_el.find('resourceMetadata') 

321 if el: 

322 _collect_metadata(el, md) 

323 

324 # fill in missing props from direct metadata 

325 

326 d = layer_el.textdict() 

327 

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

330 

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

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

333 

334 if not md.keywords: 

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

336 

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 ) 

347 

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

349 

350 

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

352 prov = layer_el.textof('provider') 

353 ds_text = layer_el.textof('datasource') 

354 

355 if ds_text: 

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

357 if prov: 

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

359 return {} 

360 

361 

362def _map_layer_wgs_extent(layer_el: gws.XmlElement, project_crs: gws.Crs): 

363 uid = layer_el.textof('id') 

364 min_size = 0.000001 

365 

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 

376 

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 

384 

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 

394 

395 gws.log.warning(f'_map_layer_wgs_extent: {uid}: NOT FOUND') 

396 

397 

398# layer trees: 

399 

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# ... 

404 

405 

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

407 if not el: 

408 return 

409 

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

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

412 

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 

417 

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 ) 

429 

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 

438 

439 

440## 

441 

442 

443def _visibility_presets(root_el: gws.XmlElement): 

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

445 

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

447 

448 Overall structure:: 

449 

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" ... /> 

457 

458 """ 

459 

460 d = {} 

461 

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 

468 

469 return d 

470 

471 

472## 

473 

474 

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

478 

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' 

483 

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

485 

486 return ds 

487 

488 

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}`. 

494 

495 text = text.strip() 

496 

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) 

502 

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) 

509 

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

511 # just an url 

512 return {'url': text} 

513 

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) 

519 

520 return {'text': text} 

521 

522 

523def _datasource_amp_delimited(text): 

524 ds = {} 

525 

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

527 if '=' not in p: 

528 continue 

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

530 

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

532 

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

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

535 else: 

536 ds[k] = v 

537 

538 if 'url' not in ds: 

539 return ds 

540 

541 # extract params from the url 

542 

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

544 

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(',')) 

551 

552 params.pop('service', None) 

553 params.pop('request', None) 

554 

555 ds['params'] = params 

556 

557 # {x} placeholders shouldn't be encoded 

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

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

560 

561 ds['url'] = url 

562 

563 return ds 

564 

565 

566def _datasource_space_delimited(text): 

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

568 

569 value_re = r"""(?x) 

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

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

572 \S+ 

573 """ 

574 

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

576 

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

583 

584 def _unesc(s): 

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

586 

587 def _mid(s): 

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

589 

590 def _value(v): 

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

592 return _unesc(_mid(v)) 

593 return v 

594 

595 ds = {} 

596 

597 while text: 

598 # keyword= 

599 key, text = _cut(text, key_re) 

600 key = key.strip('= ') 

601 

602 if key == 'sql': 

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

604 ds[key] = text 

605 break 

606 

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) 

611 

612 if text.startswith('.'): 

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

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

615 

616 if text.startswith('('): 

617 v, text = _cut(text, parens_re) 

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

619 

620 else: 

621 # just param=val 

622 v, text = _cut(text, value_re) 

623 ds[key] = _value(v) 

624 

625 return ds 

626 

627 

628def _datasource_pipe_delimited(text): 

629 if '|' not in text: 

630 return {'path': text} 

631 

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

633 

634 if '=' not in rest: 

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

636 

637 ds = {'path': path} 

638 

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

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

641 ds[k] = v 

642 

643 return ds 

644 

645 

646## 

647 

648 

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

650 """Parse qgis property blocks. 

651 

652 There are following forms: 

653 

654 Scalar property:: 

655 

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

657 

658 Dict:: 

659 

660 <QFieldSync> 

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

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

663 </QFieldSync> 

664 

665 Option map:: 

666 

667 <data-defined-properties> 

668 <Option type="Map"> 

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

670 <Option name="properties"/> 

671 </Option> 

672 </data-defined-properties> 

673 

674 

675 """ 

676 

677 if not el: 

678 return {} 

679 

680 _, val = _parse_property_tag(el) 

681 return val 

682 

683 

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' 

688 

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

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

691 

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

693 d = {} 

694 

695 for c in el: 

696 k, v = _parse_property_tag(c) 

697 if k: 

698 d[k] = v 

699 

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

701 return name, d['Option'] 

702 

703 return name, d 

704 

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 

711 

712 if typ == 'QStringList': 

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

714 return name, val 

715 

716 if typ == 'QString': 

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

718 return name, val 

719 

720 if typ == 'bool': 

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

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

723 

724 if typ == 'int': 

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

726 return name, _parse_int(val) 

727 

728 if typ == 'double': 

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

730 return name, _parse_float(val) 

731 

732 return '', None 

733 

734 

735## 

736 

737 

738def _extent_from_tag(el: Optional[gws.XmlElement]): 

739 if not el: 

740 return 

741 

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 ) 

752 

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

762 

763 # <wgs84extent> 

764 # <xmin>1</xmin> 

765 # <ymin>2</ymin> 

766 # <xmax>3</xmax> 

767 # <ymax>4</ymax> 

768 # </wgs84extent> 

769 

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 ) 

779 

780 

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) 

805 

806 

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 

814 

815 

816def _parse_int(s) -> int: 

817 try: 

818 return int(s) 

819 except Exception: 

820 return 0 

821 

822 

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