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

418 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

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 """An item of a QGIS print layout.""" 

20 

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

31 

32 

33class PrintTemplate(gws.Data): 

34 """A QGIS print layout.""" 

35 

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

44 

45 

46class Caps(gws.Data): 

47 """Capabilities of a QGIS project, as parsed from the project XML.""" 

48 

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

67 

68 

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

70 """Parse a QGIS project XML string. 

71 

72 Args: 

73 xml: Project XML. 

74 

75 Returns: 

76 Project capabilities. 

77 

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) 

83 

84 

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

86 """Parse a QGIS project XML element. 

87 

88 Layers listed as restricted in the project WMS settings are skipped. 

89 

90 Args: 

91 root_el: Root element of the project XML. 

92 

93 Returns: 

94 Project capabilities. 

95 

96 Raises: 

97 ``gws.Error``: If the project CRS is invalid. 

98 """ 

99 caps = Caps() 

100 

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) 

106 

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 

112 

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

114 if ext: 

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

116 

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

118 if ext: 

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

120 

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

124 

125 return caps 

126 

127 

128## 

129 

130 

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

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

133 

134 el = root_el.find('projectMetadata') 

135 if el: 

136 _collect_metadata(el, md) 

137 

138 # @TODO supplementary metadata 

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

140 

141 

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] 

152 

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] 

168 

169 

170def _add_dict(dst, src, mapping): 

171 for dkey, skey in mapping: 

172 if skey in src: 

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

174 

175 

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

177 # extract metadata from projectMetadata/resourceMetadata 

178 

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

180 

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) 

188 

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 

195 

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 ) 

209 

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

211 md.accessConstraints = e.text 

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

213 

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

215 md.license = e.text 

216 break 

217 

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

219 if e: 

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

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

222 

223 

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

225 

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

227 

228_LT0 = _QGraphicsItem_UserType + 100 

229 

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} 

250 

251 

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 

264 

265 

266def _print_templates(root_el: gws.XmlElement): 

267 templates = [] 

268 

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 ) 

276 

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

280 

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

282 

283 templates.append(tpl) 

284 

285 return templates 

286 

287 

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 ) 

299 

300 

301## 

302 

303 

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) 

307 

308 layers_dct = {} 

309 

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 

318 

319 return layers_dct 

320 

321 

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

323 sl = gws.SourceLayer() 

324 

325 sl.metadata = _map_layer_metadata(layer_el) 

326 

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] 

333 

334 sl.dataSource = _map_layer_datasource(layer_el) 

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

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

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

338 

339 uid = layer_el.textof('id') 

340 if use_layer_ids: 

341 layer_name = uid 

342 else: 

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

344 

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

346 sl.name = layer_name 

347 sl.sourceId = uid 

348 

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

350 if crs: 

351 sl.supportedCrs = [crs] 

352 

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

354 if ext: 

355 sl.wgsExtent = ext 

356 

357 return sl 

358 

359 

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

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

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

363 

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

365 

366 el = layer_el.find('resourceMetadata') 

367 if el: 

368 _collect_metadata(el, md) 

369 

370 # fill in missing props from direct metadata 

371 

372 d = layer_el.textdict() 

373 

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

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

376 

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

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

379 

380 if not md.keywords: 

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

382 

383 if not md.metaLinks: 

384 md.metaLinks = [] 

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

386 md.metaLinks.append( 

387 gws.MetadataLink( 

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

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

390 title=e.text, 

391 ) 

392 ) 

393 

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

395 

396 

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

398 prov = layer_el.textof('provider') 

399 ds_text = layer_el.textof('datasource') 

400 

401 if ds_text: 

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

403 if prov: 

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

405 return {} 

406 

407 

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

409 """Compute the WGS extent of a map layer. 

410 

411 The extent is taken from the first valid source of the following: the 

412 explicit extent in the layer metadata, the ``wgs84extent`` element, and 

413 the ``extent`` element, which is assumed to be in the layer CRS. 

414 

415 Args: 

416 layer_el: ``maplayer`` element. 

417 layer_crs: CRS of the layer. 

418 

419 Returns: 

420 WGS extent, or ``None`` if no valid extent is found. 

