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
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-03 10:12 +0100
1"""MapServer support.
3This module dynamically creates and renders MapServer maps.
5To render a map, create a map object with `new_map`, add layers to it using ``add_`` methods
6and invoke ``draw``.
8Reference: MapServer documentation (https://mapserver.org/documentation.html)
10Example usage::
12 import gws.gis.ms as ms
14 # create a new map
15 map = ms.new_map()
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 )
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 ''')
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 )
55 # save the image to a file
56 img.to_path('/path/to/output.png')
59"""
61from typing import Optional
62import mapscript
63import re
65import gws
66import gws.lib.image
69def version() -> str:
70 """Returns the MapServer version string."""
72 return mapscript.msGetVersion()
75class Error(gws.Error):
76 pass
79class LayerType(gws.Enum):
80 """MapServer layer type."""
82 point = 'point'
83 line = 'line'
84 polygon = 'polygon'
85 raster = 'raster'
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}
96class LayerOptions(gws.Data):
97 """Options for a mapserver layer."""
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."""
125def new_map(config: str = '') -> 'Map':
126 """Creates a new Map instance from a Mapfile string."""
128 return Map(config)
131class Map:
132 """MapServer map object wrapper."""
134 mapObj: mapscript.mapObj
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()
144 self.mapObj.setConfigOption('MS_ERRORFILE', 'stderr')
145 # self.mapObj.debug = mapscript.MS_DEBUGLEVEL_DEVDEBUG
146 self.mapObj.debug = mapscript.MS_DEBUGLEVEL_ERRORSONLY
148 def copy(self) -> 'Map':
149 """Creates a copy of the current map object."""
151 c = Map()
152 c.mapObj = self.mapObj.clone()
153 return c
155 def add_layer_from_config(self, config: str) -> mapscript.layerObj:
156 """Adds a layer to the map using a configuration string."""
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
165 def add_layer(self, opts: LayerOptions) -> mapscript.layerObj:
166 """Adds a layer to the map."""
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
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
180 if not opts.crs:
181 raise Error('missing layer CRS')
182 lo.setProjection(opts.crs.epsg)
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)
210 # @TODO: support style values
211 if opts.style:
212 cls = mapscript.classObj(lo)
214 if opts.style.with_geometry == 'all':
215 style_obj = self._create_style_obj(opts.style)
216 cls.insertStyle(style_obj)
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'
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)
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
239 def draw(self, bounds: gws.Bounds, size: gws.Size) -> gws.Image:
240 """Renders the map within the given bounds and size.
242 Args:
243 bounds: The spatial extent to render.
244 size: The output image size.
246 Returns:
247 The rendered map image.
248 """
250 # @TODO: options for image format, transparency, etc.
252 try:
253 gws.debug.time_start(f'mapserver.draw {bounds=} {size=}')
255 self.mapObj.setOutputFormat(mapscript.outputFormatObj('AGG/PNG'))
256 self.mapObj.outputformat.transparent = mapscript.MS_TRUE
258 self.mapObj.setExtent(*bounds.extent)
259 self.mapObj.setSize(*size)
260 self.mapObj.setProjection(bounds.crs.epsg)
262 res = self.mapObj.draw()
263 img = gws.lib.image.from_bytes(res.getBytes())
265 gws.debug.time_end()
267 return img
269 except mapscript.MapServerError as exc:
270 raise Error(f'ms: draw error: {exc}') from exc
272 def to_string(self) -> str:
273 """Converts the map object to a configuration string."""
275 try:
276 return self.mapObj.convertToString()
277 except mapscript.MapServerError as exc:
278 raise Error(f'ms: convert error: {exc}') from exc
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
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
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
348 def style_symbol(self, style: gws.StyleValues) -> mapscript.styleObj:
349 mo = self.mapObj
350 so = mapscript.styleObj()
351 so.setSymbolByName(mo, style.marker)
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
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}')
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}
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}