Coverage for gws-app/gws/lib/svg/draw.py: 79%
293 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
1"""SVG builders."""
3from typing import Optional, cast
5import base64
6import math
7import shapely
8import shapely.geometry
9import shapely.ops
11import gws
12import gws.lib.font
13import gws.gis.render
14import gws.base.shape
15import gws.lib.uom
16import gws.lib.xmlx as xmlx
18from . import element
20DEFAULT_FONT_SIZE = 10
21DEFAULT_MARKER_SIZE = 10
22DEFAULT_POINT_SIZE = 10
24MAX_SOUP_POINTS = 5000
25MAX_SOUP_TAGS = 5000
28def shape_to_fragment(shape: gws.Shape, view: gws.MapView, label: str = None, style: gws.Style = None) -> list[gws.XmlElement]:
29 """Convert a shape to a list of XmlElements (a "fragment")."""
31 if not shape:
32 return []
34 geom = cast(gws.base.shape.Shape, shape).geom
35 if geom.is_empty:
36 return []
38 trans = gws.gis.render.map_view_transformer(view)
39 geom = shapely.ops.transform(trans, geom)
41 if not style:
42 return [_geometry(geom)]
44 sv = style.values
45 with_geometry = sv.with_geometry == 'all'
46 with_label = label and _is_label_visible(view, sv)
47 gt = _geom_type(geom)
49 text = None
51 if with_label:
52 extra_y_offset = 0
53 if sv.label_offset_y is None:
54 if gt == _TYPE_POINT:
55 extra_y_offset = (sv.label_font_size or DEFAULT_FONT_SIZE) * 2
56 if gt == _TYPE_LINESTRING:
57 extra_y_offset = 6
58 text = _label(geom, label, sv, extra_y_offset)
60 marker = None
61 marker_id = None
63 if with_geometry and sv.marker:
64 marker_id = '_M' + gws.u.random_string(8)
65 marker = _marker(marker_id, sv)
67 atts: dict = {}
69 icon = None
71 if with_geometry and sv.icon:
72 res = _parse_icon(sv.icon, view.dpi)
73 if res:
74 icon_el, w, h = res
75 x, y, w, h = _icon_size_and_position(geom, sv, w, h)
76 atts = {
77 'x': f'{int(x)}',
78 'y': f'{int(y)}',
79 'width': f'{int(w)}',
80 'height': f'{int(h)}',
81 }
82 icon = xmlx.tag(
83 icon_el.name,
84 gws.u.merge(icon_el.attrib, atts),
85 *icon_el.children()
86 )
88 body = None
90 if with_geometry:
91 _add_paint_atts(atts, sv)
92 if marker:
93 atts['marker-start'] = atts['marker-mid'] = atts['marker-end'] = f'url(#{marker_id})'
94 if gt in {_TYPE_POINT, _TYPE_MULTIPOINT}:
95 atts['r'] = (sv.point_size or DEFAULT_POINT_SIZE) // 2
96 if gt in {_TYPE_LINESTRING, _TYPE_MULTILINESTRING}:
97 atts['fill'] = 'none'
98 body = _geometry(geom, atts)
100 return gws.u.compact([marker, body, icon, text])
103def soup_to_fragment(view: gws.MapView, points: list[gws.Point], tags: list) -> list[gws.XmlElement]:
104 """Convert an svg "soup" to a list of XmlElements (a "fragment").
106 A soup has two components:
108 - a list of points, in the map coordinate system
109 - a list of tuples suitable for `xmlx.tag` input (tag-name, {atts}, child1, child2....)
111 The idea is to represent client-side svg drawings (e.g. dimensions) in a resolution-independent way
113 First, points are converted to pixels using the view's transform. Then, each tag's attributes are iterated.
114 If any attribute value is an array, it's assumed to be a 'function'.
115 The first element is a function name, the rest are arguments.
116 Attribute 'functions' are
118 - ['x', n] - returns points[n][0]
119 - ['y', n] - returns points[n][1]
120 - ['r', p1, p2, r] - computes a slope between points[p1] points[p2] and returns a string
121 `rotate(slope, points[r].x, points[r].y)`
123 """
125 if len(points) > MAX_SOUP_POINTS:
126 raise gws.Error(f'too many soup points: {len(points)}')
127 if len(tags) > MAX_SOUP_TAGS:
128 raise gws.Error(f'too many soup tags: {len(tags)}')
130 trans = gws.gis.render.map_view_transformer(view)
132 try:
133 px = [trans(*p) for p in points]
134 except Exception as exc:
135 raise gws.Error('invalid soup') from exc
137 def eval_func(v):
138 if v[0] == 'x':
139 return round(px[v[1]][0])
140 if v[0] == 'y':
141 return round(px[v[1]][1])
142 if v[0] == 'r':
143 a = _slope(px[v[1]], px[v[2]])
144 adeg = math.degrees(a)
145 x, y = px[v[3]]
146 return f'rotate({adeg:.0f}, {x:.0f}, {y:.0f})'
147 raise gws.Error(f'unknown soup function: {v[0]!r}')
149 def eval_funcs(tag):
150 res = []
151 for arg in tag:
152 if isinstance(arg, dict):
153 d = {}
154 for k, v in arg.items():
155 d[k] = eval_func(v) if isinstance(v, (list, tuple)) else v
156 res.append(d)
157 elif isinstance(arg, (list, tuple)):
158 res.append(eval_funcs(arg))
159 else:
160 res.append(arg)
161 return res
163 els = []
165 try:
166 for tag in tags:
167 els.append(xmlx.tag(*eval_funcs(tag)))
168 except Exception as exc:
169 raise gws.Error('invalid soup') from exc
171 return element.normalize_fragment(els)
174# ----------------------------------------------------------------------------------------------------------------------
175# geometry
177def _geometry(geom: shapely.geometry.base.BaseGeometry, atts: dict = None) -> gws.XmlElement:
178 def _xy(xy):
179 x, y = xy
180 return f'{x} {y}'
182 def _lpath(coords):
183 ps = []
184 cs = iter(coords)
185 for c in cs:
186 ps.append(f'M {_xy(c)}')
187 break
188 for c in cs:
189 ps.append(f'L {_xy(c)}')
190 return ' '.join(ps)
192 gt = _geom_type(geom)
194 if gt == _TYPE_POINT:
195 g = cast(shapely.geometry.Point, geom)
196 return xmlx.tag('circle', {'cx': int(g.x), 'cy': int(g.y)}, atts)
198 if gt == _TYPE_LINESTRING:
199 g = cast(shapely.geometry.LineString, geom)
200 d = _lpath(g.coords)
201 return xmlx.tag('path', {'d': d}, atts)
203 if gt == _TYPE_POLYGON:
204 g = cast(shapely.geometry.Polygon, geom)
205 d = ' '.join(_lpath(interior.coords) + ' z' for interior in g.interiors)
206 d = _lpath(g.exterior.coords) + ' z ' + d
207 return xmlx.tag('path', {'fill-rule': 'evenodd', 'd': d.strip()}, atts)
209 if gt >= _TYPE_MULTIPOINT:
210 g = cast(shapely.geometry.base.BaseMultipartGeometry, geom)
211 return xmlx.tag('g', *[_geometry(p, atts) for p in g.geoms])
214def _enum_points(geom):
215 gt = _geom_type(geom)
217 if gt in {_TYPE_POINT, _TYPE_LINESTRING, _TYPE_LINEARRING}:
218 return geom.coords
219 if gt == _TYPE_POLYGON:
220 return geom.exterior.coords
221 if gt >= _TYPE_MULTIPOINT:
222 return [p for g in geom.geoms for p in _enum_points(g)]
225# https://shapely.readthedocs.io/en/stable/reference/shapely.get_type_id.html
227_TYPE_POINT = 0
228_TYPE_LINESTRING = 1
229_TYPE_LINEARRING = 2
230_TYPE_POLYGON = 3
231_TYPE_MULTIPOINT = 4
232_TYPE_MULTILINESTRING = 5
233_TYPE_MULTIPOLYGON = 6
234_TYPE_GEOMETRYCOLLECTION = 7
237def _geom_type(geom):
238 p = shapely.get_type_id(geom)
239 if _TYPE_POINT <= p <= _TYPE_MULTIPOLYGON:
240 return p
241 raise gws.Error(f'unsupported geometry type {geom.type!r}')
244# ----------------------------------------------------------------------------------------------------------------------
245# marker
247# @TODO only type=circle is implemented
249def _marker(uid, sv: gws.StyleValues) -> gws.XmlElement:
250 size = sv.marker_size or DEFAULT_MARKER_SIZE
251 size2 = size // 2
253 content = None
254 atts: dict = {}
256 _add_paint_atts(atts, sv, 'marker_')
258 if sv.marker == 'circle':
259 atts.update({
260 'cx': size2,
261 'cy': size2,
262 'r': size2,
263 })
264 content = 'circle', atts
266 if content:
267 return xmlx.tag('marker', {
268 'id': uid,
269 'viewBox': f'0 0 {size} {size}',
270 'refX': size2,
271 'refY': size2,
272 'markerUnits': 'userSpaceOnUse',
273 'markerWidth': size,
274 'markerHeight': size,
275 }, content)
278# ----------------------------------------------------------------------------------------------------------------------
279# labels
281# @TODO label positioning needs more work
283def _is_label_visible(view: gws.MapView, sv: gws.StyleValues) -> bool:
284 if sv.with_label != 'all':
285 return False
286 if view.scale < int(sv.get('label_min_scale', 0)):
287 return False
288 if view.scale > int(sv.get('label_max_scale', 1e10)):
289 return False
290 return True
293def _label(geom, label: str, sv: gws.StyleValues, extra_y_offset=0) -> gws.XmlElement:
294 xy = _label_position(geom, sv, extra_y_offset)
295 return _label_text(xy[0], xy[1], label, sv)
298def _label_position(geom, sv: gws.StyleValues, extra_y_offset=0) -> gws.Point:
299 if sv.label_placement == 'start':
300 x, y = _enum_points(geom)[0]
301 elif sv.label_placement == 'end':
302 x, y = _enum_points(geom)[-1]
303 else:
304 c = geom.centroid
305 x, y = c.x, c.y
306 return (
307 round(x) + (sv.label_offset_x or 0),
308 round(y) + extra_y_offset + (sv.label_font_size >> 1) + (sv.label_offset_y or 0)
309 )
312def _label_text(cx, cy, label, sv: gws.StyleValues) -> gws.XmlElement:
313 font_name = _font_name(sv)
314 font_size = sv.label_font_size or DEFAULT_FONT_SIZE
315 font = gws.lib.font.from_name(font_name, font_size)
317 anchor = 'start'
319 if sv.label_align == 'right':
320 anchor = 'end'
321 elif sv.label_align == 'center':
322 anchor = 'middle'
324 atts = {'text-anchor': anchor}
326 _add_font_atts(atts, sv, 'label_')
327 _add_paint_atts(atts, sv, 'label_')
329 lines = label.split('\n')
330 _, em_height = _font_size(font, 'MMM')
331 metrics = [_font_size(font, s) for s in lines]
333 line_height = sv.label_line_height or 1
334 padding = sv.label_padding or [0, 0, 0, 0]
336 ly = cy - padding[2]
337 lx = cx
339 if anchor == 'start':
340 lx += padding[3]
341 elif anchor == 'end':
342 lx -= padding[1]
343 else:
344 lx += padding[3] // 2
346 height = em_height * len(lines) + line_height * (len(lines) - 1) + padding[0] + padding[2]
348 pad_bottom = metrics[-1][1] - em_height
349 if pad_bottom > 0:
350 height += pad_bottom
351 ly -= pad_bottom
353 spans = []
354 for s in reversed(lines):
355 spans.append(['tspan', {'x': lx, 'y': ly}, s])
356 ly -= (em_height + line_height)
358 tags = []
360 tags.append(('text', atts, *reversed(spans)))
362 # @TODO a hack to emulate 'paint-order' which wkhtmltopdf doesn't seem to support
363 # place a copy without the stroke above the text
364 if atts.get('stroke'):
365 no_stroke_atts = {k: v for k, v in atts.items() if not k.startswith('stroke')}
366 tags.append(('text', no_stroke_atts, *reversed(spans)))
368 # @TODO label backgrounds don't really work
369 if sv.label_background:
370 width = max(xy[0] for xy in metrics) + padding[1] + padding[3]
372 if anchor == 'start':
373 bx = cx
374 elif anchor == 'end':
375 bx = cx - width
376 else:
377 bx = cx - width // 2
379 ratts = {
380 'x': bx,
381 'y': cy - height,
382 'width': width,
383 'height': height,
384 'fill': sv.label_background,
385 }
387 tags.insert(0, ('rect', ratts))
389 # a hack to move labels forward: emit a (non-supported) z-index attribute
390 # and sort elements by it later on (see `fragment_to_element`)
392 return xmlx.tag('g', {'z-index': 100}, *tags)
395# ----------------------------------------------------------------------------------------------------------------------
396# icons
398# @TODO options for icon positioning
401def _parse_icon(icon, dpi) -> Optional[tuple[gws.XmlElement, float, float]]:
402 # see lib.style.icon
404 svg: Optional[gws.XmlElement] = None
405 if gws.u.is_data_object(icon):
406 svg = icon.svg
407 if not svg:
408 return
410 w = svg.attr('width')
411 h = svg.attr('height')
413 if not w or not h:
414 gws.log.error(f'xml_icon: width and height required')
415 return
417 try:
418 w, wu = gws.lib.uom.parse(w, gws.Uom.px)
419 h, hu = gws.lib.uom.parse(h, gws.Uom.px)
420 except ValueError:
421 gws.log.error(f'xml_icon: invalid units: {w!r} {h!r}')
422 return
424 if wu == gws.Uom.mm:
425 w = gws.lib.uom.mm_to_px(w, dpi)
426 if hu == gws.Uom.mm:
427 h = gws.lib.uom.mm_to_px(h, dpi)
429 return svg, w, h
432def _icon_size_and_position(geom, sv, width, height) -> tuple[int, int, int, int]:
433 c = geom.centroid
434 return (
435 int(c.x - width / 2),
436 int(c.y - height / 2),
437 int(width),
438 int(height))
441# ----------------------------------------------------------------------------------------------------------------------
442# fonts
444# @TODO: allow for more fonts and customize the mapping
447_DEFAULT_FONT = 'DejaVuSans'
450def _add_font_atts(atts, sv, prefix=''):
451 font_name = _font_name(sv, prefix)
452 font_size = sv.get(prefix + 'font_size') or DEFAULT_FONT_SIZE
454 atts.update(gws.u.compact({
455 'font-family': font_name.split('-')[0],
456 'font-size': f'{font_size}px',
457 'font-weight': sv.get(prefix + 'font_weight'),
458 'font-style': sv.get(prefix + 'font_style'),
459 }))
462def _font_name(sv, prefix=''):
463 w = sv.get(prefix + 'font_weight')
464 if w == 'bold':
465 return _DEFAULT_FONT + '-Bold'
466 return _DEFAULT_FONT
469def _font_size(font, text):
470 bb = font.getbbox(text)
471 return bb[2] - bb[0], bb[3] - bb[1]
474# ----------------------------------------------------------------------------------------------------------------------
475# paint
477def _add_paint_atts(atts, sv, prefix=''):
478 atts['fill'] = sv.get(prefix + 'fill') or 'none'
480 v = sv.get(prefix + 'stroke')
481 if not v:
482 return
484 atts['stroke'] = v
486 v = sv.get(prefix + 'stroke_width')
487 atts['stroke-width'] = f'{v or 1}px'
489 v = sv.get(prefix + 'stroke_dasharray')
490 if v:
491 atts['stroke-dasharray'] = ' '.join(str(x) for x in v)
493 for k in 'dashoffset', 'linecap', 'linejoin', 'miterlimit':
494 v = sv.get(prefix + 'stroke_' + k)
495 if v:
496 atts['stroke-' + k] = v
499# ----------------------------------------------------------------------------------------------------------------------
500# misc
502def _slope(a: gws.Point, b: gws.Point) -> float:
503 # slope between two points
504 dx = b[0] - a[0]
505 dy = b[1] - a[1]
507 if dx == 0:
508 dx = 0.01
510 return math.atan(dy / dx)