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

287 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-05 18:00 -0700

1# -*- coding: utf-8 -*- 

2 

3import datetime 

4import logging 

5import math 

6import os 

7import textwrap 

8 

9from PIL import Image, ImageDraw, ImageFont 

10 

11import artemis_sg 

12import artemis_sg.spreadsheet as spreadsheet 

13from artemis_sg.config import CFG 

14 

15 

16class SlideGenerator: 

17 # constants 

18 LINE_SPACING = 1 

19 TEXT_WIDTH = 80 

20 MAX_FONTSIZE = 18 

21 

22 SLIDE_MAX_BATCH = 100 

23 SLIDE_PPI = 96 

24 SLIDE_W = 10.0 

25 SLIDE_H = 5.625 

26 GUTTER = 0.375 

27 LOGO_H = 1 

28 LOGO_W = 1 

29 ADDL_IMG_H = 1.5 

30 ADDL_IMG_W = 3 

31 BLACK = {"red": 0.0, "green": 0.0, "blue": 0.0} 

32 WHITE = {"red": 1.0, "green": 1.0, "blue": 1.0} 

33 EMU_INCH = 914400 

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

35 

36 BLACKLIST_KEYS = [ 

37 "IMAGE", 

38 "ON HAND", 

39 "ORDER QTY", 

40 "GJB SUGGESTED", 

41 "DATE RECEIVED", 

42 "SUBJECT", 

43 "QTYINSTOCK", 

44 "SALESPRICE", 

45 "AVAILABLE START DATE", 

46 "CATEGORY", 

47 "LINK", 

48 ] 

49 

50 # methods 

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

52 self.slides = slides 

53 self.gcloud = gcloud 

54 self.vendor = vendor 

55 self.slides_api_call_count = 0 

56 

57 ######################################################################################################## 

58 def gj_binding_map(self, code): 

59 code = code.upper() 

60 return { 

61 "P": "Paperback", 

62 "H": "Hardcover", 

63 "C": "Hardcover", 

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

65 "CD": "CD", 

66 }.get(code, code) 

67 

68 def gj_type_map(self, code): 

69 code = code.upper() 

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

71 

72 def get_req_update_artemis_slide( 

73 self, deckID, bookSlideID, item, text_bucket_path, g_reqs 

74 ): 

75 namespace = ( 

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

77 ) 

78 image_count = len(item.image_urls) 

79 main_dim = self.get_main_image_size(image_count) 

80 

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

82 g_reqs += self.get_req_slide_bg_color(bookSlideID, self.BLACK) 

83 

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

85 cover_url = item.image_urls.pop() 

86 g_reqs += self.get_req_create_image( 

87 bookSlideID, 

88 cover_url, 

89 main_dim, 

90 (self.GUTTER, self.GUTTER), 

91 ) 

92 

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

94 if i > 2: 

95 continue 

96 

97 logging.info(f"{namespace}: {str(i + 2)} image on book slide") 

98 g_reqs += self.get_req_create_image( 

99 bookSlideID, 

100 url, 

101 (self.ADDL_IMG_W, self.ADDL_IMG_H), 

102 ( 

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

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

105 ), 

106 ) 

107 

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

109 text_box_dim, max_lines = self.get_text_box_size_lines(image_count) 

110 big_text = self.create_slide_text(item, max_lines) 

111 

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

113 text_filepath = self.create_text_image_file( 

114 item.isbn, text_bucket_path, big_text, text_box_dim 

115 ) 

116 

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

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

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

120 blob_name = car_prefix + "/" + car_file 

121 self.gcloud.upload_cloud_blob(text_filepath, blob_name) 

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

123 os.remove(text_filepath) 

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

125 url = self.gcloud.generate_cloud_signed_url(blob_name) 

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

127 g_reqs += self.get_req_create_image( 

128 bookSlideID, url, text_box_dim, (self.SLIDE_W / 2, self.GUTTER) 

129 ) 

130 

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

132 text_box_w = self.SLIDE_W 

133 text_box_h = self.GUTTER # FIXME: with a better constant 

134 text_fields = self.create_text_fields_via_batchUpdate( 

135 deckID, 

136 self.get_req_create_text_box( 

137 bookSlideID, 

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

139 (text_box_w, text_box_h), 

140 ), 

141 ) # FIXME: remove magic number 

142 textFieldID = text_fields[0] 

143 text_d = {textFieldID: item.isbn} 

144 g_reqs += self.get_req_insert_text(text_d) 

145 g_reqs += self.get_req_text_field_fontsize(textFieldID, 6) 

146 g_reqs += self.get_req_text_field_color(textFieldID, self.WHITE) 

