Coverage for gws-app/gws/plugin/template/html/__init__.py: 46%
190 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""HTML templates.
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.
10The arguments passed to a template can be accessed via the ``_ARGS`` object.
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.
16This template supports the following extensions to Jump.
18The ``@page`` command, which sets parameters for the printed page::
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 )
26The ``@map`` command, which renders a map of the render input::
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 )
38The ``@legend`` command, which renders the legend of the visible layers of a
39map, or of the given layers::
41 @legend (
42 number="<optional, index of the map, 0 by default>"
43 layers="<optional, comma separated list of layer UIDs>"
44 )
46The ``@header`` and ``@footer`` block commands, which define headers and
47footers for multi-page printing::
49 @header
50 content
51 @end header
53 @footer
54 content
55 @end footer
57Headers and footers are separate sub-templates, which receive the same
58arguments as the main template and two additional arguments:
60- ``numpages`` - the total number of pages in the document
61- ``page`` - the current page number
63They are rendered as a separate PDF, which is placed over the content PDF.
65The ``@pagebreak`` command, which renders a page break.
67Example::
69 templates+ {
70 subject "feature.title"
71 type "html"
72 text "{{name}}"
73 }
75 printers+ {
76 template {
77 type "html"
78 path "/data/templates/print.cx.html"
79 mimeTypes [ "application/pdf" ]
80 }
81 }
82"""
84from typing import Optional, cast
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
97@gws.ext.config.template('html')
98class Config(gws.base.template.Config):
99 """Jump template that renders HTML, PDF or image output."""
101 path: Optional[gws.FilePath]
102 """Template file."""
103 text: str = ''
104 """Template source."""
107@gws.ext.props.template('html')
108class Props(gws.base.template.Props):
109 pass
112@gws.ext.object.template('html')
113class Object(gws.base.template.Object):
114 """Jump template that renders HTML, PDF or PNG output."""
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."""
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')
131 def render(self, tri):
132 self.notify(tri, 'begin_print')
134 engine = Engine(self, tri)
135 self.compile(engine)
137 args = self.prepare_args(tri)
138 res = engine.call(self.compiledFn, args=args, error=self.error_handler)
140 if not isinstance(res, gws.Response):
141 res = self.finalize(tri, res, args, engine)
143 self.notify(tri, 'end_print')
144 return res
146 def compile(self, engine: 'Engine'):
147 """Compile the template if needed.
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.
155 Args:
156 engine: Jump engine.
157 """
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
163 if self.root.app.developer_option('template.always_reload'):
164 self.compiledFn = None
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))
171 self.compiledFn = engine.compile(self.text, path=self.path)
172 self.compiledTime = gws.u.utime()
174 def error_handler(self, exc, path, line, env):
175 """Handle a template runtime error.
177 The error is logged. With the developer option
178 ``template.raise_errors``, the error is raised, otherwise rendering
179 continues.
181 Args:
182 exc: The exception.
183 path: Template path.
184 line: Template line.
185 env: Template environment.
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
194 gws.log.warning(f'TEMPLATE_ERROR: {self}: {exc} IN {path}:{line}')
195 return True
197 ##
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,
210 ):
211 """Render a map of the render input as HTML.
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.
223 Returns:
224 HTML fragment.
225 """
226 self.notify(tri, 'begin_map')
228 src: gws.MapRenderInput = tri.maps[index]
229 dst: gws.MapRenderInput = gws.MapRenderInput(src)
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
240 mro: gws.MapRenderOutput = gws.gis.render.render_map(dst)
241 html = gws.gis.render.output_to_html_string(mro)
243 self.notify(tri, 'end_map')
244 return html
246 def render_legend(
247 self,
248 tri: gws.TemplateRenderInput,
249 index,
250 layers,
252 ):
253 """Render a legend as an HTML image tag.
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.
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]
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))
269 if not layer_list:
270 gws.log.debug(f'no layers for a legend')
271 return
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]))
278 lro = legend.render(tri.args)
279 if not lro:
280 gws.log.debug(f'empty legend render')
281 return
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}"/>'
287 def render_page_break(self, tri: gws.TemplateRenderInput):
288 """Render a page break.
290 Args:
291 tri: Template render input.
293 Returns:
294 HTML fragment.
295 """
296 self.notify(tri, 'page_break')
297 return '<div style="page-break-after: always"></div>'
299 ##
301 def finalize(self, tri: gws.TemplateRenderInput, html: str, args: gws.TemplateArgs, main_engine: 'Engine'):
302 """Convert the generated HTML into the output format.
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.
310 Returns:
311 Content response with HTML, or a PDF or PNG file.
313 Raises:
314 ``gws.Error``: If the output MIME type is not supported.
315 """
316 self.notify(tri, 'finalize_print')
318 mime = tri.mimeOut
319 if not mime and self.mimeTypes:
320 mime = self.mimeTypes[0]
321 if not mime:
322 mime = gws.lib.mime.HTML
324 if mime == gws.lib.mime.HTML:
325 return gws.ContentResponse(mimeType=mime, content=html.lstrip())
327 if mime == gws.lib.mime.PDF:
328 res_path = self.finalize_pdf(tri, html, args, main_engine)
329 return gws.ContentResponse(contentPath=res_path)
331 if mime == gws.lib.mime.PNG:
332 res_path = self.finalize_png(tri, html, args, main_engine)
333 return gws.ContentResponse(contentPath=res_path)
335 raise gws.Error(f'invalid output mime: {tri.mimeOut!r}')
337 def finalize_pdf(self, tri: gws.TemplateRenderInput, html: str, args: gws.TemplateArgs, main_engine: 'Engine'):
338 """Render the generated HTML as PDF.
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.
343 Args:
344 tri: Template render input.
345 html: Generated HTML.
346 args: Template arguments.
347 main_engine: Engine that rendered the HTML.
349 Returns:
350 Path to the PDF file.
351 """
352 content_pdf_path = gws.u.ephemeral_path('content.pdf')
354 page_size = main_engine.pageSize or self.pageSize
355 page_margin = main_engine.pageMargin or self.pageMargin
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 )
364 has_frame = main_engine.header or main_engine.footer
365 if not has_frame:
366 return content_pdf_path
368 args = gws.u.merge(args, numpages=gws.lib.pdf.page_count(content_pdf_path))
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)
374 frame_pdf_path = gws.u.ephemeral_path('frame.pdf')
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 )
383 combined_pdf_path = gws.u.ephemeral_path('combined.pdf')
384 gws.lib.pdf.overlay(frame_pdf_path, content_pdf_path, combined_pdf_path)
386 return combined_pdf_path
388 def finalize_png(self, tri: gws.TemplateRenderInput, html: str, args: gws.TemplateArgs, main_engine: 'Engine'):
389 """Render the generated HTML as PNG.
391 Args:
392 tri: Template render input.
393 html: Generated HTML.
394 args: Template arguments.
395 main_engine: Engine that rendered the HTML.
397 Returns:
398 Path to the PNG file.
399 """
400 out_png_path = gws.u.ephemeral_path('out.png')
402 page_size = main_engine.pageSize or self.pageSize
403 page_margin = main_engine.pageMargin or self.pageMargin
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 )
412 return out_png_path
414 ##
416 def decorate_html(self, html):
417 """Add the charset and, for file templates, a base URL to the HTML.
419 The base URL is the template directory, so that relative paths in the
420 template are resolved against it.
422 Args:
423 html: HTML text.
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
434 def frame_template(self, header, footer, page_size):
435 """Create the Jump source of the header and footer frame.
437 The frame has one page per content page, with the header at the top
438 and the footer at the bottom.
440 Args:
441 header: Header template source.
442 footer: Footer template source.
443 page_size: Page size in mm.
445 Returns:
446 Template source.
447 """
448 w, h, _ = page_size
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 '''
469##
472class Engine(gws.lib.vendor.jump.Engine):
473 """Jump engine with the commands of HTML templates.
475 The engine collects the page settings, header and footer defined by the
476 template while it renders.
477 """
479 pageMargin: Optional[gws.UomExtent] = None
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``."""
488 def __init__(self, template: Object, tri: Optional[gws.TemplateRenderInput] = None):
489 """Create the engine.
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
499 def def_page(self, **kw):
500 """Handle the ``@page`` command.
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 m = _list(kw, 'margin', int, 4)
510 self.pageMargin = (*m, gws.Uom.mm) if m else self.template.pageMargin
512 def def_map(self, **kw):
513 """Handle the ``@map`` command.
515 Args:
516 **kw: Command arguments ``width``, ``height``, ``number``, ``bbox``, ``center``, ``scale`` and ``rotation``.
518 Returns:
519 HTML fragment.
520 """
521 if not self.tri:
522 return
523 return self.template.render_map(
524 self.tri,
525 width=_scalar(kw, 'width', int, self.template.mapSize[0]),
526 height=_scalar(kw, 'height', int, self.template.mapSize[1]),
527 index=_scalar(kw, 'number', int, 0),
528 bbox=_list(kw, 'bbox', float, 4),
529 center=_list(kw, 'center', float, 2),
530 scale=_scalar(kw, 'scale', int),
531 rotation=_scalar(kw, 'rotation', int),
532 )
534 def def_legend(self, **kw):
535 """Handle the ``@legend`` command.
537 Args:
538 **kw: Command arguments ``number`` and ``layers``.
540 Returns:
541 HTML fragment.
542 """
543 if not self.tri:
544 return
545 return self.template.render_legend(
546 self.tri,
547 index=_scalar(kw, 'number', int, 0),
548 layers=kw.get('layers'),
549 )
551 def def_pagebreak(self, **kw):
552 """Handle the ``@pagebreak`` command.
554 Args:
555 **kw: Command arguments, not used.
557 Returns:
558 HTML fragment.
559 """
560 if not self.tri:
561 return
562 return self.template.render_page_break(self.tri)
564 def mbox_header(self, text):
565 """Handle the ``@header`` block command.
567 Args:
568 text: Header template source.
569 """
570 self.header = text
572 def mbox_footer(self, text):
573 """Handle the ``@footer`` block command.
575 Args:
576 text: Footer template source.
577 """
578 self.footer = text
581def _scalar(kw, name, typ, default=None):
582 """Read a scalar command argument and convert it to ``typ``."""
583 val = kw.get(name)
584 if val is None:
585 return default
586 return typ(val)
589def _list(kw, name, typ, size, default=None):
590 """Read a space separated list argument, a single value is repeated ``size`` times."""
591 val = kw.get(name)
592 if val is None:
593 return default
594 a = [typ(s) for s in val.split()]
595 if len(a) == 1:
596 return a * size
597 if len(a) == size:
598 return a
599 raise TypeError('invalid length')