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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""MapServer map wrapper."""
3import mapscript
4import re
6import gws
7import gws.lib.image
10def version() -> str:
11 """Return the MapServer version string.
13 Returns:
14 The version string, as returned by ``mapscript.msGetVersion``.
15 """
17 return mapscript.msGetVersion()
20class Error(gws.Error):
21 """MapServer error."""
23 pass
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}
35def new_map(config: str = '') -> 'Map':
36 """Create a new map.
38 Args:
39 config: Mapfile content. If empty, an empty map is created.
41 Returns:
42 A Map object.
43 """
45 return Map(config)
48class Map:
49 """Wrapper around a ``mapscript.mapObj``.
51 MapServer errors are written to stderr.
52 """
54 mapObj: mapscript.mapObj
55 """The wrapped MapServer map object."""
57 def __init__(self, config: str = ''):
58 """Create a map.
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()
71 self.mapObj.setConfigOption('MS_ERRORFILE', 'stderr')
73 def copy(self) -> 'Map':
74 """Create a copy of the map.
76 Returns:
77 A new Map object with a clone of the MapServer map.
78 """
80 c = Map()
81 c.mapObj = self.mapObj.clone()
82 return c
84 def add_layer_from_config(self, config: str) -> mapscript.layerObj:
85 """Add a layer to the map from a mapfile ``LAYER`` block.
87 Args:
88 config: Layer configuration in the mapfile syntax.
90 Returns:
91 The new MapServer layer object.
93 Raises:
94 ``Error``: If MapServer cannot create the layer.
95 """
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
104 def add_layer(self, opts: gws.MapServerLayerOptions) -> mapscript.layerObj:
105 """Add a layer to the map from layer options.
107 The layer is named ``_gws_<n>`` and switched on.
109 Args:
110 opts: Layer options. ``crs`` is required.
112 Returns:
113 The new MapServer layer object.
115 Raises:
116 ``Error``: If the CRS is missing, the connection type is not supported, or MapServer cannot create the layer.
117 """
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
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
132 if not opts.crs:
133 raise Error('missing layer CRS')
134 lo.setProjection(opts.crs.epsg)
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)
162 # @TODO: support style values
163 if opts.style:
164 cls = mapscript.classObj(lo)
166 if opts.style.with_geometry == 'all':
167 style_obj = self._create_style_obj(opts.style)
168 cls.insertStyle(style_obj)
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'
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)
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
191 def draw(self, bounds: gws.Bounds, size: gws.Size) -> gws.Image:
192 """Render the map as a transparent PNG.
194 Args:
195 bounds: Extent and CRS to render.
196 size: Image size ``(width, height)`` in pixels.
198 Returns:
199 The rendered image.
201 Raises:
202 ``Error``: If MapServer cannot render the map.
203 """
205 # @TODO: options for image format, transparency, etc.
207 try:
208 gws.debug.time_start(f'mapserver.draw {bounds=} {size=}')
210 self.mapObj.setOutputFormat(mapscript.outputFormatObj('AGG/PNG'))
211 self.mapObj.outputformat.transparent = mapscript.MS_TRUE
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.
216 self.mapObj.setSize(int(size[0]), int(size[1]))
217 self.mapObj.setExtent(*bounds.extent)
218 self.mapObj.setProjection(bounds.crs.epsg)
220 res = self.mapObj.draw()
221 img = gws.lib.image.from_bytes(res.getBytes())
223 gws.debug.time_end()
225 return img
227 except mapscript.MapServerError as exc:
228 raise Error(f'ms: draw error: {exc}') from exc
230 def to_string(self) -> str:
231 """Convert the map to a mapfile string.
233 Returns:
234 Mapfile content.
236 Raises:
237 ``Error``: If MapServer cannot convert the map.
238 """
240 try:
241 return self.mapObj.convertToString()
242 except mapscript.MapServerError as exc:
243 raise Error(f'ms: convert error: {exc}') from exc
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
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
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
315 def style_symbol(self, style: gws.StyleValues) -> mapscript.styleObj:
316 """Create a MapServer marker style from style values.
318 The marker symbol is looked up by name in the symbol set of the map.
320 Args:
321 style: Style values with ``marker`` and optional ``marker_*`` values.
323 Returns:
324 A MapServer style object.
325 """
327 mo = self.mapObj
328 so = mapscript.styleObj()
329 so.setSymbolByName(mo, style.marker)
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
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}')
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}
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}