Coverage for src/artemis_sg/slide_generator.py: 80%

288 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2024-03-06 08:01 -0800

1import datetime 

2import logging 

3import math 

4import os 

5import textwrap 

6 

7from PIL import Image, ImageColor, ImageDraw, ImageFont 

8from rich.console import Console 

9from rich.text import Text 

10 

11import artemis_sg 

12from artemis_sg import spreadsheet 

13from artemis_sg.config import CFG 

14 

15console = Console() 

16 

17 

18class SlideGenerator: 

19 # constants 

20 EMU_INCH = 914400 

21 

22 # methods 

23 def __init__(self, slides, gcloud, vendor): 

24 self.slides = slides 

25 self.gcloud = gcloud 

26 self.vendor = vendor 

27 self.slides_api_call_count = 0 

28 

29 ########################################################################### 

30 def color_to_rgbcolor(self, color): 

31 red, green, blue = ImageColor.getrgb(color) 

32 return { 

33 "red": red/255.0, 

34 "green": green/255.0, 

35 "blue": blue/255.0 

36 } 

37 

38 def gj_binding_map(self, code): 

39 code = code.upper() 

40 return CFG["asg"]["slide_generator"]["gj_binding_map"].get(code, code) 

41 

42 def gj_type_map(self, code): 

43 code = code.upper() 

44 return CFG["asg"]["slide_generator"]["gj_type_map"].get(code, code) 

45 

46 def get_req_update_artemis_slide( 

47 self, deck_id, book_slide_id, item, text_bucket_path, g_reqs 

48 ): 

49 namespace = ( 

50 f"{type(self).__name__}.{self.get_req_update_artemis_slide.__name__}" 

51 ) 

52 

53 bg_color = CFG["asg"]["slide_generator"]["bg_color"] 

54 slide_w = CFG["asg"]["slide_generator"]["slide_w"] 

55 slide_h = CFG["asg"]["slide_generator"]["slide_h"] 

56 gutter = CFG["asg"]["slide_generator"]["gutter"] 

57 addl_img_w = CFG["asg"]["slide_generator"]["addl_img_w"] 

58 addl_img_h = CFG["asg"]["slide_generator"]["addl_img_h"] 

59 image_count = len(item.image_urls) 

60 main_dim = self.get_main_image_size(image_count) 

61 

62 logging.info(f"{namespace}: background to {bg_color}") 

63 g_reqs += self.get_req_slide_bg_color( 

64 book_slide_id, 

65 self.color_to_rgbcolor(bg_color)) 

66 

67 logging.info(f"{namespace}: cover image on book slide") 

68 cover_url = item.image_urls.pop() 

69 g_reqs += self.get_req_create_image( 

70 book_slide_id, 

71 cover_url, 

72 main_dim, 

73 (gutter, gutter), 

74 ) 

75 

76 for i, url in enumerate(item.image_urls): 76 ↛ 77line 76 didn't jump to line 77, because the loop on line 76 never started

77 if i > CFG["asg"]["slide_generator"]["text_box_resize_img_threshold"]: 

78 continue 

79 

80 logging.info(f"{namespace}: {i + 2!s} image on book slide") 

81 g_reqs += self.get_req_create_image( 

82 book_slide_id, 

83 url, 

84 (addl_img_w, addl_img_h), 

85 ( 

86 (gutter + ((addl_img_w + gutter) * i)), 

87 (slide_h - gutter - addl_img_h), 

88 ), 

89 ) 

90 

91 logging.info(f"{namespace}: Create text") 

92 text_box_dim, max_lines = self.get_text_box_size_lines(image_count) 

93 big_text = self.create_slide_text(item, max_lines) 

94 

95 logging.info(f"{namespace}: Create text image") 

96 text_filepath = self.create_text_image_file( 

97 item.isbn, text_bucket_path, big_text, text_box_dim 

98 ) 

99 

100 logging.info(f"{namespace}: Upload text image to GC storage") 

101 cdr, car_file = os.path.split(text_filepath) 

102 cdr, car_prefix = os.path.split(cdr) 

103 blob_name = car_prefix + "/" + car_file 

104 self.gcloud.upload_cloud_blob(text_filepath, blob_name) 

