Coverage for gws-app/gws/base/printer/worker.py: 18%
150 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"""Print job worker."""
3from typing import Optional, cast
5import gws
6import gws.base.model
7import gws.base.job
8import gws.lib.crs
9import gws.gis.render
10import gws.lib.image
11import gws.lib.intl
12import gws.lib.mime
13import gws.lib.osx
14import gws.lib.style
15import gws.lib.uom
17_PAPER_COLOR = 'white'
20class Object(gws.base.job.worker.Object):
21 """Print job worker.
23 Executes a print request and stores the result in the job.
24 """
26 project: gws.Project
27 """Project of the request."""
28 tri: gws.TemplateRenderInput
29 """Template render input."""
30 printer: gws.Printer
31 """Printer, for template requests."""
32 template: gws.Template
33 """Template to render."""
34 contentPath: str
35 """Path of the rendered output."""
37 @classmethod
38 def run(cls, root: gws.Root, job: gws.Job):
39 """Run a print job, called by the job manager.
41 Args:
42 root: Root object.
43 job: Job, with the path of the pickled request in its payload.
44 """
46 request = gws.u.unserialize_from_path(job.payload.get('requestPath'))
47 w = cls(root, job.user, job, request)
48 w.work()
50 def __init__(self, root: gws.Root, user: gws.User, job: Optional[gws.Job], request: gws.PrintRequest):
51 """Create a worker.
53 Args:
54 root: Root object.
55 user: User who requested the print.
56 job: Job, or ``None`` to print without a job.
57 request: Print request.
58 """
60 super().__init__(root, user, job)
61 self.request = request
62 self.page_number = 0
63 self.contentPath = ''
65 def work(self):
66 """Prepare and render the print, set ``contentPath`` and mark the job complete.
68 Raises:
69 ``gws.Error``: If the request is invalid.
70 """
72 self.prepare()
73 res = self.template.render(self.tri)
74 self.contentPath = res.contentPath
75 self.update_job(
76 state=gws.JobState.complete,
77 result=gws.PrintResult(
78 path=self.contentPath,
79 mimeType=gws.lib.mime.for_path(self.contentPath),
80 ),
81 )
83 def prepare(self):
84 """Prepare the template render input.
86 Resolves the project and the template, determines the output format,
87 CRS and DPI, prepares the maps and sets the number of job steps.
88 For template requests, the DPI is limited by the printer quality levels;
89 for map requests, a temporary map template of the requested size is created.
91 Raises:
92 ``gws.Error``: If the output format is invalid.
93 """
95 self.project = cast(gws.Project, self.user.require(self.request.projectUid, gws.ext.object.project))
97 self.tri = gws.TemplateRenderInput(
98 project=self.project,
99 user=self.user,
100 notify=self.notify,
101 )
103 fmt = self.request.outputFormat or 'pdf'
104 if fmt.lower() == 'pdf' or fmt == gws.lib.mime.PDF:
105 self.tri.mimeOut = gws.lib.mime.PDF
106 elif fmt.lower() == 'png' or fmt == gws.lib.mime.PNG:
107 self.tri.mimeOut = gws.lib.mime.PNG
108 else:
109 raise gws.Error(f'invalid outputFormat {fmt!r}')
111 self.tri.locale = gws.lib.intl.locale(self.request.localeUid, self.tri.project.localeUids)
112 self.tri.crs = gws.lib.crs.get(self.request.crs) or self.project.map.bounds.crs
114 if self.request.type == 'template':
115 self.printer = cast(gws.Printer, self.user.require(self.request.printerUid, gws.ext.object.printer))
116 self.template = self.printer.template
117 dpis = [ql.dpi for ql in self.printer.qualityLevels if ql.dpi]
118 max_dpi = max(dpis) if dpis else gws.lib.uom.OGC_SCREEN_PPI
119 self.tri.dpi = int(gws.u.clamp(self.request.dpi or 0, gws.lib.uom.OGC_SCREEN_PPI, min(gws.gis.render.MAX_DPI, max_dpi)))
120 else:
121 self.tri.dpi = int(gws.u.clamp(self.request.dpi or 0, gws.lib.uom.OGC_SCREEN_PPI, gws.gis.render.MAX_DPI))
122 mm = gws.lib.uom.size_px_to_mm(self.request.outputSize, gws.lib.uom.OGC_SCREEN_PPI)
123 px = gws.lib.uom.size_mm_to_px(mm, self.tri.dpi)
124 self.template = self.root.create_temporary(
125 gws.ext.object.template,
126 type='map',
127 pageSize=(px[0], px[1], gws.Uom.px))
129 self.tri.maps = [self.prepare_map(self.tri, m) for m in (self.request.maps or [])]
131 extra = dict(
132 project=self.project,
133 user=self.user,
134 scale=self.tri.maps[0].scale if self.tri.maps else 0,
135 rotation=self.tri.maps[0].rotation if self.tri.maps else 0,
136 dpi=self.tri.dpi,
137 crs=self.tri.crs.srid,
138 )
140 # @TODO read the args feature from the request
141 self.tri.args = gws.u.merge(self.request.args, extra)
143 n = sum(len(mp.planes) for mp in self.tri.maps) + 1
144 self.update_job(numSteps=n)
146 def notify(self, event, details=None):
147 """Handle a render progress event and update the job.
149 Args:
150 event: Event name: ``begin_plane``, ``finalize_print`` or ``page_break``.
151 details: Event details.
153 Raises:
154 ``gws.JobTerminated``: If the job is no longer running.
155 """
157 job = self.get_job()
158 if not job:
159 return
161 if event == 'begin_plane':
162 name = gws.u.get(details, 'layer.title')
163 self.update_job(step=job.step + 1, stepName=name or '')
164 return
166 if event == 'finalize_print':
167 self.update_job(step=job.step + 1)
168 return
170 if event == 'page_break':
171 self.page_number += 1
172 return
174 def prepare_map(self, tri: gws.TemplateRenderInput, mp: gws.PrintMap) -> gws.MapRenderInput:
175 """Convert a print map from the request to a map render input.
177 Parses the styles sent by the client (untrusted), converts the planes
178 and resolves the visible layers. Planes that cannot be prepared are skipped.
180 Args:
181 tri: Template render input.
182 mp: Print map.
184 Returns:
185 The map render input.
186 """
188 planes = []
190 style_opts = gws.lib.style.parser.Options(
191 trusted=False,
192 strict=False,
193 imageDirs=[self.root.app.webMgr.site.staticRoot.dir],
194 )
195 style_dct = {}
197 for p in (mp.styles or []):
198 style = gws.lib.style.from_props(p, style_opts)
199 if style:
200 style_dct[style.cssSelector] = style
202 if mp.planes:
203 for n, p in enumerate(mp.planes):
204 pp = self.prepare_map_plane(n, p, style_dct)
205 if pp:
206 planes.append(pp)
208 layers = gws.u.compact(
209 self.user.acquire(uid, gws.ext.object.layer)
210 for uid in (mp.visibleLayers or []))
212 return gws.MapRenderInput(
213 backgroundColor=_PAPER_COLOR,
214 bbox=mp.bbox,
215 center=mp.center,
216 targetCrs=tri.crs,
217 dpi=tri.dpi,
218 notify=tri.notify,
219 planes=planes,
220 project=tri.project,
221 rotation=mp.rotation or 0,
222 scale=mp.scale,
223 user=tri.user,
224 visibleLayers=layers,
225 )
227 def prepare_map_plane(self, n, plane: gws.PrintPlane, style_dct) -> Optional[gws.MapRenderInputPlane]:
228 """Convert a print plane to a render plane.
230 Args:
231 n: Plane index, used in log messages.
232 plane: Print plane.
233 style_dct: Styles by CSS selector.
235 Returns:
236 The render plane, or ``None`` if the plane is fully transparent or
237 cannot be prepared (layer not found or not renderable, invalid image, no features).
239 Raises:
240 ``gws.Error``: If the plane type is invalid.
241 """
243 opacity = 1
244 s = plane.get('opacity')
245 if s is not None:
246 opacity = float(s)
247 if opacity == 0:
248 return
250 if plane.type == gws.PrintPlaneType.raster:
251 layer = cast(gws.Layer, self.user.acquire(plane.layerUid, gws.ext.object.layer))
252 if not layer:
253 gws.log.warning(f'PREPARE_FAILED: plane {n}: {plane.layerUid=} not found')
254 return
255 if not layer.canRenderBox:
256 gws.log.warning(f'PREPARE_FAILED: plane {n}: {plane.layerUid=} canRenderBox false')
257 return
258 return gws.MapRenderInputPlane(
259 type=gws.MapRenderInputPlaneType.imageLayer,
260 layer=layer,
261 opacity=opacity,
262 compositeLayerUids=plane.get('compositeLayerUids'),
263 )
265 if plane.type == gws.PrintPlaneType.vector:
266 layer = cast(gws.Layer, self.user.acquire(plane.layerUid, gws.ext.object.layer))
267 if not layer:
268 gws.log.warning(f'PREPARE_FAILED: plane {n}: {plane.layerUid=} not found')
269 return
270 if not layer.canRenderSvg:
271 gws.log.warning(f'PREPARE_FAILED: plane {n}: {plane.layerUid=} canRenderSvg false')
272 return
273 style = style_dct.get(plane.cssSelector)
274 return gws.MapRenderInputPlane(
275 type=gws.MapRenderInputPlaneType.svgLayer,
276 layer=layer,
277 opacity=opacity,
278 styles=[style] if style else [],
279 )
281 if plane.type == gws.PrintPlaneType.bitmap:
282 img = None
283 if plane.bitmapMode in ('RGBA', 'RGB'):
284 img = gws.lib.image.from_raw_data(
285 plane.bitmapData,
286 plane.bitmapMode,
287 (plane.bitmapWidth, plane.bitmapHeight))
288 if not img:
289 gws.log.warning(f'PREPARE_FAILED: plane {n}: bitmap error')
290 return
291 return gws.MapRenderInputPlane(
292 type=gws.MapRenderInputPlaneType.image,
293 image=img,
294 opacity=opacity,
295 )
297 if plane.type == gws.PrintPlaneType.url:
298 try:
299 img = gws.lib.image.from_data_url(plane.url)
300 except gws.lib.image.Error:
301 gws.log.warning(f'PREPARE_FAILED: plane {n}: url error')
302 return
303 return gws.MapRenderInputPlane(
304 type=gws.MapRenderInputPlaneType.image,
305 image=img,
306 opacity=opacity,
307 )
309 if plane.type == gws.PrintPlaneType.features:
310 model = self.root.app.modelMgr.default_model()
311 used_styles = {}
313 mc = gws.ModelContext(op=gws.ModelOperation.read, target=gws.ModelReadTarget.map, user=self.user)
314 features = []
316 for props in plane.features:
317 feature = model.feature_from_props(props, mc)
318 if not feature or not feature.shape():
319 continue
320 feature.cssSelector = feature.cssSelector or plane.cssSelector
321 if feature.cssSelector in style_dct:
322 used_styles[feature.cssSelector] = style_dct[feature.cssSelector]
323 features.append(feature)
325 if not features:
326 gws.log.warning(f'PREPARE_FAILED: plane {n}: no features')
327 return
329 return gws.MapRenderInputPlane(
330 type=gws.MapRenderInputPlaneType.features,
331 features=features,
332 opacity=opacity,
333 styles=list(used_styles.values()),
334 )
336 if plane.type == gws.PrintPlaneType.soup:
337 return gws.MapRenderInputPlane(
338 type=gws.MapRenderInputPlaneType.svgSoup,
339 soupPoints=plane.soupPoints,
340 soupTags=plane.soupTags,
341 opacity=opacity,
342 )
344 raise gws.Error(f'invalid plane type {plane.type!r}')