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

253 statements  

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

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 

63import re 

64 

65import gws 

66import gws.lib.image 

67 

68 

69def version() -> str: 

70 """Returns the MapServer version string.""" 

71 

72 return mapscript.msGetVersion() 

73 

74 

75class Error(gws.Error): 

76 pass 

77 

78 

79class LayerType(gws.Enum): 

80 """MapServer layer type.""" 

81 

82 point = 'point' 

83 line = 'line' 

84 polygon = 'polygon' 

85 raster = 'raster' 

86 

87 

88_LAYER_TYPE_TO_MS = { 

89 LayerType.point: mapscript.MS_LAYER_POINT, 

90 LayerType.line: mapscript.MS_LAYER_LINE, 

91 LayerType.polygon: mapscript.MS_LAYER_POLYGON, 

92 LayerType.raster: mapscript.MS_LAYER_RASTER, 

93} 

94 

95 

96class LayerOptions(gws.Data): 

97 """Options for a mapserver layer.""" 

98 

99 type: LayerType 

100 """Layer type.""" 

101 path: str 

102 """Path to the image file.""" 

103 tileIndex: str 

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

105 crs: gws.Crs 

106 """Layer CRS.""" 

107 connectionType: str 

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

109 connectionString: str 

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

111 dataString: str 

112 """Layer DATA option.""" 

113 style: gws.StyleValues 

114 """Style for the layer.""" 

115 processing: list[str] 

116 """Processing options for the layer.""" 

117 transparentColor: str 

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

119 sldPath: str 

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

121 sldName: str 

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

123 

124 

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

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

127 

128 return Map(config) 

129 

130 

131class Map: 

132 """MapServer map object wrapper.""" 

133 

134 mapObj: mapscript.mapObj 

135 

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

137 if config: 

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

139 gws.u.write_file(tmp, config) 

140 self.mapObj = mapscript.mapObj(tmp) 

141 else: 

142 self.mapObj = mapscript.mapObj() 

143 

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

145 # self.mapObj.debug = mapscript.MS_DEBUGLEVEL_DEVDEBUG 

146 self.mapObj.debug = mapscript.MS_DEBUGLEVEL_ERRORSONLY 

147 

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

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

150 

151 c = Map() 

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

153 return c 

154 

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

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

157 

158 try: 

159 lo = mapscript.layerObj(self.mapObj) 

160 lo.updateFromString(config) 

161 return lo 

162 except mapscript.MapServerError as exc: 

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

164 

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

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

167 

168 try: 

169 lo = self._make_layer(opts) 

170 return lo 

171 except mapscript.MapServerError as exc: 

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

173 

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

175 lo = mapscript.layerObj(self.mapObj) 

176 lc = self.mapObj.numlayers 

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

178 lo.status = mapscript.MS_ON 

179 

180 if not opts.crs: 

181 raise Error('missing layer CRS') 

182 lo.setProjection(opts.crs.epsg) 

183 

184 if opts.type: 

185 lo.type = _LAYER_TYPE_TO_MS[opts.type] 

186 if opts.path: 

187 lo.data = opts.path 

188 if opts.tileIndex: 

189 lo.tileindex = opts.tileIndex 

190 if opts.processing: 

191 for p in opts.processing: 

192 lo.addProcessing(p) 

193 if opts.transparentColor: 

194 r, g, b, a = _css_color_to_rgb(opts.transparentColor) 

195 co = mapscript.colorObj() 

196 co.setRGB(r, g, b, a) 

197 lo.offsite = co 

198 if opts.connectionType: 

199 if opts.connectionType == 'postgres': 

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

201 else: 

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

203 if opts.connectionString: 

204 lo.connection = opts.connectionString 

205 if opts.dataString: 

206 lo.data = opts.dataString 

207 if opts.sldPath: 

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

209 

210 # @TODO: support style values 

211 if opts.style: 

212 cls = mapscript.classObj(lo) 

213 

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

215 style_obj = self._create_style_obj(opts.style) 

216 cls.insertStyle(style_obj) 

217 

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

219 label_obj = self._create_label_obj(opts.style) 

220 cls.addLabel(label_obj) 

221 lo.labelitem = 'label' 

222 

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

224 if opts.style.marker: 

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

226 so = self.style_symbol(opts.style) 

227 cls.insertStyle(so) 

228 

229 if opts.style.icon: 

230 symbol = mapscript.symbolObj('icon', opts.style.icon) 

231 symbol.type = mapscript.MS_SYMBOL_PIXMAP 

232 lo.map.symbolset.appendSymbol(symbol) 

233 so = mapscript.styleObj() 

234 so.setSymbolByName(lo.map, 'icon') 

235 so.size = 100 

236 cls.insertStyle(so) 

237 return lo 

238 

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

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

241 

242 Args: 

243 bounds: The spatial extent to render. 

244 size: The output image size. 

245 

246 Returns: 

247 The rendered map image. 

