Coverage for gws-app/gws/lib/svg/draw.py: 81%
318 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"""Build SVG fragments from shapes and drawing soups."""
3from typing import Optional, cast
5import base64
6import math
7import shapely
8import shapely.geometry
9import shapely.ops
11import gws
12import gws.lib.extent
13import gws.lib.font
14import gws.lib.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 an SVG fragment.
31 The shape is transformed to pixel coordinates using the view. Without a style, only the geometry is drawn.
32 With a style, the fragment can contain a marker definition, the geometry, an icon and a label,
33 depending on the style values and the view scale.
35 Args:
36 shape: Shape to draw.
37 view: Map view, which defines the pixel transformation, scale and dpi.
38 label: Label text, can contain newlines.
39 style: Style to apply.
41 Returns:
42 A list of SVG elements, empty if the shape is missing or empty.
44 Raises:
45 ``gws.Error``: If the geometry type is not supported.
46 """
48 if not shape:
49 return []
51 geom = cast(gws.lib.shape.Shape, shape).geom
52 if geom.is_empty:
53 return []
55 trans = _map_view_transformer(view)
56 geom = shapely.ops.transform(trans, geom)
58 if not style:
59 return [_geometry(geom)]
61 sv = style.values
62 with_geometry = sv.with_geometry == 'all'
63 with_label = label and _is_label_visible(view, sv)
64 gt = _geom_type(geom)
66 text = None
68 if with_label:
69 extra_y_offset = 0
70 if sv.label_offset_y is None:
71 if gt == _TYPE_POINT:
72 extra_y_offset = (sv.label_font_size or DEFAULT_FONT_SIZE) * 2
73 if gt == _TYPE_LINESTRING:
74 extra_y_offset = 6
75 text = _label(geom, label, sv, extra_y_offset)
77 marker = None
78 marker_id = None
80 if with_geometry and sv.marker:
81 marker_id = '_M' + gws.u.random_string(8)
82 marker = _marker(marker_id, sv)
84 atts: dict = {}
86 icon = None
88 if with_geometry and sv.icon:
89 res = _parse_icon(sv.icon, view.dpi)
90 if res:
91 icon_el, w, h = res
92 x, y, w, h = _icon_size_and_position(geom, sv, w, h)
93 atts = {
94 'x': f'{int(x)}',
95 'y': f'{int(y)}',
96 'width': f'{int(w)}',
97 'height': f'{int(h)}',
98 }
99 icon = xmlx.tag(
100 icon_el.name,
101 gws.u.merge(icon_el.attrib, atts),
102 *icon_el.children()
103 )
105 body = None
107 if with_geometry:
108 _add_paint_atts(atts, sv)
109 if marker:
110 atts['marker-start'] = atts['marker-mid'] = atts['marker-end'] = f'url(#{marker_id})'
111 if gt in {_TYPE_POINT, _TYPE_MULTIPOINT}:
112 atts['r'] = (sv.point_size or DEFAULT_POINT_SIZE) // 2
113 if gt in {_TYPE_LINESTRING, _TYPE_MULTILINESTRING}:
114 atts['fill'] = 'none'
115 body = _geometry(geom, atts)
117 return gws.u.compact([marker, body, icon, text])
120def soup_to_fragment(view: gws.MapView, points: list[gws.Point], tags: list) -> list[gws.XmlElement]:
121 """Convert an SVG "soup" to an SVG fragment.
123 A soup represents client-side SVG drawings (e.g. dimensions) in a resolution-independent way.
124 It has two components:
126 - a list of points, in the map coordinate system,
127 - a list of tuples ``(tag-name, {atts}, child1, child2, ...)``, where children are tuples of the same form or strings.
129 First, points are converted to pixels using the view's transform. Then, the attributes of each tag are evaluated.
130 If an attribute value is a list, it is a function call: the first element is a function name,
131 the rest are arguments. The functions are:
133 - ``['x', n]``: returns the x pixel coordinate of ``points[n]``,
134 - ``['y', n]``: returns the y pixel coordinate of ``points[n]``,
135 - ``['r', p1, p2, r]``: computes the slope between ``points[p1]`` and ``points[p2]`` and returns
136 ``rotate(slope, x, y)`` with the coordinates of ``points[r]``.
138 The result is normalized, so unsafe tags and attributes are removed.
140 Args:
141 view: Map view, which defines the pixel transformation.
142 points: Points in map coordinates.
143 tags: Tag tuples.
145 Returns:
146 A list of SVG elements.
148 Raises:
149 ``gws.Error``: If the soup is too large or invalid, or uses an unknown function.
150 """
152 if len(points) > MAX_SOUP_POINTS:
153 raise gws.Error(f'too many soup points: {len(points)}')
154 if len(tags) > MAX_SOUP_TAGS:
155 raise gws.Error(f'too many soup tags: {len(tags)}')
157 trans = _map_view_transformer(view)
159 try:
160 px = [trans(*p) for p in points]
161 except Exception as exc:
162 raise gws.Error('invalid soup') from exc
164 def eval_func(v):
165 if v[0] == 'x':
166 return round(px[v[1]][0])
167 if v[0] == 'y':
168 return round(px[v[1]][1])
169 if v[0] == 'r':
170 a = _slope(px[v[1]], px[v[2]])
171 adeg = math.degrees(a)
172 x, y = px[v[3]]
173 return f'rotate({adeg:.0f}, {x:.0f}, {y:.0f})'
174 raise gws.Error(f'unknown soup function: {v[0]!r}')
176 def eval_funcs(tag):
177 res = []
178 for arg in tag:
179 if isinstance(arg, dict):
180 d = {}
181 for k, v in arg.items():
182 d[k] = eval_func(v) if isinstance(v, (list, tuple)) else v
183 res.append(d)
184 elif isinstance(arg, (list, tuple)):
185 res.append(eval_funcs(arg))
186 else:
187 res.append(arg)
188 return res
190 def soup_tag(ls):
191 args = [soup_tag(a) if isinstance(a, (list, tuple)) else a for a in ls[1:]]
192 return xmlx.tag(ls[0], *args)
194 els = []
196 try:
197 for tag in tags:
198 els.append(soup_tag(eval_funcs(tag)))
199 except Exception as exc:
200 raise gws.Error('invalid soup') from exc
202 return element.normalize_fragment(els)
205# ----------------------------------------------------------------------------------------------------------------------
206# transform
208def _map_view_transformer(view: gws.MapView):
209 """Create a transformer from map coordinates to pixel coordinates of a view.
211 Pixel coordinates are integers, relative to the top left corner of the view
212 at the view's scale and DPI. For a rotated view, points are rotated around the view center.
214 Args:
215 view: Map view.
217 Returns:
218 A function ``f(x, y) -> (px, py)``.
219 """
221 # @TODO cache the transformer
223 def translate(x, y):
224 x = x - ext[0]
225 y = ext[3] - y
226 return x * m2px, y * m2px
228 def translate_int(x, y):
229 x, y = translate(x, y)
230 return int(x), int(y)
232 def rotate(x, y):
233 return (
234 cosa * (x - ox) - sina * (y - oy) + ox,
235 sina * (x - ox) + cosa * (y - oy) + oy)
237 def translate_rotate_int(x, y):
238 x, y = translate(x, y)
239 x, y = rotate(x, y)
240 return int(x), int(y)
242 m2px = 1000.0 * gws.lib.uom.mm_to_px(1 / view.scale, view.dpi)
244 ext = view.bounds.extent
246 if not view.rotation:
247 return translate_int
249 ox, oy = translate(*gws.lib.extent.center(ext))
250 cosa = math.cos(math.radians(view.rotation))
251 sina = math.sin(math.radians(view.rotation))
253 return translate_rotate_int
256# ----------------------------------------------------------------------------------------------------------------------
257# geometry
259def _geometry(geom: shapely.geometry.base.BaseGeometry, atts: dict = None) -> gws.XmlElement:
260 def _xy(xy):
261 x, y = xy
262 return f'{x} {y}'
264 def _lpath(coords):
265 ps = []
266 cs = iter(coords)
267 for c in cs:
268 ps.append(f'M {_xy(c)}')
269 break
270 for c in cs:
271 ps.append(f'L {_xy(c)}')
272 return ' '.join(ps)
274 gt = _geom_type(geom)
276 if gt == _TYPE_POINT:
277 g = cast(shapely.geometry.Point, geom)
278 return xmlx.tag('circle', {'cx': int(g.x), 'cy': int(g.y)}, atts)
280 if gt == _TYPE_LINESTRING:
281 g = cast(shapely.geometry.LineString, geom)
282 d = _lpath(g.coords)
283 return xmlx.tag('path', {'d': d}, atts)
285 if gt == _TYPE_POLYGON:
286 g = cast(shapely.geometry.Polygon, geom)
287 d = ' '.join(_lpath(interior.coords) + ' z' for interior in g.interiors)
288 d = _lpath(g.exterior.coords) + ' z ' + d
289 return xmlx.tag('path', {'fill-rule': 'evenodd', 'd': d.strip()}, atts)
291 if gt >= _TYPE_MULTIPOINT:
292 g = cast(shapely.geometry.base.BaseMultipartGeometry, geom)
293 return xmlx.tag('g', *[_geometry(p, atts) for p in g.geoms])
296def _enum_points(geom):
297 gt = _geom_type(geom)
299 if gt in {_TYPE_POINT, _TYPE_LINESTRING, _TYPE_LINEARRING}:
300 return geom.coords
301 if gt == _TYPE_POLYGON:
302 return geom.exterior.coords
303 if gt >= _TYPE_MULTIPOINT:
304 return [p for g in geom.geoms for p in _enum_points(g)]
307# https://shapely.readthedocs.io/en/stable/reference/shapely.get_type_id.html
309_TYPE_POINT = 0
310_TYPE_LINESTRING = 1
311_TYPE_LINEARRING = 2
312_TYPE_POLYGON = 3
313_TYPE_MULTIPOINT = 4
314_TYPE_MULTILINESTRING = 5
315_TYPE_MULTIPOLYGON = 6
316_TYPE_GEOMETRYCOLLECTION = 7
319def _geom_type(geom):
320 p = shapely.get_type_id(geom)
321 if _TYPE_POINT <= p <= _TYPE_MULTIPOLYGON:
322 return p
323 raise gws.Error(f'unsupported geometry type {geom.type!r}')
326# ----------------------------------------------------------------------------------------------------------------------
327# marker
329# @TODO only type=circle is implemented
331def _marker(uid, sv: gws.StyleValues) -> gws.XmlElement:
332 size = sv.marker_size or DEFAULT_MARKER_SIZE
333 size2 = size // 2
335 content = None
336 atts: dict = {}
338 _add_paint_atts(atts, sv, 'marker_')
340 if sv.marker == 'circle':
341 atts.update({
342 'cx': size2,
343 'cy': size2,
344 'r': size2,
345 })
346 content = xmlx.tag('circle', atts)
348 if content:
349 return xmlx.tag('marker', {
350 'id': uid,
351 'viewBox': f'0 0 {size} {size}',
352 'refX': size2,
353 'refY': size2,
354 'markerUnits': 'userSpaceOnUse',
355 'markerWidth': size,
356 'markerHeight': size,
357 }, content)
360# ----------------------------------------------------------------------------------------------------------------------
361# labels
363# @TODO label positioning needs more work
365def _is_label_visible(view: gws.MapView, sv: gws.StyleValues) -> bool:
366 if sv.with_label != 'all':
367 return False
368 if view.scale < int(sv.get('label_min_scale', 0)):
369 return False
370 if view.scale > int(sv.get('label_max_scale', 1e10)):
371 return False
372 return True
375def _label(geom, label: str, sv: gws.StyleValues, extra_y_offset=0) -> gws.XmlElement:
376 xy = _label_position(geom, sv, extra_y_offset)
377 return _label_text(xy[0], xy[1], label, sv)
380def _label_position(geom, sv: gws.StyleValues, extra_y_offset=0) -> gws.Point:
381 if sv.label_placement == 'start':
382 x, y = _enum_points(geom)[0]
383 elif sv.label_placement == 'end':
384 x, y = _enum_points(geom)[-1]
385 else:
386 c = geom.centroid
387 x, y = c.x, c.y
388 return (
389 round(x) + (sv.label_offset_x or 0),
390 round(y) + extra_y_offset + (sv.label_font_size >> 1) + (sv.label_offset_y or 0)
391 )
394def _label_text(cx, cy, label, sv: gws.StyleValues) -> gws.XmlElement:
395 font_name = _font_name(sv)
396 font_size = sv.label_font_size or DEFAULT_FONT_SIZE
397 font = gws.lib.font.from_name(font_name, font_size)
399 anchor = 'start'
401 if sv.label_align == 'right':
402 anchor = 'end'
403 elif sv.label_align == 'center':
404 anchor = 'middle'
406 atts = {'text-anchor': anchor}
408 _add_font_atts(atts, sv, 'label_')
409 _add_paint_atts(atts, sv, 'label_')
411 lines = label.split('\n')
412 _, em_height = _font_size(font, 'MMM')
413 metrics = [_font_size(font, s) for s in lines]
415 line_height = sv.label_line_height or 1
416 padding = sv.label_padding or [0, 0, 0, 0]
418 ly = cy - padding[2]
419 lx = cx
421 if anchor == 'start':
422 lx += padding[3]
423 elif anchor == 'end':
424 lx -= padding[1]
425 else:
426 lx += padding[3] // 2
428 height = em_height * len(lines) + line_height * (len(lines) - 1) + padding[0] + padding[2]
430 pad_bottom = metrics[-1][1] - em_height
431 if pad_bottom > 0:
432 height += pad_bottom
433 ly -= pad_bottom
435 spans = []
436 for s in reversed(lines):
437 spans.append(xmlx.tag('tspan', {'x': lx, 'y': ly}, s))
438 ly -= (em_height + line_height)
440 tags = []
442 tags.append(xmlx.tag('text', atts, *reversed(spans)))
444 # @TODO a hack to emulate 'paint-order' which wkhtmltopdf doesn't seem to support
445 # place a copy without the stroke above the text
446 if atts.get('stroke'):
447 no_stroke_atts = {k: v for k, v in atts.items() if not k.startswith('stroke')}
448 tags.append(xmlx.tag('text', no_stroke_atts, *reversed(spans)))
450 # @TODO label backgrounds don't really work
451 if sv.label_background:
452 width = max(xy[0] for xy in metrics) + padding[1] + padding[3]
454 if anchor == 'start':
455 bx = cx
456 elif anchor == 'end':
457 bx = cx - width
458 else:
459 bx = cx - width // 2
461 ratts = {
462 'x': bx,
463 'y': cy - height,
464 'width': width,
465 'height': height,
466 'fill': sv.label_background,
467 }
469 tags.insert(0, xmlx.tag('rect', ratts))
471 # a hack to move labels forward: emit a (non-supported) z-index attribute
472 # and sort elements by it later on (see `fragment_to_element`)
474 return xmlx.tag('g', {'z-index': 100}, *tags)
477# ----------------------------------------------------------------------------------------------------------------------
478# icons
480# @TODO options for icon positioning
483def _parse_icon(icon, dpi) -> Optional[tuple[gws.XmlElement, float, float]]:
484 # see lib.style.icon
486 svg: Optional[gws.XmlElement] = None
487 if gws.u.is_data_object(icon):
488 svg = icon.svg
489 if not svg:
490 return
492 w = svg.get('width')
493 h = svg.get('height')
495 if not w or not h:
496 gws.log.error(f'xml_icon: width and height required')
497 return
499 try:
500 w, wu = gws.lib.uom.parse(w, gws.Uom.px)
501 h, hu = gws.lib.uom.parse(h, gws.Uom.px)
502 except ValueError:
503 gws.log.error(f'xml_icon: invalid units: {w!r} {h!r}')
504 return
506 if wu == gws.Uom.mm:
507 w = gws.lib.uom.mm_to_px(w, dpi)
508 if hu == gws.Uom.mm:
509 h = gws.lib.uom.mm_to_px(h, dpi)
511 return svg, w, h
514def _icon_size_and_position(geom, sv, width, height) -> tuple[int, int, int, int]:
515 c = geom.centroid
516 return (
517 int(c.x - width / 2),
518 int(c.y - height / 2),
519 int(width),
520 int(height))
523# ----------------------------------------------------------------------------------------------------------------------
524# fonts
526# @TODO: allow for more fonts and customize the mapping
529_DEFAULT_FONT = 'DejaVuSans'
532def _add_font_atts(atts, sv, prefix=''):
533 font_name = _font_name(sv, prefix)
534 font_size = sv.get(prefix + 'font_size') or DEFAULT_FONT_SIZE
536 atts.update(gws.u.compact({
537 'font-family': font_name.split('-')[0],
538 'font-size': f'{font_size}px',
539 'font-weight': sv.get(prefix + 'font_weight'),
540 'font-style': sv.get(prefix + 'font_style'),
541 }))
544def _font_name(sv, prefix=''):
545 w = sv.get(prefix + 'font_weight')
546 if w == 'bold':
547 return _DEFAULT_FONT + '-Bold'
548 return _DEFAULT_FONT
551def _font_size(font, text):
552 bb = font.getbbox(text)
553 return bb[2] - bb[0], bb[3] - bb[1]
556# ----------------------------------------------------------------------------------------------------------------------
557# paint
559def _add_paint_atts(atts, sv, prefix=''):
560 atts['fill'] = sv.get(prefix + 'fill') or 'none'
562 v = sv.get(prefix + 'stroke')
563 if not v:
564 return
566 atts['stroke'] = v
568 v = sv.get(prefix + 'stroke_width')
569 atts['stroke-width'] = f'{v or 1}px'
571 v = sv.get(prefix + 'stroke_dasharray')
572 if v:
573 atts['stroke-dasharray'] = ' '.join(str(x) for x in v)
575 for k in 'dashoffset', 'linecap', 'linejoin', 'miterlimit':
576 v = sv.get(prefix + 'stroke_' + k)
577 if v:
578 atts['stroke-' + k] = v
581# ----------------------------------------------------------------------------------------------------------------------
582# misc
584def _slope(a: gws.Point, b: gws.Point) -> float:
585 # slope between two points
586 dx = b[0] - a[0]
587 dy = b[1] - a[1]
589 if dx == 0:
590 dx = 0.01
592 return math.atan(dy / dx)