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

222 statements  

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

1"""MapServer map wrapper.""" 

2 

3import mapscript 

4import os 

5import re 

6 

7import gws 

8import gws.lib.image 

9 

10 

11def version() -> str: 

12 """Return the MapServer version string. 

13 

14 Returns: 

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

16 """ 

17 

18 return mapscript.msGetVersion() 

19 

20 

21class Error(gws.Error): 

22 """MapServer error.""" 

23 

24 pass 

25 

26 

27_LAYER_TYPE_TO_MS = { 

28 gws.MapServerLayerType.point: mapscript.MS_LAYER_POINT, 

29 gws.MapServerLayerType.line: mapscript.MS_LAYER_LINE, 

30 gws.MapServerLayerType.polygon: mapscript.MS_LAYER_POLYGON, 

31 gws.MapServerLayerType.raster: mapscript.MS_LAYER_RASTER, 

32} 

33 

34 

35 

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

37 """Create a new map. 

38 

39 Args: 

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

41 

42 Returns: 

43 A Map object. 

44 """ 

45 

46 return Map(config) 

47 

48 

49class Map: 

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

51 

52 MapServer errors are written to stderr. 

53 """ 

54 

55 mapObj: mapscript.mapObj 

56 """The wrapped MapServer map object.""" 

57 

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

59 """Create a map. 

60 

61 Args: 

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

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

64 """ 

65 if config: 

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

67 gws.u.write_file(tmp, config) 

68 self.mapObj = mapscript.mapObj(tmp) 

69 else: 

70 self.mapObj = mapscript.mapObj() 

71 

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

73 

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

75 """Create a copy of the map. 

76 

77 Returns: 

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

79 """ 

80 

81 c = Map() 

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

83 return c 

84 

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

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

87 

88 Args: 

89 config: Layer configuration in the mapfile syntax. 

90 

91 Returns: 

92 The new MapServer layer object. 

93 

94 Raises: 

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

96 """ 

97 

98 try: 

99 lo = mapscript.layerObj(self.mapObj) 

100 lo.updateFromString(config) 

101 return lo 

102 except mapscript.MapServerError as exc: 

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

104 

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

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

107 

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

109 

110 Args: 

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

112 

113 Returns: 

114 The new MapServer layer object. 

115 

116 Raises: 

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

118 """ 

119 

120 try: 

121 lo = self._make_layer(opts) 

122 return lo 

123 except mapscript.MapServerError as exc: 

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

125 

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

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

128 lo = mapscript.layerObj(self.mapObj) 

129 lc = self.mapObj.numlayers 

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

131 lo.status = mapscript.MS_ON 

132 

133 if not opts.crs: 

134 raise Error('missing layer CRS') 

135 lo.setProjection(opts.crs.epsg) 

136 

137 if opts.type: 

138 lo.type = _LAYER_TYPE_TO_MS[opts.type] 

139 if opts.path: 

140 lo.data = opts.path 

141 if opts.tileIndex: 

142 lo.tileindex = opts.tileIndex 

143 if opts.processing: 

144 for p in opts.processing: 

145 lo.addProcessing(p) 

146 if opts.transparentColor: 

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

148 co = mapscript.colorObj() 

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

150 lo.offsite = co 

151 if opts.connectionType: 

152 if opts.connectionType == 'postgres': 

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

154 else: 

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

156 if opts.connectionString: 

157 lo.connection = opts.connectionString 

158 if opts.dataString: 

159 lo.data = opts.dataString 

160 if opts.sldPath: 

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

162 

163 # @TODO: support style values 

164 if opts.style: 

165 cls = mapscript.classObj(lo) 

166 

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

168 style_obj = self._create_style_obj(opts.style) 

169 cls.insertStyle(style_obj) 

170 

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

172 label_obj = self._create_label_obj(opts.style) 

173 cls.addLabel(label_obj) 

174 lo.labelitem = 'label' 

175 

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

177 if opts.style.marker: 

178 self.mapObj.setSymbolSet(os.path.join(os.path.dirname(__file__), 'symbolset.sym')) 

179 so = self.style_symbol(opts.style) 

180 cls.insertStyle(so) 

181 

182 if opts.style.icon: 

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

184 symbol.type = mapscript.MS_SYMBOL_PIXMAP 

185 lo.map.symbolset.appendSymbol(symbol) 

186 so = mapscript.styleObj() 

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

188 so.size = 100 

189 cls.insertStyle(so) 

190 return lo 

191 

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

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

194 

195 Args: 

196 bounds: Extent and CRS to render. 

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

198 

199 Returns: 

200 The rendered image. 

201 

202 Raises: 

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

204 """ 

205 

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

207 

208 try: 

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

210 

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

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

213 

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

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

216 

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

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

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

220 

221 res = self.mapObj.draw() 

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

223 

224 gws.debug.time_end() 

225 

