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

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

115 

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

129 

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 ) 

138 

139 # @TODO read the args feature from the request 

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

141 

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

143 self.update_job(numSteps=n) 

144 

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

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

147 

148 Args: 

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

150 details: Event details. 

151 

152 Raises: 

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

154 """ 

155 

156 job = self.get_job() 

157 if not job: 

158 return 

159 

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 

164 

165 if event == 'finalize_print': 

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

167 return 

168 

169 if event == 'page_break': 

170 self.page_number += 1 

171 return 

172 

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. 

175 

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. 

178 

179 Args: 

180 tri: Template render input. 

181 mp: Print map. 

182 

183 Returns: 

184 The map render input. 

185 """ 

186 

187 planes = [] 

188 

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

195 

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 

200 

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) 

206 

207 layers = gws.u.compact( 

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

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

210 

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 ) 

225 

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

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

228 

229 Args: 

230 n: Plane index, used in log messages. 

231 plane: Print plane. 

232 style_dct: Styles by CSS selector. 

233 

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

237 

238 Raises: 

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

240 """ 

241 

242 opacity = 1 

243 s = plane.get('opacity') 

244 if s is not None: 

245 opacity = float(s) 

246 if opacity == 0: 

247 return 

248 

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 ) 

263 

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 ) 

279 

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 ) 

295 

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 ) 

306 

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

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

309 used_styles = {} 

310 

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

312 features = [] 

313 

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) 

322 

323 if not features: 

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

325 return 

326 

327 return gws.MapRenderInputPlane( 

328 type=gws.MapRenderInputPlaneType.features, 

329 features=features, 

330 opacity=opacity, 

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

332 ) 

333 

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 ) 

341 

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