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 22:59 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 22:59 +0200
1"""HTML template.
3This module handles templates written in the Jump language. Apart from html, this template can generate pdf (for printing) and image outputs.
5The arguments passed to a template can be accessed via the ``_ARGS`` object.
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.
10This template supports the following extensions to Jump:
12The ``@page`` command, which sets parameters for the printed page::
14 @page (
15 width="<page width in mm>"
16 height="<page height in mm>"
17 margin="<page margins in mm>"
18 )
20The ``@map`` command, which renders the current map::
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>"
31The ``@legend`` command, which renders the map legend::
33 @legend(
34 layers="<optional, space separated list of layer UIDs>"
35 )
37The ``@header`` and ``@footer`` block commands, which define headers and footers for multi-page printing::
39 @header
40 content
41 @end header
43 @footer
44 content
45 @end footer
47Headers and footers are separate sub-templates, which receive the same arguments as the main template and two additional arguments:
49- ``numpages`` - the total number of pages in the document
50- ``page`` - the current page number
52The ``@pagebreak`` command, which renders a page break.
54"""
56from typing import Optional, cast
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
68gws.ext.new.template('html')
71class Config(gws.base.template.Config):
72 """HTML template configuration."""
74 path: Optional[gws.FilePath]
75 """Path to a template file."""
76 text: str = ''
77 """Template content."""
80class Props(gws.base.template.Props):
81 pass
84class Object(gws.base.template.Object):
85 path: str
86 text: str
87 compiledTime: float = 0
88 compiledFn = None
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')
96 def render(self, tri):
97 self.notify(tri, 'begin_print')
99 engine = Engine(self, tri)
100 self.compile(engine)
102 args = self.prepare_args(tri)
103 res = engine.call(self.compiledFn, args=args, error=self.error_handler)
105 if not isinstance(res, gws.Response):
106 res = self.finalize(tri, res, args, engine)
108 self.notify(tri, 'end_print')
109 return res
111 def compile(self, engine: 'Engine'):
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
117 if self.root.app.developer_option('template.always_reload'):
118 self.compiledFn = None
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))
125 self.compiledFn = engine.compile(self.text, path=self.path)
126 self.compiledTime = gws.u.utime()
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
133 gws.log.warning(f'TEMPLATE_ERROR: {self}: {exc} IN {path}:{line}')
134 return True
136 ##
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,
149 ):
150 self.notify(tri, 'begin_map')
152 src: gws.MapRenderInput = tri.maps[index]
153 dst: gws.MapRenderInput = gws.MapRenderInput(src)
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
164 mro: gws.MapRenderOutput = gws.gis.render.render_map(dst)
165 html = gws.gis.render.output_to_html_string(mro)
167 self.notify(tri, 'end_map')
168 return html
170 def render_legend(
171 self,
172 tri: gws.TemplateRenderInput,
173 index,
174 layers,
176 ):
177 src: gws.MapRenderInput = tri.maps[index]
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))
183 if not layer_list:
184 gws.log.debug(f'no layers for a legend')
185 return
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]))
192 lro = legend.render(tri.args)
193 if not lro:
194 gws.log.debug(f'empty legend render')
195 return
197 img_path = gws.base.legend.output_to_image_path(lro)
198 return f'<img src="{img_path}"/>'
200 def render_page_break(self, tri: gws.TemplateRenderInput):
201 self.notify(tri, 'page_break')
202 return '<div style="page-break-after: always"></div>'
204 ##
206 def finalize(self, tri: gws.TemplateRenderInput, html: str, args: gws.TemplateArgs, main_engine: 'Engine'):
207 self.notify(tri, 'finalize_print')
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
215 if mime == gws.lib.mime.HTML:
216 return gws.ContentResponse(mime=mime, content=html)
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)
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)
226 raise gws.Error(f'invalid output mime: {tri.mimeOut!r}')
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')
231 page_size = main_engine.pageSize or self.pageSize
232 page_margin = main_engine.pageMargin or self.pageMargin
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 )
241 has_frame = main_engine.header or main_engine.footer
242 if not has_frame:
243 return content_pdf_path
245 args = gws.u.merge(args, numpages=gws.lib.pdf.page_count(content_pdf_path))
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)
251 frame_pdf_path = gws.u.ephemeral_path('frame.pdf')
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 )
260 combined_pdf_path = gws.u.ephemeral_path('combined.pdf')
261 gws.lib.pdf.overlay(frame_pdf_path, content_pdf_path, combined_pdf_path)
263 return combined_pdf_path
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')
268 page_size = main_engine.pageSize or self.pageSize
269 page_margin = main_engine.pageMargin or self.pageMargin
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 )
278 return out_png_path
280 ##
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
289 def frame_template(self, header, footer, page_size):
290 w, h, _ = page_size
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 '''
311##
314class Engine(gws.lib.vendor.jump.Engine):
315 pageMargin: list[int] = []
316 pageSize: gws.UomSize = []
317 header: str = ''
318 footer: str = ''
320 def __init__(self, template: Object, tri: Optional[gws.TemplateRenderInput] = None):
321 super().__init__()
322 self.template = template
323 self.tri = tri
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)
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 )
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 )
355 def def_pagebreak(self, **kw):
356 if not self.tri:
357 return
358 return self.template.render_page_break(self.tri)
360 def mbox_header(self, text):
361 self.header = text
363 def mbox_footer(self, text):
364 self.footer = text
367def _scalar(kw, name, typ, default=None):
368 val = kw.get(name)
369 if val is None:
370 return default
371 return typ(val)
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')