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

177 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-17 01:37 +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""" 

52 

53from typing import Optional, cast 

54 

55import gws 

56import gws.base.legend 

57import gws.base.template 

58import gws.gis.render 

59import gws.lib.htmlx 

60import gws.lib.mime 

61import gws.lib.osx 

62import gws.lib.pdf 

63import gws.lib.vendor.jump 

64 

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

66 

67 

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

69 path: Optional[gws.FilePath] 

70 """path to a template file""" 

71 text: str = '' 

72 """template content""" 

73 

74 

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

76 pass 

77 

78 

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

80 path: str 

81 text: str 

82 compiledTime: float = 0 

83 compiledFn = None 

84 

85 def configure(self): 

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

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

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

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

90 

91 def render(self, tri): 

92 self.notify(tri, 'begin_print') 

93 

94 engine = Engine(self, tri) 

95 self.compile(engine) 

96 

97 args = self.prepare_args(tri) 

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

99 

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

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

102 

103 self.notify(tri, 'end_print') 

104 return res 

105 

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

107 

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

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

110 self.compiledFn = None 

111 

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

113 self.compiledFn = None 

114 

115 if not self.compiledFn: 

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

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

118 gws.u.write_file( 

119 gws.u.ensure_dir(f'{gws.c.VAR_DIR}/debug') + f'/compiled_template_{self.uid}', 

120 engine.translate(self.text, path=self.path) 

121 ) 

122 

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

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

125 

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

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

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

129 for k, v in sorted(getattr(env, 'ARGS', {}).items()): 

130 gws.log.error(f'TEMPLATE_ERROR: {self}: ARGS {k}={v!r}') 

131 gws.log.error(f'TEMPLATE_ERROR: {self}: stop') 

132 return False 

133 

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

135 return True 

136 

137 ## 

138 

139 def render_map( 

140 self, 

141 tri: gws.TemplateRenderInput, 

142 width, 

143 height, 

144 index, 

145 bbox=None, 

146 center=None, 

147 scale=None, 

148 rotation=None, 

149 

150 ): 

151 self.notify(tri, 'begin_map') 

152 

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

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

155 

156 dst.bbox = bbox or src.bbox 

157 dst.center = center or src.center 

158 dst.crs = tri.crs 

159 dst.dpi = tri.dpi 

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

161 dst.rotation = rotation or src.rotation 

162 dst.scale = scale or src.scale 

163 dst.notify = tri.notify 

164 

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

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

167 

168 self.notify(tri, 'end_map') 

169 return html 

170 

171 def render_legend( 

172 self, 

173 tri: gws.TemplateRenderInput, 

174 index, 

175 layers, 

176 

177 ): 

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

179 

180 layer_list = src.visibleLayers 

181 if layers: 

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

183 

184 if not layer_list: 

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

186 return 

187 

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

189 gws.ext.object.legend, 

190 type='combined', 

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

192 

193 lro = legend.render(tri.args) 

194 if not lro: 

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

196 return 

197 

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

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

200 

201 ## 

202 

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

204 self.notify(tri, 'finalize_print') 

205 

206 mime = tri.mimeOut 

207 if not mime and self.mimeTypes: 

208 mime = self.mimeTypes[0] 

209 if not mime: 

210 mime = gws.lib.mime.HTML 

211 

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

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

214 

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

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

217 return gws.ContentResponse(contentPath=res_path) 

218 

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

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

221 return gws.ContentResponse(contentPath=res_path) 

222 

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

224 

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

226 content_pdf_path = gws.u.printtemp('content.pdf') 

227 

228 page_size = main_engine.pageSize or self.pageSize 

229 page_margin = main_engine.pageMargin or self.pageMargin 

230 

231 gws.lib.htmlx.render_to_pdf( 

232 self.decorate_html(html), 

233 out_path=content_pdf_path, 

234 page_size=page_size, 

235 page_margin=page_margin, 

236 ) 

237 

238 has_frame = main_engine.header or main_engine.footer 

239 if not has_frame: 

240 return content_pdf_path 

241 

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

243 

244 frame_engine = Engine(self, tri) 

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

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

247 

248 frame_pdf_path = gws.u.printtemp('frame.pdf') 

249 

250 gws.lib.htmlx.render_to_pdf( 

251 self.decorate_html(frame_html), 

252 out_path=frame_pdf_path, 

253 page_size=page_size, 

254 page_margin=None, 

255 ) 

256 

257 combined_pdf_path = gws.u.printtemp('combined.pdf') 

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

259 

260 return combined_pdf_path 

261 

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

263 out_png_path = gws.u.printtemp('out.png') 

264 

265 page_size = main_engine.pageSize or self.pageSize 

266 page_margin = main_engine.pageMargin or self.pageMargin 

267 

268 gws.lib.htmlx.render_to_png( 

269 self.decorate_html(html), 

270 out_path=out_png_path, 

271 page_size=page_size, 

272 page_margin=page_margin, 

273 ) 

274 

275 return out_png_path 

276 

277 ## 

278 

279 def decorate_html(self, html): 

280 if self.path: 

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

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

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

284 return html 

285 

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

287 w, h, _ = page_size 

288 

289 return f''' 

290 <html> 

291 <style> 

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

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

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

295 </style> 

296 <body> 

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

298 <table class="FRAME_TABLE" border=0 cellspacing=0 cellpadding=0> 

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

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

301 </table> 

302 @end 

303 </body> 

304 </html> 

305 ''' 

306 

307 

308## 

309 

310 

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

312 pageMargin: list[int] = [] 

313 pageSize: gws.UomSize = [] 

314 header: str = '' 

315 footer: str = '' 

316 

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

318 super().__init__() 

319 self.template = template 

320 self.tri = tri 

321 

322 def def_page(self, **kw): 

323 self.pageSize = ( 

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

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

326 gws.Uom.mm) 

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

328 

329 def def_map(self, **kw): 

330 if not self.tri: 

331 return 

332 return self.template.render_map( 

333 self.tri, 

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

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

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

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

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

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

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

341 ) 

342 

343 def def_legend(self, **kw): 

344 if not self.tri: 

345 return 

346 return self.template.render_legend( 

347 self.tri, 

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

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

350 ) 

351 

352 def mbox_header(self, text): 

353 self.header = text 

354 

355 def mbox_footer(self, text): 

356 self.footer = text 

357 

358 

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

360 val = kw.get(name) 

361 if val is None: 

362 return default 

363 return typ(val) 

364 

365 

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

367 val = kw.get(name) 

368 if val is None: 

369 return default 

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

371 if len(a) == 1: 

372 return a * size 

373 if len(a) == size: 

374 return a 

375 raise TypeError('invalid length')