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

189 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""HTML templates. 

2 

3The ``html`` template is written in the Jump template language. It produces 

4HTML and, for printing, PDF or PNG output. PDF and PNG are rendered from the 

5generated HTML (``gws.lib.htmlx``). The output type is the ``mimeOut`` of the 

6render input, or the first configured ``mimeTypes`` entry, or HTML. The 

7template source is given as ``text`` or as a file ``path``; a template file 

8is recompiled when it changes. 

9 

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

11 

12If a template explicitly returns a :obj:`gws.Response` object, the generated 

13text is ignored and the object is returned as the render result. Otherwise, 

14the result is a :obj:`gws.ContentResponse` object with the generated content. 

15 

16This template supports the following extensions to Jump. 

17 

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

19 

20 @page ( 

21 width="<page width in mm>" 

22 height="<page height in mm>" 

23 margin="<page margins in mm, one value or four values>" 

24 ) 

25 

26The ``@map`` command, which renders a map of the render input:: 

27 

28 @map ( 

29 width="<width in mm>" 

30 height="<height in mm>" 

31 number="<optional, index of the map, 0 by default>" 

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

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

34 scale="<optional, scale>" 

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

36 ) 

37 

38The ``@legend`` command, which renders the legend of the visible layers of a 

39map, or of the given layers:: 

40 

41 @legend ( 

42 number="<optional, index of the map, 0 by default>" 

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

44 ) 

45 

46The ``@header`` and ``@footer`` block commands, which define headers and 

47footers for multi-page printing:: 

48 

49 @header 

50 content 

51 @end header 

52 

53 @footer 

54 content 

55 @end footer 

56 

57Headers and footers are separate sub-templates, which receive the same 

58arguments as the main template and two additional arguments: 

59 

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

61- ``page`` - the current page number 

62 

63They are rendered as a separate PDF, which is placed over the content PDF. 

64 

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

66 

67Example:: 

68 

69 templates+ { 

70 subject "feature.title" 

71 type "html" 

72 text "{{name}}" 

73 } 

74 

75 printers+ { 

76 template { 

77 type "html" 

78 path "/data/templates/print.cx.html" 

79 mimeTypes [ "application/pdf" ] 

80 } 

81 } 

82""" 

83 

84from typing import Optional, cast 

85 

86import gws 

87import gws.base.legend 

88import gws.base.template 

89import gws.gis.render 

90import gws.lib.htmlx 

91import gws.lib.mime 

92import gws.lib.osx 

93import gws.lib.pdf 

94import gws.lib.vendor.jump 

95 

96 

97@gws.ext.config.template('html') 

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

99 """Jump template that renders HTML, PDF or image output.""" 

100 

101 path: Optional[gws.FilePath] 

102 """Template file.""" 

103 text: str = '' 

104 """Template source.""" 

105 

106 

107@gws.ext.props.template('html') 

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

109 pass 

110 

111 

112@gws.ext.object.template('html') 

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

114 """Jump template that renders HTML, PDF or PNG output.""" 

115 

116 path: str 

117 """Template file path.""" 

118 text: str 

119 """Template source.""" 

120 compiledTime: float = 0 

121 """Time of the last compilation.""" 

122 compiledFn = None 

123 """Compiled template function.""" 

124 

125 def configure(self): 

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

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

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

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

130 

131 def render(self, tri): 

132 self.notify(tri, 'begin_print') 

133 

134 engine = Engine(self, tri) 

135 self.compile(engine) 

136 

137 args = self.prepare_args(tri) 

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

139 

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

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

142 

143 self.notify(tri, 'end_print') 

144 return res 

145 

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

147 """Compile the template if needed. 

148 

149 The template file is read again if it has changed since the last 

150 compilation. With the developer option ``template.always_reload``, 

151 the template is compiled on each call; with 

152 ``template.save_compiled``, the translated source is written to a 

153 debug file. 

154 

155 Args: 

156 engine: Jump engine. 

157 """ 

158 

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

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

161 self.compiledFn = None 

162 

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

164 self.compiledFn = None 

165 

166 if not self.compiledFn: 

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

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

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

170 

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

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

173 

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

175 """Handle a template runtime error. 

176 

177 The error is logged. With the developer option 

178 ``template.raise_errors``, the error is raised, otherwise rendering 

179 continues. 

180 

181 Args: 

182 exc: The exception. 

183 path: Template path. 

184 line: Template line. 

185 env: Template environment. 

186 

187 Returns: 

188 ``True`` to continue rendering, ``False`` to raise the error. 

189 """ 

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

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

192 return False 

193 

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

195 return True 

196 

197 ## 

198 

199 def render_map( 

200 self, 

201 tri: gws.TemplateRenderInput, 

202 width, 

203 height, 

204 index, 

205 bbox=None, 

206 center=None, 

207 scale=None, 

208 rotation=None, 

209 

210 ): 

211 """Render a map of the render input as HTML. 

