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

1"""SVG builders.""" 

2 

3from typing import Optional, cast 

4 

5import base64 

6import math 

7import shapely 

8import shapely.geometry 

9import shapely.ops 

10 

11import gws 

12import gws.lib.font 

13import gws.gis.render 

14import gws.base.shape 

15import gws.lib.uom 

16import gws.lib.xmlx as xmlx 

17 

18from . import element 

19 

20DEFAULT_FONT_SIZE = 10 

21DEFAULT_MARKER_SIZE = 10 

22DEFAULT_POINT_SIZE = 10 

23 

24MAX_SOUP_POINTS = 5000 

25MAX_SOUP_TAGS = 5000 

26 

27 

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").""" 

30 

31 if not shape: 

32 return [] 

33 

34 geom = cast(gws.base.shape.Shape, shape).geom 

35 if geom.is_empty: 

36 return [] 

37 

38 trans = gws.gis.render.map_view_transformer(view) 

39 geom = shapely.ops.transform(trans, geom) 

40 

41 if not style: 

42 return [_geometry(geom)] 

43 

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) 

48 

49 text = None 

50 

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) 

59 

60 marker = None 

61 marker_id = None 

62 

63 if with_geometry and sv.marker: 

64 marker_id = '_M' + gws.u.random_string(8) 

65 marker = _marker(marker_id, sv) 

66 

67 atts: dict = {} 

68 

69 icon = None 

70 

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 ) 

87 

88 body = None 

89 

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) 

99 

100 return gws.u.compact([marker, body, icon, text]) 

101 

102 

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"). 

105 

106 A soup has two components: 

107 

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....) 

110 

111 The idea is to represent client-side svg drawings (e.g. dimensions) in a resolution-independent way 

112 

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 

117 

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)` 

122 

123 """ 

124 

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)}') 

129 

130 trans = gws.gis.render.map_view_transformer(view) 

131 

132 try: 

133 px = [trans(*p) for p in points] 

134 except Exception as exc: 

135 raise gws.Error('invalid soup') from exc 

136 

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}') 

148 

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 

162 

163 els = [] 

164 

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 

170 

171 return element.normalize_fragment(els) 

172 

173 

174# ---------------------------------------------------------------------------------------------------------------------- 

175# geometry 

176 

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}' 

181 

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) 

191 

192 gt = _geom_type(geom) 

193 

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) 

197 

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) 

202 

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) 

208 

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]) 

212 

213 

214def _enum_points(geom): 

215 gt = _geom_type(geom) 

216 

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)] 

223 

224 

225# https://shapely.readthedocs.io/en/stable/reference/shapely.get_type_id.html 

226 

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 

235 

236 

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}') 

242 

243 

244# ---------------------------------------------------------------------------------------------------------------------- 

245# marker 

246 

247# @TODO only type=circle is implemented 

248 

249def _marker(uid, sv: gws.StyleValues) -> gws.XmlElement: 

250 size = sv.marker_size or DEFAULT_MARKER_SIZE 

251 size2 = size // 2 

252 

253 content = None 

254 atts: dict = {} 

255 

256 _add_paint_atts(atts, sv, 'marker_') 

257 

258 if sv.marker == 'circle': 

259 atts.update({ 

260 'cx': size2, 

261 'cy': size2, 

262 'r': size2, 

263 }) 

264 content = 'circle', atts 

265 

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) 

276 

277 

278# ---------------------------------------------------------------------------------------------------------------------- 

279# labels 

280 

281# @TODO label positioning needs more work 

282 

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 

291 

292 

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) 

296 

297 

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 ) 

310 

311 

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) 

316 

317 anchor = 'start' 

318 

319 if sv.label_align == 'right': 

320 anchor = 'end' 

321 elif sv.label_align == 'center': 

322 anchor = 'middle' 

323 

324 atts = {'text-anchor': anchor} 

325 

326 _add_font_atts(atts, sv, 'label_') 

327 _add_paint_atts(atts, sv, 'label_') 

328 

329 lines = label.split('\n') 

330 _, em_height = _font_size(font, 'MMM') 

331 metrics = [_font_size(font, s) for s in lines] 

332 

333 line_height = sv.label_line_height or 1 

334 padding = sv.label_padding or [0, 0, 0, 0] 

335 

336 ly = cy - padding[2] 

337 lx = cx 

338 

339 if anchor == 'start': 

340 lx += padding[3] 

341 elif anchor == 'end': 

342 lx -= padding[1] 

343 else: 

344 lx += padding[3] // 2 

345 

346 height = em_height * len(lines) + line_height * (len(lines) - 1) + padding[0] + padding[2] 

347 

348 pad_bottom = metrics[-1][1] - em_height 

349 if pad_bottom > 0: 

350 height += pad_bottom 

351 ly -= pad_bottom 

352 

353 spans = [] 

354 for s in reversed(lines): 

355 spans.append(['tspan', {'x': lx, 'y': ly}, s]) 

356 ly -= (em_height + line_height) 

357 

358 tags = [] 

359 

360 tags.append(('text', atts, *reversed(spans))) 

361 

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))) 

367 

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] 

371 

372 if anchor == 'start': 

373 bx = cx 

374 elif anchor == 'end': 

375 bx = cx - width 

376 else: 

377 bx = cx - width // 2 

378 

379 ratts = { 

380 'x': bx, 

381 'y': cy - height, 

382 'width': width, 

383 'height': height, 

384 'fill': sv.label_background, 

385 } 

386 

387 tags.insert(0, ('rect', ratts)) 

388 

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`) 

