Coverage for src/artemis_sg/slide_generator.py: 80%
288 statements
« prev ^ index » next coverage.py v7.3.1, created at 2024-03-06 08:01 -0800
« prev ^ index » next coverage.py v7.3.1, created at 2024-03-06 08:01 -0800
1import datetime
2import logging
3import math
4import os
5import textwrap
7from PIL import Image, ImageColor, ImageDraw, ImageFont
8from rich.console import Console
9from rich.text import Text
11import artemis_sg
12from artemis_sg import spreadsheet
13from artemis_sg.config import CFG
15console = Console()
18class SlideGenerator:
19 # constants
20 EMU_INCH = 914400
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
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 }
38 def gj_binding_map(self, code):
39 code = code.upper()
40 return CFG["asg"]["slide_generator"]["gj_binding_map"].get(code, code)
42 def gj_type_map(self, code):
43 code = code.upper()
44 return CFG["asg"]["slide_generator"]["gj_type_map"].get(code, code)
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 )
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)
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))
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 )
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
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 )
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)
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 )
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 )
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"]))
136 logging.info(f"{namespace}: logo image on book slide")
137 g_reqs += self.get_req_create_logo(book_slide_id)
139 return g_reqs
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
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 )
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
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 )
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 )
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
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 )
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
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
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
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
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
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)
342 def create_slide_text(self, item, max_lines):
343 namespace = f"{type(self).__name__}.{self.create_slide_text.__name__}"
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
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__}"
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 )
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)
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)
406 fontsize -= 1
407 logging.info(f"{namespace}: Font size is '{fontsize}'")
408 font = ImageFont.truetype(typeface, fontsize)
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)
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
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)
437 return url_list
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
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)
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"]
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"]
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)
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)
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 )
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
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
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)
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
569 ###########################################################################
572def main(vendor_code, sheet_id, worksheet, scraped_items_db, title):
573 # namespace = "slide_generator.main"
574 from googleapiclient.discovery import build
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
581 # vendor object
582 vendr = Vendor(vendor_code)
583 vendr.set_vendor_data()
585 # Slides API object
586 creds = app_creds()
587 slides = build("slides", "v1", credentials=creds)
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)
594 sheet_data = spreadsheet.get_sheet_data(sheet_id, worksheet)
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)
600 sg = SlideGenerator(slides, gcloud, vendr)
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)