Coverage for gws-app/gws/gis/render/__init__.py: 74%

133 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""Map rendering. 

2 

3This package renders maps for printing, exporting and templates. It works with 

4three kinds of objects, defined in ``types.pyinc``: 

5 

6- ``gws.MapView``: the geometry of a rendered map: bounds, center, scale, 

7 rotation, size in mm and pixels and the DPI. 

8- ``gws.MapRenderInput``: what to render: the map size, the bbox or the 

9 center and scale, the target CRS, the DPI and a list of input planes 

10 (image layers, SVG layers, features, images, SVG "soups"). 

11- ``gws.MapRenderOutput``: the rendered map, a list of output planes, 

12 each either a raster image or a list of SVG elements, and the view. 

13 

14Design 

15------ 

16 

17``render_map`` creates two views of the map: a vector view, always at 

18``gws.lib.uom.PDF_DPI``, and a raster view. For pixel sizes, the raster view 

19is the vector view. For mm sizes, rasters use the requested DPI, clamped to 

20``MIN_DPI`` and ``MAX_DPI``. 

21 

22Input planes are processed bottom-up. Image layers are rendered by the 

23layer as a box, SVG layers and features are converted to SVG elements. 

24Consecutive raster planes are composed into one image, consecutive SVG 

25planes are merged into one element list, so the output alternates between 

26raster and SVG planes. The background color is applied to the first raster 

27plane only. A plane that fails to render is logged and skipped. 

28 

29The output can be converted to HTML (``output_to_html_element``, 

30``output_to_html_string``), where raster planes become ``img`` tags and SVG 

31planes ``svg`` elements, stacked in a ``div``. 

32 

33The package also creates views from a center and scale or from a bbox 

34(``map_view_from_center``, ``map_view_from_bbox``). 

35 

36Example:: 

37 

38 mri = gws.MapRenderInput( 

39 bbox=(0, 0, 1000, 1000), 

40 targetCrs=gws.lib.crs.WEBMERCATOR, 

41 dpi=300, 

42 mapSize=(200, 200, gws.Uom.mm), 

43 planes=[ 

44 gws.MapRenderInputPlane(type=gws.MapRenderInputPlaneType.imageLayer, layer=layer), 

45 ], 

46 user=user, 

47 ) 

48 mro = gws.gis.render.render_map(mri) 

49 html = gws.gis.render.output_to_html_string(mro) 

50""" 

51 

52 

53import gws 

54import gws.lib.crs 

55import gws.lib.extent 

56import gws.lib.image 

57import gws.lib.svg 

58import gws.lib.uom 

59import gws.gis.zoom 

60import gws.lib.xmlx as xmlx 

61 

62MAX_DPI = 1200 

63"""Max. DPI for raster planes.""" 

64MIN_DPI = gws.lib.uom.PDF_DPI 

65"""Min. DPI for raster planes.""" 

66 

67 

68# Map Views 

69 

70def map_view_from_center( 

71 size: gws.UomSize, 

72 center: gws.Point, 

73 crs: gws.Crs, 

74 dpi: int, 

75 scale: float, 

76 rotation: float = 0, 

77) -> gws.MapView: 

78 """Create a map view from a center point and a scale. 

79 

80 The extent is computed from the size in mm. For a geographic CRS, meters are converted 

81 to degrees with ``gws.lib.crs.METERS_PER_DEGREE``. 

82 

83 Args: 

84 size: Map size in mm or pixels. 

85 center: Center point. 

86 crs: CRS of the view. 

87 dpi: Resolution in dots per inch. 

88 scale: Scale denominator. 

89 rotation: Rotation angle in degrees. 

90 

91 Returns: 

92 A map view. 

93 """ 

94 return _map_view(None, center, crs, dpi, rotation, scale, size) 

95 

96 

97def map_view_from_bbox( 

98 size: gws.UomSize, 

99 bbox: gws.Extent, 

100 crs: gws.Crs, 

101 dpi: int, 

102 rotation: float = 0, 

103) -> gws.MapView: 

104 """Create a map view from a bounding box. 

105 

106 The scale is computed from the bbox width and the pixel width. 

107 

108 Args: 

109 size: Map size in mm or pixels. 

110 bbox: Bounding box. 

111 crs: CRS of the view. 

112 dpi: Resolution in dots per inch. 

113 rotation: Rotation angle in degrees. 

114 

115 Returns: 

116 A map view. 

