Coverage for gws-app/gws/gis/ms/__init__.py: 84%

240 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-16 22:59 +0200

1"""MapServer support. 

2 

3This module dynamically creates and renders MapServer maps. 

4 

5To render a map, create a map object with `new_map`, add layers to it using ``add_`` methods 

6and invoke ``draw``. 

7 

8Reference: MapServer documentation (https://mapserver.org/documentation.html) 

9 

10Example usage:: 

11 

12 import gws.gis.ms as ms 

13 

14 # create a new map 

15 map = ms.new_map() 

16 

17 # add a raster layer from an image file 

18 map.add_layer( 

19 ms.LayerOptions( 

20 type=ms.LayerType.raster, 

21 path='/path/to/image.tif', 

22 ) 

23 ) 

24 

25 # add a layer using a configuration string 

26 map.add_layer_from_config(''' 

27 LAYER 

28 TYPE LINE 

29 STATUS ON 

30 FEATURE 

31 POINTS 

32 751539 6669003 

33 751539 6672326 

34 755559 6672326 

35 END 

36 END 

37 CLASS 

38 STYLE 

39 COLOR 0 255 0 

40 WIDTH 5 

41 END 

42 END 

43 END 

44 ''') 

45 

46 # draw the map into an Image object 

47 img = map.draw( 

48 bounds=gws.Bounds( 

49 extent=[738040, 6653804, 765743, 6683686], 

50 crs=gws.lib.crs.WEBMERCATOR, 

51 ), 

52 size=(800, 600), 

53 ) 

54 

55 # save the image to a file 

56 img.to_path('/path/to/output.png') 

57 

58 

59""" 

60 

61from typing import Optional 

62import mapscript 

63 

64import gws 

65import gws.lib.image 

66 

67 

68def version() -> str: 

69 """Returns the MapServer version string.""" 

70 

71 return mapscript.msGetVersion() 

72 

73 

74class Error(gws.Error): 

75 pass 

76 

77 

78class LayerType(gws.Enum): 

79 """MapServer layer type.""" 

80 

81 point = 'point' 

82 line = 'line' 

83 polygon = 'polygon' 

84 raster = 'raster' 

85 

86 

87_LAYER_TYPE_TO_MS = { 

88 LayerType.point: mapscript.MS_LAYER_POINT, 

89 LayerType.line: mapscript.MS_LAYER_LINE, 

90 LayerType.polygon: mapscript.MS_LAYER_POLYGON, 

91 LayerType.raster: mapscript.MS_LAYER_RASTER, 

92} 

93 

94 

95class LayerOptions(gws.Data): 

96 """Options for a mapserver layer.""" 

97 

98 type: LayerType 

99 """Layer type.""" 

100 path: str 

101 """Path to the image file.""" 

102 tileIndex: str 

103 """Path to the tile index SHP file""" 

104 crs: gws.Crs 

105 """Layer CRS.""" 

106 connectionType: str 

107 """Type of connection (e.g., 'postgres').""" 

108 connectionString: str 

109 """Connection string for the data source.""" 

110 dataString: str 

111 """Layer DATA option.""" 

112 style: gws.StyleValues 

113 """Style for the layer.""" 

114 processing: list[str] 

115 """Processing options for the layer.""" 

116 transparentColor: str 

117 """Color to treat as transparent in the layer (OFFSITE).""" 

118 sldPath: str 

119 """Path to SLD file for styling the layer.""" 

120 sldName: str 

121 """Name of an SLD NamedLayer to apply.""" 

122 

123 

124def new_map(config: str = '') -> 'Map': 

125 """Creates a new Map instance from a Mapfile string.""" 

126 

127 return Map(config) 

128 

129 

130class Map: 

131 """MapServer map object wrapper.""" 

132 

133 mapObj: mapscript.mapObj 

134 

135 def __init__(self, config: str = ''): 

136 if config: 

137 tmp = gws.c.EPHEMERAL_DIR + '/mapse_' + gws.u.random_string(16) + '.map' 

138 gws.u.write_file(tmp, config) 