105 logging.debug(f"{namespace}: Deleting local text image") 

106 os.remove(text_filepath) 

107 logging.info(f"{namespace}: Create URL for text image") 

108 url = self.gcloud.generate_cloud_signed_url(blob_name) 

109 logging.info(f"{namespace}: text image to slide") 

110 g_reqs += self.get_req_create_image( 

111 book_slide_id, url, text_box_dim, (slide_w / 2, gutter) 

112 ) 

113 

114 logging.info(f"{namespace}: ISBN text on book slide") 

115 text_box_w = slide_w 

116 text_box_h = gutter 

117 text_fields = self.create_text_fields_via_batch_update( 

118 deck_id, 

119 self.get_req_create_text_box( 

120 book_slide_id, 

121 (slide_w - CFG["asg"]["slide_generator"]["tiny_isbn_x_inset"], 

122 slide_h - gutter), 

123 (text_box_w, text_box_h), 

124 ), 

125 ) 

126 text_field_id = text_fields[0] 

127 text_d = {text_field_id: item.isbn} 

128 g_reqs += self.get_req_insert_text(text_d) 

129 g_reqs += self.get_req_text_field_fontsize( 

130 text_field_id, 

131 CFG["asg"]["slide_generator"]["tiny_isbn_fontsize"]) 

132 g_reqs += self.get_req_text_field_color( 

133 text_field_id, 

134 self.color_to_rgbcolor(CFG["asg"]["slide_generator"]["text_color"])) 

135 

136 logging.info(f"{namespace}: logo image on book slide") 

137 g_reqs += self.get_req_create_logo(book_slide_id) 

138 

139 return g_reqs 

140 

141 def create_text_fields_via_batch_update(self, deck_id, reqs): 

142 text_object_id_list = [] 

143 rsp = self.slide_batch_update_get_replies(deck_id, reqs) 

144 for obj in rsp: 

145 text_object_id_list.append(obj["createShape"]["objectId"]) 

146 return text_object_id_list 

147 

148 def create_book_slides_via_batch_update(self, deck_id, book_list): 

149 namespace = ( 

150 f"{type(self).__name__}.{self.create_book_slides_via_batch_update.__name__}" 

151 ) 

152 

153 logging.info(f"{namespace}: Create slides for books") 

154 book_slide_id_list = [] 

155 reqs = [] 

156 for _i in range(len(book_list)): 156 ↛ 157line 156 didn't jump to line 157, because the loop on line 156 never started

157 reqs += [ 

158 {"createSlide": {"slideLayoutReference": {"predefinedLayout": "BLANK"}}} 

159 ] 

160 rsp = self.slide_batch_update_get_replies(deck_id, reqs) 

161 for i in rsp: 

162 book_slide_id_list.append(i["createSlide"]["objectId"]) 

163 return book_slide_id_list 

164 

165 def slide_batch_update(self, deck_id, reqs): 

166 return ( 

167 self.slides.presentations() 

168 .batchUpdate(body={"requests": reqs}, presentationId=deck_id) 

169 .execute() 

170 ) 

171 

172 def slide_batch_update_get_replies(self, deck_id, reqs): 

173 return ( 

174 self.slides.presentations() 

175 .batchUpdate(body={"requests": reqs}, presentationId=deck_id) 

176 .execute() 

177 .get("replies") 

178 ) 

179 

180 def get_req_create_image(self, slide_id, url, size, translate): 

181 w, h = size 

182 translate_x, translate_y = translate 

183 reqs = [ 

184 { 

185 "createImage": { 

186 "elementProperties": { 

187 "pageObjectId": slide_id, 

188 "size": { 

189 "width": { 

190 "magnitude": self.EMU_INCH * w, 

191 "unit": "EMU", 

192 }, 

193 "height": { 

194 "magnitude": self.EMU_INCH * h, 

195 "unit": "EMU", 

196 }, 

197 }, 

198 "transform": { 

199 "scaleX": 1, 

200 "scaleY": 1, 

201 "translateX": self.EMU_INCH * translate_x, 

202 "translateY": self.EMU_INCH * translate_y, 

203 "unit": "EMU", 

204 }, 

205 }, 

206 "url": url, 

207 }, 

208 } 

209 ] 

