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
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""MapServer map wrapper."""
3import mapscript
4import os
5import re
7import gws
8import gws.lib.image
11def version() -> str:
12 """Return the MapServer version string.
14 Returns:
15 The version string, as returned by ``mapscript.msGetVersion``.
16 """
18 return mapscript.msGetVersion()
21class Error(gws.Error):
22 """MapServer error."""
24 pass
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}
36def new_map(config: str = '') -> 'Map':
37 """Create a new map.
39 Args:
40 config: Mapfile content. If empty, an empty map is created.
42 Returns:
43 A Map object.
44 """
46 return Map(config)
49class Map:
50 """Wrapper around a ``mapscript.mapObj``.
52 MapServer errors are written to stderr.
53 """
55 mapObj: mapscript.mapObj
56 """The wrapped MapServer map object."""
58 def __init__(self, config: str = ''):
59 """Create a map.
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()
72 self.mapObj.setConfigOption('MS_ERRORFILE', 'stderr')
74 def copy(self) -> 'Map':
75 """Create a copy of the map.
77 Returns:
78 A new Map object with a clone of the MapServer map.
79 """
81 c = Map()
82 c.mapObj = self.mapObj.clone()
83 return c
85 def add_layer_from_config(self, config: str) -> mapscript.layerObj:
86 """Add a layer to the map from a mapfile ``LAYER`` block.
88 Args:
89 config: Layer configuration in the mapfile syntax.
91 Returns:
92 The new MapServer layer object.
94 Raises:
95 ``Error``: If MapServer cannot create the layer.
96 """
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
105 def add_layer(self, opts: gws.MapServerLayerOptions) -> mapscript.layerObj:
106 """Add a layer to the map from layer options.
108 The layer is named ``_gws_<n>`` and switched on.
110 Args:
111 opts: Layer options. ``crs`` is required.
113 Returns:
114 The new MapServer layer object.
116 Raises:
117 ``Error``: If the CRS is missing, the connection type is not supported, or MapServer cannot create the layer.
118 """
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
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
133 if not opts.crs:
134 raise Error('missing layer CRS')
135 lo.setProjection(opts.crs.epsg)
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)
163 # @TODO: support style values
164 if opts.style:
165 cls = mapscript.classObj(lo)
167 if opts.style.with_geometry == 'all':
168 style_obj = self._create_style_obj(opts.style)
169 cls.insertStyle(style_obj)
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'
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)
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
192 def draw(self, bounds: gws.Bounds, size: gws.Size) -> gws.Image:
193 """Render the map as a transparent PNG.
195 Args:
196 bounds: Extent and CRS to render.
197 size: Image size ``(width, height)`` in pixels.
199 Returns:
200 The rendered image.
202 Raises:
203 ``Error``: If MapServer cannot render the map.
204 """
206 # @TODO: options for image format, transparency, etc.
208 try:
209 gws.debug.time_start(f'mapserver.draw {bounds=} {size=}')
211 self.mapObj.setOutputFormat(mapscript.outputFormatObj('AGG/PNG'))
212 self.mapObj.outputformat.transparent = mapscript.MS_TRUE
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.
217 self.mapObj.setSize(int(size[0]), int(size[1]))
218 self.mapObj.setExtent(*bounds.extent)
219 self.mapObj.setProjection(bounds.crs.epsg)
221 res = self.mapObj.draw()
222 img = gws.lib.image.from_bytes(res.getBytes())
224 gws.debug.time_end()
226 return img
228 except mapscript.MapServerError as exc:
229 raise Error(f'ms: draw error: {exc}') from exc
231 def to_string(self) -> str:
232 """Convert the map to a mapfile string.
234 Returns:
235 Mapfile content.
237 Raises:
238 ``Error``: If MapServer cannot convert the map.
239 """
241 try:
242 return self.mapObj.convertToString()
243 except mapscript.MapServerError as exc:
244 raise Error(f'ms: convert error: {exc}') from exc
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
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
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
316 def style_symbol(self, style: gws.StyleValues) -> mapscript.styleObj:
317 """Create a MapServer marker style from style values.
319 The marker symbol is looked up by name in the symbol set of the map.
321 Args:
322 style: Style values with ``marker`` and optional ``marker_*`` values.
324 Returns:
325 A MapServer style object.
326 """
328 mo = self.mapObj
329 so = mapscript.styleObj()
330 so.setSymbolByName(mo, style.marker)
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
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}')
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}
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}