139 self.mapObj = mapscript.mapObj(tmp) 

140 else: 

141 self.mapObj = mapscript.mapObj() 

142 

143 self.mapObj.setConfigOption('MS_ERRORFILE', 'stderr') 

144 # self.mapObj.debug = mapscript.MS_DEBUGLEVEL_DEVDEBUG 

145 self.mapObj.debug = mapscript.MS_DEBUGLEVEL_ERRORSONLY 

146 

147 def copy(self) -> 'Map': 

148 """Creates a copy of the current map object.""" 

149 

150 c = Map() 

151 c.mapObj = self.mapObj.clone() 

152 return c 

153 

154 def add_layer_from_config(self, config: str) -> mapscript.layerObj: 

155 """Adds a layer to the map using a configuration string.""" 

156 

157 try: 

158 lo = mapscript.layerObj(self.mapObj) 

159 lo.updateFromString(config) 

160 return lo 

161 except mapscript.MapServerError as exc: 

162 raise Error(f'ms: add error:: {exc}') from exc 

163 

164 def add_layer(self, opts: LayerOptions) -> mapscript.layerObj: 

165 """Adds a layer to the map.""" 

166 

167 try: 

168 lo = self._make_layer(opts) 

169 return lo 

170 except mapscript.MapServerError as exc: 

171 raise Error(f'ms: add error:: {exc}') from exc 

172 

173 def _make_layer(self, opts: LayerOptions) -> mapscript.layerObj: 

174 lo = mapscript.layerObj(self.mapObj) 

175 lc = self.mapObj.numlayers 

176 lo.name = f'_gws_{lc}' 

177 lo.status = mapscript.MS_ON 

178 

179 if not opts.crs: 

180 raise Error('missing layer CRS') 

181 lo.setProjection(opts.crs.epsg) 

182 

183 if opts.type: 

184 lo.type = _LAYER_TYPE_TO_MS[opts.type] 

185 if opts.path: 

186 lo.data = opts.path 

187 if opts.tileIndex: 

188 lo.tileindex = opts.tileIndex 

189 if opts.processing: 

190 for p in opts.processing: 

191 lo.addProcessing(p) 

192 if opts.transparentColor: 

193 co = mapscript.colorObj() 

194 co.setHex(opts.transparentColor) 

195 lo.offsite = co 

196 if opts.connectionType: 

197 if opts.connectionType == 'postgres': 

198 lo.setConnectionType(mapscript.MS_POSTGIS, '') 

199 else: 

200 raise Error(f'unsupported connectionType {opts.connectionType!r}') 

201 if opts.connectionString: 

202 lo.connection = opts.connectionString 

203 if opts.dataString: 

204 lo.data = opts.dataString 

205 if opts.sldPath: 

206 lo.applySLD(gws.u.read_file(opts.sldPath), opts.sldName) 

207 

208 # @TODO: support style values 

209 if opts.style: 

210 cls = mapscript.classObj(lo) 

211 

212 if opts.style.with_geometry == 'all': 

213 style_obj = self._create_style_obj(opts.style) 

214 cls.insertStyle(style_obj) 

215 

216 if opts.style.with_label == 'all': 

217 label_obj = self._create_label_obj(opts.style) 

218 cls.addLabel(label_obj) 

219 lo.labelitem = 'label' 

220 

221 if opts.style.marker or opts.style.icon: 

222 if opts.style.marker: 

223 self.mapObj.setSymbolSet('/gws-app/gws/gis/ms/symbolset.sym') 

224 so = self.style_symbol(opts.style) 

225 cls.insertStyle(so) 

226 

227 if opts.style.icon: 

228 symbol = mapscript.symbolObj("icon", opts.style.icon) 

229 symbol.type = mapscript.MS_SYMBOL_PIXMAP 

230 lo.map.symbolset.appendSymbol(symbol) 

231 so = mapscript.styleObj() 

232 so.setSymbolByName(lo.map, "icon") 

233 so.size = 100 

234 cls.insertStyle(so) 

235 return lo 

236 

