Coverage for gws-app/gws/lib/mapserver/core.py: 77%

221 statements  

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

1"""MapServer map wrapper.""" 

2 

3import mapscript 

4import re 

5 

6import gws 

7import gws.lib.image 

8 

9 

10def version() -> str: 

11 """Return the MapServer version string. 

12 

13 Returns: 

14 The version string, as returned by ``mapscript.msGetVersion``. 

15 """ 

16 

17 return mapscript.msGetVersion() 

18 

19 

20class Error(gws.Error): 

21 """MapServer error.""" 

22 

23 pass 

24 

25 

26_LAYER_TYPE_TO_MS = { 

27 gws.MapServerLayerType.point: mapscript.MS_LAYER_POINT, 

28 gws.MapServerLayerType.line: mapscript.MS_LAYER_LINE, 

29 gws.MapServerLayerType.polygon: mapscript.MS_LAYER_POLYGON, 

30 gws.MapServerLayerType.raster: mapscript.MS_LAYER_RASTER, 

31} 

32 

33 

34 

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

36 """Create a new map. 

37 

38 Args: 

39 config: Mapfile content. If empty, an empty map is created. 

40 

41 Returns: 

42 A Map object. 

43 """ 

44 

45 return Map(config) 

46 

47 

48class Map: 

49 """Wrapper around a ``mapscript.mapObj``. 

50 

51 MapServer errors are written to stderr. 

52 """ 

53 

54 mapObj: mapscript.mapObj 

55 """The wrapped MapServer map object.""" 

56 

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

58 """Create a map. 

59 

60 Args: 

61 config: Mapfile content. It is written to a temporary file in the ephemeral directory 

62 and loaded from there. If empty, an empty map is created. 

63 """ 

64 if config: 

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

66 gws.u.write_file(tmp, config) 

67 self.mapObj = mapscript.mapObj(tmp) 

68 else: 

69 self.mapObj = mapscript.mapObj() 

70 

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

72 

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

74 """Create a copy of the map. 

75 

76 Returns: 

77 A new Map object with a clone of the MapServer map. 

78 """ 

79 

80 c = Map() 

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

82 return c 

83 

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

85 """Add a layer to the map from a mapfile ``LAYER`` block. 

86 

87 Args: 

88 config: Layer configuration in the mapfile syntax. 

89 

90 Returns: 

91 The new MapServer layer object. 

92 

93 Raises: 

94 ``Error``: If MapServer cannot create the layer. 

95 """ 

96 

97 try: 

98 lo = mapscript.layerObj(self.mapObj) 

99 lo.updateFromString(config) 

100 return lo 

101 except mapscript.MapServerError as exc: 

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

103 

104 def add_layer(self, opts: gws.MapServerLayerOptions) -> mapscript.layerObj: 

105 """Add a layer to the map from layer options. 

106 

107 The layer is named ``_gws_<n>`` and switched on. 

108 

109 Args: 

110 opts: Layer options. ``crs`` is required. 

111 

112 Returns: 

113 The new MapServer layer object. 

114 

115 Raises: 

116 ``Error``: If the CRS is missing, the connection type is not supported, or MapServer cannot create the layer. 

117 """ 

118 

119 try: 

120 lo = self._make_layer(opts) 

121 return lo 

122 except mapscript.MapServerError as exc: 

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

124 

125 def _make_layer(self, opts: gws.MapServerLayerOptions) -> mapscript.layerObj: 

126 """Create a MapServer layer from layer options.""" 

127 lo = mapscript.layerObj(self.mapObj) 

128 lc = self.mapObj.numlayers 

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

130 lo.status = mapscript.MS_ON 

131 

132 if not opts.crs: 

133 raise Error('missing layer CRS') 

134 lo.setProjection(opts.crs.epsg) 

135 

136 if opts.type: 

137 lo.type = _LAYER_TYPE_TO_MS[opts.type] 

138 if opts.path: 

139 lo.data = opts.path 

140 if opts.tileIndex: 

141 lo.tileindex = opts.tileIndex 

142 if opts.processing: 

143 for p in opts.processing: 

144 lo.addProcessing(p) 

145 if opts.transparentColor: 

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

147 co = mapscript.colorObj() 

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

149 lo.offsite = co 

150 if opts.connectionType: 

151 if opts.connectionType == 'postgres': 

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

153 else: 

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

155 if opts.connectionString: 

156 lo.connection = opts.connectionString 

157 if opts.dataString: 

158 lo.data = opts.dataString 

159 if opts.sldPath: 

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

161 

162 # @TODO: support style values 

163 if opts.style: 

164 cls = mapscript.classObj(lo) 

165 

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

167 style_obj = self._create_style_obj(opts.style) 

168 cls.insertStyle(style_obj) 

169 

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

171 label_obj = self._create_label_obj(opts.style) 

172 cls.addLabel(label_obj) 

173 lo.labelitem = 'label' 

174 

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

176 if opts.style.marker: 

177 self.mapObj.setSymbolSet('/gws-app/gws/lib/mapserver/symbolset.sym') 

178 so = self.style_symbol(opts.style) 

179 cls.insertStyle(so) 

180 

181 if opts.style.icon: 

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

183 symbol.type = mapscript.MS_SYMBOL_PIXMAP 

184 lo.map.symbolset.appendSymbol(symbol) 

185 so = mapscript.styleObj() 

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

187 so.size = 100 

188 cls.insertStyle(so) 

189 return lo 

190 

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

192 """Render the map as a transparent PNG. 

193 

194 Args: 

195 bounds: Extent and CRS to render. 

196 size: Image size ``(width, height)`` in pixels. 

197 

198 Returns: 

199 The rendered image. 

200 

201 Raises: 

202 ``Error``: If MapServer cannot render the map. 

203 """ 

