Coverage for gws-app/gws/lib/svg/draw.py: 82%
315 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +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 is not None:
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(svg: gws.XmlElement, dpi) -> Optional[tuple[gws.XmlElement, float, float]]:
484 # see lib.style.icon
486 if not isinstance(svg, gws.XmlElement):
487 return
489 w = svg.get('width')
490 h = svg.get('height')
492 if not w or not h:
493 gws.log.error(f'xml_icon: width and height required')
494 return
496 try:
497 w, wu = gws.lib.uom.parse(w, gws.Uom.px)
498 h, hu = gws.lib.uom.parse(h, gws.Uom.px)
499 except ValueError:
500 gws.log.error(f'xml_icon: invalid units: {w!r} {h!r}')
501 return
503 if wu == gws.Uom.mm:
504 w = gws.lib.uom.mm_to_px(w, dpi)
505 if hu == gws.Uom.mm:
506 h = gws.lib.uom.mm_to_px(h, dpi)
508 return svg, w, h
511def _icon_size_and_position(geom, sv, width, height) -> tuple[int, int, int, int]:
512 c = geom.centroid
513 return (
514 int(c.x - width / 2),
515 int(c.y - height / 2),
516 int(width),
517 int(height))
520# ----------------------------------------------------------------------------------------------------------------------
521# fonts
523# @TODO: allow for more fonts and customize the mapping
526_DEFAULT_FONT = 'DejaVuSans'
529def _add_font_atts(atts, sv, prefix=''):
530 font_name = _font_name(sv, prefix)
531 font_size = sv.get(prefix + 'font_size') or DEFAULT_FONT_SIZE
533 atts.update(gws.u.compact({
534 'font-family': font_name.split('-')[0],
535 'font-size': f'{font_size}px',
536 'font-weight': sv.get(prefix + 'font_weight'),
537 'font-style': sv.get(prefix + 'font_style'),
538 }))
541def _font_name(sv, prefix=''):
542 w = sv.get(prefix + 'font_weight')
543 if w == 'bold':
544 return _DEFAULT_FONT + '-Bold'
545 return _DEFAULT_FONT
548def _font_size(font, text):
549 bb = font.getbbox(text)
550 return bb[2] - bb[0], bb[3] - bb[1]
553# ----------------------------------------------------------------------------------------------------------------------
554# paint
556def _add_paint_atts(atts, sv, prefix=''):
557 atts['fill'] = sv.get(prefix + 'fill') or 'none'
559 v = sv.get(prefix + 'stroke')
560 if not v:
561 return
563 atts['stroke'] = v
565 v = sv.get(prefix + 'stroke_width')
566 atts['stroke-width'] = f'{v or 1}px'
568 v = sv.get(prefix + 'stroke_dasharray')
569 if v:
570 atts['stroke-dasharray'] = ' '.join(str(x) for x in v)
572 for k in 'dashoffset', 'linecap', 'linejoin', 'miterlimit':
573 v = sv.get(prefix + 'stroke_' + k)
574 if v:
575 atts['stroke-' + k] = v
578# ----------------------------------------------------------------------------------------------------------------------
579# misc
581def _slope(a: gws.Point, b: gws.Point) -> float:
582 # slope between two points
583 dx = b[0] - a[0]
584 dy = b[1] - a[1]
586 if dx == 0:
587 dx = 0.01
589 return math.atan(dy / dx)