Coverage for gws-app/gws/plugin/template/html/__init__.py: 44%

181 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-16 23:09 +0200

1"""HTML template. 

2 

3This module handles templates written in the Jump language. Apart from html, this template can generate pdf (for printing) and image outputs. 

4 

5The arguments passed to a template can be accessed via the ``_ARGS`` object. 

6 

7If a template explicitly returns a :obj:`gws.Response` object, the generated text is ignored and the object is returned as a render result. 

8Otherwise, the result of the rendering will be a :obj:`gws.ContentResponse` object with the generated content. 

9 

10This template supports the following extensions to Jump: 

11 

12The ``@page`` command, which sets parameters for the printed page:: 

13 

14 @page ( 

15 width="<page width in mm>" 

16 height="<page height in mm>" 

17 margin="<page margins in mm>" 

18 ) 

19 

20The ``@map`` command, which renders the current map:: 

21 

22 @map ( 

23 width="<width in mm>" 

24 height="<height in mm>" 

25 bbox="<optional, bounding box in projection units>" 

26 center="<optional, center coordinates in projection units>" 

27 scale="<optional, scale factor>" 

28 rotation="<optional, rotation in degrees>" 

29 

30 

31The ``@legend`` command, which renders the map legend:: 

32 

33 @legend( 

34 layers="<optional, space separated list of layer UIDs>" 

35 ) 

36 

37The ``@header`` and ``@footer`` block commands, which define headers and footers for multi-page printing:: 

38 

39 @header 

40 content 

41 @end header 

42 

43 @footer 

44 content 

45 @end footer 

46 

47Headers and footers are separate sub-templates, which receive the same arguments as the main template and two additional arguments: 

48 

49- ``numpages`` - the total number of pages in the document 

50- ``page`` - the current page number 

51 

52The ``@pagebreak`` command, which renders a page break. 

53 

54""" 

55 

56from typing import Optional, cast 

57 

58import gws 

59import gws.base.legend 

60import gws.base.template 

61import gws.gis.render 

62import gws.lib.htmlx 

63import gws.lib.mime 

64import gws.lib.osx 

65import gws.lib.pdf 

66import gws.lib.vendor.jump 

67 

68gws.ext.new.template('html') 

69 

70 

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

72 """HTML template configuration.""" 

73 

74 path: Optional[gws.FilePath] 

75 """Path to a template file.""" 

76 text: str = '' 

77 """Template content.""" 

78 

79 

80class Props(gws.base.template.Props): 

81 pass 

82 

83 

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

85 path: str 

86 text: str 

87 compiledTime: float = 0 

88 compiledFn = None 

89 

90 def configure(self): 

91 self.path = self.cfg('path') 

92 self.text = self.cfg('text', default='') 

93 if not self.path and not self.text: 

94 raise gws.Error('either "path" or "text" required') 

95 

96 def render(self, tri): 

97 self.notify(tri, 'begin_print') 

98 

99 engine = Engine(self, tri) 

100 self.compile(engine) 

101 

102 args = self.prepare_args(tri) 

103 res = engine.call(self.compiledFn, args=args, error=self.error_handler) 

104 

105 if not isinstance(res, gws.Response): 

106 res = self.finalize(tri, res, args, engine) 

107 

108 self.notify(tri, 'end_print') 

109 return res 

110 

111 def compile(self, engine: 'Engine'): 

112 

113 if self.path and (not self.text or gws.lib.osx.file_mtime(self.path) > self.compiledTime): 

114 self.text = gws.u.read_file(self.path) 

115 self.compiledFn = None 

116 

117 if self.root.app.developer_option('template.always_reload'): 

118 self.compiledFn = None 

119 

120 if not self.compiledFn: 

121 gws.log.debug(f'compiling {self} {self.path=}') 

122 if self.root.app.developer_option('template.save_compiled'): 

123 gws.u.write_debug_file(f'compiled_template_{self.uid}', engine.translate(self.text, path=self.path)) 

124 

125 self.compiledFn = engine.compile(self.text, path=self.path) 

126 self.compiledTime = gws.u.utime() 

127 

128 def error_handler(self, exc, path, line, env): 

129 if self.root.app.developer_option('template.raise_errors'): 

130 gws.log.error(f'TEMPLATE_ERROR: {self}: {exc} IN {path}:{line}') 

131 return False 

132 

133 gws.log.warning(f'TEMPLATE_ERROR: {self}: {exc} IN {path}:{line}') 

134 return True 

135 

136 ## 

137 

