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

433 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

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 = _map_layer_opacity(layer_el) 

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

337 sl.isReadOnly = layer_el.get('readOnly') == '1' 

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

339 

340 uid = layer_el.textof('id') 

341 if use_layer_ids: 

342 layer_name = uid 

343 else: 

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

345 

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

347 sl.name = layer_name 

348 sl.sourceId = uid 

349 

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

351 if crs: 

352 sl.supportedCrs = [crs] 

353 

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

355 if ext: 

356 sl.wgsExtent = ext 

357 

358 return sl 

359 

360 

361def _map_layer_opacity(layer_el: gws.XmlElement) -> float: 

362 """Read the layer opacity, for raster layers from the renderer.""" 

363 s = layer_el.textof('layerOpacity') 

364 if not s: 

365 el = layer_el.find('pipe/rasterrenderer') 

366 if el is not None: 

367 s = el.get('opacity') 

368 return _parse_float(s or '1') 

369 

370 

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

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

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

374 

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

376 

377 el = layer_el.find('resourceMetadata') 

378 if el: 

379 _collect_metadata(el, md) 

380 

381 # fill in missing props from direct metadata 

382 

383 d = layer_el.textdict() 

384 

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

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

387 

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

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

390 

391 if not md.keywords: 

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

393 

394 if not md.metaLinks: 

395 md.metaLinks = [] 

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

397 md.metaLinks.append( 

398 gws.MetadataLink( 

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

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

401 title=e.text, 

402 ) 

403 ) 

404 

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

406 

407 

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

409 prov = layer_el.textof('provider') 

410 ds_text = layer_el.textof('datasource') 

411 

412 if ds_text: 

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

414 if prov: 

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

416 return {} 

417 

418 

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

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

421 

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

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

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

425 

426 Args: 

427 layer_el: ``maplayer`` element. 

428 layer_crs: CRS of the layer. 

429 

430 Returns: 

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

432 """ 

433 uid = layer_el.textof('id') 

434 

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

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

437 if el: 

438 ext = _extent_from_tag(el) 

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

440 if ext and crs: 

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

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

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

444 return ext 

445 

446 # extent in <maplayer>/<wgs84extent> 

447 el = layer_el.find('wgs84extent') 

448 if el: 

449 ext = _extent_from_tag(el) 

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

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

452 return ext 

453 

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

455 el = layer_el.find('extent') 

456 if el: 

457 ext = _extent_from_tag(el) 

458 if ext: 

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

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

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

462 return ext 

463 

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

465 

466 

467# layer trees: 

468 

469# <layer-tree-group> 

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

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

472# ... 

473 

474 

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

476 if not el: 

477 return 

478 

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

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

481 

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

483 title = el.get('name') 

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

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

486 

487 return gws.SourceLayer( 

488 title=title, 

489 name=name, 

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

491 isVisible=visible, 

492 isExpanded=expanded, 

493 isGroup=True, 

494 isQueryable=False, 

495 isImage=False, 

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

497 ) 

498 

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

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

501 if sl: 

502 sl.isVisible = visible 

503 sl.isExpanded = expanded 

504 sl.isGroup = False 

505 sl.isImage = True 

506 return sl 

507 

508 

509## 

510 

511 

512def _visibility_presets(root_el: gws.XmlElement): 

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

514 

515 d = {} 

516 

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

518 ls = [] 

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

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

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

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

523 

524 return d 

525 

526 

527## 

528 

529 

530def parse_datasource(prov, text): 

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

532 

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

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

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

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

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

538 

539 Args: 

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

541 text: Data source string. 

542 

543 Returns: 

544 Data source properties. 

545 

546 Raises: 

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

548 """ 

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

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

551 

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

553 ds['provider'] = 'wmts' 

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

555 ds['provider'] = 'xyz' 

556 

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

558 

559 return ds 

560 

561 

562def _parse_datasource(text): 

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

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

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

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

567 

568 text = text.strip() 

569 

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

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

572 # used for WMS, e.g. 

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

574 return _datasource_amp_delimited(text) 

575 

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

577 # key=value, space separated 

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

579 # dbname='...' host=... port=... 

580 # pagingEnabled='...' preferCoordinatesForWfsT11=... 

581 return _datasource_space_delimited(text) 

582 

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

584 # just an url 

585 return {'url': text} 

586 

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

588 # path or path|options 

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

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

591 return _datasource_pipe_delimited(text) 

592 

593 return {'text': text} 

594 

595 

596def _datasource_amp_delimited(text): 

597 ds = {} 

598 

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

600 if '=' not in p: 

601 continue 

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

603 

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

605 

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

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

608 else: 

609 ds[k] = v 

610 

611 if 'url' not in ds: 

612 return ds 

613 

614 # extract params from the url 

615 

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

617 params = gws.u.to_lower_dict(params) 

618 

619 if 'typename' in params: 

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

621 if 'layers' in params: 

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

623 if 'styles' in params: 

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

625 

626 params.pop('service', None) 

627 params.pop('request', None) 

628 

629 ds['params'] = params 

630 

631 # {x} placeholders shouldn't be encoded 

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

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

634 

