Coverage for gws-app/gws/plugin/qgis/template.py: 0%
141 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +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
31import gws
32import gws.base.template
33import gws.config.util
34import gws.plugin.template.html
35import gws.lib.htmlx
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 provider: Optional[provider.Config]
47 """qgis provider"""
48 index: Optional[int]
49 """template index"""
50 mapPosition: Optional[gws.UomSizeStr]
51 """position for the main map"""
52 cssPath: Optional[gws.FilePath]
53 """css file"""
56class _HtmlBlock(gws.Data):
57 attrName: str
58 template: gws.plugin.template.html.Object
61class Object(gws.base.template.Object):
62 serviceProvider: provider.Object
63 qgisTemplate: caps.PrintTemplate
64 mapPosition: gws.UomSize
65 cssPath: str
66 htmlBlocks: dict[str, _HtmlBlock]
68 def configure(self):
69 self.configure_provider()
70 self.cssPath = self.cfg('cssPath', '')
71 self._load()
73 def configure_provider(self):
74 return gws.config.util.configure_service_provider_for(self, provider.Object)
76 def render(self, tri):
77 # @TODO reload only if changed
78 self._load()
80 self.notify(tri, 'begin_print')
82 # render the map
84 self.notify(tri, 'begin_map')
85 map_pdf_path = gws.u.printtemp('q.map.pdf')
86 mro = self._render_map(tri, map_pdf_path)
87 self.notify(tri, 'end_map')
89 # render qgis
91 self.notify(tri, 'begin_page')
92 qgis_pdf_path = gws.u.printtemp('q.qgis.pdf')
93 self._render_qgis(tri, mro, qgis_pdf_path)
94 self.notify(tri, 'end_page')
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.printtemp('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 }
209 qgis_project = self.serviceProvider.qgis_project()
210 changed = self._render_html_blocks(tri, qgis_project)
212 if changed:
213 # we have html templates, create a copy of the project
214 new_project_path = out_path + '.qgs'
215 qgis_project.to_path(new_project_path)
216 params['MAP'] = new_project_path
218 if mro:
219 # NB we don't render the map here, but still need map0:xxxx for scale bars and arrows
220 # NB the extent is mandatory!
221 params = gws.u.merge(params, {
222 'CRS': mro.view.bounds.crs.epsg,
223 'MAP0:EXTENT': mro.view.bounds.extent,
224 'MAP0:ROTATION': mro.view.rotation,
225 'MAP0:SCALE': mro.view.scale,
226 })
228 res = self.serviceProvider.call_server(params)
229 gws.u.write_file_b(out_path, res.content)
231 def _collect_html_blocks(self):
232 self.htmlBlocks = {}
234 for el in self.qgisTemplate.elements:
235 if el.type not in {'html', 'label'}:
236 continue
238 attr = 'html' if el.type == 'html' else 'labelText'
239 text = el.attributes.get(attr, '').strip()
241 if text:
242 self.htmlBlocks[el.uuid] = _HtmlBlock(
243 attrName=attr,
244 template=self.root.create_shared(
245 gws.ext.object.template,
246 uid='qgis_html_' + gws.u.sha256(text),
247 type='html',
248 text=text,
249 )
250 )
252 def _render_html_blocks(self, tri: gws.TemplateRenderInput, qgis_project: project.Object):
253 if not self.htmlBlocks:
254 # there are no html blocks...
255 return False
257 tri_for_blocks = gws.TemplateRenderInput(
258 args=tri.args,
259 crs=tri.crs,
260 dpi=tri.dpi,
261 maps=tri.maps,
262 mimeOut=gws.lib.mime.HTML,
263 user=tri.user
264 )
266 render_results = {}
268 for uuid, block in self.htmlBlocks.items():
269 res = block.template.render(tri_for_blocks)
270 if res.content != block.template.text:
271 render_results[uuid] = [block.attrName, res.content]
273 if not render_results:
274 # no blocks are changed - means, they contain no our placeholders
275 return False
277 for layout_el in qgis_project.xml_root().findall('Layouts/Layout'):
278 for item_el in layout_el:
279 uuid = item_el.get('uuid')
280 if uuid in render_results:
281 attr, content = render_results[uuid]
282 item_el.set(attr, content)
284 return True