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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Map rendering.
3This package renders maps for printing, exporting and templates. It works with
4three kinds of objects, defined in ``types.pyinc``:
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.
14Design
15------
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``.
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.
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``.
33The package also creates views from a center and scale or from a bbox
34(``map_view_from_center``, ``map_view_from_bbox``).
36Example::
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"""
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
61MAX_DPI = 1200
62"""Max. DPI for raster planes."""
63MIN_DPI = gws.lib.uom.PDF_DPI
64"""Min. DPI for raster planes."""
67# Map Views
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.
79 The extent is computed from the size in mm, assuming that the CRS units are meters.
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.
89 Returns:
90 A map view.
91 """
92 return _map_view(None, center, crs, dpi, rotation, scale, size)
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.
104 The scale is computed from the bbox width and the pixel width.
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.
113 Returns:
114 A map view.
115 """
116 return _map_view(bbox, None, crs, dpi, rotation, None, size)
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 )
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)
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
148 if center:
149 view.center = center
150 view.scale = scale
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
159 raise gws.Error('center or bbox required')
162# Rendering
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
174def render_map(mri: gws.MapRenderInput) -> gws.MapRenderOutput:
175 """Render a map.
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.
181 Args:
182 mri: Render input.
184 Returns:
185 The render output, with the vector view as its view.
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 )
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)
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
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)
209 else:
210 raise gws.Error(f'invalid size {mri.mapSize!r}')
212 # NB: planes are top-to-bottom
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)
224 rd.mro.view = rd.vectorView
225 return rd.mro
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
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
252 if plane.type == gws.MapRenderInputPlaneType.image:
253 _add_image(rd, plane.image, opacity)
254 return
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
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
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
283def _add_image(rd: _Renderer, img, opacity):
284 last_type = rd.mro.planes[-1].type if rd.mro.planes else None
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)))
293 rd.mro.planes[-1].image = rd.mro.planes[-1].image.compose(img, opacity)
294 rd.imgCount += 1
297def _add_svg_elements(rd: _Renderer, elements, opacity):
298 # @TODO opacity for svgs
300 last_type = rd.mro.planes[-1].type if rd.mro.planes else None
302 if last_type != gws.MapRenderOutputPlaneType.svg:
303 rd.mro.planes.append(gws.MapRenderOutputPlane(
304 type=gws.MapRenderOutputPlaneType.svg,
305 elements=[]))
307 rd.mro.planes[-1].elements.extend(elements)
308 rd.svgCount += 1
311# Output
314def output_to_html_element(mro: gws.MapRenderOutput, wrap='relative') -> gws.XmlElement:
315 """Convert a render output to an HTML element.
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.
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.
325 Returns:
326 A ``div`` element.
327 """
328 w, h = mro.view.mmSize
330 css_size = f'left:0;top:0;width:{int(w)}mm;height:{int(h)}mm'
331 css_abs = f'position:absolute;{css_size}'
333 tags: list[gws.XmlElement] = []
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}))
344 if not tags:
345 tags.append(xmlx.tag('img'))
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)
353def output_to_html_string(mro: gws.MapRenderOutput, wrap='relative') -> str:
354 """Convert a render output to an HTML string.
356 Args:
357 mro: Render output.
358 wrap: CSS position of the wrapper ``div``, see ``output_to_html_element``.
360 Returns:
361 The HTML of a ``div`` element.
362 """
363 div = output_to_html_element(mro, wrap)
364 return div.to_string()