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

1"""QGIS print template.""" 

2from typing import Optional 

3 

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 

13 

14from . import caps, project, provider 

15 

16 

17@gws.ext.config.template('qgis') 

18class Config(gws.base.template.Config): 

19 """Print template based on a print layout of a QGIS project.""" 

20 

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.""" 

29 

30 

31class _HtmlBlock(gws.Data): 

32 """A label or HTML item of a print layout, as an ``html`` template.""" 

33 

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.""" 

38 

39 

40@gws.ext.object.template('qgis') 

41class Object(gws.base.template.Object): 

42 """Print template based on a print layout of a QGIS project. 

43 

44 The map is rendered by WebSuite and the layout by QGIS Server; the result 

45 is one PDF. 

46 """ 

47 

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.""" 

58 

59 def configure(self): 

60 self.configure_provider() 

61 self.cssPath = self.cfg('cssPath', '') 

62 self._load() 

63 

64 def configure_provider(self): 

65 """Set the QGIS provider. 

66 

67 Returns: 

68 ``True`` if a provider was set. 

69 

70 Raises: 

71 ``gws.Error``: If no provider is found. 

72 """ 

73 return gws.config.util.configure_provider_for(self, provider.Object) 

74 

75 def render(self, tri): 

76 # @TODO reload only if changed 

77 self._load() 

78 

79 self.notify(tri, 'begin_print') 

80 

81 # render the map 

82 

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') 

87 

88 # render qgis 

89 

90 qgis_pdf_path = gws.u.ephemeral_path('q.qgis.pdf') 

91 self._render_qgis(tri, mro, qgis_pdf_path) 

92 

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) 

97 

98 # combine map and qgis 

99 

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) 

103 

104 self.notify(tri, 'end_print') 

105 return gws.ContentResponse(contentPath=comb_path) 

106 

107 ## 

108 

109 def _load(self): 

110 """Find the print layout and read the page and map sizes. 

111 

112 The layout is selected by ``index``, or by the template title, or the 

113 first layout is used. 

114 """ 

115 

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) 

123 

124 if not self.title: 

125 self.title = self.qgisTemplate.title 

126 

127 self.mapPosition = self.cfg('mapPosition') 

128 

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 

135 

136 if not self.pageSize or not self.mapSize or not self.mapPosition: 

137 raise gws.Error('cannot read page or map size') 

138 

139 self._collect_html_blocks() 

140 

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') 

146 

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') 

152 

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 

157 

158 notify = tri.notify or (lambda *args: None) 

159 mp = tri.maps[0] 

160 

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 ) 

175 

176 mro = gws.gis.render.render_map(mri) 

177 html = gws.gis.render.output_to_html_string(mro) 

178 

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>" 

189 

190 if self.cssPath: 

191 html = f"""<link rel="stylesheet" href="file://{self.cssPath}">""" + html 

192 

193 gws.lib.htmlx.render_to_pdf(self._decorate_html(html), out_path, self.pageSize) 

194 return mro 

195 

196 def _decorate_html(self, html): 

197 html = '<meta charset="utf8" />\n' + html 

198 return html 

199 

200 def _render_qgis(self, tri: gws.TemplateRenderInput, mro: gws.MapRenderOutput, out_path): 

201 """Print the layout without the map with QGIS Server GetPrint.""" 

202 

203 # prepare params for the qgis server 

204 

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 } 

213 

214 qgis_project = self.provider.qgis_project() 

215 changed = self._render_html_blocks(tri, qgis_project) 

216 project_copy_path = '' 

217 

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 

225 

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 }) 

235 

236 res = self.provider.call_server(params) 

237 gws.u.write_file_b(out_path, res.content) 

238 

239 if project_copy_path: 

240 gws.lib.osx.unlink(project_copy_path) 

241 

242 def _collect_html_blocks(self): 

243 """Create ``html`` templates from the label and HTML items of the layout.""" 

244 self.htmlBlocks = {} 

245 

246 for el in self.qgisTemplate.elements: 

247 if el.type not in {'html', 'label'}: 

248 continue 

249 

250 attr = 'html' if el.type == 'html' else 'labelText' 

251 text = el.attributes.get(attr, '').strip() 

252 

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 ) 

263 

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 

269 

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 ) 

278 

279 render_results = {} 

280 

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] 

285 

286 if not render_results: 

287 # no blocks are changed - means, they contain no our placeholders 

288 return False 

289 

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) 

296 

297 return True