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

1"""Build SVG fragments from shapes and drawing soups.""" 

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

13import gws.lib.font 

14import gws.lib.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 an SVG fragment. 

30 

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. 

34 

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. 

40 

41 Returns: 

42 A list of SVG elements, empty if the shape is missing or empty. 

43 

44 Raises: 

45 ``gws.Error``: If the geometry type is not supported. 

46 """ 

47 

48 if not shape: 

49 return [] 

50 

51 geom = cast(gws.lib.shape.Shape, shape).geom 

52 if geom.is_empty: 

53 return [] 

54 

55 trans = _map_view_transformer(view) 

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

57 

58 if not style: 

59 return [_geometry(geom)] 

60 

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) 

65 

66 text = None 

67 

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) 

76 

77 marker = None 

78 marker_id = None 

79 

80 if with_geometry and sv.marker: 

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

82 marker = _marker(marker_id, sv) 

83 

84 atts: dict = {} 

85 

86 icon = None 

87 

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 ) 

104 

105 body = None 

106 

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) 

116 

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

118 

119 

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. 

122 

123 A soup represents client-side SVG drawings (e.g. dimensions) in a resolution-independent way. 

124 It has two components: 

125 

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. 

128 

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: 

132 

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]``. 

137 

138 The result is normalized, so unsafe tags and attributes are removed. 

139 

140 Args: 

141 view: Map view, which defines the pixel transformation. 

142 points: Points in map coordinates. 

143 tags: Tag tuples. 

144 

145 Returns: 

146 A list of SVG elements. 

147 

148 Raises: 

149 ``gws.Error``: If the soup is too large or invalid, or uses an unknown function. 

150 """ 

151 

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

156 

157 trans = _map_view_transformer(view) 

158 

159 try: 

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

161 except Exception as exc: 

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

163 

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

175 

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 

189 

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) 

193 

194 els = [] 

195 

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 

201 

202 return element.normalize_fragment(els) 

203 

204 

205# ---------------------------------------------------------------------------------------------------------------------- 

206# transform 

207 

208def _map_view_transformer(view: gws.MapView): 

209 """Create a transformer from map coordinates to pixel coordinates of a view. 

210 

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. 

213 

214 Args: 

215 view: Map view. 

216 

217 Returns: 

218 A function ``f(x, y) -> (px, py)``. 

219 """ 

220 

221 # @TODO cache the transformer 

222 

223 def translate(x, y): 

224 x = x - ext[0] 

225 y = ext[3] - y 

226 return x * m2px, y * m2px 

227 

228 def translate_int(x, y): 

229 x, y = translate(x, y) 

230 return int(x), int(y) 

231 

232 def rotate(x, y): 

233 return ( 

234 cosa * (x - ox) - sina * (y - oy) + ox, 

235 sina * (x - ox) + cosa * (y - oy) + oy) 

236 

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) 

241 

242 m2px = 1000.0 * gws.lib.uom.mm_to_px(1 / view.scale, view.dpi) 

243 

244 ext = view.bounds.extent 

245 

246 if not view.rotation: 

247 return translate_int 

248 

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

252 

253 return translate_rotate_int 

254 

255 

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

257# geometry 

258 

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

263 

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) 

273 

274 gt = _geom_type(geom) 

275 

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) 

279 

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) 

284 

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) 

290 

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

294 

295 

296def _enum_points(geom): 

297 gt = _geom_type(geom) 

298 

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

305 

306 

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

308 

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 

317 

318 

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

324 

325 

326# ---------------------------------------------------------------------------------------------------------------------- 

327# marker 

328 

329# @TODO only type=circle is implemented 

330 

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

332 size = sv.marker_size or DEFAULT_MARKER_SIZE 

333 size2 = size // 2 

334 

335 content = None 

336 atts: dict = {} 

337 

338 _add_paint_atts(atts, sv, 'marker_') 

339 

340 if sv.marker == 'circle': 

341 atts.update({ 

342 'cx': size2, 

343 'cy': size2, 

344 'r': size2, 

345 }) 

346 content = xmlx.tag('circle', atts) 

347 

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) 

358 

359 

360# ---------------------------------------------------------------------------------------------------------------------- 

361# labels 

362 

363# @TODO label positioning needs more work 

364 

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 

373 

374 

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) 

378 

379 

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 ) 

392 

393 

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) 

398 

399 anchor = 'start' 

400 

401 if sv.label_align == 'right': 

402 anchor = 'end' 

403 elif sv.label_align == 'center': 

404 anchor = 'middle' 

405 

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

407 

408 _add_font_atts(atts, sv, 'label_') 

409 _add_paint_atts(atts, sv, 'label_') 

410 

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

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

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

414 

415 line_height = sv.label_line_height or 1 

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

417 

418 ly = cy - padding[2] 

419 lx = cx 

420 

421 if anchor == 'start': 

422 lx += padding[3] 

423 elif anchor == 'end': 

424 lx -= padding[1] 

425 else: 

426 lx += padding[3] // 2 

427 

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

429 

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

431 if pad_bottom > 0: 

432 height += pad_bottom 

433 ly -= pad_bottom 

434 

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) 

439 

440 tags = [] 

441 

442 tags.append(xmlx.tag('text', atts, *reversed(spans))) 

443 

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

449 

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] 

453 

454 if anchor == 'start': 

455 bx = cx 

456 elif anchor == 'end': 

457 bx = cx - width 

458 else: 

459 bx = cx - width // 2 

460 

461 ratts = { 

462 'x': bx, 

463 'y': cy - height, 

464 'width': width, 

465 'height': height, 

466 'fill': sv.label_background, 

467 } 

468 

469 tags.insert(0, xmlx.tag('rect', ratts)) 

470 

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

473 

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

475 

476 

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

478# icons 

479 

480# @TODO options for icon positioning 

481 

482 

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

484 # see lib.style.icon 

485 

486 svg: Optional[gws.XmlElement] = None 

487 if gws.u.is_data_object(icon): 

488 svg = icon.svg 

489 if not svg: 

490 return 

491 

492 w = svg.get('width') 

493 h = svg.get('height') 

494 

495 if not w or not h: 

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

497 return 

498 

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 

505 

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) 

510 

511 return svg, w, h 

512 

513 

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

521 

522 

523# ---------------------------------------------------------------------------------------------------------------------- 

524# fonts 

525 

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

527 

528 

529_DEFAULT_FONT = 'DejaVuSans' 

530 

531 

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 

535 

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

542 

543 

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 

549 

550 

551def _font_size(font, text): 

552 bb = font.getbbox(text) 

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

554 

555 

556# ---------------------------------------------------------------------------------------------------------------------- 

557# paint 

558 

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

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

561 

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

563 if not v: 

564 return 

565 

566 atts['stroke'] = v 

567 

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

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

570 

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

572 if v: 

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

574 

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

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

577 if v: 

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

579 

580 

581# ---------------------------------------------------------------------------------------------------------------------- 

582# misc 

583 

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] 

588 

589 if dx == 0: 

590 dx = 0.01 

591 

592 return math.atan(dy / dx)