237 def draw(self, bounds: gws.Bounds, size: gws.Size) -> gws.Image: 

238 """Renders the map within the given bounds and size. 

239 

240 Args: 

241 bounds: The spatial extent to render. 

242 size: The output image size. 

243 

244 Returns: 

245 The rendered map image. 

246 """ 

247 

248 # @TODO: options for image format, transparency, etc. 

249 

250 try: 

251 gws.debug.time_start(f'mapserver.draw {bounds=} {size=}') 

252 

253 self.mapObj.setOutputFormat(mapscript.outputFormatObj('AGG/PNG')) 

254 self.mapObj.outputformat.transparent = mapscript.MS_TRUE 

255 

256 self.mapObj.setExtent(*bounds.extent) 

257 self.mapObj.setSize(*size) 

258 self.mapObj.setProjection(bounds.crs.epsg) 

259 

260 res = self.mapObj.draw() 

261 img = gws.lib.image.from_bytes(res.getBytes()) 

262 

263 gws.debug.time_end() 

264 

265 return img 

266 

267 except mapscript.MapServerError as exc: 

268 raise Error(f'ms: draw error: {exc}') from exc 

269 

270 def to_string(self) -> str: 

271 """Converts the map object to a configuration string.""" 

272 

273 try: 

274 return self.mapObj.convertToString() 

275 except mapscript.MapServerError as exc: 

276 raise Error(f'ms: convert error: {exc}') from exc 

277 

278 def _create_style_obj(self, style: gws.StyleValues) -> mapscript.styleObj: 

279 so = mapscript.styleObj() 

280 if style.fill: 

281 so.color.setRGB(*_css_color_to_rgb(style.fill)) 

282 if style.stroke: 

283 so.outlinecolor.setRGB(*_css_color_to_rgb(style.stroke)) 

284 so.outlinewidth = max(0.1 * style.stroke_width, 1) 

285 if style.stroke_dasharray: 

286 so.pattern_set(style.stroke_dasharray) 

287 if style.stroke_dashoffset: 

288 so.gap = style.stroke_dashoffset 

289 if style.stroke_linecap: 

290 so.linecap = _const_mapping.get(style.stroke_linecap.lower()) 

291 if style.stroke_linejoin: 

292 so.linejoin = _const_mapping.get(style.stroke_linejoin.lower()) 

293 if style.stroke_miterlimit: 

294 so.linejoinmaxsize = style.stroke_miterlimit 

295 if style.stroke_width: 

296 so.width = style.stroke_width 

297 if style.offset_x: 

298 so.offsetx = style.offset_x 

299 if style.offset_y: 

300 so.offsety = style.offset_y 

301 return so 

302 

303 def _create_label_obj(self, style: gws.StyleValues) -> mapscript.labelObj: 

304 lo = mapscript.labelObj() 

305 so = mapscript.styleObj() 

306 lo.force = mapscript.MS_TRUE 

307 

308 if style.label_align: 

309 lo.align = _const_mapping.get(style.label_align) 

310 if style.label_background: 

311 so.setGeomTransform('labelpoly') 

312 so.color.setRGB(*_css_color_to_rgb(style.label_background)) 

313 if style.label_fill: 

314 lo.color.setRGB(*_css_color_to_rgb(style.label_fill)) 

315 if style.label_font_family: 

316 lo.font = style.label_font_family # + '-' + style.label_font_style + '-' + style.label_font_weight 

317 if style.label_font_size: 

318 lo.size = style.label_font_size 

319 if style.label_max_scale: 

320 lo.maxscaledenom = style.label_max_scale 

321 if style.label_min_scale: 

322 lo.minscaledenom = style.label_min_scale 

323 if style.label_offset_x: 

324 lo.offsetx = style.label_offset_x 

325 if style.label_offset_y: 

326 lo.offsety = style.label_offset_y 

327 if style.label_padding: 

328 lo.buffer = max(style.label_padding) 

329 if style.label_placement: 

330 lo.position = _const_mapping.get(style.label_placement) 

331 if style.label_stroke: 

