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

287 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2023-10-12 17:31 -0700

1import datetime 

2import logging 

3import math 

4import os 

5import textwrap 

6from types import MappingProxyType 

7 

8from PIL import Image, ImageDraw, ImageFont 

9from rich.console import Console 

10from rich.text import Text 

11 

12import artemis_sg 

13from artemis_sg import spreadsheet 

14from artemis_sg.config import CFG 

15 

16console = Console() 

17 

18 

19class SlideGenerator: 

20 # constants 

21 # TODO: (#163) move all of these to CFG 

22 LINE_SPACING = 1 

23 TEXT_WIDTH = 80 

24 MAX_FONTSIZE = 18 

25 

26 SLIDE_MAX_BATCH = 100 

27 SLIDE_PPI = 96 

28 SLIDE_W = 10.0 

29 SLIDE_H = 5.625 

30 GUTTER = 0.375 

31 TEXT_BOX_RESIZE_IMG_THRESHHOLD = 2 

32 LOGO_H = 1 

33 LOGO_W = 1 

34 ADDL_IMG_H = 1.5 

35 ADDL_IMG_W = 3 

36 # make color dictionaries immutable 

37 BLACK = MappingProxyType({"red": 0.0, "green": 0.0, "blue": 0.0}) 

38 WHITE = MappingProxyType({"red": 1.0, "green": 1.0, "blue": 1.0}) 

39 EMU_INCH = 914400 

40 LOGO_URL = "https://images.squarespace-cdn.com/content/v1/6110970ca45ca157a1e98b76/e4ea0607-01c0-40e0-a7c0-b56563b67bef/artemis.png?format=1500w" 

41 

42 # TODO: (#163) move these to CFG 

43 BLACKLIST_KEYS = ( 

44 "IMAGE", 

45 "ON HAND", 

46 "ORDER QTY", 

47 "GJB SUGGESTED", 

48 "DATE RECEIVED", 

49 "SUBJECT", 

50 "QTYINSTOCK", 

51 "SALESPRICE", 

52 "AVAILABLE START DATE", 

53 "CATEGORY", 

54 "LINK", 

55 ) 

56 

57 # methods 

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

59 self.slides = slides 

60 self.gcloud = gcloud 

61 self.vendor = vendor 

62 self.slides_api_call_count = 0 

63 

64 ########################################################################### 

65 def gj_binding_map(self, code): 

66 code = code.upper() 

67 # TODO: (#163) move these to CFG 

68 return { 

69 "P": "Paperback", 

70 "H": "Hardcover", 

71 "C": "Hardcover", 

72 "C NDJ": "Cloth, no dust jacket", 

73 "CD": "CD", 

74 }.get(code, code) 

75 

76 def gj_type_map(self, code): 

77 code = code.upper() 

78 # TODO: (#163) move these to CFG 

79 return {"R": "Remainder", "H": "Return"}.get(code, code) 

80 

81 def get_req_update_artemis_slide( 

82 self, deck_id, book_slide_id, item, text_bucket_path, g_reqs 

83 ): 

84 namespace = ( 

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

86 ) 

87 image_count = len(item.image_urls) 

88 main_dim = self.get_main_image_size(image_count) 

89 

90 logging.info(f"{namespace}: background to black") 

91 g_reqs += self.get_req_slide_bg_color(book_slide_id, dict(self.BLACK)) 

92 

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

94 cover_url = item.image_urls.pop() 

95 g_reqs += self.get_req_create_image( 

96 book_slide_id, 

97 cover_url, 

98 main_dim, 

99 (self.GUTTER, self.GUTTER), 

100 ) 

101 

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

103 if i > self.TEXT_BOX_RESIZE_IMG_THRESHHOLD: 

104 continue 

105 

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

107 g_reqs += self.get_req_create_image( 

108 book_slide_id, 

109 url, 

110 (self.ADDL_IMG_W, self.ADDL_IMG_H), 

111 ( 

112 (self.GUTTER + ((self.ADDL_IMG_W + self.GUTTER) * i)), 

113 (self.SLIDE_H - self.GUTTER - self.ADDL_IMG_H), 

114 ), 

115 ) 

116 

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

118 text_box_dim, max_lines = self.get_text_box_size_lines(image_count) 

119 big_text = self.create_slide_text(item, max_lines) 

120 

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

122 text_filepath = self.create_text_image_file( 

123 item.isbn, text_bucket_path, big_text, text_box_dim 

124 ) 

125 

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

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

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

129 blob_name = car_prefix + "/" + car_file 

130 self.gcloud.upload_cloud_blob(text_filepath, blob_name) 

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

132 os.remove(text_filepath) 

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