635 ds['url'] = url 

636 

637 return ds 

638 

639 

640def _datasource_space_delimited(text): 

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

642 

643 value_re = r"""(?x) 

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

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

646 \S+ 

647 """ 

648 

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

650 

651 def _cut(u, rx): 

652 m = re.match(rx, u) 

653 if not m: 

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

655 v = m.group(0) 

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

657 

658 def _unesc(s): 

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

660 

661 def _mid(s): 

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

663 

664 def _value(v): 

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

666 return _unesc(_mid(v)) 

667 return v 

668 

669 ds = {} 

670 

671 while text: 

672 # keyword= 

673 key, text = _cut(text, key_re) 

674 key = key.strip('= ') 

675 

676 if key == 'sql': 

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

678 ds[key] = text 

679 break 

680 

681 elif key == 'table': 

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

683 v, text = _cut(text, value_re) 

684 parts = [_value(v)] 

685 

686 if text.startswith('.'): 

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

688 parts.append(_value(v)) 

689 

690 if len(parts) == 1 and (parts[0].startswith('(') or parts[0].upper().startswith('SELECT ')): 

691 ds['table'] = parts[0] 

692 else: 

693 ds['table'] = '.'.join(_quote_table_name_part(p) for p in parts) 

694 

695 if text.startswith('('): 

696 v, text = _cut(text, parens_re) 

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

698 

699 else: 

700 # just param=val 

701 v, text = _cut(text, value_re) 

702 ds[key] = _value(v) 

703 

704 return ds 

705 

706 

707def _quote_table_name_part(name): 

708 if re.match(r'^\w+$', name): 

709 return name 

710 return '"' + name.replace('"', '""') + '"' 

711 

712 

713def _datasource_pipe_delimited(text): 

714 if '|' not in text: 

715 return {'path': text} 

716 

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

718 

719 if '=' not in rest: 

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

721 

722 ds = {'path': path} 

723 

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

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

726 ds[k] = v 

727 

728 return ds 

729 

730 

731## 

732 

733 

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

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

736 

737 if not el: 

738 return {} 

739 

740 _, val = _parse_property_tag(el) 

741 return val 

742 

743 

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

745 typ = el.get('type') 

746 name = el.tag 

747 is_opt = el.tag == 'Option' 

748 

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

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

751 

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

753 d = {} 

754 

755 for c in el: 

756 k, v = _parse_property_tag(c) 

757 if k: 

758 d[k] = v 

759 

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

761 return name, d['Option'] 

762 

763 return name, d 

764 

765 if typ == 'List': 

766 ls = [] 

767 for c in el: 

768 _, v = _parse_property_tag(c) 

769 ls.append(v) 

770 return name, ls 

771 

772 if typ == 'QStringList': 

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

774 return name, val 

775 

776 if typ == 'QString': 

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

778 return name, val 

779 

780 if typ == 'bool': 

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

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

783 

784 if typ == 'int': 

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

786 return name, _parse_int(val) 

787 

788 if typ == 'double': 

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

790 return name, _parse_float(val) 

791 

792 return '', None 

793 

794 

795## 

796 

797 

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

799 if not el: 

800 return 

801 

802 ext = _extent_from_tag2(el) 

803 if not ext: 

804 return 

805 

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

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

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

809 return 

810 

811 # ignore all-zero extents 

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

813 return 

814 

815 # avoid single-point extents 

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

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

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

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

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

821 

822 return ext 

823 

824 

825def _extent_from_tag2(el: gws.XmlElement): 

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

827 if el.get('minx'): 

828 return _extent_from_list( 

829 [ 

830 el.get('minx'), 

831 el.get('miny'), 

832 el.get('maxx'), 

833 el.get('maxy'), 

834 ] 

835 ) 

836 

837 # <WMSExtent type="QStringList"> 

838 # <value>1</value> 

839 # <value>2</value> 

840 # <value>3</value> 

841 # <value>4</value> 

842 # </WMSExtent> 

843 # 

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

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

846 

847 # <wgs84extent> 

848 # <xmin>1</xmin> 

849 # <ymin>2</ymin> 

850 # <xmax>3</xmax> 

851 # <ymax>4</ymax> 

852 # </wgs84extent> 

853 

854 if el.find('xmin'): 

855 return _extent_from_list( 

856 [ 

857 el.textof('xmin'), 

858 el.textof('ymin'), 

859 el.textof('xmax'), 

860 el.textof('ymax'), 

861 ] 

862 ) 

863 

864 

865def _extent_from_list(ls): 

866 if len(ls) != 4: 

867 return 

868 try: 

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

870 except ValueError: 

871 return 

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

873 return 

874 return gws.Extent([ 

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

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

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

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

879 ]) 

880 

881 

882def _parse_msize(s): 

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

884 try: 

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

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

887 except Exception: 

888 return None 

889 

890 

891def _parse_int(s) -> int: 

892 try: 

893 return int(s) 

894 except Exception: 

895 return 0 

896 

897 

898def _parse_float(s) -> float: 

899 try: 

900 x = float(s) 

901 except Exception: 

902 return 0 

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

904 return 0 

905 return x