Coverage for gws-app/gws/lib/gml/writer.py: 98%

102 statements  

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

1"""GML geometry writer.""" 

2 

3from typing import Optional 

4 

5import shapely.geometry 

6 

7import gws 

8import gws.lib.uom 

9import gws.lib.xmlx as xmlx 

10from gws.lib.xmlx import tag 

11 

12# @TODO PostGis options 2 and 4 (https://postgis.net/docs/ST_AsGML.html) 

13 

14 

15DEFAULT_VERSION = 3 

16 

17 

18def shape_to_element( 

19 shape: gws.Shape, 

20 version: int = DEFAULT_VERSION, 

21 coordinate_precision: Optional[int] = None, 

22 always_xy: bool = False, 

23 with_xmlns: bool = True, 

24 with_inline_xmlns: bool = False, 

25 namespace: Optional[gws.XmlNamespace] = None, 

26 crs_format: Optional[gws.CrsFormat] = None, 

27) -> gws.XmlElement: 

28 """Convert a Shape to a GML geometry element. 

29 

30 In GML 3, line strings are written as ``Curve`` elements and multi line strings 

31 and multi polygons as ``MultiCurve`` and ``MultiSurface``. 

32 

33 Args: 

34 shape: A Shape object. 

35 version: GML version (2 or 3). 

36 coordinate_precision: Number of decimal places. The default depends on the units of the shape CRS. 

37 always_xy: If ``True``, coordinates are always written in the XY (lon/lat) order, 

38 otherwise in the axis order of the CRS. 

39 with_xmlns: If ``True``, put the elements in the GML namespace. 

40 with_inline_xmlns: If ``True``, declare the namespace on the geometry element. 

41 namespace: Namespace to use (default ``GML_2`` for version 2 and ``GML``, that is GML 3.2, for version 3). 

42 crs_format: CRS format for ``srsName`` (default ``url`` for version 2 and ``urn`` for version 3). 

43 

44 Returns: 

45 A GML element. 

46 

47 Raises: 

48 ``gws.Error``: If the version or the geometry type is not supported. 

49 """ 

50 

51 v = int(version or DEFAULT_VERSION) 

52 if v == 2: 

53 cls = _Writer2 

54 elif v == 3: 

55 cls = _Writer3 

56 else: 

57 raise gws.Error(f'unsupported GML version {version!r}') 

58 

59 wr = cls(shape, coordinate_precision, always_xy, with_xmlns, namespace, crs_format) 

60 

61 geom: shapely.geometry.base.BaseGeometry = getattr(shape, 'geom') 

62 

63 # OGC 07-036r1 10.1.4.1 

64 # If no srsName attribute is given, the CRS shall be specified as part of the larger context this geometry element is part of... 

65 # NOTE It is expected that the attribute will be specified at the direct position level only in rare cases. 

66 

67 el = wr.element(geom) 

68 if wr.ns and with_inline_xmlns: 

69 el.declare(wr.ns) 

70 

71 return el 

72 

73 

74_METHODS = { 

75 'Point': 'point', 

76 'LineString': 'linestring', 

77 'Polygon': 'polygon', 

78 'MultiPoint': 'multipoint', 

79 'MultiLineString': 'multilinestring', 

80 'MultiPolygon': 'multipolygon', 

81 'GeometryCollection': 'geometrycollection', 

82} 

83 

84 

85class _Writer: 

86 """Base GML writer, holds the output options and the version-independent geometry types.""" 

87 

88 version: int 

89 """GML version.""" 

90 defaultCrsFormat: gws.CrsFormat 

91 """CRS format used when none is given.""" 

92 defaultNamespace: gws.XmlNamespace 

93 """Namespace used when none is given.""" 

94 

95 precision: int 

96 """Number of decimal places for coordinates.""" 

97 swap_xy: bool 

98 """If ``True``, coordinates are written in the YX order.""" 

99 crsName: dict 

100 """The ``srsName`` attribute for geometry elements.""" 

101 pfx: str 

102 """Not used.""" 

103 ns: Optional[gws.XmlNamespace] 

104 """Namespace of the elements, or ``None`` for no namespace.""" 

105 

106 def __init__(self, shape, coordinate_precision, always_xy, with_xmlns, namespace, crs_format): 

107 """Create a writer. 

108 

109 Args: 

110 shape: The shape to write, provides the CRS. 

111 coordinate_precision: Number of decimal places, or ``None`` for the default of the CRS units. 

112 always_xy: If ``True``, write coordinates in the XY order. 

113 with_xmlns: If ``True``, put the elements in a namespace. 

114 namespace: Namespace to use, or ``None`` for the default. 

115 crs_format: CRS format to use, or ``None`` for the default. 

116 """ 

