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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +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, space 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_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
324 if mime_type == gws.lib.mime.HTML:
325 return gws.ContentResponse(mimeType=mime_type, content=html.lstrip())
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)
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)
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: 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``."""
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 self.pageMargin = _list(kw, 'margin', int, 4, self.template.pageMargin)
511 def def_map(self, **kw):
512 """Handle the ``@map`` command.
514 Args:
515 **kw: Command arguments ``width``, ``height``, ``number``, ``bbox``, ``center``, ``scale`` and ``rotation``.
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 )
533 def def_legend(self, **kw):
534 """Handle the ``@legend`` command.
536 Args:
537 **kw: Command arguments ``number`` and ``layers``.
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 )
550 def def_pagebreak(self, **kw):
551 """Handle the ``@pagebreak`` command.
553 Args:
554 **kw: Command arguments, not used.
556 Returns:
557 HTML fragment.
558 """
559 if not self.tri:
560 return
561 return self.template.render_page_break(self.tri)
563 def mbox_header(self, text):
564 """Handle the ``@header`` block command.
566 Args:
567 text: Header template source.
568 """
569 self.header = text
571 def mbox_footer(self, text):
572 """Handle the ``@footer`` block command.
574 Args:
575 text: Footer template source.
576 """
577 self.footer = text
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)
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')