134 url = self.gcloud.generate_cloud_signed_url(blob_name) 

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

136 g_reqs += self.get_req_create_image( 

137 book_slide_id, url, text_box_dim, (self.SLIDE_W / 2, self.GUTTER) 

138 ) 

139 

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

141 text_box_w = self.SLIDE_W 

142 text_box_h = self.GUTTER 

143 text_fields = self.create_text_fields_via_batch_update( 

144 deck_id, 

145 self.get_req_create_text_box( 

146 book_slide_id, 

147 (self.SLIDE_W - 1.0, self.SLIDE_H - self.GUTTER), 

148 (text_box_w, text_box_h), 

149 ), 

150 ) # FIXME: remove magic number 

151 text_field_id = text_fields[0] 

152 text_d = {text_field_id: item.isbn} 

153 g_reqs += self.get_req_insert_text(text_d) 

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

155 g_reqs += self.get_req_text_field_fontsize(text_field_id, 6) 

156 g_reqs += self.get_req_text_field_color(text_field_id, dict(self.WHITE)) 

157 

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

159 g_reqs += self.get_req_create_logo(book_slide_id) 

160 

161 return g_reqs 

162 

163 def create_text_fields_via_batch_update(self, deck_id, reqs): 

164 text_object_id_list = [] 

165 rsp = self.slide_batch_update_get_replies(deck_id, reqs) 

166 for obj in rsp: 

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

168 return text_object_id_list 

169 

170 def create_book_slides_via_batch_update(self, deck_id, book_list): 

171 namespace = ( 

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

173 ) 

174 

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

176 book_slide_id_list = [] 

177 reqs = [] 

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

179 reqs += [ 

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

181 ] 

182 rsp = self.slide_batch_update_get_replies(deck_id, reqs) 

183 for i in rsp: 

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

185 return book_slide_id_list 

186 

187 def slide_batch_update(self, deck_id, reqs): 

188 return ( 

189 self.slides.presentations() 

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

191 .execute() 

192 ) 

193 

194 def slide_batch_update_get_replies(self, deck_id, reqs): 

195 return ( 

196 self.slides.presentations() 

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

198 .execute() 

199 .get("replies") 

200 ) 

201 

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

203 w, h = size 

204 translate_x, translate_y = translate 

205 reqs = [ 

206 { 

207 "createImage": { 

208 "elementProperties": { 

209 "pageObjectId": slide_id, 

210 "size": { 

211 "width": { 

212 "magnitude": self.EMU_INCH * w, 

213 "unit": "EMU", 

214 }, 

215 "height": { 

216 "magnitude": self.EMU_INCH * h, 

217 "unit": "EMU", 

218 }, 

219 }, 

220 "transform": { 

221 "scaleX": 1, 

222 "scaleY": 1, 

223 "translateX": self.EMU_INCH * translate_x, 

224 "translateY": self.EMU_INCH * translate_y, 

225 "unit": "EMU", 

226 }, 

227 }, 

228 "url": url, 

229 }, 

230 } 

231 ] 

232 return reqs 

233 

234 def get_req_create_logo(self, slide_id): 

235 # Place logo in upper right corner of slide 

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

237 translate_x = self.SLIDE_W - self.LOGO_W 

238 translate_y = 0 

239 return self.get_req_create_image( 

240 slide_id, 

241 self.LOGO_URL, 

242 (self.LOGO_W, self.LOGO_H), 

243 (translate_x, translate_y), 

244 ) 

245 

246 def get_req_slide_bg_color(self, slide_id, rgb_d): 

247 reqs = [ 

248 { 

249 "updatePageProperties": { 

250 "objectId": slide_id, 

251 "fields": "pageBackgroundFill", 

252 "pageProperties": { 

253 "pageBackgroundFill": { 

254 "solidFill": { 

255 "color": { 

256 "rgbColor": rgb_d, 

257 } 

258 } 

259 } 

260 }, 

261 }, 

262 }, 

263 ] 

264 return reqs 

265 

266 def get_req_text_field_color(self, field_id, rgb_d): 

267 reqs = [ 

268 { 

269 "updateTextStyle": { 

270 "objectId": field_id, 

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

272 "style": { 

273 "foregroundColor": { 

274 "opaqueColor": { 

275 "rgbColor": rgb_d, 

276 } 

277 } 

278 }, 

279 "fields": "foregroundColor", 

280 } 

281 } 

282 ] 

283 return reqs 

284 

285 def get_req_text_field_fontsize(self, field_id, pt_size): 

286 reqs = [ 

287 { 

288 "updateTextStyle": { 

289 "objectId": field_id, 

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

291 "style": { 

292 "fontSize": { 

293 "magnitude": pt_size, 

294 "unit": "PT", 

295 } 

296 }, 

297 "fields": "fontSize", 

298 } 

299 }, 

300 ] 