147 

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

149 g_reqs += self.get_req_create_logo(bookSlideID) 

150 

151 return g_reqs 

152 

153 def create_text_fields_via_batchUpdate(self, deckID, reqs): 

154 textObjectIdList = [] 

155 rsp = self.slide_batchUpdate(deckID, reqs, True) 

156 for obj in rsp: 

157 textObjectIdList.append(obj["createShape"]["objectId"]) 

158 return textObjectIdList 

159 

160 def create_book_slides_via_batchUpdate(self, deckID, bookList): 

161 namespace = ( 

162 f"{type(self).__name__}.{self.create_book_slides_via_batchUpdate.__name__}" 

163 ) 

164 

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

166 bookSlideIDList = [] 

167 reqs = [] 

168 for i in range(len(bookList)): 168 ↛ 169line 168 didn't jump to line 169, because the loop on line 168 never started

169 reqs += [ 

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

171 ] 

172 rsp = self.slide_batchUpdate(deckID, reqs, True) 

173 for i in rsp: 

174 bookSlideIDList.append(i["createSlide"]["objectId"]) 

175 return bookSlideIDList 

176 

177 def slide_batchUpdate(self, deckID, reqs, get_replies=False): 

178 if get_replies: 

179 return ( 

180 self.slides.presentations() 

181 .batchUpdate(body={"requests": reqs}, presentationId=deckID) 

182 .execute() 

183 .get("replies") 

184 ) 

185 else: 

186 return ( 

187 self.slides.presentations() 

188 .batchUpdate(body={"requests": reqs}, presentationId=deckID) 

189 .execute() 

190 ) 

191 

192 def get_req_create_image(self, slideID, url, size, translate): 

193 w, h = size 

194 translate_x, translate_y = translate 

195 reqs = [ 

196 { 

197 "createImage": { 

198 "elementProperties": { 

199 "pageObjectId": slideID, 

200 "size": { 

201 "width": { 

202 "magnitude": self.EMU_INCH * w, 

203 "unit": "EMU", 

204 }, 

205 "height": { 

206 "magnitude": self.EMU_INCH * h, 

207 "unit": "EMU", 

208 }, 

209 }, 

210 "transform": { 

211 "scaleX": 1, 

212 "scaleY": 1, 

213 "translateX": self.EMU_INCH * translate_x, 

214 "translateY": self.EMU_INCH * translate_y, 

215 "unit": "EMU", 

216 }, 

217 }, 

218 "url": url, 

219 }, 

220 } 

221 ] 

222 return reqs 

223 

224 def get_req_create_logo(self, slideID): 

225 # Place logo in upper right corner of slide 

226 translate_x = self.SLIDE_W - self.LOGO_W 

227 translate_y = 0 

228 return self.get_req_create_image( 

229 slideID, 

230 self.LOGO_URL, 

231 (self.LOGO_W, self.LOGO_H), 

232 (translate_x, translate_y), 

233 ) 

234 

235 def get_req_slide_bg_color(self, slideID, rgb_d): 

236 reqs = [ 

237 { 

238 "updatePageProperties": { 

239 "objectId": slideID, 

240 "fields": "pageBackgroundFill", 

241 "pageProperties": { 

242 "pageBackgroundFill": { 

243 "solidFill": { 

244 "color": { 

245 "rgbColor": rgb_d, 

246 } 

247 } 

248 } 

249 }, 

250 }, 

251 }, 

252 ] 

253 return reqs 

254 

255 def get_req_text_field_color(self, fieldID, rgb_d): 

256 reqs = [ 

257 { 

258 "updateTextStyle": { 

259 "objectId": fieldID, 

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

261 "style": { 

262 "foregroundColor": { 

263 "opaqueColor": { 

264 "rgbColor": rgb_d, 

265 } 

266 } 

267 }, 

268 "fields": "foregroundColor", 

269 } 

270 } 

271 ] 

272 return reqs 

273 

274 def get_req_text_field_fontsize(self, fieldID, pt_size): 

275 reqs = [ 

276 { 

277 "updateTextStyle": { 

278 "objectId": fieldID, 

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

280 "style": { 

281 "fontSize": { 

282 "magnitude": pt_size, 

283 "unit": "PT", 

284 } 

285 }, 

286 "fields": "fontSize", 

287 } 

288 }, 

289 ] 

290 return reqs 

291 

292 def get_req_insert_text(self, textDict): 

293 reqs = [] 

294 for key in textDict.keys(): 

295 reqs.append( 

296 { 

297 "insertText": { 

298 "objectId": key, 

299 "text": textDict[key], 

300 }, 

301 } 

302 ) 

