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

417 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-03 10:12 +0100

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 

542 if 'typename' in params: 

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

544 if 'layers' in params: 

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

546 if 'styles' in params: 

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

548 

549 params.pop('service', None) 

550 params.pop('request', None) 

551 

552 ds['params'] = params 

553 

554 # {x} placeholders shouldn't be encoded 

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

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

557 

558 ds['url'] = url 

559 

560 return ds 

561 

562 

563def _datasource_space_delimited(text): 

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

565 

566 value_re = r"""(?x) 

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

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

569 \S+ 

570 """ 

571 

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

573 

574 def _cut(u, rx): 

575 m = re.match(rx, u) 

576 if not m: 

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

578 v = m.group(0) 

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

580 

581 def _unesc(s): 

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

583 

584 def _mid(s): 

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

586 

587 def _value(v): 

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

589 return _unesc(_mid(v)) 

590 return v 

591 

592 ds = {} 

593 

594 while text: 

595 # keyword= 

596 key, text = _cut(text, key_re) 

597 key = key.strip('= ') 

598 

599 if key == 'sql': 

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

601 ds[key] = text 

602 break 

603 

604 elif key == 'table': 

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

606 v, text = _cut(text, value_re) 

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

608 

609 if text.startswith('.'): 

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

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

612 

613 if text.startswith('('): 

614 v, text = _cut(text, parens_re) 

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

616 

617 else: 

618 # just param=val 

619 v, text = _cut(text, value_re) 

620 ds[key] = _value(v) 

621 

622 return ds 

623 

624 

625def _datasource_pipe_delimited(text): 

626 if '|' not in text: 

627 return {'path': text} 

628 

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

630 

631 if '=' not in rest: 

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

633 

634 ds = {'path': path} 

635 

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

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

638 ds[k] = v 

639 

640 return ds 

641 

642 

643## 

644 

645 

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

647 """Parse qgis property blocks. 

648 

649 There are following forms: 

650 

651 Scalar property:: 

652 

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

654 

655 Dict:: 

656 

657 <QFieldSync> 

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

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

660 </QFieldSync> 

661 

662 Option map:: 

663 

664 <data-defined-properties> 

665 <Option type="Map"> 

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

667 <Option name="properties"/> 

668 </Option> 

669 </data-defined-properties> 

670 

671 

672 """ 

673 

674 if not el: 

675 return {} 

676 

677 _, val = _parse_property_tag(el) 

678 return val 

679 

680 

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

682 typ = el.get('type') 

683 name = el.tag 

684 is_opt = el.tag == 'Option' 

685 

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

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

688 

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

690 d = {} 

691 

692 for c in el: 

693 k, v = _parse_property_tag(c) 

694 if k: 

695 d[k] = v 

696 

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

698 return name, d['Option'] 

699 

700 return name, d 

701 

702 if typ == 'List': 

703 ls = [] 

704 for c in el: 

705 _, v = _parse_property_tag(c) 

706 ls.append(v) 

707 return name, ls 

708 

709 if typ == 'QStringList': 

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

711 return name, val 

712 

713 if typ == 'QString': 

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

715 return name, val 

716 

717 if typ == 'bool': 

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

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

720 

721 if typ == 'int': 

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

723 return name, _parse_int(val) 

724 

725 if typ == 'double': 

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

727 return name, _parse_float(val) 

728 

729 return '', None 

730 

731 

732## 

733 

734 

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

736 if not el: 

737 return 

738 

739 ext = _extent_from_tag2(el) 

740 if not ext: 

741 return 

742 

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

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

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

746 return 

747 

748 # ignore all-zero extents 

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

750 return 

751 

752 # avoid single-point extents 

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

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

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

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

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

758 

759 return ext 

760 

761 

762def _extent_from_tag2(el: gws.XmlElement): 

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

764 if el.get('minx'): 

765 return _extent_from_list( 

766 [ 

767 el.get('minx'), 

768 el.get('miny'), 

769 el.get('maxx'), 

770 el.get('maxy'), 

771 ] 

772 ) 

773 

774 # <WMSExtent type="QStringList"> 

775 # <value>1</value> 

776 # <value>2</value> 

777 # <value>3</value> 

778 # <value>4</value> 

779 # </WMSExtent> 

780 # 

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

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

783 

784 # <wgs84extent> 

785 # <xmin>1</xmin> 

786 # <ymin>2</ymin> 

787 # <xmax>3</xmax> 

788 # <ymax>4</ymax> 

789 # </wgs84extent> 

790 

791 if el.find('xmin'): 

792 return _extent_from_list( 

793 [ 

794 el.textof('xmin'), 

795 el.textof('ymin'), 

796 el.textof('xmax'), 

797 el.textof('ymax'), 

798 ] 

799 ) 

800 

801 

802def _extent_from_list(ls): 

803 if len(ls) != 4: 

804 return 

805 try: 

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

807 except ValueError: 

808 return 

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

810 return 

811 return gws.Extent([ 

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

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

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

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

816 ]) 

817 

818 

819def _parse_msize(s): 

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

821 try: 

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

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

824 except Exception: 

825 return None 

826 

827 

828def _parse_int(s) -> int: 

829 try: 

830 return int(s) 

831 except Exception: 

832 return 0 

833 

834 

835def _parse_float(s) -> float: 

836 try: 

837 x = float(s) 

838 except Exception: 

839 return 0 

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

841 return 0 

842 return x