332 lo.outlinecolor.setRGB(*_css_color_to_rgb(style.label_stroke)) 

333 if style.label_stroke_dasharray: 

334 so.pattern_set(style.label_stroke_dasharray) 

335 if style.label_stroke_linecap: 

336 so.linecap = _const_mapping.get(style.label_stroke_linecap.lower()) 

337 if style.label_stroke_linejoin: 

338 so.linejoin = _const_mapping.get(style.label_stroke_linejoin.lower()) 

339 if style.label_stroke_miterlimit: 

340 so.linejoinmaxsize = style.label_stroke_miterlimit 

341 if style.label_stroke_width: 

342 lo.outlinewidth = style.label_stroke_width 

343 lo.insertStyle(so) 

344 return lo 

345 

346 def style_symbol(self, style: gws.StyleValues) -> mapscript.styleObj: 

347 mo = self.mapObj 

348 so = mapscript.styleObj() 

349 so.setSymbolByName(mo, style.marker) 

350 

351 if style.marker_fill: 

352 so.color.setRGB(*_css_color_to_rgb(style.marker_fill)) 

353 if style.marker_size: 

354 so.size = style.marker_size 

355 if style.marker_stroke: 

356 so.outlinecolor.setRGB(*_css_color_to_rgb(style.marker_stroke)) 

357 if style.marker_stroke_dasharray: 

358 so.pattern_set(style.marker_stroke_dasharray) 

359 if style.marker_stroke_dashoffset: 

360 so.gap = style.marker_stroke_dashoffset 

361 if style.marker_stroke_linecap: 

362 so.linecap = _const_mapping.get(style.marker_stroke_linecap.lower()) 

363 if style.marker_stroke_linejoin: 

364 so.linejoin = _const_mapping.get(style.marker_stroke_linejoin.lower()) 

365 if style.marker_stroke_miterlimit: 

366 so.linejoinmaxsize = style.marker_stroke_miterlimit 

367 if style.marker_stroke_width: 

368 so.outlinewidth = style.marker_stroke_width 

369 return so 

370 

371def _css_color_to_rgb(color_name: str) -> tuple[int, int, int]: 

372 try: 

373 return _CSS_COLOR_NAMES[color_name.lower()] 

374 except KeyError: 

375 raise ValueError(f"Unbekannter CSS-Farbenname: '{color_name}'") 

376 

377 

378def _color_to_str(color: str) -> str: 

379 if isinstance(color, str): 

380 r, g, b = _css_color_to_rgb(color) 

381 return f"{r} {g} {b}" 

382 

383_CSS_COLOR_NAMES = { 

384 'black': (0, 0, 0), 

385 'white': (255, 255, 255), 

386 'red': (255, 0, 0), 

387 'lime': (0, 255, 0), 

388 'blue': (0, 0, 255), 

389 'yellow': (255, 255, 0), 

390 'cyan': (0, 255, 255), 

391 'aqua': (0, 255, 255), 

392 'magenta': (255, 0, 255), 

393 'fuchsia': (255, 0, 255), 

394 'gray': (128, 128, 128), 

395 'grey': (128, 128, 128), 

396 'maroon': (128, 0, 0), 

397 'olive': (128, 128, 0), 

398 'green': (0, 128, 0), 

399 'purple': (128, 0, 128), 

400 'teal': (0, 128, 128), 

401 'navy': (0, 0, 128), 

402} 

403 

404_const_mapping = { 

405 'butt': mapscript.MS_CJC_BUTT, 

406 'round': mapscript.MS_CJC_ROUND, 

407 'square': mapscript.MS_CJC_SQUARE, 

408 'bevel': mapscript.MS_CJC_BEVEL, 

409 'miter': mapscript.MS_CJC_MITER, 

410 'left': mapscript.MS_ALIGN_LEFT, 

411 'center': mapscript.MS_ALIGN_CENTER, 

412 'right': mapscript.MS_ALIGN_RIGHT, 

413 'start': mapscript.MS_CL, 

414 'middle': mapscript.MS_CC, 

415 'end': mapscript.MS_CR, 

416}