301 return reqs 

302 

303 def get_req_insert_text(self, text_dict): 

304 reqs = [] 

305 for key in text_dict: 

306 reqs.append( 

307 { 

308 "insertText": { 

309 "objectId": key, 

310 "text": text_dict[key], 

311 }, 

312 } 

313 ) 

314 return reqs 

315 

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

317 reqs = [ 

318 { 

319 "createShape": { 

320 "elementProperties": { 

321 "pageObjectId": slide_id, 

322 "size": { 

323 "width": { 

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

325 "unit": "EMU", 

326 }, 

327 "height": { 

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

329 "unit": "EMU", 

330 }, 

331 }, 

332 "transform": { 

333 "scaleX": 1, 

334 "scaleY": 1, 

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

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

337 "unit": "EMU", 

338 }, 

339 }, 

340 "shapeType": "TEXT_BOX", 

341 }, 

342 } 

343 ] 

344 return reqs 

345 

346 def get_slide_text_key_map(self, key, item): 

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

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

349 mapping_d = { 

350 "AUTHOR": "by " + t, 

351 "PUB LIST": "List price: " + t, 

352 "LISTPRICE": "List price: " + t, 

353 "NET COST": "Your net price: " + t, 

354 "YOUR NET PRICE": "Your net price: " + t, 

355 "PUB DATE": "Pub Date: " + t, 

356 "PUBLISHERDATE": "Pub Date: " + t, 

357 "BINDING": "Format: " + self.gj_binding_map(t), 

358 "FORMAT": "Format: " + t, 

359 "TYPE": "Type: " + self.gj_type_map(t), 

360 "PAGES": "Pages: " + t + " pp.", 

361 "SIZE": "Size: " + t, 

362 "ITEM#": "Item #: " + t, 

363 "TBCODE": "Item #: " + t, 

364 item.isbn_key: "ISBN: " + t, 

365 } 

366 return mapping_d.get(key, t) 

367 

368 def create_slide_text(self, item, max_lines): 

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

370 

371 big_text = "" 

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

373 for k in item.data: 

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

375 if key in self.BLACKLIST_KEYS: 

376 continue 

377 t = self.get_slide_text_key_map(key, item) 

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

379 t = textwrap.fill( 

380 t, width=self.TEXT_WIDTH, max_lines=max_lines - line_count 

381 ) 

382 t = t + "\n\n" 

383 big_text += t 

384 return big_text 

385 

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

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

388 

389 w, h = size 

390 image = Image.new( 

391 "RGB", 

392 (int(w * self.SLIDE_PPI), int(h * self.SLIDE_PPI)), 

393 # FIXME: This should use BLACK (or BACKGROUND) 

394 (0, 0, 0), 

395 ) 

396 

397 fontsize = 1 

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

399 "arial.ttf", 

400 "LiberationSans-Regular.ttf", 

401 "DejaVuSans.ttf", 

402 ): 

403 try: 

404 font = ImageFont.truetype(typeface, fontsize) 

405 break 

406 except OSError: 

407 font = None 

408 continue 

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

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

411 return None 

412 draw = ImageDraw.Draw(image) 

413 

414 # dynamically size text to fit box 

415 while ( 

416 draw.multiline_textbbox( 

417 xy=(0, 0), text=text, font=font, spacing=self.LINE_SPACING 

418 )[2] 

419 < image.size[0] 

420 and draw.multiline_textbbox( 

421 xy=(0, 0), text=text, font=font, spacing=self.LINE_SPACING 

422 )[3] 

423 < image.size[1] 

424 and fontsize < self.MAX_FONTSIZE 

425 ): 

426 fontsize += 1 

427 font = ImageFont.truetype(typeface, fontsize) 

428 

429 fontsize -= 1 

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

431 font = ImageFont.truetype(typeface, fontsize) 

432 

433 # center text 

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

435 xy=(0, 0), text=text, font=font, spacing=self.LINE_SPACING 

436 ) 

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

438 

439 draw.multiline_text( 

440 (0, y_offset), text, font=font, spacing=self.LINE_SPACING 

441 ) # put the text on the image 

442 text_file = "%s_text.png" % isbn 

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

444 image.save(text_file) 

445 return text_file 

446 

447 def get_cloud_urls(self, item, bucket_prefix): 

448 blob_names = self.gcloud.list_image_blobs(bucket_prefix) 

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

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

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

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

453 sl = sorted(image_list) 

454 # generate URLs for item images on google cloud storage 

455 url_list = [] 

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

457 url = self.gcloud.generate_cloud_signed_url(name) 