138 def render_map( 

139 self, 

140 tri: gws.TemplateRenderInput, 

141 width, 

142 height, 

143 index, 

144 bbox=None, 

145 center=None, 

146 scale=None, 

147 rotation=None, 

148 

149 ): 

150 self.notify(tri, 'begin_map') 

151 

152 src: gws.MapRenderInput = tri.maps[index] 

153 dst: gws.MapRenderInput = gws.MapRenderInput(src) 

154 

155 dst.bbox = bbox or src.bbox 

156 dst.center = center or src.center 

157 dst.crs = tri.crs 

158 dst.dpi = tri.dpi 

159 dst.mapSize = width, height, gws.Uom.mm 

160 dst.rotation = rotation or src.rotation 

161 dst.scale = scale or src.scale 

162 dst.notify = tri.notify 

163 

164 mro: gws.MapRenderOutput = gws.gis.render.render_map(dst) 

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

166 

167 self.notify(tri, 'end_map') 

168 return html 

169 

170 def render_legend( 

171 self, 

172 tri: gws.TemplateRenderInput, 

173 index, 

174 layers, 

175 

176 ): 

177 src: gws.MapRenderInput = tri.maps[index] 

178 

179 layer_list = src.visibleLayers 

180 if layers: 

181 layer_list = gws.u.compact(tri.user.acquire(la) for la in gws.u.to_list(layers)) 

182 

183 if not layer_list: 

184 gws.log.debug(f'no layers for a legend') 

185 return 

186 

187 legend = cast(gws.Legend, self.root.create_temporary( 

188 gws.ext.object.legend, 

189 type='combined', 

190 layerUids=[la.uid for la in layer_list])) 

191 

192 lro = legend.render(tri.args) 

193 if not lro: 

194 gws.log.debug(f'empty legend render') 

195 return 

196 

197 img_path = gws.base.legend.output_to_image_path(lro) 

198 return f'<img src="{img_path}"/>' 

199 

200 def render_page_break(self, tri: gws.TemplateRenderInput): 

201 self.notify(tri, 'page_break') 

202 return '<div style="page-break-after: always"></div>' 

203 

204 ## 

205 

206 def finalize(self, tri: gws.TemplateRenderInput, html: str, args: gws.TemplateArgs, main_engine: 'Engine'): 

207 self.notify(tri, 'finalize_print') 

208 

209 mime = tri.mimeOut 

210 if not mime and self.mimeTypes: 

211 mime = self.mimeTypes[0] 

212 if not mime: 

213 mime = gws.lib.mime.HTML 

214 

215 if mime == gws.lib.mime.HTML: 

216 return gws.ContentResponse(mime=mime, content=html) 

217 

218 if mime == gws.lib.mime.PDF: 

219 res_path = self.finalize_pdf(tri, html, args, main_engine) 

220 return gws.ContentResponse(contentPath=res_path) 

221 

222 if mime == gws.lib.mime.PNG: 

223 res_path = self.finalize_png(tri, html, args, main_engine) 

224 return gws.ContentResponse(contentPath=res_path) 

225 

226 raise gws.Error(f'invalid output mime: {tri.mimeOut!r}') 

227 

228 def finalize_pdf(self, tri: gws.TemplateRenderInput, html: str, args: gws.TemplateArgs, main_engine: 'Engine'): 

229 content_pdf_path = gws.u.ephemeral_path('content.pdf') 

230 

231 page_size = main_engine.pageSize or self.pageSize 

232 page_margin = main_engine.pageMargin or self.pageMargin 

233 

234 gws.lib.htmlx.render_to_pdf( 

235 self.decorate_html(html), 

236 out_path=content_pdf_path, 

237 page_size=page_size, 

238 page_margin=page_margin, 

239 ) 

240 

241 has_frame = main_engine.header or main_engine.footer 

242 if not has_frame: 

243 return content_pdf_path 

244 

245 args = gws.u.merge(args, numpages=gws.lib.pdf.page_count(content_pdf_path)) 

246 

247 frame_engine = Engine(self, tri) 

248 frame_text = self.frame_template(main_engine.header or '', main_engine.footer or '', page_size) 

249 frame_html = frame_engine.render(frame_text, args=args, error=self.error_handler) 

250 

251 frame_pdf_path = gws.u.ephemeral_path('frame.pdf') 

252 

253 gws.lib.htmlx.render_to_pdf( 

254 self.decorate_html(frame_html), 

255 out_path=frame_pdf_path, 

256 page_size=page_size, 

257 page_margin=None, 

258 ) 

