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

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

2

3 import datetime

4 import logging

5 import math

6 import os

7 import textwrap

8

9 from PIL import Image, ImageDraw, ImageFont

10

11 import artemis_sg

12 import artemis_sg.spreadsheet as spreadsheet

13 from artemis_sg.config import CFG

14

15

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

543 def 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}")