117 

118 crs_format = crs_format or self.defaultCrsFormat 

119 self.crsName = {'srsName': shape.crs.to_string(crs_format)} 

120 

121 self.swap_xy = (shape.crs.axis_for_format(crs_format) == gws.Axis.yx) and not always_xy 

122 

123 self.precision = gws.lib.uom.DEFAULT_PRECISION[shape.crs.uom] 

124 if coordinate_precision is not None: 

125 self.precision = coordinate_precision 

126 

127 self.ns = None 

128 self.nsu = '' 

129 if with_xmlns: 

130 self.ns = namespace or self.defaultNamespace 

131 self.nsu = '{' + self.ns.uri + '}' 

132 

133 def t(self, name, *args): 

134 """Create an element in the writer namespace. 

135 

136 Args: 

137 name: Element name without a namespace. 

138 *args: Attributes, text and child elements, as accepted by ``gws.lib.xmlx.tag``. 

139 

140 Returns: 

141 An XML element. 

142 """ 

143 

144 return tag(self.nsu + name, *args) 

145 

146 def element(self, geom): 

147 """Convert a geometry to a GML element. 

148 

149 Args: 

150 geom: A shapely geometry. 

151 

152 Returns: 

153 A GML element. 

154 

155 Raises: 

156 ``gws.Error``: If the geometry type is not supported. 

157 """ 

158 

159 typ = geom.geom_type 

160 name = _METHODS.get(typ) 

161 if name: 

162 return getattr(self, name)(geom) 

163 raise gws.Error(f'cannot convert geometry type {typ!r} to GML') 

164 

165 def multipoint(self, geom): 

166 """Create a ``MultiPoint`` element. 

167 

168 Args: 

169 geom: A shapely geometry. 

170 

171 Returns: 

172 A GML element. 

173 """ 

174 

175 return self.t( 

176 'MultiPoint', 

177 self.crsName, 

178 [self.t('pointMember', self.element(p)) for p in geom.geoms], 

179 ) 

180 

181 def geometrycollection(self, geom): 

182 """Create a ``MultiGeometry`` element. 

183 

184 Args: 

185 geom: A shapely geometry. 

186 

187 Returns: 

188 A GML element. 

189 """ 

190 

191 return self.t( 

192 'MultiGeometry', 

193 self.crsName, 

194 [self.t('geometryMember', self.element(p)) for p in geom.geoms], 

195 ) 

196 

197 def round_coords(self, geom): 

198 """Round the coordinates of a geometry and swap them if needed. 

199 

200 Args: 

201 geom: A shapely geometry with a ``coords`` sequence. 

202 

203 Yields: 

204 Coordinate pairs in the output axis order. 

205 """ 

206 

207 for x, y in geom.coords: 

208 x = int(x) if self.precision == 0 else round(x, self.precision) 

209 y = int(y) if self.precision == 0 else round(y, self.precision) 

210 if self.swap_xy: 

211 x, y = y, x 

212 yield x, y 

213 

214 

215class _Writer2(_Writer): 

216 """GML 2 writer.""" 

217 

218 version = 2 

219 defaultCrsFormat = gws.CrsFormat.url 

220 defaultNamespace = xmlx.namespace.c.GML_2 

221 

222 def point(self, geom): 

223 """Create a ``Point`` element. 

224 

225 Args: 

226 geom: A shapely geometry. 

227 

228 Returns: 

229 A GML element. 

230 """ 

231 

232 return self.t('Point', self.crsName, self.coordinates(geom)) 

233 

234 def linestring(self, geom): 

235 """Create a ``LineString`` element. 

236 

237 Args: 

238 geom: A shapely geometry. 

239 

240 Returns: 

241 A GML element. 

242 """ 

243 

244 return self.t('LineString', self.crsName, self.coordinates(geom)) 

245 

246 def polygon(self, geom): 

247 """Create a ``Polygon`` element with ``outerBoundaryIs`` and ``innerBoundaryIs`` rings. 

248 

249 Args: 

250 geom: A shapely geometry. 

251 

252 Returns: 

253 A GML element. 

254 """ 

255 

256 return self.t( 

257 'Polygon', 

258 self.crsName, 

259 self.t('outerBoundaryIs', self.t('LinearRing', self.coordinates(geom.exterior))), 

260 [self.t('innerBoundaryIs', self.t('LinearRing', self.coordinates(interior))) for interior in geom.interiors], 

261 ) 

262 

263 def multilinestring(self, geom): 