210 return reqs 

211 

212 def get_req_create_logo(self, slide_id): 

213 # Place logo in upper right corner of slide 

214 # TODO: (#163) move this to CFG 

215 translate_x = ( 

216 CFG["asg"]["slide_generator"]["slide_w"] - 

217 CFG["asg"]["slide_generator"]["logo_w"]) 

218 translate_y = 0 

219 return self.get_req_create_image( 

220 slide_id, 

221 CFG["asg"]["slide_generator"]["logo_url"], 

222 ( 

223 CFG["asg"]["slide_generator"]["logo_w"], 

224 CFG["asg"]["slide_generator"]["logo_h"] 

225 ), 

226 (translate_x, translate_y), 

227 ) 

228 

229 def get_req_slide_bg_color(self, slide_id, rgb_d): 

230 reqs = [ 

231 { 

232 "updatePageProperties": { 

233 "objectId": slide_id, 

234 "fields": "pageBackgroundFill", 

235 "pageProperties": { 

236 "pageBackgroundFill": { 

237 "solidFill": { 

238 "color": { 

239 "rgbColor": rgb_d, 

240 } 

241 } 

242 } 

243 }, 

244 }, 

245 }, 

246 ] 

247 return reqs 

248 

249 def get_req_text_field_color(self, field_id, rgb_d): 

250 reqs = [ 

251 { 

252 "updateTextStyle": { 

253 "objectId": field_id, 

254 "textRange": {"type": "ALL"}, 

255 "style": { 

256 "foregroundColor": { 

257 "opaqueColor": { 

258 "rgbColor": rgb_d, 

259 } 

260 } 

261 }, 

262 "fields": "foregroundColor", 

263 } 

264 } 

265 ] 

266 return reqs 

267 

268 def get_req_text_field_fontsize(self, field_id, pt_size): 

269 reqs = [ 

270 { 

271 "updateTextStyle": { 

272 "objectId": field_id, 

273 "textRange": {"type": "ALL"}, 

274 "style": { 

275 "fontSize": { 

276 "magnitude": pt_size, 

277 "unit": "PT", 

278 } 

279 }, 

280 "fields": "fontSize", 

281 } 

282 }, 

283 ] 

284 return reqs 

285 

286 def get_req_insert_text(self, text_dict): 

287 reqs = [] 

288 for key in text_dict: 

289 reqs.append( 

290 { 

291 "insertText": { 

292 "objectId": key, 

293 "text": text_dict[key], 

294 }, 

295 } 

296 ) 

297 return reqs 

298 

299 def get_req_create_text_box(self, slide_id, coord=(0, 0), field_size=(1, 1)): 

300 reqs = [ 

301 { 

302 "createShape": { 

303 "elementProperties": { 

304 "pageObjectId": slide_id, 

305 "size": { 

306 "width": { 

307 "magnitude": self.EMU_INCH * field_size[0], 

308 "unit": "EMU", 

309 }, 

310 "height": { 

311 "magnitude": self.EMU_INCH * field_size[1], 

312 "unit": "EMU", 

313 }, 

314 }, 

315 "transform": { 

316 "scaleX": 1, 

317 "scaleY": 1, 

318 "translateX": self.EMU_INCH * coord[0], 

319 "translateY": self.EMU_INCH * coord[1], 

320 "unit": "EMU", 

321 }, 

322 }, 

323 "shapeType": "TEXT_BOX", 

324 }, 

325 } 

326 ] 

327 return reqs 

328 

329 def get_slide_text_key_map(self, key, item): 

330 t = str(item.data[key]) 

331 # hacky exceptions 

332 if key == "BINDING": 332 ↛ 333line 332 didn't jump to line 333, because the condition on line 332 was never true

333 t = self.gj_binding_map(t) 

334 if key == "TYPE": 334 ↛ 335line 334 didn't jump to line 335, because the condition on line 334 was never true

335 t = self.gj_type_map(t) 

336 try: 

337 fstr = CFG["asg"]["slide_generator"]["text_map"][key] 

338 except KeyError: 

339 fstr = "ISBN: {t}" if key == item.isbn_key else key.title() + ": {t}" 

340 return fstr.format(t = t) 