458 url_list.append(url) 

459 

460 return url_list 

461 

462 def get_text_bucket_prefix(self, bucket_prefix): 

463 # hack a text_bucket_prefix value 

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

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

466 text_bucket_prefix = bucket_prefix + "_text" 

467 return text_bucket_prefix 

468 

469 #################################################################################### 

470 def generate(self, items, bucket_prefix, deck_title=None): 

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

472 text_bucket_prefix = self.get_text_bucket_prefix(bucket_prefix) 

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

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

475 os.mkdir(text_bucket_path) 

476 

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

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

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

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

481 data = {"title": title} 

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

483 self.slides_api_call_count += 1 

484 deck_id = rsp["presentationId"] 

485 

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

487 title_slide_id = title_slide["objectId"] 

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

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

490 

491 reqs = [] 

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

493 subtitle = self.vendor.vendor_name 

494 if deck_title: 494 ↛ 496line 494 didn't jump to line 496

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

496 title_card_text = { 

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

498 subtitle_id: subtitle, 

499 } 

500 reqs += self.get_req_insert_text(title_card_text) 

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

502 reqs += self.get_req_text_field_color(title_id, dict(self.WHITE)) 

503 reqs += self.get_req_text_field_color(subtitle_id, dict(self.WHITE)) 

504 reqs += self.get_req_slide_bg_color(title_slide_id, dict(self.BLACK)) 

505 reqs += self.get_req_create_logo(title_slide_id) 

506 

507 # find images and delete books entries without images 

508 for item in items: 

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

510 

511 # update title slide 

512 self.slide_batch_update(deck_id, reqs) 

513 # clear reqs 

514 reqs = [] 

515 # create book slides 

516 items_with_images = items.get_items_with_image_urls() 

517 book_slide_id_list = self.create_book_slides_via_batch_update( 

518 deck_id, items_with_images 

519 ) 

520 

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

522 batches = math.ceil(len(e_books) / self.SLIDE_MAX_BATCH) 

523 upper_index = len(e_books) 

524 offset = 0 

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

526 upper = offset + self.SLIDE_MAX_BATCH 

527 if upper > upper_index: 

528 upper = upper_index 

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

530 reqs = self.get_req_update_artemis_slide( 

531 deck_id, book_slide_id, book, text_bucket_path, reqs 

532 ) 

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

534 # pp.pprint(reqs) 

535 # exit() 

536 self.slide_batch_update(deck_id, reqs) 

537 reqs = [] 

538 offset = offset + self.SLIDE_MAX_BATCH 

539 

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

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

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

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

544 return link 

545 

546 def get_main_image_size(self, image_count): 

547 w = (self.SLIDE_W / 2) - (self.GUTTER * 2) 

548 h = self.SLIDE_H - (self.GUTTER * 2) 

549 if image_count > 1: 549 ↛ 550line 549 didn't jump to line 550, because the condition on line 549 was never true

550 h = self.SLIDE_H - (self.GUTTER * 3) - (self.ADDL_IMG_H) 

551 return (w, h) 

552 

553 def get_text_box_size_lines(self, image_count): 

554 w = (self.SLIDE_W / 2) - (self.GUTTER * 2) 

555 h = self.SLIDE_H - (self.GUTTER * 2) 

556 # TODO: (#163) move title default to CFG 

557 max_lines = 36 

558 if image_count > self.TEXT_BOX_RESIZE_IMG_THRESHHOLD: 558 ↛ 559line 558 didn't jump to line 559, because the condition on line 558 was never true

559 h = self.SLIDE_H - (self.GUTTER * 2) - (self.ADDL_IMG_H) 

560 # TODO: (#163) move title default to CFG 

561 max_lines = 28 

562 return (w, h), max_lines 

563 

564 ########################################################################### 

565 

566 

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

568 # namespace = "slide_generator.main" 

569 from googleapiclient.discovery import build 

570 

571 from artemis_sg.app_creds import app_creds 

572 from artemis_sg.gcloud import GCloud 

573 from artemis_sg.items import Items 

574 from artemis_sg.vendor import Vendor 

575 

576 # vendor object 

577 vendr = Vendor(vendor_code) 

578 vendr.set_vendor_data() 

579 

580 # Slides API object 

581 creds = app_creds() 

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

583 

584 # GCloud object 

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

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

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

588 

589 sheet_data = spreadsheet.get_sheet_data(sheet_id, worksheet) 

590 

591 sheet_keys = sheet_data.pop(0) 

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

593 items_obj.load_scraped_data(scraped_items_db) 

594 

595 sg = SlideGenerator(slides, gcloud, vendr) 

596 

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

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

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

600 deck_text.stylize("green") 

601 console.print(deck_text)