Coverage for src/artemis_sg/slide_generator.py: 81%
287 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-10-12 06:30 -0700
« prev ^ index » next coverage.py v7.3.1, created at 2023-10-12 06:30 -0700
1import datetime
2import logging
3import math
4import os
5import textwrap
6from types import MappingProxyType
8from PIL import Image, ImageDraw, ImageFont
9from rich.console import Console
10from rich.text import Text
12import artemis_sg
13from artemis_sg import spreadsheet
14from artemis_sg.config import CFG
16console = Console()
19class SlideGenerator:
20 # constants
21 # TODO: (#163) move all of these to CFG
22 LINE_SPACING = 1
23 TEXT_WIDTH = 80
24 MAX_FONTSIZE = 18
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"
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 )
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
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)
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)
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)
90 logging.info(f"{namespace}: background to black")
91 g_reqs += self.get_req_slide_bg_color(book_slide_id, dict(self.BLACK))
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 )
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
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 )
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)
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 )
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 )
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))
158 logging.info(f"{namespace}: logo image on book slide")
159 g_reqs += self.get_req_create_logo(book_slide_id)
161 return g_reqs
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
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 )
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
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 )
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 )
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
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 )
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
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
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
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
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
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)
368 def create_slide_text(self, item, max_lines):
369 namespace = f"{type(self).__name__}.{self.create_slide_text.__name__}"
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
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__}"
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 )
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)
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)
429 fontsize -= 1
430 logging.info(f"{namespace}: Font size is '{fontsize}'")
431 font = ImageFont.truetype(typeface, fontsize)
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)
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
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)
460 return url_list
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
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)
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"]
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"]
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)
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)
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 )
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
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
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)
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
564 ###########################################################################
567def main(vendor_code, sheet_id, worksheet, scraped_items_db, title):
568 # namespace = "slide_generator.main"
569 from googleapiclient.discovery import build
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
576 # vendor object
577 vendr = Vendor(vendor_code)
578 vendr.set_vendor_data()
580 # Slides API object
581 creds = app_creds()
582 slides = build("slides", "v1", credentials=creds)
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)
589 sheet_data = spreadsheet.get_sheet_data(sheet_id, worksheet)
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)
595 sg = SlideGenerator(slides, gcloud, vendr)
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)