Coverage for gws-app/gws/plugin/qgis/template.py: 0%
140 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"""QGIS print template."""
2from typing import Optional
4import gws
5import gws.base.template
6import gws.config.util
7import gws.plugin.template.html
8import gws.lib.htmlx
9import gws.lib.osx
10import gws.lib.mime
11import gws.lib.pdf
12import gws.gis.render
14from . import caps, project, provider
17@gws.ext.config.template('qgis')
18class Config(gws.base.template.Config):
19 """Print template based on a print layout of a QGIS project."""
21 provider: Optional[provider.Config]
22 """QGIS project that contains the print layout."""
23 index: Optional[int]
24 """Print layout index in the QGIS project."""
25 mapPosition: Optional[gws.UomSizeStr]
26 """Position of the main map on the page."""
27 cssPath: Optional[gws.FilePath]
28 """Stylesheet for the HTML map overlay."""
31class _HtmlBlock(gws.Data):
32 """A label or HTML item of a print layout, as an ``html`` template."""
34 attrName: str
35 """Attribute of the layout item that holds the text."""
36 template: gws.plugin.template.html.Object
37 """Template created from the text."""
40@gws.ext.object.template('qgis')
41class Object(gws.base.template.Object):
42 """Print template based on a print layout of a QGIS project.
44 The map is rendered by WebSuite and the layout by QGIS Server; the result
45 is one PDF.
46 """
48 provider: provider.Object
49 """QGIS provider."""
50 qgisTemplate: caps.PrintTemplate
51 """The print layout."""
52 mapPosition: gws.UomSize
53 """Position of the map item on the page."""
54 cssPath: str
55 """Stylesheet for the HTML map overlay."""
56 htmlBlocks: dict[str, _HtmlBlock]
57 """Label and HTML items of the layout, keyed by item UUID."""
59 def configure(self):
60 self.configure_provider()
61 self.cssPath = self.cfg('cssPath', '')
62 self._load()
64 def configure_provider(self):
65 """Set the QGIS provider.
67 Returns:
68 ``True`` if a provider was set.
70 Raises:
71 ``gws.Error``: If no provider is found.
72 """
73 return gws.config.util.configure_provider_for(self, provider.Object)
75 def render(self, tri):
76 # @TODO reload only if changed
77 self._load()
79 self.notify(tri, 'begin_print')
81 # render the map
83 self.notify(tri, 'begin_map')
84 map_pdf_path = gws.u.ephemeral_path('q.map.pdf')
85 mro = self._render_map(tri, map_pdf_path)
86 self.notify(tri, 'end_map')
88 # render qgis
90 qgis_pdf_path = gws.u.ephemeral_path('q.qgis.pdf')
91 self._render_qgis(tri, mro, qgis_pdf_path)
93 if not mro:
94 # no map, just return the rendered qgis
95 self.notify(tri, 'end_print')
96 return gws.ContentResponse(contentPath=qgis_pdf_path)
98 # combine map and qgis
100 self.notify(tri, 'finalize_print')
101 comb_path = gws.u.ephemeral_path('q.comb.pdf')
102 gws.lib.pdf.overlay(map_pdf_path, qgis_pdf_path, comb_path)
104 self.notify(tri, 'end_print')
105 return gws.ContentResponse(contentPath=comb_path)
107 ##
109 def _load(self):
110 """Find the print layout and read the page and map sizes.
112 The layout is selected by ``index``, or by the template title, or the
113 first layout is used.
114 """
116 idx = self.cfg('index')
117 if idx is not None:
118 self.qgisTemplate = self._find_template_by_index(idx)
119 elif self.title:
120 self.qgisTemplate = self._find_template_by_title(self.title)
121 else:
122 self.qgisTemplate = self._find_template_by_index(0)
124 if not self.title:
125 self.title = self.qgisTemplate.title
127 self.mapPosition = self.cfg('mapPosition')
129 for el in self.qgisTemplate.elements:
130 if el.type == 'page' and el.size:
131 self.pageSize = el.size
132 if el.type == 'map' and el.size:
133 self.mapSize = el.size
134 self.mapPosition = el.position
136 if not self.pageSize or not self.mapSize or not self.mapPosition:
137 raise gws.Error('cannot read page or map size')
139 self._collect_html_blocks()
141 def _find_template_by_index(self, idx):
142 try:
143 return self.provider.printTemplates[idx]
144 except IndexError:
145 raise gws.Error(f'print template #{idx} not found')
147 def _find_template_by_title(self, title):
148 for tpl in self.provider.printTemplates:
149 if tpl.title == title:
150 return tpl
151 raise gws.Error(f'print template {title!r} not found')
153 def _render_map(self, tri: gws.TemplateRenderInput, out_path):
154 """Render the first map of the render input as a PDF at the position of the map item."""
155 if not tri.maps:
156 return
158 notify = tri.notify or (lambda *args: None)
159 mp = tri.maps[0]
161 mri = gws.MapRenderInput(
162 backgroundColor=mp.backgroundColor,
163 bbox=mp.bbox,
164 center=mp.center,
165 targetCrs=tri.crs,
166 dpi=tri.dpi,
167 mapSize=self.mapSize,
168 notify=notify,
169 planes=mp.planes,
170 project=tri.project,
171 rotation=mp.rotation,
172 scale=mp.scale,
173 user=tri.user,
174 )
176 mro = gws.gis.render.render_map(mri)
177 html = gws.gis.render.output_to_html_string(mro)
179 x, y, _ = self.mapPosition
180 w, h, _ = self.mapSize
181 css = f"""
182 position: fixed;
183 left: {int(x)}mm;
184 top: {int(y)}mm;
185 width: {int(w)}mm;
186 height: {int(h)}mm;
187 """
188 html = f"<div style='{css}'>{html}</div>"
190 if self.cssPath:
191 html = f"""<link rel="stylesheet" href="file://{self.cssPath}">""" + html
193 gws.lib.htmlx.render_to_pdf(self._decorate_html(html), out_path, self.pageSize)
194 return mro
196 def _decorate_html(self, html):
197 html = '<meta charset="utf8" />\n' + html
198 return html
200 def _render_qgis(self, tri: gws.TemplateRenderInput, mro: gws.MapRenderOutput, out_path):
201 """Print the layout without the map with QGIS Server GetPrint."""
203 # prepare params for the qgis server
205 params = {
206 'REQUEST': gws.OwsVerb.GetPrint,
207 'CRS': 'EPSG:3857', # crs doesn't matter, but required
208 'FORMAT': 'pdf',
209 'TEMPLATE': self.qgisTemplate.title,
210 'TRANSPARENT': 'true',
211 'MAP': self.provider.server_project_path(),
212 }
214 qgis_project = self.provider.qgis_project()
215 changed = self._render_html_blocks(tri, qgis_project)
216 project_copy_path = ''
218 if changed:
219 # we have html templates, create a copy of the project
220 # NB it must be in a shared dir, so that qgis container can access it
221 # NB since we relocate the project, all assets must be absolute
222 project_copy_path = gws.c.QGIS_DIR + '/' + gws.u.random_string(64) + '.qgs'
223 qgis_project.to_path(project_copy_path)
224 params['MAP'] = project_copy_path
226 if mro:
227 # NB we don't render the map here, but still need map0:xxxx for scale bars and arrows
228 # NB the extent is mandatory!
229 params = gws.u.merge(params, {
230 'CRS': mro.view.bounds.crs.epsg,
231 'MAP0:EXTENT': mro.view.bounds.extent,
232 'MAP0:ROTATION': mro.view.rotation,
233 'MAP0:SCALE': mro.view.scale,
234 })
236 res = self.provider.call_server(params)
237 gws.u.write_file_b(out_path, res.content)
239 if project_copy_path:
240 gws.lib.osx.unlink(project_copy_path)
242 def _collect_html_blocks(self):
243 """Create ``html`` templates from the label and HTML items of the layout."""
244 self.htmlBlocks = {}
246 for el in self.qgisTemplate.elements:
247 if el.type not in {'html', 'label'}:
248 continue
250 attr = 'html' if el.type == 'html' else 'labelText'
251 text = el.attributes.get(attr, '').strip()
253 if text:
254 self.htmlBlocks[el.uuid] = _HtmlBlock(
255 attrName=attr,
256 template=self.root.create_shared(
257 gws.ext.object.template,
258 uid='qgis_html_' + gws.u.sha256(text),
259 type='html',
260 text=text,
261 )
262 )
264 def _render_html_blocks(self, tri: gws.TemplateRenderInput, qgis_project: project.Object):
265 """Render the HTML blocks into the project XML, return ``True`` if anything changed."""
266 if not self.htmlBlocks:
267 # there are no html blocks...
268 return False
270 tri_for_blocks = gws.TemplateRenderInput(
271 args=tri.args,
272 crs=tri.crs,
273 dpi=tri.dpi,
274 maps=tri.maps,
275 mimeOut=gws.lib.mime.HTML,
276 user=tri.user
277 )
279 render_results = {}
281 for uuid, block in self.htmlBlocks.items():
282 res = block.template.render(tri_for_blocks)
283 if res.content != block.template.text:
284 render_results[uuid] = [block.attrName, res.content]
286 if not render_results:
287 # no blocks are changed - means, they contain no our placeholders
288 return False
290 for layout_el in qgis_project.xml_root().findall('Layouts/Layout'):
291 for item_el in layout_el:
292 uuid = item_el.get('uuid')
293 if uuid in render_results:
294 attr, content = render_results[uuid]
295 item_el.set(attr, content)
297 return True