Coverage for gws-app/gws/lib/svg/draw.py: 15%
276 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +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
18DEFAULT_FONT_SIZE = 10
19DEFAULT_MARKER_SIZE = 10
20DEFAULT_POINT_SIZE = 10
23def shape_to_fragment(shape: gws.Shape, view: gws.MapView, label: str = None, style: gws.Style = None) -> list[gws.XmlElement]:
24 """Convert a shape to a list of XmlElements (a "fragment")."""
26 if not shape:
27 return []
29 geom = cast(gws.base.shape.Shape, shape).geom
30 if geom.is_empty:
31 return []
33 trans = gws.gis.render.map_view_transformer(view)
34 geom = shapely.ops.transform(trans, geom)
36 if not style:
37 return [_geometry(geom)]
39 sv = style.values
40 with_geometry = sv.with_geometry == 'all'
41 with_label = label and _is_label_visible(view, sv)
42 gt = _geom_type(geom)
44 text = None
46 if with_label:
47 extra_y_offset = 0
48 if sv.label_offset_y is None:
49 if gt == _TYPE_POINT:
50 extra_y_offset = (sv.label_font_size or DEFAULT_FONT_SIZE) * 2
51 if gt == _TYPE_LINESTRING:
52 extra_y_offset = 6
53 text = _label(geom, label, sv, extra_y_offset)
55 marker = None
56 marker_id = None
58 if with_geometry and sv.marker:
59 marker_id = '_M' + gws.u.random_string(8)
60 marker = _marker(marker_id, sv)
62 atts: dict = {}
64 icon = None
66 if with_geometry and sv.icon:
67 res = _parse_icon(sv.icon, view.dpi)
68 if res:
69 icon_el, w, h = res
70 x, y, w, h = _icon_size_and_position(geom, sv, w, h)
71 atts = {
72 'x': f'{int(x)}',
73 'y': f'{int(y)}',
74 'width': f'{int(w)}',
75 'height': f'{int(h)}',
76 }
77 icon = xmlx.tag(
78 icon_el.name,
79 gws.u.merge(icon_el.attrib, atts),
80 *icon_el.children()
81 )
83 body = None
85 if with_geometry:
86 _add_paint_atts(atts, sv)
87 if marker:
88 atts['marker-start'] = atts['marker-mid'] = atts['marker-end'] = f'url(#{marker_id})'
89 if gt in {_TYPE_POINT, _TYPE_MULTIPOINT}:
90 atts['r'] = (sv.point_size or DEFAULT_POINT_SIZE) // 2
91 if gt in {_TYPE_LINESTRING, _TYPE_MULTILINESTRING}:
92 atts['fill'] = 'none'
93 body = _geometry(geom, atts)
95 return gws.u.compact([marker, body, icon, text])
98def soup_to_fragment(view: gws.MapView, points: list[gws.Point], tags: list) -> list[gws.XmlElement]:
99 """Convert an svg "soup" to a list of XmlElements (a "fragment").
101 A soup has two components:
103 - a list of points, in the map coordinate system
104 - a list of tuples suitable for `xmlx.tag` input (tag-name, {atts}, child1, child2....)
106 The idea is to represent client-side svg drawings (e.g. dimensions) in a resolution-independent way
108 First, points are converted to pixels using the view's transform. Then, each tag's attributes are iterated.
109 If any attribute value is an array, it's assumed to be a 'function'.
110 The first element is a function name, the rest are arguments.
111 Attribute 'functions' are
113 - ['x', n] - returns points[n][0]
114 - ['y', n] - returns points[n][1]
115 - ['r', p1, p2, r] - computes a slope between points[p1] points[p2] and returns a string
116 `rotate(slope, points[r].x, points[r].y)`
118 """
120 trans = gws.gis.render.map_view_transformer(view)
121 px = [trans(*p) for p in points]
123 def eval_func(v):
124 if v[0] == 'x':
125 return round(px[v[1]][0])
126 if v[0] == 'y':
127 return round(px[v[1]][1])
128 if v[0] == 'r':
129 a = _slope(px[v[1]], px[v[2]])
130 adeg = math.degrees(a)
131 x, y = px[v[3]]
132 return f'rotate({adeg:.0f}, {x:.0f}, {y:.0f})'
134 def convert(tag):
135 for arg in tag:
136 if isinstance(arg, (list, tuple)):
137 convert(arg)
138 elif isinstance(arg, dict):
139 for k, v in arg.items():
140 if isinstance(v, (list, tuple)):
141 arg[k] = eval_func(v)
143 els = []
145 for tag in tags:
146 convert(tag)
147 els.append(xmlx.tag(*tag))
149 return els
152# ----------------------------------------------------------------------------------------------------------------------
153# geometry
155def _geometry(geom: shapely.geometry.base.BaseGeometry, atts: dict = None) -> gws.XmlElement:
156 def _xy(xy):
157 x, y = xy
158 return f'{x} {y}'
160 def _lpath(coords):
161 ps = []
162 cs = iter(coords)
163 for c in cs:
164 ps.append(f'M {_xy(c)}')
165 break
166 for c in cs:
167 ps.append(f'L {_xy(c)}')
168 return ' '.join(ps)
170 gt = _geom_type(geom)
172 if gt == _TYPE_POINT:
173 g = cast(shapely.geometry.Point, geom)
174 return xmlx.tag('circle', {'cx': int(g.x), 'cy': int(g.y)}, atts)
176 if gt == _TYPE_LINESTRING:
177 g = cast(shapely.geometry.LineString, geom)
178 d = _lpath(g.coords)
179 return xmlx.tag('path', {'d': d}, atts)
181 if gt == _TYPE_POLYGON:
182 g = cast(shapely.geometry.Polygon, geom)
183 d = ' '.join(_lpath(interior.coords) + ' z' for interior in g.interiors)
184 d = _lpath(g.exterior.coords) + ' z ' + d
185 return xmlx.tag('path', {'fill-rule': 'evenodd', 'd': d.strip()}, atts)
187 if gt >= _TYPE_MULTIPOINT:
188 g = cast(shapely.geometry.base.BaseMultipartGeometry, geom)
189 return xmlx.tag('g', *[_geometry(p, atts) for p in g.geoms])
192def _enum_points(geom):
193 gt = _geom_type(geom)
195 if gt in {_TYPE_POINT, _TYPE_LINESTRING, _TYPE_LINEARRING}:
196 return geom.coords
197 if gt == _TYPE_POLYGON:
198 return geom.exterior.coords
199 if gt >= _TYPE_MULTIPOINT:
200 return [p for g in geom.geoms for p in _enum_points(g)]
203# https://shapely.readthedocs.io/en/stable/reference/shapely.get_type_id.html
205_TYPE_POINT = 0
206_TYPE_LINESTRING = 1
207_TYPE_LINEARRING = 2
208_TYPE_POLYGON = 3
209_TYPE_MULTIPOINT = 4
210_TYPE_MULTILINESTRING = 5
211_TYPE_MULTIPOLYGON = 6
212_TYPE_GEOMETRYCOLLECTION = 7
215def _geom_type(geom):
216 p = shapely.get_type_id(geom)
217 if _TYPE_POINT <= p <= _TYPE_MULTIPOLYGON:
218 return p
219 raise gws.Error(f'unsupported geometry type {geom.type!r}')
222# ----------------------------------------------------------------------------------------------------------------------
223# marker
225# @TODO only type=circle is implemented
227def _marker(uid, sv: gws.StyleValues) -> gws.XmlElement:
228 size = sv.marker_size or DEFAULT_MARKER_SIZE
229 size2 = size // 2
231 content = None
232 atts: dict = {}
234 _add_paint_atts(atts, sv, 'marker_')
236 if sv.marker == 'circle':
237 atts.update({
238 'cx': size2,
239 'cy': size2,
240 'r': size2,
241 })
242 content = 'circle', atts
244 if content:
245 return xmlx.tag('marker', {
246 'id': uid,
247 'viewBox': f'0 0 {size} {size}',
248 'refX': size2,
249 'refY': size2,
250 'markerUnits': 'userSpaceOnUse',
251 'markerWidth': size,
252 'markerHeight': size,
253 }, content)
256# ----------------------------------------------------------------------------------------------------------------------
257# labels
259# @TODO label positioning needs more work
261def _is_label_visible(view: gws.MapView, sv: gws.StyleValues) -> bool:
262 if sv.with_label != 'all':
263 return False
264 if view.scale < int(sv.get('label_min_scale', 0)):
265 return False
266 if view.scale > int(sv.get('label_max_scale', 1e10)):
267 return False
268 return True
271def _label(geom, label: str, sv: gws.StyleValues, extra_y_offset=0) -> gws.XmlElement:
272 xy = _label_position(geom, sv, extra_y_offset)
273 return _label_text(xy[0], xy[1], label, sv)
276def _label_position(geom, sv: gws.StyleValues, extra_y_offset=0) -> gws.Point:
277 if sv.label_placement == 'start':
278 x, y = _enum_points(geom)[0]
279 elif sv.label_placement == 'end':
280 x, y = _enum_points(geom)[-1]
281 else:
282 c = geom.centroid
283 x, y = c.x, c.y
284 return (
285 round(x) + (sv.label_offset_x or 0),
286 round(y) + extra_y_offset + (sv.label_font_size >> 1) + (sv.label_offset_y or 0)
287 )
290def _label_text(cx, cy, label, sv: gws.StyleValues) -> gws.XmlElement:
291 font_name = _font_name(sv)
292 font_size = sv.label_font_size or DEFAULT_FONT_SIZE
293 font = gws.lib.font.from_name(font_name, font_size)
295 anchor = 'start'
297 if sv.label_align == 'right':
298 anchor = 'end'
299 elif sv.label_align == 'center':
300 anchor = 'middle'
302 atts = {'text-anchor': anchor}
304 _add_font_atts(atts, sv, 'label_')
305 _add_paint_atts(atts, sv, 'label_')
307 lines = label.split('\n')
308 _, em_height = _font_size(font, 'MMM')
309 metrics = [_font_size(font, s) for s in lines]
311 line_height = sv.label_line_height or 1
312 padding = sv.label_padding or [0, 0, 0, 0]
314 ly = cy - padding[2]
315 lx = cx
317 if anchor == 'start':
318 lx += padding[3]
319 elif anchor == 'end':
320 lx -= padding[1]
321 else:
322 lx += padding[3] // 2
324 height = em_height * len(lines) + line_height * (len(lines) - 1) + padding[0] + padding[2]
326 pad_bottom = metrics[-1][1] - em_height
327 if pad_bottom > 0:
328 height += pad_bottom
329 ly -= pad_bottom
331 spans = []
332 for s in reversed(lines):
333 spans.append(['tspan', {'x': lx, 'y': ly}, s])
334 ly -= (em_height + line_height)
336 tags = []
338 tags.append(('text', atts, *reversed(spans)))
340 # @TODO a hack to emulate 'paint-order' which wkhtmltopdf doesn't seem to support
341 # place a copy without the stroke above the text
342 if atts.get('stroke'):
343 no_stroke_atts = {k: v for k, v in atts.items() if not k.startswith('stroke')}
344 tags.append(('text', no_stroke_atts, *reversed(spans)))
346 # @TODO label backgrounds don't really work
347 if sv.label_background:
348 width = max(xy[0] for xy in metrics) + padding[1] + padding[3]
350 if anchor == 'start':
351 bx = cx
352 elif anchor == 'end':
353 bx = cx - width
354 else:
355 bx = cx - width // 2
357 ratts = {
358 'x': bx,
359 'y': cy - height,
360 'width': width,
361 'height': height,
362 'fill': sv.label_background,
363 }
365 tags.insert(0, ('rect', ratts))
367 # a hack to move labels forward: emit a (non-supported) z-index attribute
368 # and sort elements by it later on (see `fragment_to_element`)
370 return xmlx.tag('g', {'z-index': 100}, *tags)
373# ----------------------------------------------------------------------------------------------------------------------
374# icons
376# @TODO options for icon positioning
379def _parse_icon(icon, dpi) -> Optional[tuple[gws.XmlElement, float, float]]:
380 # see lib.style.icon
382 svg: Optional[gws.XmlElement] = None
383 if gws.u.is_data_object(icon):
384 svg = icon.svg
385 if not svg:
386 return
388 w = svg.attr('width')
389 h = svg.attr('height')
391 if not w or not h:
392 gws.log.error(f'xml_icon: width and height required')
393 return
395 try:
396 w, wu = gws.lib.uom.parse(w, gws.Uom.px)
397 h, hu = gws.lib.uom.parse(h, gws.Uom.px)
398 except ValueError:
399 gws.log.error(f'xml_icon: invalid units: {w!r} {h!r}')
400 return
402 if wu == gws.Uom.mm:
403 w = gws.lib.uom.mm_to_px(w, dpi)
404 if hu == gws.Uom.mm:
405 h = gws.lib.uom.mm_to_px(h, dpi)
407 return svg, w, h
410def _icon_size_and_position(geom, sv, width, height) -> tuple[int, int, int, int]:
411 c = geom.centroid
412 return (
413 int(c.x - width / 2),
414 int(c.y - height / 2),
415 int(width),
416 int(height))
419# ----------------------------------------------------------------------------------------------------------------------
420# fonts
422# @TODO: allow for more fonts and customize the mapping
425_DEFAULT_FONT = 'DejaVuSans'
428def _add_font_atts(atts, sv, prefix=''):
429 font_name = _font_name(sv, prefix)
430 font_size = sv.get(prefix + 'font_size') or DEFAULT_FONT_SIZE
432 atts.update(gws.u.compact({
433 'font-family': font_name.split('-')[0],
434 'font-size': f'{font_size}px',
435 'font-weight': sv.get(prefix + 'font_weight'),
436 'font-style': sv.get(prefix + 'font_style'),
437 }))
440def _font_name(sv, prefix=''):
441 w = sv.get(prefix + 'font_weight')
442 if w == 'bold':
443 return _DEFAULT_FONT + '-Bold'
444 return _DEFAULT_FONT
447def _font_size(font, text):
448 bb = font.getbbox(text)
449 return bb[2] - bb[0], bb[3] - bb[1]
452# ----------------------------------------------------------------------------------------------------------------------
453# paint
455def _add_paint_atts(atts, sv, prefix=''):
456 atts['fill'] = sv.get(prefix + 'fill') or 'none'
458 v = sv.get(prefix + 'stroke')
459 if not v:
460 return
462 atts['stroke'] = v
464 v = sv.get(prefix + 'stroke_width')
465 atts['stroke-width'] = f'{v or 1}px'
467 v = sv.get(prefix + 'stroke_dasharray')
468 if v:
469 atts['stroke-dasharray'] = ' '.join(str(x) for x in v)
471 for k in 'dashoffset', 'linecap', 'linejoin', 'miterlimit':
472 v = sv.get(prefix + 'stroke_' + k)
473 if v:
474 atts['stroke-' + k] = v
477# ----------------------------------------------------------------------------------------------------------------------
478# misc
480def _slope(a: gws.Point, b: gws.Point) -> float:
481 # slope between two points
482 dx = b[0] - a[0]
483 dy = b[1] - a[1]
485 if dx == 0:
486 dx = 0.01
488 return math.atan(dy / dx)