204 

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

206 

207 try: 

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

209 

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

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

212 

213 # setSize re-derives the extent from the current cellsize, so it must 

214 # come first, otherwise a reused map object overwrites the new extent. 

215 

216 self.mapObj.setSize(int(size[0]), int(size[1])) 

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

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

219 

220 res = self.mapObj.draw() 

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

222 

223 gws.debug.time_end() 

224 

225 return img 

226 

227 except mapscript.MapServerError as exc: 

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

229 

230 def to_string(self) -> str: 

231 """Convert the map to a mapfile string. 

232 

233 Returns: 

234 Mapfile content. 

235 

236 Raises: 

237 ``Error``: If MapServer cannot convert the map. 

238 """ 

239 

240 try: 

241 return self.mapObj.convertToString() 

242 except mapscript.MapServerError as exc: 

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

244 

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

246 """Create a MapServer geometry style from style values.""" 

247 so = mapscript.styleObj() 

248 if style.fill: 

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

250 if style.stroke: 

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

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

253 if style.stroke_dasharray: 

254 so.pattern_set(style.stroke_dasharray) 

255 if style.stroke_dashoffset: 

256 so.gap = style.stroke_dashoffset 

257 if style.stroke_linecap: 

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

259 if style.stroke_linejoin: 

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

261 if style.stroke_miterlimit: 

262 so.linejoinmaxsize = style.stroke_miterlimit 

263 if style.stroke_width: 

264 so.width = style.stroke_width 

265 if style.offset_x: 

266 so.offsetx = style.offset_x 

267 if style.offset_y: 

268 so.offsety = style.offset_y 

269 return so 

270 

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

272 """Create a MapServer label from style values.""" 

273 lo = mapscript.labelObj() 

274 so = mapscript.styleObj() 

275 lo.force = mapscript.MS_TRUE 

276 

277 if style.label_align: 

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

279 if style.label_background: 

280 so.setGeomTransform('labelpoly') 

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

282 if style.label_fill: 

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

284 if style.label_font_family: 

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

286 if style.label_font_size: 

287 lo.size = style.label_font_size 

288 if style.label_max_scale: 

289 lo.maxscaledenom = style.label_max_scale 

290 if style.label_min_scale: 

291 lo.minscaledenom = style.label_min_scale 

292 if style.label_offset_x: 

293 lo.offsetx = style.label_offset_x 

294 if style.label_offset_y: 

295 lo.offsety = style.label_offset_y 

296 if style.label_padding: 

297 lo.buffer = max(style.label_padding) 

298 if style.label_placement: 

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

300 if style.label_stroke: 

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

302 if style.label_stroke_dasharray: 

303 so.pattern_set(style.label_stroke_dasharray) 

304 if style.label_stroke_linecap: 

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

306 if style.label_stroke_linejoin: 

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

308 if style.label_stroke_miterlimit: 

309 so.linejoinmaxsize = style.label_stroke_miterlimit 

310 if style.label_stroke_width: 

311 lo.outlinewidth = style.label_stroke_width 

312 lo.insertStyle(so) 

313 return lo 

314 

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

316 """Create a MapServer marker style from style values. 

317 

318 The marker symbol is looked up by name in the symbol set of the map. 

319 

320 Args: 

321 style: Style values with ``marker`` and optional ``marker_*`` values. 

322 

323 Returns: 

324 A MapServer style object. 

325 """ 

326 

327 mo = self.mapObj 

328 so = mapscript.styleObj() 

329 so.setSymbolByName(mo, style.marker) 

330 

331 if style.marker_fill: 

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

333 if style.marker_size: 

334 so.size = style.marker_size 

335 if style.marker_stroke: 

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

337 if style.marker_stroke_dasharray: 

338 so.pattern_set(style.marker_stroke_dasharray) 

339 if style.marker_stroke_dashoffset: 

340 so.gap = style.marker_stroke_dashoffset 

341 if style.marker_stroke_linecap: 

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

343 if style.marker_stroke_linejoin: 

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

345 if style.marker_stroke_miterlimit: 

346 so.linejoinmaxsize = style.marker_stroke_miterlimit 

347 if style.marker_stroke_width: 

348 so.outlinewidth = style.marker_stroke_width 

349 return so 

350 

351 

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

353 """Convert a CSS color (basic name, ``#rrggbb``, ``#rrggbbaa``, ``rgb()`` or ``rgba()``) to RGBA values.""" 

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

355 if s in _CSS_COLOR_NAMES: 

356 r, g, b = _CSS_COLOR_NAMES[s] 

357 return r, g, b, 255 

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

359 if m: 

360 h = m.group(1) 

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

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

363 if m: 

364 h = m.group(1) 

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

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

367 if m: 

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

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

370 if m: 

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

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

373 

374 

375_CSS_COLOR_NAMES = { 

376 'black': (0, 0, 0), 

377 'white': (255, 255, 255), 

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

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

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

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

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

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

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

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

386 'gray': (128, 128, 128), 

387 'grey': (128, 128, 128), 

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

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

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

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

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

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

394} 

395 

396_const_mapping = { 

397 'butt': mapscript.MS_CJC_BUTT, 

398 'round': mapscript.MS_CJC_ROUND, 

399 'square': mapscript.MS_CJC_SQUARE, 

400 'bevel': mapscript.MS_CJC_BEVEL, 

401 'miter': mapscript.MS_CJC_MITER, 

402 'left': mapscript.MS_ALIGN_LEFT, 

403 'center': mapscript.MS_ALIGN_CENTER, 

404 'right': mapscript.MS_ALIGN_RIGHT, 

405 'start': mapscript.MS_CL, 

406 'middle': mapscript.MS_CC, 

407 'end': mapscript.MS_CR, 

408}