212 

213 Args: 

214 tri: Template render input. 

215 width: Map width in mm. 

216 height: Map height in mm. 

217 index: Index of the map in ``tri.maps``. 

218 bbox: Bounding box, defaults to the bounding box of the map. 

219 center: Center, defaults to the center of the map. 

220 scale: Scale, defaults to the scale of the map. 

221 rotation: Rotation in degrees, defaults to the rotation of the map. 

222 

223 Returns: 

224 HTML fragment. 

225 """ 

226 self.notify(tri, 'begin_map') 

227 

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

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

230 

231 dst.bbox = bbox or src.bbox 

232 dst.center = center or src.center 

233 dst.targetCrs = tri.crs 

234 dst.dpi = tri.dpi 

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

236 dst.rotation = rotation or src.rotation 

237 dst.scale = scale or src.scale 

238 dst.notify = tri.notify 

239 

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

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

242 

243 self.notify(tri, 'end_map') 

244 return html 

245 

246 def render_legend( 

247 self, 

248 tri: gws.TemplateRenderInput, 

249 index, 

250 layers, 

251 

252 ): 

253 """Render a legend as an HTML image tag. 

254 

255 Args: 

256 tri: Template render input. 

257 index: Index of the map in ``tri.maps``, whose visible layers are used by default. 

258 layers: Layer UIDs to use instead of the visible layers. 

259 

260 Returns: 

261 An ``img`` tag pointing to the legend image, or ``None`` if there is no legend. 

262 """ 

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

264 

265 layer_list = src.visibleLayers 

266 if layers: 

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

268 

269 if not layer_list: 

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

271 return 

272 

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

274 gws.ext.object.legend, 

275 type='combined', 

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

277 

278 lro = legend.render(tri.args) 

279 if not lro: 

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

281 return 

282 

283 img_path = gws.u.ephemeral_path('legend.png') 

284 lro.image.to_path(img_path, gws.lib.mime.PNG) 

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

286 

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

288 """Render a page break. 

289 

290 Args: 

291 tri: Template render input. 

292 

293 Returns: 

294 HTML fragment. 

295 """ 

296 self.notify(tri, 'page_break') 

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

298 

299 ## 

300 

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

302 """Convert the generated HTML into the output format. 

303 

304 Args: 

305 tri: Template render input. 

306 html: Generated HTML. 

307 args: Template arguments. 

308 main_engine: Engine that rendered the HTML, holds the page settings, header and footer. 

309 

310 Returns: 

311 Content response with HTML, or a PDF or PNG file. 

312 

313 Raises: 

314 ``gws.Error``: If the output MIME type is not supported. 

315 """ 

316 self.notify(tri, 'finalize_print') 

317 

318 mime_type = tri.mimeOut 

319 if not mime_type and self.mimeTypes: 

320 mime_type = self.mimeTypes[0] 

321 if not mime_type: 

322 mime_type = gws.lib.mime.HTML 

323 

324 if mime_type == gws.lib.mime.HTML: 

325 return gws.ContentResponse(mimeType=mime_type, content=html.lstrip()) 

326 

327 if mime_type == gws.lib.mime.PDF: 

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

329 return gws.ContentResponse(contentPath=res_path) 

330 

331 if mime_type == gws.lib.mime.PNG: 

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

333 return gws.ContentResponse(contentPath=res_path) 

334 

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

336 

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

338 """Render the generated HTML as PDF. 

339 

340 If a header or footer is defined, they are rendered on a separate 

341 PDF, one page per content page, which is placed over the content. 

342 

343 Args: 

344 tri: Template render input. 

345 html: Generated HTML. 

346 args: Template arguments. 

347 main_engine: Engine that rendered the HTML. 

348 

349 Returns: 

350 Path to the PDF file. 

351 """ 

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

353 

354 page_size = main_engine.pageSize or self.pageSize 

355 page_margin = main_engine.pageMargin or self.pageMargin 

356 

357 gws.lib.htmlx.render_to_pdf( 

358 self.decorate_html(html), 

359 out_path=content_pdf_path, 

360 page_size=page_size, 

361 page_margin=page_margin, 

362 ) 

363 

364 has_frame = main_engine.header or main_engine.footer 

365 if not has_frame: 

366 return content_pdf_path 

367 

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

369 

370 frame_engine = Engine(self, tri) 

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

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

373 

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

375 

376 gws.lib.htmlx.render_to_pdf( 

377 self.decorate_html(frame_html), 

378 out_path=frame_pdf_path, 

379 page_size=page_size, 

380 page_margin=None, 

381 ) 

382 

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

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

385 

386 return combined_pdf_path 

387 

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

389 """Render the generated HTML as PNG. 

