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

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 

30 

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 

39 

40from . import caps, project, provider 

41 

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

43 

44 

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

54 

55 

56class _HtmlBlock(gws.Data): 

57 attrName: str 

58 template: gws.plugin.template.html.Object 

59 

60 

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] 

67 

68 def configure(self): 

69 self.configure_provider() 

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

71 self._load() 

72 

73 def configure_provider(self): 

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

75 

76 def render(self, tri): 

77 # @TODO reload only if changed 

78 self._load() 

79 

80 self.notify(tri, 'begin_print') 

81 

82 # render the map 

83 

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

88 

89 # render qgis 

90 

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

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.printtemp('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 } 

208 

209 qgis_project = self.serviceProvider.qgis_project() 

210 changed = self._render_html_blocks(tri, qgis_project) 

211 

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 

217 

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

227 

228 res = self.serviceProvider.call_server(params) 

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

230 

231 def _collect_html_blocks(self): 

232 self.htmlBlocks = {} 

233 

234 for el in self.qgisTemplate.elements: 

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

236 continue 

237 

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

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

240 

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 ) 

251 

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 

256 

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 ) 

265 

266 render_results = {} 

267 

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] 

272 

273 if not render_results: 

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

275 return False 

276 

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) 

283 

284 return True