259 

260 combined_pdf_path = gws.u.ephemeral_path('combined.pdf') 

261 gws.lib.pdf.overlay(frame_pdf_path, content_pdf_path, combined_pdf_path) 

262 

263 return combined_pdf_path 

264 

265 def finalize_png(self, tri: gws.TemplateRenderInput, html: str, args: gws.TemplateArgs, main_engine: 'Engine'): 

266 out_png_path = gws.u.ephemeral_path('out.png') 

267 

268 page_size = main_engine.pageSize or self.pageSize 

269 page_margin = main_engine.pageMargin or self.pageMargin 

270 

271 gws.lib.htmlx.render_to_png( 

272 self.decorate_html(html), 

273 out_path=out_png_path, 

274 page_size=page_size, 

275 page_margin=page_margin, 

276 ) 

277 

278 return out_png_path 

279 

280 ## 

281 

282 def decorate_html(self, html): 

283 if self.path: 

284 d = gws.u.dirname(self.path) 

285 html = f'<base href="file://{d}/" />\n' + html 

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

287 return html 

288 

289 def frame_template(self, header, footer, page_size): 

290 w, h, _ = page_size 

291 

292 return f''' 

293 <html> 

294 <style> 

295 body, .FRAME_TABLE, .FRAME_TR, .FRAME_TD {{ margin: 0; padding: 0; border: none; }} 

296 body, .FRAME_TABLE {{ width: {w}mm; height: {h}mm; }} 

297 .FRAME_TR, .FRAME_TD {{ width: {w}mm; height: {h // 2}mm; }} 

298 </style> 

299 <body> 

300 @for page in range(1, numpages + 1) 

301 <table class="FRAME_TABLE" border="1" cellspacing="0" cellpadding="0"> 

302 <tr class="FRAME_TR" valign="top"><td class="FRAME_TD">{header}</td></tr> 

303 <tr class="FRAME_TR" valign="bottom"><td class="FRAME_TD">{footer}</td></tr> 

304 </table> 

305 @end 

306 </body> 

307 </html> 

308 ''' 

309 

310 

311## 

312 

313 

314class Engine(gws.lib.vendor.jump.Engine): 

315 pageMargin: list[int] = [] 

316 pageSize: gws.UomSize = [] 

317 header: str = '' 

318 footer: str = '' 

319 

320 def __init__(self, template: Object, tri: Optional[gws.TemplateRenderInput] = None): 

321 super().__init__() 

322 self.template = template 

323 self.tri = tri 

324 

325 def def_page(self, **kw): 

326 self.pageSize = ( 

327 _scalar(kw, 'width', int, self.template.pageSize[0]), 

328 _scalar(kw, 'height', int, self.template.pageSize[1]), 

329 gws.Uom.mm) 

330 self.pageMargin = _list(kw, 'margin', int, 4, self.template.pageMargin) 

331 

332 def def_map(self, **kw): 

333 if not self.tri: 

334 return 

335 return self.template.render_map( 

336 self.tri, 

337 width=_scalar(kw, 'width', int, self.template.mapSize[0]), 

338 height=_scalar(kw, 'height', int, self.template.mapSize[1]), 

339 index=_scalar(kw, 'number', int, 0), 

340 bbox=_list(kw, 'bbox', float, 4), 

341 center=_list(kw, 'center', float, 2), 

342 scale=_scalar(kw, 'scale', int), 

343 rotation=_scalar(kw, 'rotation', int), 

344 ) 

345 

346 def def_legend(self, **kw): 

347 if not self.tri: 

348 return 

349 return self.template.render_legend( 

350 self.tri, 

351 index=_scalar(kw, 'number', int, 0), 

352 layers=kw.get('layers'), 

353 ) 

354 

355 def def_pagebreak(self, **kw): 

356 if not self.tri: 

357 return 

358 return self.template.render_page_break(self.tri) 

359 

360 def mbox_header(self, text): 

361 self.header = text 

362 

363 def mbox_footer(self, text): 

364 self.footer = text 

365 

366 

367def _scalar(kw, name, typ, default=None): 

368 val = kw.get(name) 

369 if val is None: 

370 return default 

371 return typ(val) 

372 

373 

374def _list(kw, name, typ, size, default=None): 

375 val = kw.get(name) 

376 if val is None: 

377 return default 

378 a = [typ(s) for s in val.split()] 

379 if len(a) == 1: 

380 return a * size 

381 if len(a) == size: 

382 return a 

383 raise TypeError('invalid length')