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
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +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.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
62MAX_DPI = 1200
63"""Max. DPI for raster planes."""
64MIN_DPI = gws.lib.uom.PDF_DPI
65"""Min. DPI for raster planes."""
68# Map Views
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.
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``.
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.
91 Returns:
92 A map view.
93 """
94 return _map_view(None, center, crs, dpi, rotation, scale, size)
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.
106 The scale is computed from the bbox width and the pixel width.
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.
115 Returns:
116 A map view.
117 """
118 return _map_view(bbox, None, crs, dpi, rotation, None, size)
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 )
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)
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
150 if center:
151 view.center = center
152 view.scale = scale
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
162 raise gws.Error('center or bbox required')
165# Rendering
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
177def render_map(mri: gws.MapRenderInput) -> gws.MapRenderOutput:
178 """Render a map.
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.
184 Args:
185 mri: Render input.
187 Returns:
188 The render output, with the vector view as its view.
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 )
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)
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
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)
212 else:
213 raise gws.Error(f'invalid size {mri.mapSize!r}')
215 # NB: planes are top-to-bottom
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)
227 rd.mro.view = rd.vectorView
228 return rd.mro
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
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
255 if plane.type == gws.MapRenderInputPlaneType.image:
256 _add_image(rd, plane.image, opacity)
257 return
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
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
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
286def _add_image(rd: _Renderer, img, opacity):
287 last_type = rd.mro.planes[-1].type if rd.mro.planes else None
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)))
296 rd.mro.planes[-1].image = rd.mro.planes[-1].image.compose(img, opacity)
297 rd.imgCount += 1
300def _add_svg_elements(rd: _Renderer, elements, opacity):
301 # @TODO opacity for svgs
303 last_type = rd.mro.planes[-1].type if rd.mro.planes else None
305 if last_type != gws.MapRenderOutputPlaneType.svg:
306 rd.mro.planes.append(gws.MapRenderOutputPlane(
307 type=gws.MapRenderOutputPlaneType.svg,
308 elements=[]))
310 rd.mro.planes[-1].elements.extend(elements)
311 rd.svgCount += 1
314# Output
317def output_to_html_element(mro: gws.MapRenderOutput, wrap='relative') -> gws.XmlElement:
318 """Convert a render output to an HTML element.
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.
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.
328 Returns:
329 A ``div`` element.
330 """
331 w, h = mro.view.mmSize
333 css_size = f'left:0;top:0;width:{int(w)}mm;height:{int(h)}mm'
334 css_abs = f'position:absolute;{css_size}'
336 tags: list[gws.XmlElement] = []
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}))
347 if not tags:
348 tags.append(xmlx.tag('img'))
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)
356def output_to_html_string(mro: gws.MapRenderOutput, wrap='relative') -> str:
357 """Convert a render output to an HTML string.
359 Args:
360 mro: Render output.
361 wrap: CSS position of the wrapper ``div``, see ``output_to_html_element``.
363 Returns:
364 The HTML of a ``div`` element.
365 """
366 div = output_to_html_element(mro, wrap)
367 return div.to_string()