Coverage for gws-app/gws/base/printer/worker.py: 18%
149 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"""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
113 self.tri.maps = [self.prepare_map(self.tri, m) for m in (self.request.maps or [])]
114 self.tri.dpi = int(min(gws.gis.render.MAX_DPI, max(self.request.dpi, gws.lib.uom.OGC_SCREEN_PPI)))
116 if self.request.type == 'template':
117 self.printer = cast(gws.Printer, self.user.require(self.request.printerUid, gws.ext.object.printer))
118 self.template = self.printer.template
119 dpis = [ql.dpi for ql in self.printer.qualityLevels if ql.dpi]
120 max_dpi = max(dpis) if dpis else gws.lib.uom.OGC_SCREEN_PPI
121 self.tri.dpi = int(max(gws.lib.uom.OGC_SCREEN_PPI, min(self.tri.dpi, max_dpi)))
122 else:
123 mm = gws.lib.uom.size_px_to_mm(self.request.outputSize, gws.lib.uom.OGC_SCREEN_PPI)
124 px = gws.lib.uom.size_mm_to_px(mm, self.tri.dpi)
125 self.template = self.root.create_temporary(
126 gws.ext.object.template,
127 type='map',
128 pageSize=(px[0], px[1], gws.Uom.px))
130 extra = dict(
131 project=self.project,
132 user=self.user,
133 scale=self.tri.maps[0].scale if self.tri.maps else 0,
134 rotation=self.tri.maps[0].rotation if self.tri.maps else 0,
135 dpi=self.tri.dpi,
136 crs=self.tri.crs.srid,
137 )
139 # @TODO read the args feature from the request
140 self.tri.args = gws.u.merge(self.request.args, extra)
142 n = sum(len(mp.planes) for mp in self.tri.maps) + 1
143 self.update_job(numSteps=n)
145 def notify(self, event, details=None):
146 """Handle a render progress event and update the job.
148 Args:
149 event: Event name: ``begin_plane``, ``finalize_print`` or ``page_break``.
150 details: Event details.
152 Raises:
153 ``gws.JobTerminated``: If the job is no longer running.
154 """
156 job = self.get_job()
157 if not job:
158 return
160 if event == 'begin_plane':
161 name = gws.u.get(details, 'layer.title')
162 self.update_job(step=job.step + 1, stepName=name or '')
163 return
165 if event == 'finalize_print':
166 self.update_job(step=job.step + 1)
167 return
169 if event == 'page_break':
170 self.page_number += 1
171 return
173 def prepare_map(self, tri: gws.TemplateRenderInput, mp: gws.PrintMap) -> gws.MapRenderInput:
174 """Convert a print map from the request to a map render input.
176 Parses the styles sent by the client (untrusted), converts the planes
177 and resolves the visible layers. Planes that cannot be prepared are skipped.
179 Args:
180 tri: Template render input.
181 mp: Print map.
183 Returns:
184 The map render input.
185 """
187 planes = []
189 style_opts = gws.lib.style.parser.Options(
190 trusted=False,
191 strict=False,
192 imageDirs=[self.root.app.webMgr.site.staticRoot.dir],
193 )
194 style_dct = {}
196 for p in (mp.styles or []):
197 style = gws.lib.style.from_props(p, style_opts)
198 if style:
199 style_dct[style.cssSelector] = style
201 if mp.planes:
202 for n, p in enumerate(mp.planes):
203 pp = self.prepare_map_plane(n, p, style_dct)
204 if pp:
205 planes.append(pp)
207 layers = gws.u.compact(
208 self.user.acquire(uid, gws.ext.object.layer)
209 for uid in (mp.visibleLayers or []))
211 return gws.MapRenderInput(
212 backgroundColor=_PAPER_COLOR,
213 bbox=mp.bbox,
214 center=mp.center,
215 targetCrs=tri.crs,
216 dpi=tri.dpi,
217 notify=tri.notify,
218 planes=planes,
219 project=tri.project,
220 rotation=mp.rotation or 0,
221 scale=mp.scale,
222 user=tri.user,
223 visibleLayers=layers,
224 )
226 def prepare_map_plane(self, n, plane: gws.PrintPlane, style_dct) -> Optional[gws.MapRenderInputPlane]:
227 """Convert a print plane to a render plane.
229 Args:
230 n: Plane index, used in log messages.
231 plane: Print plane.
232 style_dct: Styles by CSS selector.
234 Returns:
235 The render plane, or ``None`` if the plane is fully transparent or
236 cannot be prepared (layer not found or not renderable, invalid image, no features).
238 Raises:
239 ``gws.Error``: If the plane type is invalid.
240 """
242 opacity = 1
243 s = plane.get('opacity')
244 if s is not None:
245 opacity = float(s)
246 if opacity == 0:
247 return
249 if plane.type == gws.PrintPlaneType.raster:
250 layer = cast(gws.Layer, self.user.acquire(plane.layerUid, gws.ext.object.layer))
251 if not layer:
252 gws.log.warning(f'PREPARE_FAILED: plane {n}: {plane.layerUid=} not found')
253 return
254 if not layer.canRenderBox:
255 gws.log.warning(f'PREPARE_FAILED: plane {n}: {plane.layerUid=} canRenderBox false')
256 return
257 return gws.MapRenderInputPlane(
258 type=gws.MapRenderInputPlaneType.imageLayer,
259 layer=layer,
260 opacity=opacity,
261 compositeLayerUids=plane.get('compositeLayerUids'),
262 )
264 if plane.type == gws.PrintPlaneType.vector:
265 layer = cast(gws.Layer, self.user.acquire(plane.layerUid, gws.ext.object.layer))
266 if not layer:
267 gws.log.warning(f'PREPARE_FAILED: plane {n}: {plane.layerUid=} not found')
268 return
269 if not layer.canRenderSvg:
270 gws.log.warning(f'PREPARE_FAILED: plane {n}: {plane.layerUid=} canRenderSvg false')
271 return
272 style = style_dct.get(plane.cssSelector)
273 return gws.MapRenderInputPlane(
274 type=gws.MapRenderInputPlaneType.svgLayer,
275 layer=layer,
276 opacity=opacity,
277 styles=[style] if style else [],
278 )
280 if plane.type == gws.PrintPlaneType.bitmap:
281 img = None
282 if plane.bitmapMode in ('RGBA', 'RGB'):
283 img = gws.lib.image.from_raw_data(
284 plane.bitmapData,
285 plane.bitmapMode,
286 (plane.bitmapWidth, plane.bitmapHeight))
287 if not img:
288 gws.log.warning(f'PREPARE_FAILED: plane {n}: bitmap error')
289 return
290 return gws.MapRenderInputPlane(
291 type=gws.MapRenderInputPlaneType.image,
292 image=img,
293 opacity=opacity,
294 )
296 if plane.type == gws.PrintPlaneType.url:
297 img = gws.lib.image.from_data_url(plane.url)
298 if not img:
299 gws.log.warning(f'PREPARE_FAILED: plane {n}: url error')
300 return
301 return gws.MapRenderInputPlane(
302 type=gws.MapRenderInputPlaneType.image,
303 image=img,
304 opacity=opacity,
305 )
307 if plane.type == gws.PrintPlaneType.features:
308 model = self.root.app.modelMgr.default_model()
309 used_styles = {}
311 mc = gws.ModelContext(op=gws.ModelOperation.read, target=gws.ModelReadTarget.map, user=self.user)
312 features = []
314 for props in plane.features:
315 feature = model.feature_from_props(props, mc)
316 if not feature or not feature.shape():
317 continue
318 feature.cssSelector = feature.cssSelector or plane.cssSelector
319 if feature.cssSelector in style_dct:
320 used_styles[feature.cssSelector] = style_dct[feature.cssSelector]
321 features.append(feature)
323 if not features:
324 gws.log.warning(f'PREPARE_FAILED: plane {n}: no features')
325 return
327 return gws.MapRenderInputPlane(
328 type=gws.MapRenderInputPlaneType.features,
329 features=features,
330 opacity=opacity,
331 styles=list(used_styles.values()),
332 )
334 if plane.type == gws.PrintPlaneType.soup:
335 return gws.MapRenderInputPlane(
336 type=gws.MapRenderInputPlaneType.svgSoup,
337 soupPoints=plane.soupPoints,
338 soupTags=plane.soupTags,
339 opacity=opacity,
340 )
342 raise gws.Error(f'invalid plane type {plane.type!r}')