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

130 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +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.extent 

55import gws.lib.image 

56import gws.lib.svg 

57import gws.lib.uom 

58import gws.gis.zoom 

59import gws.lib.xmlx as xmlx 

60 

61MAX_DPI = 1200 

62"""Max. DPI for raster planes.""" 

63MIN_DPI = gws.lib.uom.PDF_DPI 

64"""Min. DPI for raster planes.""" 

65 

66 

67# Map Views 

68 

69def map_view_from_center( 

70 size: gws.UomSize, 

71 center: gws.Point, 

72 crs: gws.Crs, 

73 dpi: int, 

74 scale: float, 

75 rotation: float = 0, 

76) -> gws.MapView: 

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

78 

79 The extent is computed from the size in mm, assuming that the CRS units are meters. 

80 

81 Args: 

82 size: Map size in mm or pixels. 

83 center: Center point. 

84 crs: CRS of the view. 

85 dpi: Resolution in dots per inch. 

86 scale: Scale denominator. 

87 rotation: Rotation angle in degrees. 

88 

89 Returns: 

90 A map view. 

91 """ 

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

93 

94 

95def map_view_from_bbox( 

96 size: gws.UomSize, 

97 bbox: gws.Extent, 

98 crs: gws.Crs, 

99 dpi: int, 

100 rotation: float = 0, 

101) -> gws.MapView: 

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

103 

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

105 

106 Args: 

107 size: Map size in mm or pixels. 

108 bbox: Bounding box. 

109 crs: CRS of the view. 

110 dpi: Resolution in dots per inch. 

111 rotation: Rotation angle in degrees. 

112 

113 Returns: 

114 A map view. 

115 """ 

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

117 

118def _map_view( 

119 bbox: gws.Extent | None, 

120 center: gws.Point | None, 

121 crs: gws.Crs, 

122 dpi: int, 

123 rotation: float, 

124 scale: float | None, 

125 size: gws.UomSize 

126) -> gws.MapView: 

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

128 view = gws.MapView( 

129 dpi=dpi, 

130 rotation=rotation, 

131 ) 

132 

133 w, h, u = size 

134 if u == gws.Uom.mm: 

135 view.mmSize = w, h 

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

137 if u == gws.Uom.px: 

138 view.pxSize = w, h 

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

140 

141 if bbox: 

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

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

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

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

146 return view 

147 

148 if center: 

149 view.center = center 

150 view.scale = scale 

151 

152 # @TODO assuming projection units are 'm' 

153 projection_units_per_mm = scale / 1000.0 

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

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

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

157 return view 

158 

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

160 

161 

162# Rendering 

163 

164 

165class _Renderer(gws.Data): 

166 mri: gws.MapRenderInput 

167 mro: gws.MapRenderOutput 

168 rasterView: gws.MapView 

169 vectorView: gws.MapView 

170 imgCount: int 

171 svgCount: int 

172 

173 

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

175 """Render a map. 

176 

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

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

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

180 

181 Args: 

182 mri: Render input. 

183 

184 Returns: 

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

186 

187 Raises: 

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

189 """ 

190 rd = _Renderer( 

191 mri=mri, 

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

193 imgCount=0, 

194 svgCount=0 

195 ) 

196 

197 # vectors always use PDF_DPI 

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

199 

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

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

202 rd.rasterView = rd.vectorView 

203 

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

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

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

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

208 

209 else: 

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

211 

212 # NB: planes are top-to-bottom 

213 

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

215 if mri.notify: 

216 mri.notify('begin_plane', p) 

217 try: 

218 _render_plane(rd, p) 

219 except Exception: 

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

221 if mri.notify: 

222 mri.notify('end_plane', p) 

223 

224 rd.mro.view = rd.vectorView 

225 return rd.mro 

226 

227 

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

229 s = plane.opacity 

230 if s is not None: 

231 opacity = s 

232 elif plane.layer: 

233 opacity = plane.layer.opacity 

234 else: 

235 opacity = 1 

236 

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

238 extra_params = {} 

239 if plane.compositeLayerUids: 

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

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

242 type=gws.LayerRenderInputType.box, 

243 targetCrs=rd.mri.targetCrs, 

244 view=rd.rasterView, 

245 extraParams=extra_params, 

246 user=rd.mri.user, 

247 )) 

248 if lro: 

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

250 return 

251 

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

253 _add_image(rd, plane.image, opacity) 

254 return 

255 

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

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

258 type=gws.LayerRenderInputType.svg, 

259 targetCrs=rd.mri.targetCrs, 

260 view=rd.vectorView, 

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

262 user=rd.mri.user, 

263 )) 

264 if lro: 

265 _add_svg_elements(rd, lro.tags, opacity) 

266 return 

267 

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

269 style_dct = {} 

270 if plane.styles: 

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

272 for f in plane.features: 

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

274 _add_svg_elements(rd, tags, opacity) 

275 return 

276 

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

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

279 _add_svg_elements(rd, els, opacity) 

280 return 

281 

282 

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

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

285 

286 if last_type != gws.MapRenderOutputPlaneType.image: 

287 # NB use background for the first composition only 

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

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

290 type=gws.MapRenderOutputPlaneType.image, 

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

292 

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

294 rd.imgCount += 1 

295 

296 

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

298 # @TODO opacity for svgs 

299 

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

301 

302 if last_type != gws.MapRenderOutputPlaneType.svg: 

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

304 type=gws.MapRenderOutputPlaneType.svg, 

305 elements=[])) 

306 

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

308 rd.svgCount += 1 

309 

310 

311# Output 

312 

313 

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

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

316 

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

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

319 

320 Args: 

321 mro: Render output. 

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

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

324 

325 Returns: 

326 A ``div`` element. 

327 """ 

328 w, h = mro.view.mmSize 

329 

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

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

332 

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

334 

335 for plane in mro.planes: 

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

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

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

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

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

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

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

343 

344 if not tags: 

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

346 

347 css_div = None 

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

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

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

351 

352 

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

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

355 

356 Args: 

357 mro: Render output. 

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

359 

360 Returns: 

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

362 """ 

363 div = output_to_html_element(mro, wrap) 

364 return div.to_string()