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 22:59 +0200

1"""QGIS Print template. 

2 

3The Qgis print templates work this way: 

4 

5We read the qgis project and locate a template object within by its title or the index, 

6by default the first template is taken. 

7 

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

10 

11When rendering, we render our map as pdf. 

12 

13Then we render these html templates, and create a clone of the qgis project 

14with resulting html injected at the proper places. 

15 

16Then we render the Qgis template without the map, using Qgis `GetPrint` to generate html. 

17 

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. 

20 

21Caveats/todos: 

22 

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 

26 

27""" 

28from typing import Optional 

29 

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 

39 

40from . import caps, project, provider 

41 

42gws.ext.new.template('qgis') 

43 

44 

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

46 """QGIS Print template configuration.""" 

47 

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

56 

57 

58class _HtmlBlock(gws.Data): 

59 attrName: str 

60 template: gws.plugin.template.html.Object 

61 

62 

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] 

69 

70 def configure(self): 

71 self.configure_provider() 

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

73 self._load() 

74 

75 def configure_provider(self): 

76 return gws.config.util.configure_service_provider_for(self, provider.Object) 

77 

78 def render(self, tri): 

79 # @TODO reload only if changed 

80 self._load() 

81 

82 self.notify(tri, 'begin_print') 

83 

84 # render the map 

85 

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

90 

91 # render qgis 

92 

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

94 self._render_qgis(tri, mro, qgis_pdf_path) 

95 

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) 

100 

101 # combine map and qgis 

102 

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) 

106 

107 self.notify(tri, 'end_print') 

108 return gws.ContentResponse(contentPath=comb_path) 

109 

110 ## 

111 

112 def _load(self): 

113 

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) 

121 

122 if not self.title: 

123 self.title = self.qgisTemplate.title 

124 

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

126 

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 

133 

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

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

136 

137 self._collect_html_blocks() 

138 

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

144 

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

150 

151 def _render_map(self, tri: gws.TemplateRenderInput, out_path): 

152 if not tri.maps: 

153 return 

154 

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

156 mp = tri.maps[0] 

157 

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 ) 

172 

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

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

175 

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

186 

187 if self.cssPath: 

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

189 

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

191 return mro 

192 

193 def _decorate_html(self, html): 

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

195 return html 

196 

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

198 

199 # prepare params for the qgis server 

200 

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 } 

209 

210 qgis_project = self.serviceProvider.qgis_project() 

211 changed = self._render_html_blocks(tri, qgis_project) 

212 project_copy_path = '' 

213 

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 

221 

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

231 

232 res = self.serviceProvider.call_server(params) 

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

234 

235 if project_copy_path: 

236 gws.lib.osx.unlink(project_copy_path) 

237 

238 def _collect_html_blocks(self): 

239 self.htmlBlocks = {} 

240 

241 for el in self.qgisTemplate.elements: 

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

243 continue 

244 

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

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

247 

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 ) 

258 

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 

263 

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 ) 

272 

273 render_results = {} 

274 

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] 

279 

280 if not render_results: 

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

282 return False 

283 

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) 

290 

291 return True