390 

391 Args: 

392 tri: Template render input. 

393 html: Generated HTML. 

394 args: Template arguments. 

395 main_engine: Engine that rendered the HTML. 

396 

397 Returns: 

398 Path to the PNG file. 

399 """ 

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

401 

402 page_size = main_engine.pageSize or self.pageSize 

403 page_margin = main_engine.pageMargin or self.pageMargin 

404 

405 gws.lib.htmlx.render_to_png( 

406 self.decorate_html(html), 

407 out_path=out_png_path, 

408 page_size=page_size, 

409 page_margin=page_margin, 

410 ) 

411 

412 return out_png_path 

413 

414 ## 

415 

416 def decorate_html(self, html): 

417 """Add the charset and, for file templates, a base URL to the HTML. 

418 

419 The base URL is the template directory, so that relative paths in the 

420 template are resolved against it. 

421 

422 Args: 

423 html: HTML text. 

424 

425 Returns: 

426 Decorated HTML. 

427 """ 

428 if self.path: 

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

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

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

432 return html 

433 

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

435 """Create the Jump source of the header and footer frame. 

436 

437 The frame has one page per content page, with the header at the top 

438 and the footer at the bottom. 

439 

440 Args: 

441 header: Header template source. 

442 footer: Footer template source. 

443 page_size: Page size in mm. 

444 

445 Returns: 

446 Template source. 

447 """ 

448 w, h, _ = page_size 

449 

450 return f''' 

451 <html> 

452 <style> 

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

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

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

456 </style> 

457 <body> 

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

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

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

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

462 </table> 

463 @end 

464 </body> 

465 </html> 

466 ''' 

467 

468 

469## 

470 

471 

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

473 """Jump engine with the commands of HTML templates. 

474 

475 The engine collects the page settings, header and footer defined by the 

476 template while it renders. 

477 """ 

478 

479 pageMargin: list[int] = [] 

480 """Page margins set by ``@page``.""" 

481 pageSize: gws.UomSize = [] 

482 """Page size set by ``@page``.""" 

483 header: str = '' 

484 """Header source set by ``@header``.""" 

485 footer: str = '' 

486 """Footer source set by ``@footer``.""" 

487 

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

489 """Create the engine. 

490 

491 Args: 

492 template: The template being rendered. 

493 tri: Template render input. Without it, ``@map``, ``@legend`` and ``@pagebreak`` render nothing. 

494 """ 

495 super().__init__() 

496 self.template = template 

497 self.tri = tri 

498 

499 def def_page(self, **kw): 

500 """Handle the ``@page`` command. 

501 

502 Args: 

503 **kw: Command arguments ``width``, ``height`` and ``margin``. 

504 """ 

505 self.pageSize = ( 

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

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

508 gws.Uom.mm) 

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

510 

511 def def_map(self, **kw): 

512 """Handle the ``@map`` command. 

513 

514 Args: 

515 **kw: Command arguments ``width``, ``height``, ``number``, ``bbox``, ``center``, ``scale`` and ``rotation``. 

516 

517 Returns: 

518 HTML fragment. 

519 """ 

520 if not self.tri: 

521 return 

522 return self.template.render_map( 

523 self.tri, 

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

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

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

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

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

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

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

531 ) 

532 

533 def def_legend(self, **kw): 

534 """Handle the ``@legend`` command. 

535 

536 Args: 

537 **kw: Command arguments ``number`` and ``layers``. 

538 

539 Returns: 

540 HTML fragment. 

541 """ 

542 if not self.tri: 

543 return 

544 return self.template.render_legend( 

545 self.tri, 

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

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

548 ) 

549 

550 def def_pagebreak(self, **kw): 

551 """Handle the ``@pagebreak`` command. 

552 

553 Args: 

554 **kw: Command arguments, not used. 

555 

556 Returns: 

557 HTML fragment. 

558 """ 

559 if not self.tri: 

560 return 

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

562 

563 def mbox_header(self, text): 

564 """Handle the ``@header`` block command. 

565 

566 Args: 

567 text: Header template source. 

568 """ 

569 self.header = text 

570 

571 def mbox_footer(self, text): 

572 """Handle the ``@footer`` block command. 

573 

574 Args: 

575 text: Footer template source. 

576 """ 

577 self.footer = text 

578 

579 

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

581 """Read a scalar command argument and convert it to ``typ``.""" 

582 val = kw.get(name) 

583 if val is None: 

584 return default 

585 return typ(val) 

586 

587 

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

589 """Read a space separated list argument, a single value is repeated ``size`` times.""" 

590 val = kw.get(name) 

591 if val is None: 

592 return default 

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

594 if len(a) == 1: 

595 return a * size 

596 if len(a) == size: 

597 return a 

598 raise TypeError('invalid length')