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
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +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
51"""
53from typing import Optional, cast
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
65gws.ext.new.template('html')
68class Config(gws.base.template.Config):
69 path: Optional[gws.FilePath]
70 """path to a template file"""
71 text: str = ''
72 """template content"""
75class Props(gws.base.template.Props):
76 pass
79class Object(gws.base.template.Object):
80 path: str
81 text: str
82 compiledTime: float = 0
83 compiledFn = None
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')
91 def render(self, tri):
92 self.notify(tri, 'begin_print')
94 engine = Engine(self, tri)
95 self.compile(engine)
97 args = self.prepare_args(tri)
98 res = engine.call(self.compiledFn, args=args, error=self.error_handler)
100 if not isinstance(res, gws.Response):
101 res = self.finalize(tri, res, args, engine)
103 self.notify(tri, 'end_print')
104 return res
106 def compile(self, engine: 'Engine'):
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
112 if self.root.app.developer_option('template.always_reload'):
113 self.compiledFn = None
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 )
123 self.compiledFn = engine.compile(self.text, path=self.path)
124 self.compiledTime = gws.u.utime()
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
134 gws.log.warning(f'TEMPLATE_ERROR: {self}: {exc} IN {path}:{line}')
135 return True
137 ##
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,
150 ):
151 self.notify(tri, 'begin_map')
153 src: gws.MapRenderInput = tri.maps[index]
154 dst: gws.MapRenderInput = gws.MapRenderInput(src)
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
165 mro: gws.MapRenderOutput = gws.gis.render.render_map(dst)
166 html = gws.gis.render.output_to_html_string(mro)
168 self.notify(tri, 'end_map')
169 return html
171 def render_legend(
172 self,
173 tri: gws.TemplateRenderInput,
174 index,
175 layers,
177 ):
178 src: gws.MapRenderInput = tri.maps[index]
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))
184 if not layer_list:
185 gws.log.debug(f'no layers for a legend')
186 return
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]))
193 lro = legend.render(tri.args)
194 if not lro:
195 gws.log.debug(f'empty legend render')
196 return
198 img_path = gws.base.legend.output_to_image_path(lro)
199 return f'<img src="{img_path}"/>'
201 ##
203 def finalize(self, tri: gws.TemplateRenderInput, html: str, args: dict, main_engine: 'Engine'):
204 self.notify(tri, 'finalize_print')
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
212 if mime == gws.lib.mime.HTML:
213 return gws.ContentResponse(mime=mime, content=html)
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)
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)
223 raise gws.Error(f'invalid output mime: {tri.mimeOut!r}')
225 def finalize_pdf(self, tri: gws.TemplateRenderInput, html: str, args: dict, main_engine: 'Engine'):
226 content_pdf_path = gws.u.printtemp('content.pdf')
228 page_size = main_engine.pageSize or self.pageSize
229 page_margin = main_engine.pageMargin or self.pageMargin
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 )
238 has_frame = main_engine.header or main_engine.footer
239 if not has_frame:
240 return content_pdf_path
242 args = gws.u.merge(args, numpages=gws.lib.pdf.page_count(content_pdf_path))
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)
248 frame_pdf_path = gws.u.printtemp('frame.pdf')
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 )
257 combined_pdf_path = gws.u.printtemp('combined.pdf')
258 gws.lib.pdf.overlay(frame_pdf_path, content_pdf_path, combined_pdf_path)
260 return combined_pdf_path
262 def finalize_png(self, tri: gws.TemplateRenderInput, html: str, args: dict, main_engine: 'Engine'):
263 out_png_path = gws.u.printtemp('out.png')
265 page_size = main_engine.pageSize or self.pageSize
266 page_margin = main_engine.pageMargin or self.pageMargin
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 )
275 return out_png_path
277 ##
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
286 def frame_template(self, header, footer, page_size):
287 w, h, _ = page_size
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 '''
308##
311class Engine(gws.lib.vendor.jump.Engine):
312 pageMargin: list[int] = []
313 pageSize: gws.UomSize = []
314 header: str = ''
315 footer: str = ''
317 def __init__(self, template: Object, tri: Optional[gws.TemplateRenderInput] = None):
318 super().__init__()
319 self.template = template
320 self.tri = tri
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)
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 )
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 )
352 def mbox_header(self, text):
353 self.header = text
355 def mbox_footer(self, text):
356 self.footer = text
359def _scalar(kw, name, typ, default=None):
360 val = kw.get(name)
361 if val is None:
362 return default
363 return typ(val)
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')