391 

392 return xmlx.tag('g', {'z-index': 100}, *tags) 

393 

394 

395# ---------------------------------------------------------------------------------------------------------------------- 

396# icons 

397 

398# @TODO options for icon positioning 

399 

400 

401def _parse_icon(icon, dpi) -> Optional[tuple[gws.XmlElement, float, float]]: 

402 # see lib.style.icon 

403 

404 svg: Optional[gws.XmlElement] = None 

405 if gws.u.is_data_object(icon): 

406 svg = icon.svg 

407 if not svg: 

408 return 

409 

410 w = svg.attr('width') 

411 h = svg.attr('height') 

412 

413 if not w or not h: 

414 gws.log.error(f'xml_icon: width and height required') 

415 return 

416 

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 

423 

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) 

428 

429 return svg, w, h 

430 

431 

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)) 

439 

440 

441# ---------------------------------------------------------------------------------------------------------------------- 

442# fonts 

443 

444# @TODO: allow for more fonts and customize the mapping 

445 

446 

447_DEFAULT_FONT = 'DejaVuSans' 

448 

449 

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 

453 

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 })) 

460 

461 

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 

467 

468 

469def _font_size(font, text): 

470 bb = font.getbbox(text) 

471 return bb[2] - bb[0], bb[3] - bb[1] 

472 

473 

474# ---------------------------------------------------------------------------------------------------------------------- 

475# paint 

476 

477def _add_paint_atts(atts, sv, prefix=''): 

478 atts['fill'] = sv.get(prefix + 'fill') or 'none' 

479 

480 v = sv.get(prefix + 'stroke') 

481 if not v: 

482 return 

483 

484 atts['stroke'] = v 

485 

486 v = sv.get(prefix + 'stroke_width') 

487 atts['stroke-width'] = f'{v or 1}px' 

488 

489 v = sv.get(prefix + 'stroke_dasharray') 

490 if v: 

491 atts['stroke-dasharray'] = ' '.join(str(x) for x in v) 

492 

493 for k in 'dashoffset', 'linecap', 'linejoin', 'miterlimit': 

494 v = sv.get(prefix + 'stroke_' + k) 

495 if v: 

496 atts['stroke-' + k] = v 

497 

498 

499# ---------------------------------------------------------------------------------------------------------------------- 

500# misc 

501 

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] 

506 

507 if dx == 0: 

508 dx = 0.01 

509 

510 return math.atan(dy / dx)