226 return img 

227 

228 except mapscript.MapServerError as exc: 

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

230 

231 def to_string(self) -> str: 

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

233 

234 Returns: 

235 Mapfile content. 

236 

237 Raises: 

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

239 """ 

240 

241 try: 

242 return self.mapObj.convertToString() 

243 except mapscript.MapServerError as exc: 

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

245 

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

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

248 so = mapscript.styleObj() 

249 if style.fill: 

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

251 if style.stroke: 

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

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

254 if style.stroke_dasharray: 

255 so.pattern_set(style.stroke_dasharray) 

256 if style.stroke_dashoffset: 

257 so.gap = style.stroke_dashoffset 

258 if style.stroke_linecap: 

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

260 if style.stroke_linejoin: 

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

262 if style.stroke_miterlimit: 

263 so.linejoinmaxsize = style.stroke_miterlimit 

264 if style.stroke_width: 

265 so.width = style.stroke_width 

266 if style.offset_x: 

267 so.offsetx = style.offset_x 

268 if style.offset_y: 

269 so.offsety = style.offset_y 

270 return so 

271 

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

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

274 lo = mapscript.labelObj() 

275 so = mapscript.styleObj() 

276 lo.force = mapscript.MS_TRUE 

277 

278 if style.label_align: 

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

280 if style.label_background: 

281 so.setGeomTransform('labelpoly') 

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

283 if style.label_fill: 

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

285 if style.label_font_family: 

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

287 if style.label_font_size: 

288 lo.size = style.label_font_size 

289 if style.label_max_scale: 

290 lo.maxscaledenom = style.label_max_scale 

291 if style.label_min_scale: 

292 lo.minscaledenom = style.label_min_scale 

293 if style.label_offset_x: 

294 lo.offsetx = style.label_offset_x 

295 if style.label_offset_y: 

296 lo.offsety = style.label_offset_y 

297 if style.label_padding: 

298 lo.buffer = max(style.label_padding) 

299 if style.label_placement: 

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

301 if style.label_stroke: 

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

303 if style.label_stroke_dasharray: 

304 so.pattern_set(style.label_stroke_dasharray) 

305 if style.label_stroke_linecap: 

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

307 if style.label_stroke_linejoin: 

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

309 if style.label_stroke_miterlimit: 

310 so.linejoinmaxsize = style.label_stroke_miterlimit 

311 if style.label_stroke_width: 

312 lo.outlinewidth = style.label_stroke_width 

313 lo.insertStyle(so) 

314 return lo 

315 

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

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

318 

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

320 

321 Args: 

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

323 

324 Returns: 

325 A MapServer style object. 

326 """ 

327 

328 mo = self.mapObj 

329 so = mapscript.styleObj() 

330 so.setSymbolByName(mo, style.marker) 

331 

332 if style.marker_fill: 

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

334 if style.marker_size: 

335 so.size = style.marker_size 

336 if style.marker_stroke: 

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

338 if style.marker_stroke_dasharray: 

339 so.pattern_set(style.marker_stroke_dasharray) 

340 if style.marker_stroke_dashoffset: 

341 so.gap = style.marker_stroke_dashoffset 

342 if style.marker_stroke_linecap: 

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

344 if style.marker_stroke_linejoin: 

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

346 if style.marker_stroke_miterlimit: 

347 so.linejoinmaxsize = style.marker_stroke_miterlimit 

348 if style.marker_stroke_width: 

349 so.outlinewidth = style.marker_stroke_width 

350 return so 

351 

352 

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

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

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

356 if s in _CSS_COLOR_NAMES: 

357 r, g, b = _CSS_COLOR_NAMES[s] 

358 return r, g, b, 255 

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

360 if m: 

361 h = m.group(1) 

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

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

364 if m: 

365 h = m.group(1) 

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

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

368 if m: 

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

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

371 if m: 

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

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

374 

375 

376_CSS_COLOR_NAMES = { 

377 'black': (0, 0, 0), 

378 'white': (255, 255, 255), 

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

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

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

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

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

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

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

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

387 'gray': (128, 128, 128), 

388 'grey': (128, 128, 128), 

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

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

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

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

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

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

395} 

396 

397_const_mapping = { 

398 'butt': mapscript.MS_CJC_BUTT, 

399 'round': mapscript.MS_CJC_ROUND, 

400 'square': mapscript.MS_CJC_SQUARE, 

401 'bevel': mapscript.MS_CJC_BEVEL, 

402 'miter': mapscript.MS_CJC_MITER, 

403 'left': mapscript.MS_ALIGN_LEFT, 

404 'center': mapscript.MS_ALIGN_CENTER, 

405 'right': mapscript.MS_ALIGN_RIGHT, 

406 'start': mapscript.MS_CL, 

407 'middle': mapscript.MS_CC, 

408 'end': mapscript.MS_CR, 

409}