341 

342 def create_slide_text(self, item, max_lines): 

343 namespace = f"{type(self).__name__}.{self.create_slide_text.__name__}" 

344 

345 big_text = "" 

346 logging.debug(f"{namespace}: Item.data: {item.data}") 

347 for k in item.data: 

348 key = k.strip().upper() 

349 if key in CFG["asg"]["slide_generator"]["blacklist_keys"]: 

350 continue 

351 t = self.get_slide_text_key_map(key, item) 

352 line_count = big_text.count("\n") 

353 t = textwrap.fill( 

354 t, 

355 width=CFG["asg"]["slide_generator"]["text_width"], 

356 max_lines=max_lines - line_count 

357 ) 

358 t = t + "\n\n" 

359 big_text += t 

360 return big_text 

361 

362 def create_text_image_file(self, isbn, text_bucket_path, text, size): 

363 namespace = f"{type(self).__name__}.{self.create_text_image_file.__name__}" 

364 

365 line_spacing = CFG["asg"]["slide_generator"]["line_spacing"] 

366 slide_ppi = CFG["asg"]["slide_generator"]["slide_ppi"] 

367 w, h = size 

368 image = Image.new( 

369 "RGB", 

370 (int(w * slide_ppi), int(h * slide_ppi)), 

371 ImageColor.getrgb(CFG["asg"]["slide_generator"]["bg_color"]), 

372 ) 

373 

374 fontsize = 1 

375 for typeface in ( 375 ↛ 386line 375 didn't jump to line 386, because the loop on line 375 didn't complete

376 "arial.ttf", 

377 "LiberationSans-Regular.ttf", 

378 "DejaVuSans.ttf", 

379 ): 

380 try: 

381 font = ImageFont.truetype(typeface, fontsize) 

382 break 

383 except OSError: 

384 font = None 

385 continue 

386 if not font: 386 ↛ 387line 386 didn't jump to line 387, because the condition on line 386 was never true

387 logging.error(f"{namespace}: Cannot access typeface '{typeface}'") 

388 return None 

389 draw = ImageDraw.Draw(image) 

390 

391 # dynamically size text to fit box 

392 while ( 

393 draw.multiline_textbbox( 

394 xy=(0, 0), text=text, font=font, spacing=line_spacing 

395 )[2] 

396 < image.size[0] 

397 and draw.multiline_textbbox( 

398 xy=(0, 0), text=text, font=font, spacing=line_spacing 

399 )[3] 

400 < image.size[1] 

401 and fontsize < CFG["asg"]["slide_generator"]["max_fontsize"] 

402 ): 

403 fontsize += 1 

404 font = ImageFont.truetype(typeface, fontsize) 

405 

406 fontsize -= 1 

407 logging.info(f"{namespace}: Font size is '{fontsize}'") 

408 font = ImageFont.truetype(typeface, fontsize) 

409 

410 # center text 

411 _delme1, _delme2, t_w, t_h = draw.multiline_textbbox( 

412 xy=(0, 0), text=text, font=font, spacing=line_spacing 

413 ) 

414 y_offset = math.floor((image.size[1] - t_h) / 2) 

415 

416 draw.multiline_text( 

417 (0, y_offset), text, font=font, spacing=line_spacing 

418 ) # put the text on the image 

419 text_file = "%s_text.png" % isbn 

420 text_file = os.path.join(text_bucket_path, text_file) 

421 image.save(text_file) 

422 return text_file 

423 

424 def get_cloud_urls(self, item, bucket_prefix): 

425 blob_names = self.gcloud.list_image_blob_names(bucket_prefix) 

426 # FIXME: This should happen in Item object at time of instantiation. 

427 if not item.isbn and "TBCODE" in item.data: 427 ↛ 428line 427 didn't jump to line 428, because the condition on line 427 was never true

428 item.isbn = item.data["TBCODE"] 

429 image_list = [blob for blob in blob_names if item.isbn in blob] 

430 sl = sorted(image_list) 

431 # generate URLs for item images on google cloud storage 

432 url_list = [] 

433 for name in sl: 433 ↛ 434line 433 didn't jump to line 434, because the loop on line 433 never started

434 url = self.gcloud.generate_cloud_signed_url(name) 