303 return reqs 

304 

305 def get_req_create_text_box(self, slideID, coord=(0, 0), field_size=(1, 1)): 

306 reqs = [ 

307 { 

308 "createShape": { 

309 "elementProperties": { 

310 "pageObjectId": slideID, 

311 "size": { 

312 "width": { 

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

314 "unit": "EMU", 

315 }, 

316 "height": { 

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

318 "unit": "EMU", 

319 }, 

320 }, 

321 "transform": { 

322 "scaleX": 1, 

323 "scaleY": 1, 

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

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

326 "unit": "EMU", 

327 }, 

328 }, 

329 "shapeType": "TEXT_BOX", 

330 }, 

331 } 

332 ] 

333 return reqs 

334 

335 def create_slide_text(self, item, max_lines): 

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

337 

338 big_text = "" 

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

340 for key in item.data: 

341 key = key.strip().upper() 

342 if key in self.BLACKLIST_KEYS: 

343 continue 

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

345 if key == "AUTHOR": 

346 t = "by " + t 

347 if key in ["PUB LIST", "LISTPRICE"]: 347 ↛ 348line 347 didn't jump to line 348, because the condition on line 347 was never true

348 t = "List price: " + t 

349 if key in ["NET COST", "YOUR NET PRICE"]: 349 ↛ 350line 349 didn't jump to line 350, because the condition on line 349 was never true

350 t = "Your net price: " + t 

351 if key in ["PUB DATE", "PUBLISHERDATE"]: 351 ↛ 352line 351 didn't jump to line 352, because the condition on line 351 was never true

352 t = "Pub Date: " + t 

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

354 t = "Format: " + self.gj_binding_map(t) 

355 if key == "FORMAT": 355 ↛ 356line 355 didn't jump to line 356, because the condition on line 355 was never true

356 t = "Format: " + t 

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

358 t = "Type: " + self.gj_type_map(t) 

359 if key == "PAGES": 359 ↛ 360line 359 didn't jump to line 360, because the condition on line 359 was never true

360 t = "Pages: " + t + " pp." 

361 if key == "SIZE": 361 ↛ 362line 361 didn't jump to line 362, because the condition on line 361 was never true

362 t = "Size: " + t 

363 if key in ["ITEM#", "TBCODE"]: 363 ↛ 364line 363 didn't jump to line 364, because the condition on line 363 was never true

364 t = "Item #: " + t 

365 if key == item.isbn_key: 

366 t = "ISBN: " + t 

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

368 t = textwrap.fill( 

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

370 ) 

371 t = t + "\n\n" 

372 big_text += t 

373 return big_text 

374 

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

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

377 

378 w, h = size 

379 if os.name == "posix": 379 ↛ 382line 379 didn't jump to line 382, because the condition on line 379 was never false

380 typeface = "LiberationSans-Regular.ttf" 

381 else: 

382 typeface = "arial.ttf" 

383 

384 image = Image.new( 

385 "RGB", 

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

387 (0, 0, 0), 

388 ) 

389 

390 fontsize = 1 

391 font = ImageFont.truetype(typeface, fontsize) 

392 draw = ImageDraw.Draw(image) 

393 

394 # dynamically size text to fit box 

395 while ( 

396 draw.multiline_textbbox( 

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

398 )[2] 

399 < image.size[0] 

400 and draw.multiline_textbbox( 

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

402 )[3] 

403 < image.size[1] 

404 and fontsize < self.MAX_FONTSIZE 

405 ): 

406 fontsize += 1 

407 font = ImageFont.truetype(typeface, fontsize) 

408 

409 fontsize -= 1 

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

411 font = ImageFont.truetype(typeface, fontsize) 

412 

413 # center text 

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

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

416 ) 

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

418 

419 draw.multiline_text( 

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

421 ) # put the text on the image 

422 text_file = "%s_text.png" % isbn 

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

424 image.save(text_file) 

425 return text_file 

426 

427 ######################################################################################################## 

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

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

430 # hack a text_bucket_prefix value 

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

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

433 text_bucket_prefix = bucket_prefix + "_text" 

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

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

436 os.mkdir(text_bucket_path) 

437 

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

439 title = "%s Artemis Slides %s" % ( 

440 self.vendor.vendor_name, 

441 datetime.datetime.now(), 

442 ) 

443 DATA = {"title": title} 

444 rsp = self.slides.presentations().create(body=DATA).execute() 

445 self.slides_api_call_count += 1 

446 deckID = rsp["presentationId"] 

447 

448 titleSlide = rsp["slides"][0] 

