Coverage for gws-app/gws/plugin/qgis/template.py: 0%
143 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
1"""QGIS Print template.
3The Qgis print templates work this way:
5We read the qgis project and locate a template object within by its title or the index,
6by default the first template is taken.
8We find all `label` and `html` blocks in the template and create our `html` templates from
9them, so that they can make use of our placeholders like `@legend`.
11When rendering, we render our map as pdf.
13Then we render these html templates, and create a clone of the qgis project
14with resulting html injected at the proper places.
16Then we render the Qgis template without the map, using Qgis `GetPrint` to generate html.
18And finally, combine two pdfs so that the qgis pdf is above the map pdf.
19This is because we need qgis to draw grids and other decorations above the map.
21Caveats/todos:
23- both qgis "paper" and the map element must be transparent
24- since we create a copy of the qgis project, it must use absolute paths to all assets
25- the position of the map in qgis is a couple of mm off when we combine, for better results, the map position/size in qgis must be integer
27"""
28from typing import Optional
30import gws
31import gws.base.template
32import gws.config.util
33import gws.plugin.template.html
34import gws.lib.htmlx
35import gws.lib.osx
36import gws.lib.mime
37import gws.lib.pdf
38import gws.gis.render
40from . import caps, project, provider
42gws.ext.new.template('qgis')
45class Config(gws.base.template.Config):
46 """QGIS Print template configuration."""
48 provider: Optional[provider.Config]
49 """Qgis provider."""
50 index: Optional[int]
51 """Template index."""
52 mapPosition: Optional[gws.UomSizeStr]
53 """Position for the main map."""
54 cssPath: Optional[gws.FilePath]
55 """Css file."""
58class _HtmlBlock(gws.Data):
59 attrName: str
60 template: gws.plugin.template.html.Object
63class Object(gws.base.template.Object):
64 serviceProvider: provider.Object
65 qgisTemplate: caps.PrintTemplate
66 mapPosition: gws.UomSize
67 cssPath: str
68 htmlBlocks: dict[str, _HtmlBlock]
70 def configure(self):
71 self.configure_provider()
72 self.cssPath = self.cfg('cssPath', '')
73 self._load()
75 def configure_provider(self):
76 return gws.config.util.configure_service_provider_for(self, provider.Object)
78 def render(self, tri):
79 # @TODO reload only if changed
80 self._load()
82 self.notify(tri, 'begin_print')
84 # render the map
86 self.notify(tri, 'begin_map')
87 map_pdf_path = gws.u.ephemeral_path('q.map.pdf')
88 mro = self._render_map(tri, map_pdf_path)
89 self.notify(tri, 'end_map')
91 # render qgis
93 qgis_pdf_path = gws.u.ephemeral_path('q.qgis.pdf')
94 self._render_qgis(tri, mro, qgis_pdf_path)
96 if not mro:
97 # no map, just return the rendered qgis
98 self.notify(tri, 'end_print')
99 return gws.ContentResponse(contentPath=qgis_pdf_path)
101 # combine map and qgis
103 self.notify(tri, 'finalize_print')
104 comb_path = gws.u.ephemeral_path('q.comb.pdf')
105 gws.lib.pdf.overlay(map_pdf_path, qgis_pdf_path, comb_path)
107 self.notify(tri, 'end_print')
108 return gws.ContentResponse(contentPath=comb_path)
110 ##
112 def _load(self):
114 idx = self.cfg('index')
115 if idx is not None:
116 self.qgisTemplate = self._find_template_by_index(idx)
117 elif self.title:
118 self.qgisTemplate = self._find_template_by_title(self.title)
119 else:
120 self.qgisTemplate = self._find_template_by_index(0)
122 if not self.title:
123 self.title = self.qgisTemplate.title
125 self.mapPosition = self.cfg('mapPosition')
127 for el in self.qgisTemplate.elements:
128 if el.type == 'page' and el.size:
129 self.pageSize = el.size
130 if el.type == 'map' and el.size:
131 self.mapSize = el.size
132 self.mapPosition = el.position
134 if not self.pageSize or not self.mapSize or not self.mapPosition:
135 raise gws.Error('cannot read page or map size')
137 self._collect_html_blocks()
139 def _find_template_by_index(self, idx):
140 try:
141 return self.serviceProvider.printTemplates[idx]
142 except IndexError:
143 raise gws.Error(f'print template #{idx} not found')
145 def _find_template_by_title(self, title):
146 for tpl in self.serviceProvider.printTemplates:
147 if tpl.title == title:
148 return tpl
149 raise gws.Error(f'print template {title!r} not found')
151 def _render_map(self, tri: gws.TemplateRenderInput, out_path):
152 if not tri.maps:
153 return
155 notify = tri.notify or (lambda *args: None)
156 mp = tri.maps[0]
158 mri = gws.MapRenderInput(
159 backgroundColor=mp.backgroundColor,
160 bbox=mp.bbox,
161 center=mp.center,
162 crs=tri.crs,
163 dpi=tri.dpi,
164 mapSize=self.mapSize,
165 notify=notify,
166 planes=mp.planes,
167 project=tri.project,
168 rotation=mp.rotation,
169 scale=mp.scale,
170 user=tri.user,
171 )
173 mro = gws.gis.render.render_map(mri)
174 html = gws.gis.render.output_to_html_string(mro)
176 x, y, _ = self.mapPosition
177 w, h, _ = self.mapSize
178 css = f"""
179 position: fixed;
180 left: {int(x)}mm;
181 top: {int(y)}mm;
182 width: {int(w)}mm;
183 height: {int(h)}mm;
184 """
185 html = f"<div style='{css}'>{html}</div>"
187 if self.cssPath:
188 html = f"""<link rel="stylesheet" href="file://{self.cssPath}">""" + html
190 gws.lib.htmlx.render_to_pdf(self._decorate_html(html), out_path, self.pageSize)
191 return mro
193 def _decorate_html(self, html):
194 html = '<meta charset="utf8" />\n' + html
195 return html
197 def _render_qgis(self, tri: gws.TemplateRenderInput, mro: gws.MapRenderOutput, out_path):
199 # prepare params for the qgis server
201 params = {
202 'REQUEST': gws.OwsVerb.GetPrint,
203 'CRS': 'EPSG:3857', # crs doesn't matter, but required
204 'FORMAT': 'pdf',
205 'TEMPLATE': self.qgisTemplate.title,
206 'TRANSPARENT': 'true',
207 'MAP': self.serviceProvider.server_project_path(),
208 }
210 qgis_project = self.serviceProvider.qgis_project()
211 changed = self._render_html_blocks(tri, qgis_project)
212 project_copy_path = ''
214 if changed:
215 # we have html templates, create a copy of the project
216 # NB it must be in a shared dir, so that qgis container can access it
217 # NB since we relocate the project, all assets must be absolute
218 project_copy_path = gws.c.QGIS_DIR + '/' + gws.u.random_string(64) + '.qgs'
219 qgis_project.to_path(project_copy_path)
220 params['MAP'] = project_copy_path
222 if mro:
223 # NB we don't render the map here, but still need map0:xxxx for scale bars and arrows
224 # NB the extent is mandatory!
225 params = gws.u.merge(params, {
226 'CRS': mro.view.bounds.crs.epsg,
227 'MAP0:EXTENT': mro.view.bounds.extent,
228 'MAP0:ROTATION': mro.view.rotation,
229 'MAP0:SCALE': mro.view.scale,
230 })
232 res = self.serviceProvider.call_server(params)
233 gws.u.write_file_b(out_path, res.content)
235 if project_copy_path:
236 gws.lib.osx.unlink(project_copy_path)
238 def _collect_html_blocks(self):
239 self.htmlBlocks = {}
241 for el in self.qgisTemplate.elements:
242 if el.type not in {'html', 'label'}:
243 continue
245 attr = 'html' if el.type == 'html' else 'labelText'
246 text = el.attributes.get(attr, '').strip()
248 if text:
249 self.htmlBlocks[el.uuid] = _HtmlBlock(
250 attrName=attr,
251 template=self.root.create_shared(
252 gws.ext.object.template,
253 uid='qgis_html_' + gws.u.sha256(text),
254 type='html',
255 text=text,
256 )
257 )
259 def _render_html_blocks(self, tri: gws.TemplateRenderInput, qgis_project: project.Object):
260 if not self.htmlBlocks:
261 # there are no html blocks...
262 return False
264 tri_for_blocks = gws.TemplateRenderInput(
265 args=tri.args,
266 crs=tri.crs,
267 dpi=tri.dpi,
268 maps=tri.maps,
269 mimeOut=gws.lib.mime.HTML,
270 user=tri.user
271 )
273 render_results = {}
275 for uuid, block in self.htmlBlocks.items():
276 res = block.template.render(tri_for_blocks)
277 if res.content != block.template.text:
278 render_results[uuid] = [block.attrName, res.content]
280 if not render_results:
281 # no blocks are changed - means, they contain no our placeholders
282 return False
284 for layout_el in qgis_project.xml_root().findall('Layouts/Layout'):
285 for item_el in layout_el:
286 uuid = item_el.get('uuid')
287 if uuid in render_results:
288 attr, content = render_results[uuid]
289 item_el.set(attr, content)
291 return True