435 url_list.append(url) 

436 

437 return url_list 

438 

439 def get_text_bucket_prefix(self, bucket_prefix): 

440 # hack a text_bucket_prefix value 

441 text_bucket_prefix = bucket_prefix.replace("images", "text") 

442 if text_bucket_prefix == bucket_prefix: 442 ↛ 444line 442 didn't jump to line 444, because the condition on line 442 was never false

443 text_bucket_prefix = bucket_prefix + "_text" 

444 return text_bucket_prefix 

445 

446 #################################################################################### 

447 def generate(self, items, bucket_prefix, deck_title=None): # noqa: PLR0915 

448 namespace = f"{type(self).__name__}.{self.generate.__name__}" 

449 slide_max_batch = CFG["asg"]["slide_generator"]["slide_max_batch"] 

450 text_bucket_prefix = self.get_text_bucket_prefix(bucket_prefix) 

451 text_bucket_path = os.path.join(artemis_sg.data_dir, text_bucket_prefix) 

452 if not os.path.isdir(text_bucket_path): 452 ↛ 453line 452 didn't jump to line 453, because the condition on line 452 was never true

453 os.mkdir(text_bucket_path) 

454 

455 logging.info(f"{namespace}: Create new slide deck") 

456 utc_dt = datetime.datetime.now(datetime.timezone.utc) 

457 local_time = utc_dt.astimezone().isoformat() 

458 title = f"{self.vendor.vendor_name} Artemis Slides {local_time}" 

459 data = {"title": title} 

460 rsp = self.slides.presentations().create(body=data).execute() 

461 self.slides_api_call_count += 1 

462 deck_id = rsp["presentationId"] 

463 

464 title_slide = rsp["slides"][0] 

465 title_slide_id = title_slide["objectId"] 

466 title_id = title_slide["pageElements"][0]["objectId"] 

467 subtitle_id = title_slide["pageElements"][1]["objectId"] 

468 

469 reqs = [] 

470 logging.info(f"{namespace}: req Insert slide deck title+subtitle") 

471 subtitle = self.vendor.vendor_name 

472 if deck_title: 472 ↛ 474line 472 didn't jump to line 474

473 subtitle = f"{subtitle}, {deck_title}" 

474 title_card_text = { 

475 title_id: "Artemis Book Sales Presents...", 

476 subtitle_id: subtitle, 

477 } 

478 reqs += self.get_req_insert_text(title_card_text) 

479 reqs += self.get_req_text_field_fontsize(title_id, 40) 

480 reqs += self.get_req_text_field_color( 

481 title_id, 

482 self.color_to_rgbcolor(CFG["asg"]["slide_generator"]["text_color"])) 

483 reqs += self.get_req_text_field_color( 

484 subtitle_id, 

485 self.color_to_rgbcolor(CFG["asg"]["slide_generator"]["text_color"])) 

486 reqs += self.get_req_slide_bg_color( 

487 title_slide_id, 

488 self.color_to_rgbcolor(CFG["asg"]["slide_generator"]["bg_color"])) 

489 reqs += self.get_req_create_logo(title_slide_id) 

490 

491 # find images and delete books entries without images 

492 for item in items: 

493 item.image_urls = self.get_cloud_urls(item, bucket_prefix) 

494 

495 # update title slide 

496 self.slide_batch_update(deck_id, reqs) 

497 # clear reqs 

498 reqs = [] 

499 # create book slides 

500 items_with_images = items.get_items_with_image_urls() 

501 book_slide_id_list = self.create_book_slides_via_batch_update( 

502 deck_id, items_with_images 

503 ) 

504 

505 e_books = list(zip(book_slide_id_list, items_with_images)) 

506 batches = math.ceil(len(e_books) / slide_max_batch) 

507 upper_index = len(e_books) 

508 offset = 0 

509 for _b in range(batches): 509 ↛ 510line 509 didn't jump to line 510, because the loop on line 509 never started

510 upper = offset + slide_max_batch 

511 if upper > upper_index: 

512 upper = upper_index 

513 for book_slide_id, book in e_books[offset:upper]: 

514 reqs = self.get_req_update_artemis_slide( 

515 deck_id, book_slide_id, book, text_bucket_path, reqs 

516 ) 