449 titleSlideID = titleSlide["objectId"] 

450 titleID = titleSlide["pageElements"][0]["objectId"] 

451 subtitleID = titleSlide["pageElements"][1]["objectId"] 

452 

453 reqs = [] 

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

455 subtitle = self.vendor.vendor_name 

456 if deck_title: 456 ↛ 458line 456 didn't jump to line 458

457 subtitle = "%s, %s" % (subtitle, deck_title) 

458 titleCardText = { 

459 titleID: "Artemis Book Sales Presents...", 

460 subtitleID: subtitle, 

461 } 

462 reqs += self.get_req_insert_text(titleCardText) 

463 reqs += self.get_req_text_field_fontsize(titleID, 40) 

464 reqs += self.get_req_text_field_color(titleID, self.WHITE) 

465 reqs += self.get_req_text_field_color(subtitleID, self.WHITE) 

466 reqs += self.get_req_slide_bg_color(titleSlideID, self.BLACK) 

467 reqs += self.get_req_create_logo(titleSlideID) 

468 

469 # find images and delete books entries without images 

470 blob_names = self.gcloud.list_image_blobs(bucket_prefix) 

471 for item in items: 

472 if not item.isbn: 472 ↛ 473line 472 didn't jump to line 473, because the condition on line 472 was never true

473 if "TBCODE" in item.data.keys(): 

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

475 imageList = [blob for blob in blob_names if item.isbn in blob] 

476 # sort image list 

477 sl = sorted(imageList) 

478 # generate URLs for book images on google cloud storage 

479 url_list = [] 

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

481 url = self.gcloud.generate_cloud_signed_url(name) 

482 url_list.append(url) 

483 

484 item.image_urls = url_list 

485 

486 # update title slide 

487 self.slide_batchUpdate(deckID, reqs) 

488 # clear reqs 

489 reqs = [] 

490 # create book slides 

491 items_with_images = items.get_items_with_image_urls() 

492 bookSlideIDList = self.create_book_slides_via_batchUpdate( 

493 deckID, items_with_images 

494 ) 

495 

496 e_books = list(zip(bookSlideIDList, items_with_images)) 

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

498 upper_index = len(e_books) 

499 offset = 0 

500 for b in range(batches): 500 ↛ 501line 500 didn't jump to line 501, because the loop on line 500 never started

501 upper = offset + self.SLIDE_MAX_BATCH 

502 if upper > upper_index: 

503 upper = upper_index 

504 for bookSlideID, book in e_books[offset:upper]: 

505 reqs = self.get_req_update_artemis_slide( 

506 deckID, bookSlideID, book, text_bucket_path, reqs 

507 ) 

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

509 # pp.pprint(reqs) 

510 # exit() 

511 self.slide_batchUpdate(deckID, reqs) 

512 reqs = [] 

513 offset = offset + self.SLIDE_MAX_BATCH 

514 

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

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

517 logging.info(" SLIDES: %r" % self.slides_api_call_count) 

518 # logging.info(" SHEETS: %r" % sheets_api_call_count) 

519 # logging.info(" CLOUD: %r" % cloud_api_call_count) 

520 link = f"https://docs.google.com/presentation/d/{deckID}" 

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

522 return link 

523 

524 def get_main_image_size(self, image_count): 

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

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

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

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

529 return (w, h) 

530 

531 def get_text_box_size_lines(self, image_count): 

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

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

534 max_lines = 36 

535 if image_count > 2: 535 ↛ 536line 535 didn't jump to line 536, because the condition on line 535 was never true

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

537 max_lines = 28 

538 return (w, h), max_lines 

539 

540 ######################################################################################################## 

541 

542 

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

544 # namespace = "slide_generator.main" 

545 from googleapiclient.discovery import build 

546 

547 from artemis_sg.app_creds import app_creds 

548 from artemis_sg.gcloud import GCloud 

549 from artemis_sg.items import Items 

550 from artemis_sg.vendor import Vendor 

551 

552 # vendor object 

553 vendr = Vendor(vendor_code) 

554 vendr.set_vendor_data() 

555 

556 # Slides API object 

557 creds = app_creds() 

558 SLIDES = build("slides", "v1", credentials=creds) 

559 

560 # GCloud object 

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

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

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

564 

565 sheet_data = spreadsheet.get_sheet_data(sheet_id, worksheet) 

566 

567 sheet_keys = sheet_data.pop(0) 

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

569 items_obj.load_scraped_data(scraped_items_db) 

570 

571 sg = SlideGenerator(SLIDES, gcloud, vendr) 

572 

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

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

575 print(f"Slide deck: {slide_deck}")