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

1"""Print job worker.""" 

2 

3from typing import Optional, cast 

4 

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 

16 

17_PAPER_COLOR = 'white' 

18 

19 

20class Object(gws.base.job.worker.Object): 

21 """Print job worker. 

22 

23 Executes a print request and stores the result in the job. 

24 """ 

25 

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.""" 

36 

37 @classmethod 

38 def run(cls, root: gws.Root, job: gws.Job): 

39 """Run a print job, called by the job manager. 

40 

41 Args: 

42 root: Root object. 

43 job: Job, with the path of the pickled request in its payload. 

44 """ 

45 

46 request = gws.u.unserialize_from_path(job.payload.get('requestPath')) 

47 w = cls(root, job.user, job, request) 

48 w.work() 

49 

50 def __init__(self, root: gws.Root, user: gws.User, job: Optional[gws.Job], request: gws.PrintRequest): 

51 """Create a worker. 

52 

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 """ 

59 

60 super().__init__(root, user, job) 

61 self.request = request 

62 self.page_number = 0 

63 self.contentPath = '' 

64 

65 def work(self): 

66 """Prepare and render the print, set ``contentPath`` and mark the job complete. 

67 

68 Raises: 

69 ``gws.Error``: If the request is invalid. 

70 """ 

71 

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 ) 

82 

83 def prepare(self): 

84 """Prepare the template render input. 

85 

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. 

90 

91 Raises: 

92 ``gws.Error``: If the output format is invalid. 

93 """ 

94 

95 self.project = cast(gws.Project, self.user.require(self.request.projectUid, gws.ext.object.project)) 

96 

97 self.tri = gws.TemplateRenderInput( 

98 project=self.project, 

99 user=self.user, 

100 notify=self.notify, 

101 ) 

102 

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}') 

110 

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 

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)) 

128 

129 self.tri.maps = [self.prepare_map(self.tri, m) for m in (self.request.maps or [])] 

130 

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 ) 

139 

140 # @TODO read the args feature from the request 

141 self.tri.args = gws.u.merge(self.request.args, extra) 

142 

143 n = sum(len(mp.planes) for mp in self.tri.maps) + 1 

144 self.update_job(numSteps=n) 

145 

146 def notify(self, event, details=None): 

147 """Handle a render progress event and update the job. 

148 

149 Args: 

150 event: Event name: ``begin_plane``, ``finalize_print`` or ``page_break``. 

151 details: Event details. 

152 

153 Raises: 

154 ``gws.JobTerminated``: If the job is no longer running. 

155 """ 

156 

157 job = self.get_job() 

158 if not job: 

159 return 

160 

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 

165 

166 if event == 'finalize_print': 

167 self.update_job(step=job.step + 1) 

168 return 

169 

170 if event == 'page_break': 

171 self.page_number += 1 

172 return 

173 

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. 

176 

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. 

179 

180 Args: 

181 tri: Template render input. 

182 mp: Print map. 

183 

184 Returns: 

185 The map render input. 

186 """ 

187 

188 planes = [] 

189 

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 = {} 

196 

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 

201 

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) 

207 

208 layers = gws.u.compact( 

209 self.user.acquire(uid, gws.ext.object.layer) 

210 for uid in (mp.visibleLayers or [])) 

211 

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 ) 

226 

227 def prepare_map_plane(self, n, plane: gws.PrintPlane, style_dct) -> Optional[gws.MapRenderInputPlane]: 

228 """Convert a print plane to a render plane. 

229 

230 Args: 

231 n: Plane index, used in log messages. 

232 plane: Print plane. 

233 style_dct: Styles by CSS selector. 

234 

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). 

238 

239 Raises: 

240 ``gws.Error``: If the plane type is invalid. 

241 """ 

242 

243 opacity = 1 

244 s = plane.get('opacity') 

245 if s is not None: 

246 opacity = float(s) 

247 if opacity == 0: 

248 return 

249 

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 ) 

264 

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 ) 

280 

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 ) 

296 

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 ) 

308 

309 if plane.type == gws.PrintPlaneType.features: 

310 model = self.root.app.modelMgr.default_model() 

311 used_styles = {} 

312 

313 mc = gws.ModelContext(op=gws.ModelOperation.read, target=gws.ModelReadTarget.map, user=self.user) 

314 features = [] 

315 

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) 

324 

325 if not features: 

326 gws.log.warning(f'PREPARE_FAILED: plane {n}: no features') 

327 return 

328 

329 return gws.MapRenderInputPlane( 

330 type=gws.MapRenderInputPlaneType.features, 

331 features=features, 

332 opacity=opacity, 

333 styles=list(used_styles.values()), 

334 ) 

335 

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 ) 

343 

344 raise gws.Error(f'invalid plane type {plane.type!r}')