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

1 import datetime

2 import logging

3 import math

4 import os

5 import textwrap

6 from types import MappingProxyType

7

8 from PIL import Image, ImageDraw, ImageFont

9 from rich.console import Console

10 from rich.text import Text

11

12 import artemis_sg

13 from artemis_sg import spreadsheet

14 from artemis_sg.config import CFG

15

16 console = Console()

17

18

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

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