517 logging.info(f"{namespace}: Execute img/text update reqs") 

518 # pp.pprint(reqs) 

519 # exit() 

520 self.slide_batch_update(deck_id, reqs) 

521 reqs = [] 

522 offset = offset + slide_max_batch 

523 

524 logging.info(f"{namespace}: Slide deck completed") 

525 logging.info(f"{namespace}: API call counts") 

526 link = f"https://docs.google.com/presentation/d/{deck_id}" 

527 logging.info(f"{namespace}: Slide deck link: {link}") 

528 return link 

529 

530 def get_main_image_size(self, image_count): 

531 w = ( 

532 (CFG["asg"]["slide_generator"]["slide_w"] / 2) 

533 - (CFG["asg"]["slide_generator"]["gutter"] * 2) 

534 ) 

535 h = ( 

536 CFG["asg"]["slide_generator"]["slide_h"] 

537 - (CFG["asg"]["slide_generator"]["gutter"] * 2) 

538 ) 

539 if image_count > 1: 539 ↛ 540line 539 didn't jump to line 540

540 h = ( 

541 CFG["asg"]["slide_generator"]["slide_h"] 

542 - (CFG["asg"]["slide_generator"]["gutter"] * 3) 

543 - (CFG["asg"]["slide_generator"]["addl_img_h"]) 

544 ) 

545 return (w, h) 

546 

547 def get_text_box_size_lines(self, image_count): 

548 w = ( 

549 (CFG["asg"]["slide_generator"]["slide_w"] / 2) 

550 - (CFG["asg"]["slide_generator"]["gutter"] * 2) 

551 ) 

552 h = ( 

553 CFG["asg"]["slide_generator"]["slide_h"] 

554 - (CFG["asg"]["slide_generator"]["gutter"] * 2) 

555 ) 

556 max_lines = CFG["asg"]["slide_generator"]["text_box_max_lines"] 

557 if ( 557 ↛ 561line 557 didn't jump to line 561

558 image_count 

559 > CFG["asg"]["slide_generator"]["text_box_resize_img_threshold"] 

560 ): 

561 h = ( 

562 CFG["asg"]["slide_generator"]["slide_h"] 

563 - (CFG["asg"]["slide_generator"]["gutter"] * 2) 

564 - (CFG["asg"]["slide_generator"]["addl_img_h"]) 

565 ) 

566 max_lines = CFG["asg"]["slide_generator"]["text_box_resized_max_lines"] 

567 return (w, h), max_lines 

568 

569 ########################################################################### 

570 

571 

572def main(vendor_code, sheet_id, worksheet, scraped_items_db, title): 

573 # namespace = "slide_generator.main" 

574 from googleapiclient.discovery import build 

575 

576 from artemis_sg.app_creds import app_creds 

577 from artemis_sg.gcloud import GCloud 

578 from artemis_sg.items import Items 

579 from artemis_sg.vendor import Vendor 

580 

581 # vendor object 

582 vendr = Vendor(vendor_code) 

583 vendr.set_vendor_data() 

584 

585 # Slides API object 

586 creds = app_creds() 

587 slides = build("slides", "v1", credentials=creds) 

588 

589 # GCloud object 

590 bucket_name = CFG["google"]["cloud"]["bucket"] 

591 cloud_key_file = CFG["google"]["cloud"]["key_file"] 

592 gcloud = GCloud(cloud_key_file=cloud_key_file, bucket_name=bucket_name) 

593 

594 sheet_data = spreadsheet.get_sheet_data(sheet_id, worksheet) 

595 

596 sheet_keys = sheet_data.pop(0) 

597 items_obj = Items(sheet_keys, sheet_data, vendr.isbn_key) 

598 items_obj.load_scraped_data(scraped_items_db) 

599 

600 sg = SlideGenerator(slides, gcloud, vendr) 

601 

602 bucket_prefix = CFG["google"]["cloud"]["bucket_prefix"] 

603 slide_deck = sg.generate(items_obj, bucket_prefix, title) 

604 deck_text = Text(f"Slide deck: {slide_deck}") 

605 deck_text.stylize("green") 

606 console.print(deck_text)