421 """ 

422 uid = layer_el.textof('id') 

423 

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

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

426 if el: 

427 ext = _extent_from_tag(el) 

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

429 if ext and crs: 

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

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

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

433 return ext 

434 

435 # extent in <maplayer>/<wgs84extent> 

436 el = layer_el.find('wgs84extent') 

437 if el: 

438 ext = _extent_from_tag(el) 

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

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

441 return ext 

442 

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

444 el = layer_el.find('extent') 

445 if el: 

446 ext = _extent_from_tag(el) 

447 if ext: 

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

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

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

451 return ext 

452 

453 gws.log.warning(f'qgis_extent: {uid}: no extent defined') 

454 

455 

456# layer trees: 

457 

458# <layer-tree-group> 

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

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

461# ... 

462 

463 

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

465 if not el: 

466 return 

467 

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

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

470 

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

472 title = el.get('name') 

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

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

475 

476 return gws.SourceLayer( 

477 title=title, 

478 name=name, 

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

480 isVisible=visible, 

481 isExpanded=expanded, 

482 isGroup=True, 

483 isQueryable=False, 

484 isImage=False, 

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

486 ) 

487 

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

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

490 if sl: 

491 sl.isVisible = visible 

492 sl.isExpanded = expanded 

493 sl.isGroup = False 

494 sl.isImage = True 

495 return sl 

496 

497 

498## 

499 

500 

501def _visibility_presets(root_el: gws.XmlElement): 

502 """Map each visibility preset (map theme) name to the IDs of its visible layers.""" 

503 

504 d = {} 

505 

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

507 ls = [] 

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

509 if la.get('visible') == '1': 

510 ls.append(la.get('id')) 

511 d[el.get('name')] = ls 

512 

513 return d 

514 

515 

516## 

517 

518 

519def parse_datasource(prov, text): 

520 """Parse a QGIS layer data source string. 

521 

522 Common formats (ampersand-delimited, space-delimited, plain URL, 

523 pipe-delimited path) are parsed into a dict with lower-case keys, 

524 anything else is returned as ``{'text': text}``. The ``provider`` key 

525 is always set; WMS sources with a tile matrix set are classified as 

526 ``wmts``, and WMS sources of type ``xyz`` as ``xyz``. 

527 

528 Args: 

529 prov: Data provider name from the project, e.g. ``postgres``. 

530 text: Data source string. 

531 

532 Returns: 

533 Data source properties. 

534 

535 Raises: 

536 ``ValueError``: If a space- or pipe-delimited string is malformed. 

537 """ 

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

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

540 

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

542 ds['provider'] = 'wmts' 

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

544 ds['provider'] = 'xyz' 

545 

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

547 

548 return ds 

549 

550 

551def _parse_datasource(text): 

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

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

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

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

556 

557 text = text.strip() 

558 

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

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

561 # used for WMS, e.g. 

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

563 return _datasource_amp_delimited(text) 

564 

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

566 # key=value, space separated 

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

568 # dbname='...' host=... port=... 

569 # pagingEnabled='...' preferCoordinatesForWfsT11=... 

570 return _datasource_space_delimited(text) 

571 

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

573 # just an url 

574 return {'url': text} 

575 

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

577 # path or path|options 

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

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

580 return _datasource_pipe_delimited(text) 

581 

582 return {'text': text} 

583 

584 

585def _datasource_amp_delimited(text): 

586 ds = {} 

587 

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

589 if '=' not in p: 

590 continue 

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

592 

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

594 

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

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

597 else: 

598 ds[k] = v 

599 

600 if 'url' not in ds: 

601 return ds 

602 

603 # extract params from the url 

604 

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

606 params = gws.u.to_lower_dict(params) 

607 

608 if 'typename' in params: 

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

610 if 'layers' in params: 

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

612 if 'styles' in params: 

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

614 

615 params.pop('service', None) 

616 params.pop('request', None) 

617 

618 ds['params'] = params 

619 

620 # {x} placeholders shouldn't be encoded 

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

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

623 

624 ds['url'] = url 

625 

626 return ds 

627 

628 

629def _datasource_space_delimited(text): 

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

631 

632 value_re = r"""(?x) 

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

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

635 \S+ 

