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

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 

18DEFAULT_FONT_SIZE = 10 

19DEFAULT_MARKER_SIZE = 10 

20DEFAULT_POINT_SIZE = 10 

21 

22 

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

25 

26 if not shape: 

27 return [] 

28 

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

30 if geom.is_empty: 

31 return [] 

32 

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

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

35 

36 if not style: 

37 return [_geometry(geom)] 

38 

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) 

43 

44 text = None 

45 

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) 

54 

55 marker = None 

56 marker_id = None 

57 

58 if with_geometry and sv.marker: 

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

60 marker = _marker(marker_id, sv) 

61 

62 atts: dict = {} 

63 

64 icon = None 

65 

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 ) 

82 

83 body = None 

84 

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) 

94 

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

96 

97 

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

100 

101 A soup has two components: 

102 

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

105 

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

107 

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 

112 

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

117 

118 """ 

119 

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

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

122 

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

133 

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) 

142 

143 els = [] 

144 

145 for tag in tags: 

146 convert(tag) 

147 els.append(xmlx.tag(*tag)) 

148 

149 return els 

150 

151 

152# ---------------------------------------------------------------------------------------------------------------------- 

153# geometry 

154 

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

159 

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) 

169 

170 gt = _geom_type(geom) 

171 

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) 

175 

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) 

180 

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) 

186 

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

190 

191 

192def _enum_points(geom): 

193 gt = _geom_type(geom) 

194 

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

201 

202 

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

204 

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 

213 

214 

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

220 

221 

222# ---------------------------------------------------------------------------------------------------------------------- 

223# marker 

224 

225# @TODO only type=circle is implemented 

226 

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

228 size = sv.marker_size or DEFAULT_MARKER_SIZE 

229 size2 = size // 2 

230 

231 content = None 

232 atts: dict = {} 

233 

234 _add_paint_atts(atts, sv, 'marker_') 

235 

236 if sv.marker == 'circle': 

237 atts.update({ 

238 'cx': size2, 

239 'cy': size2, 

240 'r': size2, 

241 }) 

242 content = 'circle', atts 

243 

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) 

254 

255 

256# ---------------------------------------------------------------------------------------------------------------------- 

257# labels 

258 

259# @TODO label positioning needs more work 

260 

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 

269 

270 

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) 

274 

275 

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 ) 

288 

289 

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) 

294 

295 anchor = 'start' 

296 

297 if sv.label_align == 'right': 

298 anchor = 'end' 

299 elif sv.label_align == 'center': 

300 anchor = 'middle' 

301 

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

303 

304 _add_font_atts(atts, sv, 'label_') 

305 _add_paint_atts(atts, sv, 'label_') 

306 

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

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

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

310 

311 line_height = sv.label_line_height or 1 

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

313 

314 ly = cy - padding[2] 

315 lx = cx 

316 

317 if anchor == 'start': 

318 lx += padding[3] 

319 elif anchor == 'end': 

320 lx -= padding[1] 

321 else: 

322 lx += padding[3] // 2 

323 

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

325 

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

327 if pad_bottom > 0: 

328 height += pad_bottom 

329 ly -= pad_bottom 

330 

331 spans = [] 

332 for s in reversed(lines): 

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

334 ly -= (em_height + line_height) 

335 

336 tags = [] 

337 

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

339 

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

345 

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] 

349 

350 if anchor == 'start': 

351 bx = cx 

352 elif anchor == 'end': 

353 bx = cx - width 

354 else: 

355 bx = cx - width // 2 

356 

357 ratts = { 

358 'x': bx, 

359 'y': cy - height, 

360 'width': width, 

361 'height': height, 

362 'fill': sv.label_background, 

363 } 

364 

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

366 

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

369 

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

371 

372 

373# ---------------------------------------------------------------------------------------------------------------------- 

374# icons 

375 

376# @TODO options for icon positioning 

377 

378 

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

380 # see lib.style.icon 

381 

382 svg: Optional[gws.XmlElement] = None 

383 if gws.u.is_data_object(icon): 

384 svg = icon.svg 

385 if not svg: 

386 return 

387 

388 w = svg.attr('width') 

389 h = svg.attr('height') 

390 

391 if not w or not h: 

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

393 return 

394 

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 

401 

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) 

406 

407 return svg, w, h 

408 

409 

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

417 

418 

419# ---------------------------------------------------------------------------------------------------------------------- 

420# fonts 

421 

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

423 

424 

425_DEFAULT_FONT = 'DejaVuSans' 

426 

427 

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 

431 

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

438 

439 

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 

445 

446 

447def _font_size(font, text): 

448 bb = font.getbbox(text) 

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

450 

451 

452# ---------------------------------------------------------------------------------------------------------------------- 

453# paint 

454 

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

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

457 

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

459 if not v: 

460 return 

461 

462 atts['stroke'] = v 

463 

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

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

466 

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

468 if v: 

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

470 

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

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

473 if v: 

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

475 

476 

477# ---------------------------------------------------------------------------------------------------------------------- 

478# misc 

479 

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] 

484 

485 if dx == 0: 

486 dx = 0.01 

487 

488 return math.atan(dy / dx)