264 """Create a ``MultiLineString`` element. 

265 

266 Args: 

267 geom: A shapely geometry. 

268 

269 Returns: 

270 A GML element. 

271 """ 

272 

273 return self.t( 

274 'MultiLineString', 

275 self.crsName, 

276 [self.t('lineStringMember', self.element(p)) for p in geom.geoms], 

277 ) 

278 

279 def multipolygon(self, geom): 

280 """Create a ``MultiPolygon`` element. 

281 

282 Args: 

283 geom: A shapely geometry. 

284 

285 Returns: 

286 A GML element. 

287 """ 

288 

289 return self.t( 

290 'MultiPolygon', 

291 self.crsName, 

292 [self.t('polygonMember', self.element(p)) for p in geom.geoms], 

293 ) 

294 

295 def coordinates(self, geom): 

296 """Create a ``coordinates`` element. 

297 

298 Args: 

299 geom: A shapely geometry. 

300 

301 Returns: 

302 A GML element. 

303 """ 

304 

305 cs = [str(x) + ',' + str(y) for x, y in self.round_coords(geom)] 

306 return self.t('coordinates', {'decimal': '.', 'cs': ',', 'ts': ' '}, ' '.join(cs)) 

307 

308 

309class _Writer3(_Writer): 

310 """GML 3 writer.""" 

311 

312 version = 3 

313 defaultCrsFormat = gws.CrsFormat.urn 

314 defaultNamespace = xmlx.namespace.c.GML 

315 

316 def point(self, geom): 

317 """Create a ``Point`` element. 

318 

319 Args: 

320 geom: A shapely geometry. 

321 

322 Returns: 

323 A GML element. 

324 """ 

325 

326 return self.t('Point', self.crsName, self.pos(geom)) 

327 

328 def linestring(self, geom): 

329 """Create a ``Curve`` element with a single ``LineStringSegment``. 

330 

331 Args: 

332 geom: A shapely geometry. 

333 

334 Returns: 

335 A GML element. 

336 """ 

337 

338 return self.t( 

339 'Curve', 

340 self.crsName, 

341 self.t('segments', self.t('LineStringSegment', self.pos_list(geom))), 

342 ) 

343 

344 def polygon(self, geom): 

345 """Create a ``Polygon`` element with ``exterior`` and ``interior`` rings. 

346 

347 Args: 

348 geom: A shapely geometry. 

349 

350 Returns: 

351 A GML element. 

352 """ 

353 

354 return self.t( 

355 'Polygon', 

356 self.crsName, 

357 self.t('exterior', self.t('LinearRing', self.pos_list(geom.exterior))), 

358 [self.t('interior', self.t('LinearRing', self.pos_list(interior))) for interior in geom.interiors], 

359 ) 

360 

361 def multilinestring(self, geom): 

362 """Create a ``MultiCurve`` element. 

363 

364 Args: 

365 geom: A shapely geometry. 

366 

367 Returns: 

368 A GML element. 

369 """ 

370 

371 return self.t( 

372 'MultiCurve', 

373 self.crsName, 

374 [self.t('curveMember', self.element(p)) for p in geom.geoms], 

375 ) 

376 

377 def multipolygon(self, geom): 

378 """Create a ``MultiSurface`` element. 

379 

380 Args: 

381 geom: A shapely geometry. 

382 

383 Returns: 

384 A GML element. 

385 """ 

386 

387 return self.t( 

388 'MultiSurface', 

389 self.crsName, 

390 [self.t('surfaceMember', self.element(p)) for p in geom.geoms], 

391 ) 

392 

393 def pos(self, geom): 

394 """Create a ``pos`` element. 

395 

396 Args: 

397 geom: A shapely geometry. 

398 

399 Returns: 

400 A GML element. 

401 """ 

402 

403 return self.t('pos', {'srsDimension': 2}, self.pos_list_content(geom)) 

404 

405 def pos_list(self, geom): 

406 """Create a ``posList`` element. 

407 

408 Args: 

409 geom: A shapely geometry. 

410 

411 Returns: 

412 A GML element. 

413 """ 

414 

415 return self.t('posList', {'srsDimension': 2}, self.pos_list_content(geom)) 

416 

417 def pos_list_content(self, geom): 

418 """Format the coordinates of a geometry as a space-separated list. 

419 

420 Args: 

421 geom: A shapely geometry with a ``coords`` sequence. 

422 

423 Returns: 

424 Coordinates as a string. 

425 """ 

426 

427 cs = [] 

428 for x, y in self.round_coords(geom): 

429 cs.append(str(x)) 

430 cs.append(str(y)) 

431 return ' '.join(cs)