Coverage for gws-app/gws/gis/ms/__init__.py: 84%
240 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 22:59 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 22:59 +0200
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
64import gws
65import gws.lib.image
68def version() -> str:
69 """Returns the MapServer version string."""
71 return mapscript.msGetVersion()
74class Error(gws.Error):
75 pass
78class LayerType(gws.Enum):
79 """MapServer layer type."""
81 point = 'point'
82 line = 'line'
83 polygon = 'polygon'
84 raster = 'raster'
87_LAYER_TYPE_TO_MS = {
88 LayerType.point: mapscript.MS_LAYER_POINT,
89 LayerType.line: mapscript.MS_LAYER_LINE,
90 LayerType.polygon: mapscript.MS_LAYER_POLYGON,
91 LayerType.raster: mapscript.MS_LAYER_RASTER,
92}
95class LayerOptions(gws.Data):
96 """Options for a mapserver layer."""
98 type: LayerType
99 """Layer type."""
100 path: str
101 """Path to the image file."""
102 tileIndex: str
103 """Path to the tile index SHP file"""
104 crs: gws.Crs
105 """Layer CRS."""
106 connectionType: str
107 """Type of connection (e.g., 'postgres')."""
108 connectionString: str
109 """Connection string for the data source."""
110 dataString: str
111 """Layer DATA option."""
112 style: gws.StyleValues
113 """Style for the layer."""
114 processing: list[str]
115 """Processing options for the layer."""
116 transparentColor: str
117 """Color to treat as transparent in the layer (OFFSITE)."""
118 sldPath: str
119 """Path to SLD file for styling the layer."""
120 sldName: str
121 """Name of an SLD NamedLayer to apply."""
124def new_map(config: str = '') -> 'Map':
125 """Creates a new Map instance from a Mapfile string."""
127 return Map(config)
130class Map:
131 """MapServer map object wrapper."""
133 mapObj: mapscript.mapObj
135 def __init__(self, config: str = ''):
136 if config:
137 tmp = gws.c.EPHEMERAL_DIR + '/mapse_' + gws.u.random_string(16) + '.map'
138 gws.u.write_file(tmp, config)
139 self.mapObj = mapscript.mapObj(tmp)
140 else:
141 self.mapObj = mapscript.mapObj()
143 self.mapObj.setConfigOption('MS_ERRORFILE', 'stderr')
144 # self.mapObj.debug = mapscript.MS_DEBUGLEVEL_DEVDEBUG
145 self.mapObj.debug = mapscript.MS_DEBUGLEVEL_ERRORSONLY
147 def copy(self) -> 'Map':
148 """Creates a copy of the current map object."""
150 c = Map()
151 c.mapObj = self.mapObj.clone()
152 return c
154 def add_layer_from_config(self, config: str) -> mapscript.layerObj:
155 """Adds a layer to the map using a configuration string."""
157 try:
158 lo = mapscript.layerObj(self.mapObj)
159 lo.updateFromString(config)
160 return lo
161 except mapscript.MapServerError as exc:
162 raise Error(f'ms: add error:: {exc}') from exc
164 def add_layer(self, opts: LayerOptions) -> mapscript.layerObj:
165 """Adds a layer to the map."""
167 try:
168 lo = self._make_layer(opts)
169 return lo
170 except mapscript.MapServerError as exc:
171 raise Error(f'ms: add error:: {exc}') from exc
173 def _make_layer(self, opts: LayerOptions) -> mapscript.layerObj:
174 lo = mapscript.layerObj(self.mapObj)
175 lc = self.mapObj.numlayers
176 lo.name = f'_gws_{lc}'
177 lo.status = mapscript.MS_ON
179 if not opts.crs:
180 raise Error('missing layer CRS')
181 lo.setProjection(opts.crs.epsg)
183 if opts.type:
184 lo.type = _LAYER_TYPE_TO_MS[opts.type]
185 if opts.path:
186 lo.data = opts.path
187 if opts.tileIndex:
188 lo.tileindex = opts.tileIndex
189 if opts.processing:
190 for p in opts.processing:
191 lo.addProcessing(p)
192 if opts.transparentColor:
193 co = mapscript.colorObj()
194 co.setHex(opts.transparentColor)
195 lo.offsite = co
196 if opts.connectionType:
197 if opts.connectionType == 'postgres':
198 lo.setConnectionType(mapscript.MS_POSTGIS, '')
199 else:
200 raise Error(f'unsupported connectionType {opts.connectionType!r}')
201 if opts.connectionString:
202 lo.connection = opts.connectionString
203 if opts.dataString:
204 lo.data = opts.dataString
205 if opts.sldPath:
206 lo.applySLD(gws.u.read_file(opts.sldPath), opts.sldName)
208 # @TODO: support style values
209 if opts.style:
210 cls = mapscript.classObj(lo)
212 if opts.style.with_geometry == 'all':
213 style_obj = self._create_style_obj(opts.style)
214 cls.insertStyle(style_obj)
216 if opts.style.with_label == 'all':
217 label_obj = self._create_label_obj(opts.style)
218 cls.addLabel(label_obj)
219 lo.labelitem = 'label'
221 if opts.style.marker or opts.style.icon:
222 if opts.style.marker:
223 self.mapObj.setSymbolSet('/gws-app/gws/gis/ms/symbolset.sym')
224 so = self.style_symbol(opts.style)
225 cls.insertStyle(so)
227 if opts.style.icon:
228 symbol = mapscript.symbolObj("icon", opts.style.icon)
229 symbol.type = mapscript.MS_SYMBOL_PIXMAP
230 lo.map.symbolset.appendSymbol(symbol)
231 so = mapscript.styleObj()
232 so.setSymbolByName(lo.map, "icon")
233 so.size = 100
234 cls.insertStyle(so)
235 return lo
237 def draw(self, bounds: gws.Bounds, size: gws.Size) -> gws.Image:
238 """Renders the map within the given bounds and size.
240 Args:
241 bounds: The spatial extent to render.
242 size: The output image size.
244 Returns:
245 The rendered map image.
246 """
248 # @TODO: options for image format, transparency, etc.
250 try:
251 gws.debug.time_start(f'mapserver.draw {bounds=} {size=}')
253 self.mapObj.setOutputFormat(mapscript.outputFormatObj('AGG/PNG'))
254 self.mapObj.outputformat.transparent = mapscript.MS_TRUE
256 self.mapObj.setExtent(*bounds.extent)
257 self.mapObj.setSize(*size)
258 self.mapObj.setProjection(bounds.crs.epsg)
260 res = self.mapObj.draw()
261 img = gws.lib.image.from_bytes(res.getBytes())
263 gws.debug.time_end()
265 return img
267 except mapscript.MapServerError as exc:
268 raise Error(f'ms: draw error: {exc}') from exc
270 def to_string(self) -> str:
271 """Converts the map object to a configuration string."""
273 try:
274 return self.mapObj.convertToString()
275 except mapscript.MapServerError as exc:
276 raise Error(f'ms: convert error: {exc}') from exc
278 def _create_style_obj(self, style: gws.StyleValues) -> mapscript.styleObj:
279 so = mapscript.styleObj()
280 if style.fill:
281 so.color.setRGB(*_css_color_to_rgb(style.fill))
282 if style.stroke:
283 so.outlinecolor.setRGB(*_css_color_to_rgb(style.stroke))
284 so.outlinewidth = max(0.1 * style.stroke_width, 1)
285 if style.stroke_dasharray:
286 so.pattern_set(style.stroke_dasharray)
287 if style.stroke_dashoffset:
288 so.gap = style.stroke_dashoffset
289 if style.stroke_linecap:
290 so.linecap = _const_mapping.get(style.stroke_linecap.lower())
291 if style.stroke_linejoin:
292 so.linejoin = _const_mapping.get(style.stroke_linejoin.lower())
293 if style.stroke_miterlimit:
294 so.linejoinmaxsize = style.stroke_miterlimit
295 if style.stroke_width:
296 so.width = style.stroke_width
297 if style.offset_x:
298 so.offsetx = style.offset_x
299 if style.offset_y:
300 so.offsety = style.offset_y
301 return so
303 def _create_label_obj(self, style: gws.StyleValues) -> mapscript.labelObj:
304 lo = mapscript.labelObj()
305 so = mapscript.styleObj()
306 lo.force = mapscript.MS_TRUE
308 if style.label_align:
309 lo.align = _const_mapping.get(style.label_align)
310 if style.label_background:
311 so.setGeomTransform('labelpoly')
312 so.color.setRGB(*_css_color_to_rgb(style.label_background))
313 if style.label_fill:
314 lo.color.setRGB(*_css_color_to_rgb(style.label_fill))
315 if style.label_font_family:
316 lo.font = style.label_font_family # + '-' + style.label_font_style + '-' + style.label_font_weight
317 if style.label_font_size:
318 lo.size = style.label_font_size
319 if style.label_max_scale:
320 lo.maxscaledenom = style.label_max_scale
321 if style.label_min_scale:
322 lo.minscaledenom = style.label_min_scale
323 if style.label_offset_x:
324 lo.offsetx = style.label_offset_x
325 if style.label_offset_y:
326 lo.offsety = style.label_offset_y
327 if style.label_padding:
328 lo.buffer = max(style.label_padding)
329 if style.label_placement:
330 lo.position = _const_mapping.get(style.label_placement)
331 if style.label_stroke:
332 lo.outlinecolor.setRGB(*_css_color_to_rgb(style.label_stroke))
333 if style.label_stroke_dasharray:
334 so.pattern_set(style.label_stroke_dasharray)
335 if style.label_stroke_linecap:
336 so.linecap = _const_mapping.get(style.label_stroke_linecap.lower())
337 if style.label_stroke_linejoin:
338 so.linejoin = _const_mapping.get(style.label_stroke_linejoin.lower())
339 if style.label_stroke_miterlimit:
340 so.linejoinmaxsize = style.label_stroke_miterlimit
341 if style.label_stroke_width:
342 lo.outlinewidth = style.label_stroke_width
343 lo.insertStyle(so)
344 return lo
346 def style_symbol(self, style: gws.StyleValues) -> mapscript.styleObj:
347 mo = self.mapObj
348 so = mapscript.styleObj()
349 so.setSymbolByName(mo, style.marker)
351 if style.marker_fill:
352 so.color.setRGB(*_css_color_to_rgb(style.marker_fill))
353 if style.marker_size:
354 so.size = style.marker_size
355 if style.marker_stroke:
356 so.outlinecolor.setRGB(*_css_color_to_rgb(style.marker_stroke))
357 if style.marker_stroke_dasharray:
358 so.pattern_set(style.marker_stroke_dasharray)
359 if style.marker_stroke_dashoffset:
360 so.gap = style.marker_stroke_dashoffset
361 if style.marker_stroke_linecap:
362 so.linecap = _const_mapping.get(style.marker_stroke_linecap.lower())
363 if style.marker_stroke_linejoin:
364 so.linejoin = _const_mapping.get(style.marker_stroke_linejoin.lower())
365 if style.marker_stroke_miterlimit:
366 so.linejoinmaxsize = style.marker_stroke_miterlimit
367 if style.marker_stroke_width:
368 so.outlinewidth = style.marker_stroke_width
369 return so
371def _css_color_to_rgb(color_name: str) -> tuple[int, int, int]:
372 try:
373 return _CSS_COLOR_NAMES[color_name.lower()]
374 except KeyError:
375 raise ValueError(f"Unbekannter CSS-Farbenname: '{color_name}'")
378def _color_to_str(color: str) -> str:
379 if isinstance(color, str):
380 r, g, b = _css_color_to_rgb(color)
381 return f"{r} {g} {b}"
383_CSS_COLOR_NAMES = {
384 'black': (0, 0, 0),
385 'white': (255, 255, 255),
386 'red': (255, 0, 0),
387 'lime': (0, 255, 0),
388 'blue': (0, 0, 255),
389 'yellow': (255, 255, 0),
390 'cyan': (0, 255, 255),
391 'aqua': (0, 255, 255),
392 'magenta': (255, 0, 255),
393 'fuchsia': (255, 0, 255),
394 'gray': (128, 128, 128),
395 'grey': (128, 128, 128),
396 'maroon': (128, 0, 0),
397 'olive': (128, 128, 0),
398 'green': (0, 128, 0),
399 'purple': (128, 0, 128),
400 'teal': (0, 128, 128),
401 'navy': (0, 0, 128),
402}
404_const_mapping = {
405 'butt': mapscript.MS_CJC_BUTT,
406 'round': mapscript.MS_CJC_ROUND,
407 'square': mapscript.MS_CJC_SQUARE,
408 'bevel': mapscript.MS_CJC_BEVEL,
409 'miter': mapscript.MS_CJC_MITER,
410 'left': mapscript.MS_ALIGN_LEFT,
411 'center': mapscript.MS_ALIGN_CENTER,
412 'right': mapscript.MS_ALIGN_RIGHT,
413 'start': mapscript.MS_CL,
414 'middle': mapscript.MS_CC,
415 'end': mapscript.MS_CR,
416}