636 """ 

637 

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

639 

640 def _cut(u, rx): 

641 m = re.match(rx, u) 

642 if not m: 

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

644 v = m.group(0) 

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

646 

647 def _unesc(s): 

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

649 

650 def _mid(s): 

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

652 

653 def _value(v): 

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

655 return _unesc(_mid(v)) 

656 return v 

657 

658 ds = {} 

659 

660 while text: 

661 # keyword= 

662 key, text = _cut(text, key_re) 

663 key = key.strip('= ') 

664 

665 if key == 'sql': 

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

667 ds[key] = text 

668 break 

669 

670 elif key == 'table': 

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

672 v, text = _cut(text, value_re) 

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

674 

675 if text.startswith('.'): 

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

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

678 

679 if text.startswith('('): 

680 v, text = _cut(text, parens_re) 

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

682 

683 else: 

684 # just param=val 

685 v, text = _cut(text, value_re) 

686 ds[key] = _value(v) 

687 

688 return ds 

689 

690 

691def _datasource_pipe_delimited(text): 

692 if '|' not in text: 

693 return {'path': text} 

694 

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

696 

697 if '=' not in rest: 

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

699 

700 ds = {'path': path} 

701 

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

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

704 ds[k] = v 

705 

706 return ds 

707 

708 

709## 

710 

711 

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

713 """Parse a QGIS property block (scalars, dicts or option maps) into a dict.""" 

714 

715 if not el: 

716 return {} 

717 

718 _, val = _parse_property_tag(el) 

719 return val 

720 

721 

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

723 typ = el.get('type') 

724 name = el.tag 

725 is_opt = el.tag == 'Option' 

726 

727 if is_opt and el.get('name'): 

728 name = el.get('name') or '' 

729 

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

731 d = {} 

732 

733 for c in el: 

734 k, v = _parse_property_tag(c) 

735 if k: 

736 d[k] = v 

737 

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

739 return name, d['Option'] 

740 

741 return name, d 

742 

743 if typ == 'List': 

744 ls = [] 

745 for c in el: 

746 _, v = _parse_property_tag(c) 

747 ls.append(v) 

748 return name, ls 

749 

750 if typ == 'QStringList': 

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

752 return name, val 

753 

754 if typ == 'QString': 

755 val = el.get('value') if is_opt else el.text 

756 return name, val 

757 

758 if typ == 'bool': 

759 val = el.get('value') if is_opt else el.text 

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

761 

762 if typ == 'int': 

763 val = el.get('value') if is_opt else el.text 

764 return name, _parse_int(val) 

765 

766 if typ == 'double': 

767 val = el.get('value') if is_opt else el.text 

768 return name, _parse_float(val) 

769 

770 return '', None 

771 

772 

773## 

774 

775 

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

777 if not el: 

778 return 

779 

780 ext = _extent_from_tag2(el) 

781 if not ext: 

782 return 

783 

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

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

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

787 return 

788 

789 # ignore all-zero extents 

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

791 return 

792 

793 # avoid single-point extents 

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

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

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

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

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

799 

800 return ext 

801 

802 

803def _extent_from_tag2(el: gws.XmlElement): 

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

805 if el.get('minx'): 

806 return _extent_from_list( 

807 [ 

808 el.get('minx'), 

809 el.get('miny'), 

810 el.get('maxx'), 

811 el.get('maxy'), 

812 ] 

813 ) 

814 

815 # <WMSExtent type="QStringList"> 

816 # <value>1</value> 

817 # <value>2</value> 

818 # <value>3</value> 

819 # <value>4</value> 

820 # </WMSExtent> 

821 # 

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

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

824 

825 # <wgs84extent> 

826 # <xmin>1</xmin> 

827 # <ymin>2</ymin> 

828 # <xmax>3</xmax> 

829 # <ymax>4</ymax> 

830 # </wgs84extent> 

831 

832 if el.find('xmin'): 

833 return _extent_from_list( 

834 [ 

835 el.textof('xmin'), 

836 el.textof('ymin'), 

837 el.textof('xmax'), 

838 el.textof('ymax'), 

839 ] 

840 ) 

841 

842 

843def _extent_from_list(ls): 

844 if len(ls) != 4: 

845 return 

846 try: 

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

848 except ValueError: 

849 return 

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

851 return 

852 return gws.Extent([ 

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

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

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

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

857 ]) 

858 

859 

860def _parse_msize(s): 

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

862 try: 

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

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

865 except Exception: 

866 return None 

867 

868 

869def _parse_int(s) -> int: 

870 try: 

871 return int(s) 

872 except Exception: 

873 return 0 

874 

875 

876def _parse_float(s) -> float: 

877 try: 

878 x = float(s) 

879 except Exception: 

880 return 0 

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

882 return 0 

883 return x