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

1 import datetime

2 import logging

3 import math

4 import os

5 import textwrap

6

7 from PIL import Image, ImageColor, ImageDraw, ImageFont

8 from rich.console import Console

9 from rich.text import Text

10

11 import artemis_sg

12 from artemis_sg import spreadsheet

13 from artemis_sg.config import CFG

14

15 console = Console()

16

17

18 class 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

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