117 """ 

118 return _map_view(bbox, None, crs, dpi, rotation, None, size) 

119 

120def _map_view( 

121 bbox: gws.Extent | None, 

122 center: gws.Point | None, 

123 crs: gws.Crs, 

124 dpi: int, 

125 rotation: float, 

126 scale: float | None, 

127 size: gws.UomSize 

128) -> gws.MapView: 

129 """Create a map view from a bbox, or from a center and scale if there is no bbox.""" 

130 view = gws.MapView( 

131 dpi=dpi, 

132 rotation=rotation, 

133 ) 

134 

135 w, h, u = size 

136 if u == gws.Uom.mm: 

137 view.mmSize = w, h 

138 view.pxSize = gws.lib.uom.size_mm_to_px(view.mmSize, view.dpi) 

139 if u == gws.Uom.px: 

140 view.pxSize = w, h 

141 view.mmSize = gws.lib.uom.size_px_to_mm(view.pxSize, view.dpi) 

142 

143 if bbox: 

144 view.bounds = gws.Bounds(crs=crs, extent=bbox) 

145 view.center = gws.lib.extent.center(bbox) 

146 bw, bh = gws.lib.extent.size(bbox) 

147 view.scale = gws.gis.zoom.res_to_scale(bw / view.pxSize[0], crs) 

148 return view 

149 

150 if center: 

151 view.center = center 

152 view.scale = scale 

153 

154 projection_units_per_mm = scale / 1000.0 

155 if crs.isGeographic: 

156 projection_units_per_mm /= gws.lib.crs.METERS_PER_DEGREE 

157 size = view.mmSize[0] * projection_units_per_mm, view.mmSize[1] * projection_units_per_mm 

158 bbox = gws.lib.extent.from_center(center, size) 

159 view.bounds = gws.Bounds(crs=crs, extent=bbox) 

160 return view 

161 

162 raise gws.Error('center or bbox required') 

163 

164 

165# Rendering 

166 

167 

168class _Renderer(gws.Data): 

169 mri: gws.MapRenderInput 

170 mro: gws.MapRenderOutput 

171 rasterView: gws.MapView 

172 vectorView: gws.MapView 

173 imgCount: int 

174 svgCount: int 

175 

176 

177def render_map(mri: gws.MapRenderInput) -> gws.MapRenderOutput: 

178 """Render a map. 

179 

180 Planes are rendered bottom-up. Consecutive raster planes are composed into one image plane, 

181 consecutive vector planes into one SVG plane. Errors in a plane are logged and the plane is skipped. 

182 If ``mri.notify`` is set, it is called with ``begin_plane`` and ``end_plane`` for each plane. 

183 

184 Args: 

185 mri: Render input. 

186 

187 Returns: 

188 The render output, with the vector view as its view. 

189 

190 Raises: 

191 gws.Error: If the map size unit is neither mm nor pixels. 