248 """ 

249 

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

251 

252 try: 

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

254 

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

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

257 

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

259 self.mapObj.setSize(*size) 

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

261 

262 res = self.mapObj.draw() 

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

264 

265 gws.debug.time_end() 

266 

267 return img 

268 

269 except mapscript.MapServerError as exc: 

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

271 

272 def to_string(self) -> str: 

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

274 

275 try: 

276 return self.mapObj.convertToString() 

277 except mapscript.MapServerError as exc: 

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

279 

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

281 so = mapscript.styleObj() 

282 if style.fill: 

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

284 if style.stroke: 

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

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

287 if style.stroke_dasharray: 

288 so.pattern_set(style.stroke_dasharray) 

289 if style.stroke_dashoffset: 

290 so.gap = style.stroke_dashoffset 

291 if style.stroke_linecap: 

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

293 if style.stroke_linejoin: 

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

295 if style.stroke_miterlimit: 

296 so.linejoinmaxsize = style.stroke_miterlimit 

297 if style.stroke_width: 

298 so.width = style.stroke_width 

299 if style.offset_x: 

300 so.offsetx = style.offset_x 

301 if style.offset_y: 

302 so.offsety = style.offset_y 

303 return so 

304 

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

306 lo = mapscript.labelObj() 

307 so = mapscript.styleObj() 

308 lo.force = mapscript.MS_TRUE 

309 

310 if style.label_align: 

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

312 if style.label_background: 

313 so.setGeomTransform('labelpoly') 

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

315 if style.label_fill: 

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

317 if style.label_font_family: 

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

319 if style.label_font_size: 

320 lo.size = style.label_font_size 

321 if style.label_max_scale: 

322 lo.maxscaledenom = style.label_max_scale 

323 if style.label_min_scale: 

324 lo.minscaledenom = style.label_min_scale 

325 if style.label_offset_x: 

326 lo.offsetx = style.label_offset_x 

327 if style.label_offset_y: 

328 lo.offsety = style.label_offset_y 

329 if style.label_padding: 

330 lo.buffer = max(style.label_padding) 

331 if style.label_placement: 

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

333 if style.label_stroke: 

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

335 if style.label_stroke_dasharray: 

336 so.pattern_set(style.label_stroke_dasharray) 

337 if style.label_stroke_linecap: 

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

339 if style.label_stroke_linejoin: 

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

341 if style.label_stroke_miterlimit: 

342 so.linejoinmaxsize = style.label_stroke_miterlimit 

343 if style.label_stroke_width: 

344 lo.outlinewidth = style.label_stroke_width 

345 lo.insertStyle(so) 

346 return lo 

347 

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

349 mo = self.mapObj 

350 so = mapscript.styleObj() 

351 so.setSymbolByName(mo, style.marker) 

352 

353 if style.marker_fill: 

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

355 if style.marker_size: 

356 so.size = style.marker_size 

357 if style.marker_stroke: 

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

359 if style.marker_stroke_dasharray: 

360 so.pattern_set(style.marker_stroke_dasharray) 

361 if style.marker_stroke_dashoffset: 

362 so.gap = style.marker_stroke_dashoffset 

363 if style.marker_stroke_linecap: 

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

365 if style.marker_stroke_linejoin: 

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

367 if style.marker_stroke_miterlimit: 

368 so.linejoinmaxsize = style.marker_stroke_miterlimit 

369 if style.marker_stroke_width: 

370 so.outlinewidth = style.marker_stroke_width 

371 return so 

372 

373 

374def _css_color_to_rgb(s: str) -> tuple[int, int, int, int]: 

375 s = re.sub(r'\s+', '', s).strip().lower() 

376 if s in _CSS_COLOR_NAMES: 

377 r, g, b = _CSS_COLOR_NAMES[s] 

378 return r, g, b, 255 

379 m = re.match(r'^#([0-9a-f]{6})$', s) 

380 if m: 

381 h = m.group(1) 

382 return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255 

383 m = re.match(r'^#([0-9a-f]{8})$', s) 

384 if m: 

385 h = m.group(1) 

386 return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), int(h[6:8], 16) 

387 m = re.match(r'^rgb\((\d+),(\d+),(\d+)\)$', s) 

388 if m: 

389 return int(m.group(1)), int(m.group(2)), int(m.group(3)), 255 

390 m = re.match(r'^rgba\((\d+),(\d+),(\d+),(\d+)\)$', s) 

391 if m: 

392 return int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4)) 

393 raise ValueError(f'invalid color string: {s!r}') 

394 

395 

396_CSS_COLOR_NAMES = { 

397 'black': (0, 0, 0), 

398 'white': (255, 255, 255), 

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

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

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

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

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

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

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

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

407 'gray': (128, 128, 128), 

408 'grey': (128, 128, 128), 

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

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

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

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

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

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

415} 

416 

417_const_mapping = { 

418 'butt': mapscript.MS_CJC_BUTT, 

419 'round': mapscript.MS_CJC_ROUND, 

420 'square': mapscript.MS_CJC_SQUARE, 

421 'bevel': mapscript.MS_CJC_BEVEL, 

422 'miter': mapscript.MS_CJC_MITER, 

423 'left': mapscript.MS_ALIGN_LEFT, 

424 'center': mapscript.MS_ALIGN_CENTER, 

425 'right': mapscript.MS_ALIGN_RIGHT, 

426 'start': mapscript.MS_CL, 

427 'middle': mapscript.MS_CC, 

428 'end': mapscript.MS_CR, 

429}