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
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-05 18:00 -0700
1# -*- coding: utf-8 -*-
3import datetime
4import logging
5import math
6import os
7import textwrap
9from PIL import Image, ImageDraw, ImageFont
11import artemis_sg
12import artemis_sg.spreadsheet as spreadsheet
13from artemis_sg.config import CFG
16class SlideGenerator:
17 # constants
18 LINE_SPACING = 1
19 TEXT_WIDTH = 80
20 MAX_FONTSIZE = 18
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
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 ]
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
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)
68 def gj_type_map(self, code):
69 code = code.upper()
70 return {"R": "Remainder", "H": "Return"}.get(code, code)
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)
81 logging.info(f"{namespace}: background to black")
82 g_reqs += self.get_req_slide_bg_color(bookSlideID, self.BLACK)
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 )
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
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 )
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)
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 )
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 )
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)
148 logging.info(f"{namespace}: logo image on book slide")
149 g_reqs += self.get_req_create_logo(bookSlideID)
151 return g_reqs
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
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 )
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
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 )
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
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 )
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
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
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
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
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
335 def create_slide_text(self, item, max_lines):
336 namespace = f"{type(self).__name__}.{self.create_slide_text.__name__}"
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
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__}"
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"
384 image = Image.new(
385 "RGB",
386 (int(w * self.SLIDE_PPI), int(h * self.SLIDE_PPI)),
387 (0, 0, 0),
388 )
390 fontsize = 1
391 font = ImageFont.truetype(typeface, fontsize)
392 draw = ImageDraw.Draw(image)
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)
409 fontsize -= 1
410 logging.info(f"{namespace}: Font size is '{fontsize}'")
411 font = ImageFont.truetype(typeface, fontsize)
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)
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
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)
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"]
448 titleSlide = rsp["slides"][0]
449 titleSlideID = titleSlide["objectId"]
450 titleID = titleSlide["pageElements"][0]["objectId"]
451 subtitleID = titleSlide["pageElements"][1]["objectId"]
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)
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)
484 item.image_urls = url_list
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 )
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
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
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)
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
540 ########################################################################################################
543def main(vendor_code, sheet_id, worksheet, scraped_items_db, title):
544 # namespace = "slide_generator.main"
545 from googleapiclient.discovery import build
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
552 # vendor object
553 vendr = Vendor(vendor_code)
554 vendr.set_vendor_data()
556 # Slides API object
557 creds = app_creds()
558 SLIDES = build("slides", "v1", credentials=creds)
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)
565 sheet_data = spreadsheet.get_sheet_data(sheet_id, worksheet)
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)
571 sg = SlideGenerator(SLIDES, gcloud, vendr)
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}")