192 """ 

193 rd = _Renderer( 

194 mri=mri, 

195 mro=gws.MapRenderOutput(planes=[]), 

196 imgCount=0, 

197 svgCount=0 

198 ) 

199 

200 # vectors always use PDF_DPI 

201 rd.vectorView = _map_view(mri.bbox, mri.center, mri.targetCrs, gws.lib.uom.PDF_DPI, mri.rotation, mri.scale, mri.mapSize) 

202 

203 if mri.mapSize[2] == gws.Uom.px: 

204 # if they want pixels, use PDF_PDI for rasters as well 

205 rd.rasterView = rd.vectorView 

206 

207 elif mri.mapSize[2] == gws.Uom.mm: 

208 # if they want mm, rasters should use they own dpi 

209 raster_dpi = min(MAX_DPI, max(MIN_DPI, rd.mri.dpi)) 

210 rd.rasterView = _map_view(mri.bbox, mri.center, mri.targetCrs, raster_dpi, mri.rotation, mri.scale, mri.mapSize) 

211 

212 else: 

213 raise gws.Error(f'invalid size {mri.mapSize!r}') 

214 

215 # NB: planes are top-to-bottom 

216 

217 for n, p in enumerate(reversed(mri.planes)): 

218 if mri.notify: 

219 mri.notify('begin_plane', p) 

220 try: 

221 _render_plane(rd, p) 

222 except Exception: 

223 gws.log.exception(f'RENDER_FAILED: plane {len(mri.planes) - n - 1}') 

224 if mri.notify: 

225 mri.notify('end_plane', p) 

226 

227 rd.mro.view = rd.vectorView 

228 return rd.mro 

229 

230 

231def _render_plane(rd: _Renderer, plane: gws.MapRenderInputPlane): 

232 s = plane.opacity 

233 if s is not None: 

234 opacity = s 

235 elif plane.layer: 

236 opacity = plane.layer.opacity 

237 else: 

238 opacity = 1 

239 

240 if plane.type == gws.MapRenderInputPlaneType.imageLayer: 

241 extra_params = {} 

242 if plane.compositeLayerUids: 

243 extra_params = {'compositeLayerUids': plane.compositeLayerUids} 

244 lro = plane.layer.render(gws.LayerRenderInput( 

245 type=gws.LayerRenderInputType.box, 

246 targetCrs=rd.mri.targetCrs, 

247 view=rd.rasterView, 

248 extraParams=extra_params, 

249 user=rd.mri.user, 

250 )) 

251 if lro: 

252 _add_image(rd, gws.lib.image.from_bytes(lro.content), opacity) 

253 return 

254 

255 if plane.type == gws.MapRenderInputPlaneType.image: 

256 _add_image(rd, plane.image, opacity) 

257 return 

258 

259 if plane.type == gws.MapRenderInputPlaneType.svgLayer: 

260 lro = plane.layer.render(gws.LayerRenderInput( 

261 type=gws.LayerRenderInputType.svg, 

262 targetCrs=rd.mri.targetCrs, 

263 view=rd.vectorView, 

264 style=plane.styles[0] if plane.styles else None, 

265 user=rd.mri.user, 

266 )) 

267 if lro: 

268 _add_svg_elements(rd, lro.tags, opacity) 

269 return 

270 

271 if plane.type == gws.MapRenderInputPlaneType.features: 

272 style_dct = {} 

273 if plane.styles: 

274 style_dct = {s.cssSelector: s for s in plane.styles} 

275 for f in plane.features: 

276 tags = f.to_svg(rd.vectorView, f.views.get('label', ''), style_dct.get(f.cssSelector)) 

277 _add_svg_elements(rd, tags, opacity) 

278 return 

279 

280 if plane.type == gws.MapRenderInputPlaneType.svgSoup: 

281 els = gws.lib.svg.soup_to_fragment(rd.vectorView, plane.soupPoints, plane.soupTags) 

282 _add_svg_elements(rd, els, opacity) 

283 return 

284 

285 

286def _add_image(rd: _Renderer, img, opacity): 

287 last_type = rd.mro.planes[-1].type if rd.mro.planes else None 

288 

289 if last_type != gws.MapRenderOutputPlaneType.image: 

290 # NB use background for the first composition only 

291 background = rd.mri.backgroundColor if rd.imgCount == 0 else None 

292 rd.mro.planes.append(gws.MapRenderOutputPlane( 

293 type=gws.MapRenderOutputPlaneType.image, 

294 image=gws.lib.image.from_size(rd.rasterView.pxSize, background))) 

295 

296 rd.mro.planes[-1].image = rd.mro.planes[-1].image.compose(img, opacity) 

297 rd.imgCount += 1 

298 

299 

300def _add_svg_elements(rd: _Renderer, elements, opacity): 

301 # @TODO opacity for svgs 

302 

303 last_type = rd.mro.planes[-1].type if rd.mro.planes else None 

304 

305 if last_type != gws.MapRenderOutputPlaneType.svg: 

306 rd.mro.planes.append(gws.MapRenderOutputPlane( 

307 type=gws.MapRenderOutputPlaneType.svg, 

308 elements=[])) 

309 

310 rd.mro.planes[-1].elements.extend(elements) 

311 rd.svgCount += 1 

312 

313 

314# Output 

315 

316 

317def output_to_html_element(mro: gws.MapRenderOutput, wrap='relative') -> gws.XmlElement: 

318 """Convert a render output to an HTML element. 

319 

320 Image planes are saved to ephemeral PNG files and become ``img`` tags, 

321 SVG planes become ``svg`` elements, all absolutely positioned in a ``div`` of the view's mm size. 

322 

323 Args: 

324 mro: Render output. 

325 wrap: CSS position of the ``div``: ``relative``, ``absolute`` or ``fixed``. 

326 With any other value, the ``div`` has no style. 

327 

328 Returns: 

329 A ``div`` element. 

330 """ 

331 w, h = mro.view.mmSize 

332 

333 css_size = f'left:0;top:0;width:{int(w)}mm;height:{int(h)}mm' 

334 css_abs = f'position:absolute;{css_size}' 

335 

336 tags: list[gws.XmlElement] = [] 

337 

338 for plane in mro.planes: 

339 if plane.type == gws.MapRenderOutputPlaneType.image: 

340 img_path = plane.image.to_path(gws.u.ephemeral_path('mro.png')) 

341 tags.append(xmlx.tag('img', {'style': css_abs, 'src': img_path})) 

342 if plane.type == gws.MapRenderOutputPlaneType.path: 

343 tags.append(xmlx.tag('img', {'style': css_abs, 'src': plane.path})) 

344 if plane.type == gws.MapRenderOutputPlaneType.svg: 

345 tags.append(gws.lib.svg.fragment_to_element(plane.elements, {'style': css_abs})) 

346 

347 if not tags: 

348 tags.append(xmlx.tag('img')) 

349 

350 css_div = None 

351 if wrap and wrap in {'relative', 'absolute', 'fixed'}: 

352 css_div = f'position:{wrap};overflow:hidden;{css_size}' 

353 return xmlx.tag('div', {'style': css_div}, *tags) 

354 

355 

356def output_to_html_string(mro: gws.MapRenderOutput, wrap='relative') -> str: 

357 """Convert a render output to an HTML string. 

358 

359 Args: 

360 mro: Render output. 

361 wrap: CSS position of the wrapper ``div``, see ``output_to_html_element``. 

362 

363 Returns: 

364 The HTML of a ``div`` element. 

365 """ 

366 div = output_to_html_element(mro, wrap) 

367 return div.to_string()