google-workspace-mcp 1.0.5__py3-none-any.whl → 1.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ from markdowndeck import create_presentation
12
12
  from google_workspace_mcp.auth import gauth
13
13
  from google_workspace_mcp.services.base import BaseGoogleService
14
14
  from google_workspace_mcp.utils.markdown_slides import MarkdownSlidesConverter
15
+ from google_workspace_mcp.utils.unit_conversion import convert_template_zones
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
@@ -37,7 +38,11 @@ class SlidesService(BaseGoogleService):
37
38
  Presentation data dictionary or error information
38
39
  """
39
40
  try:
40
- return self.service.presentations().get(presentationId=presentation_id).execute()
41
+ return (
42
+ self.service.presentations()
43
+ .get(presentationId=presentation_id)
44
+ .execute()
45
+ )
41
46
  except Exception as e:
42
47
  return self.handle_api_error("get_presentation", e)
43
48
 
@@ -57,7 +62,9 @@ class SlidesService(BaseGoogleService):
57
62
  except Exception as e:
58
63
  return self.handle_api_error("create_presentation", e)
59
64
 
60
- def create_slide(self, presentation_id: str, layout: str = "TITLE_AND_BODY") -> dict[str, Any]:
65
+ def create_slide(
66
+ self, presentation_id: str, layout: str = "TITLE_AND_BODY"
67
+ ) -> dict[str, Any]:
61
68
  """
62
69
  Add a new slide to an existing presentation.
63
70
 
@@ -80,11 +87,17 @@ class SlidesService(BaseGoogleService):
80
87
  }
81
88
  ]
82
89
 
83
- logger.info(f"Sending API request to create slide: {json.dumps(requests[0], indent=2)}")
90
+ logger.info(
91
+ f"Sending API request to create slide: {json.dumps(requests[0], indent=2)}"
92
+ )
84
93
 
85
94
  # Execute the request
86
95
  response = (
87
- self.service.presentations().batchUpdate(presentationId=presentation_id, body={"requests": requests}).execute()
96
+ self.service.presentations()
97
+ .batchUpdate(
98
+ presentationId=presentation_id, body={"requests": requests}
99
+ )
100
+ .execute()
88
101
  )
89
102
 
90
103
  logger.info(f"API response: {json.dumps(response, indent=2)}")
@@ -161,12 +174,20 @@ class SlidesService(BaseGoogleService):
161
174
  },
162
175
  ]
163
176
 
164
- logger.info(f"Sending API request to create shape: {json.dumps(requests[0], indent=2)}")
165
- logger.info(f"Sending API request to insert text: {json.dumps(requests[1], indent=2)}")
177
+ logger.info(
178
+ f"Sending API request to create shape: {json.dumps(requests[0], indent=2)}"
179
+ )
180
+ logger.info(
181
+ f"Sending API request to insert text: {json.dumps(requests[1], indent=2)}"
182
+ )
166
183
 
167
184
  # Execute the request
168
185
  response = (
169
- self.service.presentations().batchUpdate(presentationId=presentation_id, body={"requests": requests}).execute()
186
+ self.service.presentations()
187
+ .batchUpdate(
188
+ presentationId=presentation_id, body={"requests": requests}
189
+ )
190
+ .execute()
170
191
  )
171
192
 
172
193
  logger.info(f"API response: {json.dumps(response, indent=2)}")
@@ -205,7 +226,9 @@ class SlidesService(BaseGoogleService):
205
226
  Response data or error information
206
227
  """
207
228
  try:
208
- logger.info(f"Adding formatted text to slide {slide_id}, position={position}, size={size}")
229
+ logger.info(
230
+ f"Adding formatted text to slide {slide_id}, position={position}, size={size}"
231
+ )
209
232
  logger.info(f"Text content: '{formatted_text[:100]}...'")
210
233
  logger.info(
211
234
  f"Checking for formatting: bold={'**' in formatted_text}, italic={'*' in formatted_text}, code={'`' in formatted_text}"
@@ -240,17 +263,23 @@ class SlidesService(BaseGoogleService):
240
263
  ]
241
264
 
242
265
  # Log the shape creation request
243
- logger.info(f"Sending API request to create shape: {json.dumps(create_requests[0], indent=2)}")
266
+ logger.info(
267
+ f"Sending API request to create shape: {json.dumps(create_requests[0], indent=2)}"
268
+ )
244
269
 
245
270
  # Execute creation request
246
271
  creation_response = (
247
272
  self.service.presentations()
248
- .batchUpdate(presentationId=presentation_id, body={"requests": create_requests})
273
+ .batchUpdate(
274
+ presentationId=presentation_id, body={"requests": create_requests}
275
+ )
249
276
  .execute()
250
277
  )
251
278
 
252
279
  # Log the response
253
- logger.info(f"API response for shape creation: {json.dumps(creation_response, indent=2)}")
280
+ logger.info(
281
+ f"API response for shape creation: {json.dumps(creation_response, indent=2)}"
282
+ )
254
283
 
255
284
  # Process the formatted text
256
285
  # First, remove formatting markers to get plain text
@@ -274,7 +303,9 @@ class SlidesService(BaseGoogleService):
274
303
  ]
275
304
 
276
305
  # Log the text insertion request
277
- logger.info(f"Sending API request to insert plain text: {json.dumps(text_request[0], indent=2)}")
306
+ logger.info(
307
+ f"Sending API request to insert plain text: {json.dumps(text_request[0], indent=2)}"
308
+ )
278
309
 
279
310
  # Execute text insertion
280
311
  text_response = (
@@ -287,7 +318,9 @@ class SlidesService(BaseGoogleService):
287
318
  )
288
319
 
289
320
  # Log the response
290
- logger.info(f"API response for plain text insertion: {json.dumps(text_response, indent=2)}")
321
+ logger.info(
322
+ f"API response for plain text insertion: {json.dumps(text_response, indent=2)}"
323
+ )
291
324
 
292
325
  # Now generate style requests if there's formatting to apply
293
326
  if "**" in formatted_text or "*" in formatted_text:
@@ -364,9 +397,13 @@ class SlidesService(BaseGoogleService):
364
397
  if style_requests:
365
398
  try:
366
399
  # Log the style requests
367
- logger.info(f"Sending API request to apply text styling with {len(style_requests)} style requests")
400
+ logger.info(
401
+ f"Sending API request to apply text styling with {len(style_requests)} style requests"
402
+ )
368
403
  for i, req in enumerate(style_requests):
369
- logger.info(f"Style request {i + 1}: {json.dumps(req, indent=2)}")
404
+ logger.info(
405
+ f"Style request {i + 1}: {json.dumps(req, indent=2)}"
406
+ )
370
407
 
371
408
  # Execute style requests
372
409
  style_response = (
@@ -379,9 +416,13 @@ class SlidesService(BaseGoogleService):
379
416
  )
380
417
 
381
418
  # Log the response
382
- logger.info(f"API response for text styling: {json.dumps(style_response, indent=2)}")
419
+ logger.info(
420
+ f"API response for text styling: {json.dumps(style_response, indent=2)}"
421
+ )
383
422
  except Exception as style_error:
384
- logger.warning(f"Failed to apply text styles: {str(style_error)}")
423
+ logger.warning(
424
+ f"Failed to apply text styles: {str(style_error)}"
425
+ )
385
426
  logger.exception("Style application error details")
386
427
 
387
428
  return {
@@ -440,7 +481,9 @@ class SlidesService(BaseGoogleService):
440
481
  },
441
482
  }
442
483
  }
443
- logger.info(f"Sending API request to create shape for bullet list: {json.dumps(log_data, indent=2)}")
484
+ logger.info(
485
+ f"Sending API request to create shape for bullet list: {json.dumps(log_data, indent=2)}"
486
+ )
444
487
 
445
488
  # Create requests
446
489
  requests = [
@@ -476,15 +519,23 @@ class SlidesService(BaseGoogleService):
476
519
  ]
477
520
 
478
521
  # Log the text insertion
479
- logger.info(f"Sending API request to insert bullet text: {json.dumps(requests[1], indent=2)}")
522
+ logger.info(
523
+ f"Sending API request to insert bullet text: {json.dumps(requests[1], indent=2)}"
524
+ )
480
525
 
481
526
  # Execute the request to create shape and insert text
482
527
  response = (
483
- self.service.presentations().batchUpdate(presentationId=presentation_id, body={"requests": requests}).execute()
528
+ self.service.presentations()
529
+ .batchUpdate(
530
+ presentationId=presentation_id, body={"requests": requests}
531
+ )
532
+ .execute()
484
533
  )
485
534
 
486
535
  # Log the response
487
- logger.info(f"API response for bullet list creation: {json.dumps(response, indent=2)}")
536
+ logger.info(
537
+ f"API response for bullet list creation: {json.dumps(response, indent=2)}"
538
+ )
488
539
 
489
540
  # Now add bullet formatting
490
541
  try:
@@ -493,14 +544,18 @@ class SlidesService(BaseGoogleService):
493
544
  {
494
545
  "createParagraphBullets": {
495
546
  "objectId": element_id,
496
- "textRange": {"type": "ALL"}, # Apply to all text in the shape
547
+ "textRange": {
548
+ "type": "ALL"
549
+ }, # Apply to all text in the shape
497
550
  "bulletPreset": "BULLET_DISC_CIRCLE_SQUARE",
498
551
  }
499
552
  }
500
553
  ]
501
554
 
502
555
  # Log the bullet formatting request
503
- logger.info(f"Sending API request to apply bullet formatting: {json.dumps(bullet_request[0], indent=2)}")
556
+ logger.info(
557
+ f"Sending API request to apply bullet formatting: {json.dumps(bullet_request[0], indent=2)}"
558
+ )
504
559
 
505
560
  bullet_response = (
506
561
  self.service.presentations()
@@ -512,9 +567,13 @@ class SlidesService(BaseGoogleService):
512
567
  )
513
568
 
514
569
  # Log the response
515
- logger.info(f"API response for bullet formatting: {json.dumps(bullet_response, indent=2)}")
570
+ logger.info(
571
+ f"API response for bullet formatting: {json.dumps(bullet_response, indent=2)}"
572
+ )
516
573
  except Exception as bullet_error:
517
- logger.warning(f"Failed to apply bullet formatting: {str(bullet_error)}")
574
+ logger.warning(
575
+ f"Failed to apply bullet formatting: {str(bullet_error)}"
576
+ )
518
577
  # No fallback here - the text is already added, just without bullets
519
578
 
520
579
  return {
@@ -527,7 +586,9 @@ class SlidesService(BaseGoogleService):
527
586
  except Exception as e:
528
587
  return self.handle_api_error("add_bulleted_list", e)
529
588
 
530
- def create_presentation_from_markdown(self, title: str, markdown_content: str) -> dict[str, Any]:
589
+ def create_presentation_from_markdown(
590
+ self, title: str, markdown_content: str
591
+ ) -> dict[str, Any]:
531
592
  """
532
593
  Create a Google Slides presentation from Markdown content using markdowndeck.
533
594
 
@@ -545,9 +606,13 @@ class SlidesService(BaseGoogleService):
545
606
  credentials = gauth.get_credentials()
546
607
 
547
608
  # Use markdowndeck to create the presentation
548
- result = create_presentation(markdown=markdown_content, title=title, credentials=credentials)
609
+ result = create_presentation(
610
+ markdown=markdown_content, title=title, credentials=credentials
611
+ )
549
612
 
550
- logger.info(f"Successfully created presentation with ID: {result.get('presentationId')}")
613
+ logger.info(
614
+ f"Successfully created presentation with ID: {result.get('presentationId')}"
615
+ )
551
616
 
552
617
  # The presentation data is already in the expected format from markdowndeck
553
618
  return result
@@ -568,7 +633,11 @@ class SlidesService(BaseGoogleService):
568
633
  """
569
634
  try:
570
635
  # Get the presentation with slide details
571
- presentation = self.service.presentations().get(presentationId=presentation_id).execute()
636
+ presentation = (
637
+ self.service.presentations()
638
+ .get(presentationId=presentation_id)
639
+ .execute()
640
+ )
572
641
 
573
642
  # Extract slide information
574
643
  slides = []
@@ -587,9 +656,13 @@ class SlidesService(BaseGoogleService):
587
656
  if "textElements" in element["shape"]["text"]:
588
657
  # Extract text content
589
658
  text_parts = []
590
- for text_element in element["shape"]["text"]["textElements"]:
659
+ for text_element in element["shape"]["text"][
660
+ "textElements"
661
+ ]:
591
662
  if "textRun" in text_element:
592
- text_parts.append(text_element["textRun"].get("content", ""))
663
+ text_parts.append(
664
+ text_element["textRun"].get("content", "")
665
+ )
593
666
  element_content = "".join(text_parts)
594
667
  elif "image" in element:
595
668
  element_type = "image"
@@ -597,9 +670,7 @@ class SlidesService(BaseGoogleService):
597
670
  element_content = element["image"]["contentUrl"]
598
671
  elif "table" in element:
599
672
  element_type = "table"
600
- element_content = (
601
- f"Table with {element['table'].get('rows', 0)} rows, {element['table'].get('columns', 0)} columns"
602
- )
673
+ element_content = f"Table with {element['table'].get('rows', 0)} rows, {element['table'].get('columns', 0)} columns"
603
674
 
604
675
  # Add to elements if we found content
605
676
  if element_type and element_content:
@@ -613,7 +684,10 @@ class SlidesService(BaseGoogleService):
613
684
 
614
685
  # Get speaker notes if present
615
686
  notes = ""
616
- if "slideProperties" in slide and "notesPage" in slide["slideProperties"]:
687
+ if (
688
+ "slideProperties" in slide
689
+ and "notesPage" in slide["slideProperties"]
690
+ ):
617
691
  notes_page = slide["slideProperties"]["notesPage"]
618
692
  if "pageElements" in notes_page:
619
693
  for element in notes_page["pageElements"]:
@@ -623,9 +697,13 @@ class SlidesService(BaseGoogleService):
623
697
  and "textElements" in element["shape"]["text"]
624
698
  ):
625
699
  note_parts = []
626
- for text_element in element["shape"]["text"]["textElements"]:
700
+ for text_element in element["shape"]["text"][
701
+ "textElements"
702
+ ]:
627
703
  if "textRun" in text_element:
628
- note_parts.append(text_element["textRun"].get("content", ""))
704
+ note_parts.append(
705
+ text_element["textRun"].get("content", "")
706
+ )
629
707
  if note_parts:
630
708
  notes = "".join(note_parts)
631
709
 
@@ -657,14 +735,22 @@ class SlidesService(BaseGoogleService):
657
735
  # Define the delete request
658
736
  requests = [{"deleteObject": {"objectId": slide_id}}]
659
737
 
660
- logger.info(f"Sending API request to delete slide: {json.dumps(requests[0], indent=2)}")
738
+ logger.info(
739
+ f"Sending API request to delete slide: {json.dumps(requests[0], indent=2)}"
740
+ )
661
741
 
662
742
  # Execute the request
663
743
  response = (
664
- self.service.presentations().batchUpdate(presentationId=presentation_id, body={"requests": requests}).execute()
744
+ self.service.presentations()
745
+ .batchUpdate(
746
+ presentationId=presentation_id, body={"requests": requests}
747
+ )
748
+ .execute()
665
749
  )
666
750
 
667
- logger.info(f"API response for slide deletion: {json.dumps(response, indent=2)}")
751
+ logger.info(
752
+ f"API response for slide deletion: {json.dumps(response, indent=2)}"
753
+ )
668
754
 
669
755
  return {
670
756
  "presentationId": presentation_id,
@@ -697,12 +783,13 @@ class SlidesService(BaseGoogleService):
697
783
  Response data or error information
698
784
  """
699
785
  try:
700
- # Create a unique element ID
701
- f"image_{slide_id}_{hash(image_url) % 10000}"
786
+ # Create a unique element ID (FIX: Actually assign the variable!)
787
+ image_id = f"image_{slide_id}_{hash(image_url) % 10000}"
702
788
 
703
789
  # Define the base request
704
790
  create_image_request = {
705
791
  "createImage": {
792
+ "objectId": image_id, # FIX: Add the missing objectId
706
793
  "url": image_url,
707
794
  "elementProperties": {
708
795
  "pageObjectId": slide_id,
@@ -711,7 +798,7 @@ class SlidesService(BaseGoogleService):
711
798
  "scaleY": 1,
712
799
  "translateX": position[0],
713
800
  "translateY": position[1],
714
- "unit": "PT",
801
+ "unit": "PT", # Could use "EMU" to match docs
715
802
  },
716
803
  },
717
804
  }
@@ -724,7 +811,9 @@ class SlidesService(BaseGoogleService):
724
811
  "height": {"magnitude": size[1], "unit": "PT"},
725
812
  }
726
813
 
727
- logger.info(f"Sending API request to create image: {json.dumps(create_image_request, indent=2)}")
814
+ logger.info(
815
+ f"Sending API request to create image: {json.dumps(create_image_request, indent=2)}"
816
+ )
728
817
 
729
818
  # Execute the request
730
819
  response = (
@@ -739,7 +828,9 @@ class SlidesService(BaseGoogleService):
739
828
  # Extract the image ID from the response
740
829
  if "replies" in response and len(response["replies"]) > 0:
741
830
  image_id = response["replies"][0].get("createImage", {}).get("objectId")
742
- logger.info(f"API response for image creation: {json.dumps(response, indent=2)}")
831
+ logger.info(
832
+ f"API response for image creation: {json.dumps(response, indent=2)}"
833
+ )
743
834
  return {
744
835
  "presentationId": presentation_id,
745
836
  "slideId": slide_id,
@@ -752,6 +843,90 @@ class SlidesService(BaseGoogleService):
752
843
  except Exception as e:
753
844
  return self.handle_api_error("add_image", e)
754
845
 
846
+ def add_image_with_unit(
847
+ self,
848
+ presentation_id: str,
849
+ slide_id: str,
850
+ image_url: str,
851
+ position: tuple[float, float] = (100, 100),
852
+ size: tuple[float, float] | None = None,
853
+ unit: str = "PT",
854
+ ) -> dict[str, Any]:
855
+ """
856
+ Add an image to a slide from a URL with support for different units.
857
+
858
+ Args:
859
+ presentation_id: The ID of the presentation
860
+ slide_id: The ID of the slide
861
+ image_url: The URL of the image to add
862
+ position: Tuple of (x, y) coordinates for position
863
+ size: Optional tuple of (width, height) for the image
864
+ unit: Unit type - "PT" for points or "EMU" for English Metric Units
865
+
866
+ Returns:
867
+ Response data or error information
868
+ """
869
+ try:
870
+ # Create a unique element ID
871
+ image_id = f"image_{slide_id}_{hash(image_url) % 10000}"
872
+
873
+ # Define the base request
874
+ create_image_request = {
875
+ "createImage": {
876
+ "objectId": image_id,
877
+ "url": image_url,
878
+ "elementProperties": {
879
+ "pageObjectId": slide_id,
880
+ "transform": {
881
+ "scaleX": 1,
882
+ "scaleY": 1,
883
+ "translateX": position[0],
884
+ "translateY": position[1],
885
+ "unit": unit, # Use the specified unit
886
+ },
887
+ },
888
+ }
889
+ }
890
+
891
+ # Add size if specified
892
+ if size:
893
+ create_image_request["createImage"]["elementProperties"]["size"] = {
894
+ "width": {"magnitude": size[0], "unit": unit},
895
+ "height": {"magnitude": size[1], "unit": unit},
896
+ }
897
+
898
+ logger.info(
899
+ f"Sending API request to create image with {unit} units: {json.dumps(create_image_request, indent=2)}"
900
+ )
901
+
902
+ # Execute the request
903
+ response = (
904
+ self.service.presentations()
905
+ .batchUpdate(
906
+ presentationId=presentation_id,
907
+ body={"requests": [create_image_request]},
908
+ )
909
+ .execute()
910
+ )
911
+
912
+ # Extract the image ID from the response
913
+ if "replies" in response and len(response["replies"]) > 0:
914
+ image_id = response["replies"][0].get("createImage", {}).get("objectId")
915
+ logger.info(
916
+ f"API response for image creation: {json.dumps(response, indent=2)}"
917
+ )
918
+ return {
919
+ "presentationId": presentation_id,
920
+ "slideId": slide_id,
921
+ "imageId": image_id,
922
+ "operation": "add_image_with_unit",
923
+ "result": "success",
924
+ }
925
+
926
+ return response
927
+ except Exception as e:
928
+ return self.handle_api_error("add_image_with_unit", e)
929
+
755
930
  def add_table(
756
931
  self,
757
932
  presentation_id: str,
@@ -804,7 +979,9 @@ class SlidesService(BaseGoogleService):
804
979
  }
805
980
  }
806
981
 
807
- logger.info(f"Sending API request to create table: {json.dumps(create_table_request, indent=2)}")
982
+ logger.info(
983
+ f"Sending API request to create table: {json.dumps(create_table_request, indent=2)}"
984
+ )
808
985
 
809
986
  # Execute table creation
810
987
  response = (
@@ -816,7 +993,9 @@ class SlidesService(BaseGoogleService):
816
993
  .execute()
817
994
  )
818
995
 
819
- logger.info(f"API response for table creation: {json.dumps(response, indent=2)}")
996
+ logger.info(
997
+ f"API response for table creation: {json.dumps(response, indent=2)}"
998
+ )
820
999
 
821
1000
  # Populate the table if data is provided
822
1001
  if data:
@@ -841,7 +1020,9 @@ class SlidesService(BaseGoogleService):
841
1020
  )
842
1021
 
843
1022
  if text_requests:
844
- logger.info(f"Sending API request to populate table with {len(text_requests)} cell entries")
1023
+ logger.info(
1024
+ f"Sending API request to populate table with {len(text_requests)} cell entries"
1025
+ )
845
1026
  table_text_response = (
846
1027
  self.service.presentations()
847
1028
  .batchUpdate(
@@ -850,7 +1031,9 @@ class SlidesService(BaseGoogleService):
850
1031
  )
851
1032
  .execute()
852
1033
  )
853
- logger.info(f"API response for table population: {json.dumps(table_text_response, indent=2)}")
1034
+ logger.info(
1035
+ f"API response for table population: {json.dumps(table_text_response, indent=2)}"
1036
+ )
854
1037
 
855
1038
  return {
856
1039
  "presentationId": presentation_id,
@@ -891,14 +1074,22 @@ class SlidesService(BaseGoogleService):
891
1074
  }
892
1075
  ]
893
1076
 
894
- logger.info(f"Sending API request to add slide notes: {json.dumps(requests[0], indent=2)}")
1077
+ logger.info(
1078
+ f"Sending API request to add slide notes: {json.dumps(requests[0], indent=2)}"
1079
+ )
895
1080
 
896
1081
  # Execute the request
897
1082
  response = (
898
- self.service.presentations().batchUpdate(presentationId=presentation_id, body={"requests": requests}).execute()
1083
+ self.service.presentations()
1084
+ .batchUpdate(
1085
+ presentationId=presentation_id, body={"requests": requests}
1086
+ )
1087
+ .execute()
899
1088
  )
900
1089
 
901
- logger.info(f"API response for slide notes: {json.dumps(response, indent=2)}")
1090
+ logger.info(
1091
+ f"API response for slide notes: {json.dumps(response, indent=2)}"
1092
+ )
902
1093
 
903
1094
  return {
904
1095
  "presentationId": presentation_id,
@@ -909,7 +1100,9 @@ class SlidesService(BaseGoogleService):
909
1100
  except Exception as e:
910
1101
  return self.handle_api_error("add_slide_notes", e)
911
1102
 
912
- def duplicate_slide(self, presentation_id: str, slide_id: str, insert_at_index: int | None = None) -> dict[str, Any]:
1103
+ def duplicate_slide(
1104
+ self, presentation_id: str, slide_id: str, insert_at_index: int | None = None
1105
+ ) -> dict[str, Any]:
913
1106
  """
914
1107
  Duplicate a slide in a presentation.
915
1108
 
@@ -927,9 +1120,13 @@ class SlidesService(BaseGoogleService):
927
1120
 
928
1121
  # If insert location is specified
929
1122
  if insert_at_index is not None:
930
- duplicate_request["duplicateObject"]["insertionIndex"] = insert_at_index
1123
+ duplicate_request["duplicateObject"]["insertionIndex"] = str(
1124
+ insert_at_index
1125
+ )
931
1126
 
932
- logger.info(f"Sending API request to duplicate slide: {json.dumps(duplicate_request, indent=2)}")
1127
+ logger.info(
1128
+ f"Sending API request to duplicate slide: {json.dumps(duplicate_request, indent=2)}"
1129
+ )
933
1130
 
934
1131
  # Execute the duplicate request
935
1132
  response = (
@@ -941,12 +1138,16 @@ class SlidesService(BaseGoogleService):
941
1138
  .execute()
942
1139
  )
943
1140
 
944
- logger.info(f"API response for slide duplication: {json.dumps(response, indent=2)}")
1141
+ logger.info(
1142
+ f"API response for slide duplication: {json.dumps(response, indent=2)}"
1143
+ )
945
1144
 
946
1145
  # Extract the duplicated slide ID
947
1146
  new_slide_id = None
948
1147
  if "replies" in response and len(response["replies"]) > 0:
949
- new_slide_id = response["replies"][0].get("duplicateObject", {}).get("objectId")
1148
+ new_slide_id = (
1149
+ response["replies"][0].get("duplicateObject", {}).get("objectId")
1150
+ )
950
1151
 
951
1152
  return {
952
1153
  "presentationId": presentation_id,
@@ -957,3 +1158,1778 @@ class SlidesService(BaseGoogleService):
957
1158
  }
958
1159
  except Exception as e:
959
1160
  return self.handle_api_error("duplicate_slide", e)
1161
+
1162
+ def calculate_optimal_font_size(
1163
+ self,
1164
+ text: str,
1165
+ box_width: float,
1166
+ box_height: float,
1167
+ font_family: str = "Arial",
1168
+ max_font_size: int = 48,
1169
+ min_font_size: int = 8,
1170
+ ) -> int:
1171
+ """
1172
+ Calculate optimal font size to fit text within given dimensions.
1173
+ Uses simple estimation since PIL may not be available.
1174
+ """
1175
+ try:
1176
+ # Try to import PIL for accurate measurement
1177
+ from PIL import Image, ImageDraw, ImageFont
1178
+
1179
+ def get_text_dimensions(text, font_size, font_family):
1180
+ try:
1181
+ img = Image.new("RGB", (1000, 1000), color="white")
1182
+ draw = ImageDraw.Draw(img)
1183
+
1184
+ try:
1185
+ font = ImageFont.truetype(f"{font_family}.ttf", font_size)
1186
+ except:
1187
+ font = ImageFont.load_default()
1188
+
1189
+ bbox = draw.textbbox((0, 0), text, font=font)
1190
+ text_width = bbox[2] - bbox[0]
1191
+ text_height = bbox[3] - bbox[1]
1192
+
1193
+ return text_width, text_height
1194
+ except Exception:
1195
+ # Fallback calculation
1196
+ char_width = font_size * 0.6
1197
+ text_width = len(text) * char_width
1198
+ text_height = font_size * 1.2
1199
+ return text_width, text_height
1200
+
1201
+ # Binary search for optimal font size
1202
+ low, high = min_font_size, max_font_size
1203
+ optimal_size = min_font_size
1204
+
1205
+ while low <= high:
1206
+ mid = (low + high) // 2
1207
+ text_width, text_height = get_text_dimensions(text, mid, font_family)
1208
+
1209
+ if text_width <= box_width and text_height <= box_height:
1210
+ optimal_size = mid
1211
+ low = mid + 1
1212
+ else:
1213
+ high = mid - 1
1214
+
1215
+ return optimal_size
1216
+
1217
+ except ImportError:
1218
+ # Fallback to simple estimation if PIL not available
1219
+ logger.info("PIL not available, using simple font size estimation")
1220
+ chars_per_line = int(box_width / (12 * 0.6)) # Base font size 12
1221
+
1222
+ if len(text) <= chars_per_line:
1223
+ return min(max_font_size, 12)
1224
+
1225
+ scale_factor = chars_per_line / len(text)
1226
+ return max(min_font_size, int(12 * scale_factor))
1227
+
1228
+ def create_textbox_with_text(
1229
+ self,
1230
+ presentation_id: str,
1231
+ slide_id: str,
1232
+ text: str,
1233
+ position: tuple[float, float],
1234
+ size: tuple[float, float],
1235
+ unit: str = "EMU",
1236
+ element_id: str | None = None,
1237
+ font_family: str = "Arial",
1238
+ font_size: float = 12,
1239
+ text_alignment: str | None = None,
1240
+ vertical_alignment: str | None = None,
1241
+ auto_size_font: bool = False,
1242
+ ) -> dict[str, Any]:
1243
+ """
1244
+ Create a text box with text, font formatting, and alignment.
1245
+
1246
+ Args:
1247
+ presentation_id: The ID of the presentation
1248
+ slide_id: The ID of the slide (page)
1249
+ text: The text content to insert
1250
+ position: Tuple of (x, y) coordinates for position
1251
+ size: Tuple of (width, height) for the text box
1252
+ unit: Unit type - "PT" for points or "EMU" for English Metric Units (default "EMU").
1253
+ element_id: Optional custom element ID, auto-generated if not provided
1254
+ font_family: Font family to use (default "Arial")
1255
+ font_size: Font size in points (default 12)
1256
+ text_alignment: Optional horizontal alignment ("LEFT", "CENTER", "RIGHT", "JUSTIFY")
1257
+ vertical_alignment: Optional vertical alignment ("TOP", "MIDDLE", "BOTTOM")
1258
+ auto_size_font: Whether to automatically calculate font size to fit (default False - DEPRECATED)
1259
+
1260
+ Returns:
1261
+ Response data or error information
1262
+ """
1263
+ try:
1264
+ # Validate unit
1265
+ if unit not in ["PT", "EMU"]:
1266
+ raise ValueError(
1267
+ "Unit must be either 'PT' (points) or 'EMU' (English Metric Units)"
1268
+ )
1269
+
1270
+ # Generate element ID if not provided
1271
+ if element_id is None:
1272
+ import time
1273
+
1274
+ element_id = f"TextBox_{int(time.time() * 1000)}"
1275
+
1276
+ # Convert size to API format
1277
+ width = {"magnitude": size[0], "unit": unit}
1278
+ height = {"magnitude": size[1], "unit": unit}
1279
+
1280
+ # Use provided font size instead of calculation
1281
+ if auto_size_font:
1282
+ logger.warning(
1283
+ "auto_size_font is deprecated - using provided font_size instead"
1284
+ )
1285
+
1286
+ # Build requests with proper sequence
1287
+ requests = [
1288
+ # Step 1: Create text box shape (no autofit - API limitation)
1289
+ {
1290
+ "createShape": {
1291
+ "objectId": element_id,
1292
+ "shapeType": "TEXT_BOX",
1293
+ "elementProperties": {
1294
+ "pageObjectId": slide_id,
1295
+ "size": {"height": height, "width": width},
1296
+ "transform": {
1297
+ "scaleX": 1,
1298
+ "scaleY": 1,
1299
+ "translateX": position[0],
1300
+ "translateY": position[1],
1301
+ "unit": unit,
1302
+ },
1303
+ },
1304
+ }
1305
+ },
1306
+ # Step 2: Set autofit to NONE (only supported value)
1307
+ {
1308
+ "updateShapeProperties": {
1309
+ "objectId": element_id,
1310
+ "shapeProperties": {"autofit": {"autofitType": "NONE"}},
1311
+ "fields": "autofit.autofitType",
1312
+ }
1313
+ },
1314
+ # Step 3: Insert text into the text box
1315
+ {
1316
+ "insertText": {
1317
+ "objectId": element_id,
1318
+ "insertionIndex": 0,
1319
+ "text": text,
1320
+ }
1321
+ },
1322
+ # Step 4: Apply font size and family
1323
+ {
1324
+ "updateTextStyle": {
1325
+ "objectId": element_id,
1326
+ "textRange": {"type": "ALL"},
1327
+ "style": {
1328
+ "fontSize": {"magnitude": font_size, "unit": "PT"},
1329
+ "fontFamily": font_family,
1330
+ },
1331
+ "fields": "fontSize,fontFamily",
1332
+ }
1333
+ },
1334
+ ]
1335
+
1336
+ # Step 5: Add text alignment if specified
1337
+ if text_alignment is not None:
1338
+ alignment_map = {
1339
+ "LEFT": "START",
1340
+ "CENTER": "CENTER",
1341
+ "RIGHT": "END",
1342
+ "JUSTIFY": "JUSTIFIED",
1343
+ }
1344
+
1345
+ api_alignment = alignment_map.get(text_alignment.upper())
1346
+ if api_alignment:
1347
+ requests.append(
1348
+ {
1349
+ "updateParagraphStyle": {
1350
+ "objectId": element_id,
1351
+ "textRange": {"type": "ALL"},
1352
+ "style": {"alignment": api_alignment},
1353
+ "fields": "alignment",
1354
+ }
1355
+ }
1356
+ )
1357
+
1358
+ # Step 6: Add vertical alignment if specified
1359
+ if vertical_alignment is not None:
1360
+ valign_map = {"TOP": "TOP", "MIDDLE": "MIDDLE", "BOTTOM": "BOTTOM"}
1361
+
1362
+ api_valign = valign_map.get(vertical_alignment.upper())
1363
+ if api_valign:
1364
+ requests.append(
1365
+ {
1366
+ "updateShapeProperties": {
1367
+ "objectId": element_id,
1368
+ "shapeProperties": {"contentAlignment": api_valign},
1369
+ "fields": "contentAlignment",
1370
+ }
1371
+ }
1372
+ )
1373
+
1374
+ logger.info(
1375
+ f"Creating text box with font {font_family} {font_size}pt, align: {text_alignment}/{vertical_alignment}"
1376
+ )
1377
+
1378
+ # Execute the request
1379
+ response = (
1380
+ self.service.presentations()
1381
+ .batchUpdate(
1382
+ presentationId=presentation_id, body={"requests": requests}
1383
+ )
1384
+ .execute()
1385
+ )
1386
+
1387
+ logger.info(f"Text box creation response: {json.dumps(response, indent=2)}")
1388
+
1389
+ # Extract object ID from response if available
1390
+ created_object_id = None
1391
+ if "replies" in response and len(response["replies"]) > 0:
1392
+ create_shape_response = response["replies"][0].get("createShape")
1393
+ if create_shape_response:
1394
+ created_object_id = create_shape_response.get("objectId")
1395
+
1396
+ return {
1397
+ "presentationId": presentation_id,
1398
+ "slideId": slide_id,
1399
+ "elementId": created_object_id or element_id,
1400
+ "text": text,
1401
+ "fontSize": font_size,
1402
+ "fontFamily": font_family,
1403
+ "textAlignment": text_alignment,
1404
+ "verticalAlignment": vertical_alignment,
1405
+ "operation": "create_textbox_with_text",
1406
+ "result": "success",
1407
+ "response": response,
1408
+ }
1409
+ except Exception as e:
1410
+ return self.handle_api_error("create_textbox_with_text", e)
1411
+
1412
+ def batch_update(
1413
+ self, presentation_id: str, requests: list[dict[str, Any]]
1414
+ ) -> dict[str, Any]:
1415
+ """
1416
+ Apply a list of raw Google Slides API update requests to a presentation in a single operation.
1417
+ For advanced users familiar with Slides API request structures.
1418
+ Allows creating multiple elements (text boxes, images, shapes) in a single API call.
1419
+
1420
+ Args:
1421
+ presentation_id: The ID of the presentation
1422
+ requests: List of Google Slides API request objects
1423
+
1424
+ Returns:
1425
+ API response data or error information
1426
+ """
1427
+ try:
1428
+ logger.info(
1429
+ f"Executing batch update with {len(requests)} requests on presentation {presentation_id}"
1430
+ )
1431
+
1432
+ # Execute all requests in a single batch operation
1433
+ response = (
1434
+ self.service.presentations()
1435
+ .batchUpdate(
1436
+ presentationId=presentation_id, body={"requests": requests}
1437
+ )
1438
+ .execute()
1439
+ )
1440
+
1441
+ logger.info(
1442
+ f"Batch update completed successfully. Response: {json.dumps(response, indent=2)}"
1443
+ )
1444
+
1445
+ return {
1446
+ "presentationId": presentation_id,
1447
+ "operation": "batch_update",
1448
+ "requestCount": len(requests),
1449
+ "result": "success",
1450
+ "replies": response.get("replies", []),
1451
+ "writeControl": response.get("writeControl", {}),
1452
+ }
1453
+ except Exception as e:
1454
+ return self.handle_api_error("batch_update", e)
1455
+
1456
+ def create_slide_from_template_data(
1457
+ self,
1458
+ presentation_id: str,
1459
+ slide_id: str,
1460
+ template_data: dict[str, Any],
1461
+ ) -> dict[str, Any]:
1462
+ """
1463
+ Create a complete slide from template data in a single batch operation.
1464
+
1465
+ Args:
1466
+ presentation_id: The ID of the presentation
1467
+ slide_id: The ID of the slide
1468
+ template_data: Dictionary containing slide elements data structure:
1469
+ {
1470
+ "title": {"text": "...", "position": {"x": 32, "y": 35, "width": 330, "height": 40}, "style": {"fontSize": 18, "fontFamily": "Roboto"}},
1471
+ "description": {"text": "...", "position": {"x": 32, "y": 95, "width": 330, "height": 160}, "style": {"fontSize": 12, "fontFamily": "Roboto"}},
1472
+ "stats": [
1473
+ {"value": "43.4M", "label": "TOTAL IMPRESSIONS", "position": {"x": 374.5, "y": 268.5}},
1474
+ {"value": "134K", "label": "TOTAL ENGAGEMENTS", "position": {"x": 516.5, "y": 268.5}},
1475
+ # ... more stats
1476
+ ],
1477
+ "image": {"url": "...", "position": {"x": 375, "y": 35}, "size": {"width": 285, "height": 215}}
1478
+ }
1479
+
1480
+ Returns:
1481
+ Response data or error information
1482
+ """
1483
+ try:
1484
+ import time
1485
+
1486
+ requests = []
1487
+ element_counter = 0
1488
+
1489
+ # Build title element
1490
+ if "title" in template_data:
1491
+ title_id = f"title_{int(time.time() * 1000)}_{element_counter}"
1492
+ requests.extend(
1493
+ self._build_textbox_requests(
1494
+ title_id, slide_id, template_data["title"]
1495
+ )
1496
+ )
1497
+ element_counter += 1
1498
+
1499
+ # Build description element
1500
+ if "description" in template_data:
1501
+ desc_id = f"description_{int(time.time() * 1000)}_{element_counter}"
1502
+ requests.extend(
1503
+ self._build_textbox_requests(
1504
+ desc_id, slide_id, template_data["description"]
1505
+ )
1506
+ )
1507
+ element_counter += 1
1508
+
1509
+ # Build stats elements
1510
+ for i, stat in enumerate(template_data.get("stats", [])):
1511
+ # Stat value
1512
+ stat_id = f"stat_value_{int(time.time() * 1000)}_{i}"
1513
+ stat_data = {
1514
+ "text": stat["value"],
1515
+ "position": stat["position"],
1516
+ "style": {
1517
+ "fontSize": 25,
1518
+ "fontFamily": "Playfair Display",
1519
+ "bold": True,
1520
+ },
1521
+ }
1522
+ requests.extend(
1523
+ self._build_textbox_requests(stat_id, slide_id, stat_data)
1524
+ )
1525
+
1526
+ # Stat label
1527
+ label_id = f"stat_label_{int(time.time() * 1000)}_{i}"
1528
+ label_pos = {
1529
+ "x": stat["position"]["x"],
1530
+ "y": stat["position"]["y"] + 33.5, # Position label below value
1531
+ "width": stat["position"].get("width", 142),
1532
+ "height": stat["position"].get("height", 40),
1533
+ }
1534
+ label_data = {
1535
+ "text": stat["label"],
1536
+ "position": label_pos,
1537
+ "style": {"fontSize": 7.5, "fontFamily": "Roboto"},
1538
+ }
1539
+ requests.extend(
1540
+ self._build_textbox_requests(label_id, slide_id, label_data)
1541
+ )
1542
+
1543
+ # Build image element
1544
+ if "image" in template_data:
1545
+ image_id = f"image_{int(time.time() * 1000)}_{element_counter}"
1546
+ requests.append(
1547
+ self._build_image_request(
1548
+ image_id, slide_id, template_data["image"]
1549
+ )
1550
+ )
1551
+
1552
+ logger.info(f"Built {len(requests)} requests for slide creation")
1553
+
1554
+ # Execute batch update
1555
+ return self.batch_update(presentation_id, requests)
1556
+
1557
+ except Exception as e:
1558
+ return self.handle_api_error("create_slide_from_template_data", e)
1559
+
1560
+ def _build_textbox_requests(
1561
+ self, object_id: str, slide_id: str, textbox_data: dict[str, Any]
1562
+ ) -> list[dict[str, Any]]:
1563
+ """Helper to build textbox creation requests"""
1564
+ pos = textbox_data["position"]
1565
+ style = textbox_data.get("style", {})
1566
+
1567
+ requests = [
1568
+ # Create shape
1569
+ {
1570
+ "createShape": {
1571
+ "objectId": object_id,
1572
+ "shapeType": "TEXT_BOX",
1573
+ "elementProperties": {
1574
+ "pageObjectId": slide_id,
1575
+ "size": {
1576
+ "width": {"magnitude": pos.get("width", 142), "unit": "PT"},
1577
+ "height": {
1578
+ "magnitude": pos.get("height", 40),
1579
+ "unit": "PT",
1580
+ },
1581
+ },
1582
+ "transform": {
1583
+ "scaleX": 1,
1584
+ "scaleY": 1,
1585
+ "translateX": pos["x"],
1586
+ "translateY": pos["y"],
1587
+ "unit": "PT",
1588
+ },
1589
+ },
1590
+ }
1591
+ },
1592
+ # Insert text
1593
+ {"insertText": {"objectId": object_id, "text": textbox_data["text"]}},
1594
+ ]
1595
+
1596
+ # Add formatting if specified
1597
+ if style:
1598
+ format_request = {
1599
+ "updateTextStyle": {
1600
+ "objectId": object_id,
1601
+ "textRange": {"type": "ALL"},
1602
+ "style": {},
1603
+ "fields": "",
1604
+ }
1605
+ }
1606
+
1607
+ if "fontSize" in style:
1608
+ format_request["updateTextStyle"]["style"]["fontSize"] = {
1609
+ "magnitude": style["fontSize"],
1610
+ "unit": "PT",
1611
+ }
1612
+ format_request["updateTextStyle"]["fields"] += "fontSize,"
1613
+
1614
+ if "fontFamily" in style:
1615
+ format_request["updateTextStyle"]["style"]["fontFamily"] = style[
1616
+ "fontFamily"
1617
+ ]
1618
+ format_request["updateTextStyle"]["fields"] += "fontFamily,"
1619
+
1620
+ if style.get("bold"):
1621
+ format_request["updateTextStyle"]["style"]["bold"] = True
1622
+ format_request["updateTextStyle"]["fields"] += "bold,"
1623
+
1624
+ # Clean up trailing comma
1625
+ format_request["updateTextStyle"]["fields"] = format_request[
1626
+ "updateTextStyle"
1627
+ ]["fields"].rstrip(",")
1628
+
1629
+ if format_request["updateTextStyle"][
1630
+ "fields"
1631
+ ]: # Only add if there are fields to update
1632
+ requests.append(format_request)
1633
+
1634
+ return requests
1635
+
1636
+ def _build_image_request(
1637
+ self, object_id: str, slide_id: str, image_data: dict[str, Any]
1638
+ ) -> dict[str, Any]:
1639
+ """Helper to build image creation request"""
1640
+ pos = image_data["position"]
1641
+ size = image_data.get("size", {})
1642
+
1643
+ request = {
1644
+ "createImage": {
1645
+ "objectId": object_id,
1646
+ "url": image_data["url"],
1647
+ "elementProperties": {
1648
+ "pageObjectId": slide_id,
1649
+ "transform": {
1650
+ "scaleX": 1,
1651
+ "scaleY": 1,
1652
+ "translateX": pos["x"],
1653
+ "translateY": pos["y"],
1654
+ "unit": "PT",
1655
+ },
1656
+ },
1657
+ }
1658
+ }
1659
+
1660
+ # Add size if specified
1661
+ if size:
1662
+ request["createImage"]["elementProperties"]["size"] = {
1663
+ "width": {"magnitude": size["width"], "unit": "PT"},
1664
+ "height": {"magnitude": size["height"], "unit": "PT"},
1665
+ }
1666
+
1667
+ return request
1668
+
1669
+ def create_slide_with_elements(
1670
+ self,
1671
+ presentation_id: str,
1672
+ slide_id: str | None = None,
1673
+ elements: list[dict[str, Any]] | None = None,
1674
+ background_color: str | None = None,
1675
+ background_image_url: str | None = None,
1676
+ create_slide: bool = False,
1677
+ layout: str = "BLANK",
1678
+ insert_at_index: int | None = None,
1679
+ ) -> dict[str, Any]:
1680
+ """
1681
+ Create a complete slide with multiple elements in a single batch operation.
1682
+ NOW SUPPORTS CREATING THE SLIDE ITSELF - eliminates the two-call pattern!
1683
+
1684
+ Args:
1685
+ presentation_id: The ID of the presentation
1686
+ slide_id: The ID of the slide (optional if create_slide=True)
1687
+ elements: List of element dictionaries (optional, can create empty slide), example:
1688
+ [
1689
+ {
1690
+ "type": "textbox",
1691
+ "content": "Slide Title",
1692
+ "position": {"x": 282, "y": 558, "width": 600, "height": 45},
1693
+ "style": {
1694
+ "fontSize": 25,
1695
+ "fontFamily": "Playfair Display",
1696
+ "bold": True,
1697
+ "textAlignment": "CENTER",
1698
+ "verticalAlignment": "MIDDLE",
1699
+ "textColor": "#FFFFFF", # White text
1700
+ "backgroundColor": "#FFFFFF80" # Semi-transparent white background
1701
+ }
1702
+ },
1703
+ {
1704
+ "type": "textbox",
1705
+ "content": "43.4M\nTOTAL IMPRESSIONS",
1706
+ "position": {"x": 333, "y": 4059, "width": 122, "height": 79},
1707
+ "textRanges": [
1708
+ {
1709
+ "startIndex": 0,
1710
+ "endIndex": 5,
1711
+ "style": {
1712
+ "fontSize": 25,
1713
+ "fontFamily": "Playfair Display",
1714
+ "bold": True,
1715
+ "textColor": "#FF0000" # Red text for the number
1716
+ }
1717
+ },
1718
+ {
1719
+ "startIndex": 6,
1720
+ "endIndex": 22,
1721
+ "style": {
1722
+ "fontSize": 7.5,
1723
+ "fontFamily": "Roboto",
1724
+ "backgroundColor": "#FFFF0080" # Semi-transparent yellow background for label
1725
+ }
1726
+ }
1727
+ ],
1728
+ "style": {"textAlignment": "CENTER"}
1729
+ },
1730
+ {
1731
+ "type": "image",
1732
+ "content": "https://drive.google.com/file/d/.../view",
1733
+ "position": {"x": 675, "y": 0, "width": 238, "height": 514}
1734
+ },
1735
+ {
1736
+ "type": "table",
1737
+ "content": {
1738
+ "headers": ["Category", "Metric"],
1739
+ "rows": [
1740
+ ["Reach & Visibility", "Total Impressions: 43,431,803"],
1741
+ ["Engagement", "Total Engagements: 134,431"]
1742
+ ]
1743
+ },
1744
+ "position": {"x": 100, "y": 300, "width": 400, "height": 200},
1745
+ "style": {
1746
+ "headerStyle": {
1747
+ "bold": true,
1748
+ "backgroundColor": "#ff6b6b"
1749
+ },
1750
+ "firstColumnBold": true,
1751
+ "fontSize": 12,
1752
+ "fontFamily": "Roboto"
1753
+ }
1754
+ }
1755
+ ]
1756
+ background_color: Optional slide background color (e.g., "#f8cdcd4f")
1757
+ background_image_url: Optional slide background image URL (takes precedence over background_color)
1758
+ Must be publicly accessible (e.g., "https://drive.google.com/uc?id=FILE_ID")
1759
+ create_slide: If True, creates the slide first. If False, adds elements to existing slide. (default: False)
1760
+ layout: Layout for new slide (BLANK, TITLE_AND_BODY, etc.) - only used if create_slide=True
1761
+ insert_at_index: Position for new slide (only used if create_slide=True)
1762
+
1763
+ Text Color Support:
1764
+ - "textColor" or "color": "#FFFFFF"
1765
+ - "foregroundColor": "#333333"
1766
+ - Supports 6-character and 8-character hex codes with alpha: "#FFFFFF", "#FFFFFF80"
1767
+ - Supports CSS rgba() format: "rgba(255, 255, 255, 0.5)"
1768
+ - Supports RGB objects: {"r": 255, "g": 255, "b": 255} or {"red": 1.0, "green": 1.0, "blue": 1.0}
1769
+
1770
+ Background Color Support:
1771
+ - "backgroundColor": "#FFFFFF80" - Semi-transparent white background
1772
+ - "backgroundColor": "rgba(255, 255, 255, 0.5)" - Semi-transparent white background (CSS format)
1773
+ - Supports same color formats as text colors
1774
+ - 8-character hex codes supported: "#FFFFFF80" (alpha channel properly applied)
1775
+ - CSS rgba() format supported: "rgba(255, 255, 255, 0.5)" (alpha channel properly applied)
1776
+ - Works in main "style" object for entire text box background
1777
+ - Creates semi-transparent background for the entire text box shape
1778
+
1779
+ Returns:
1780
+ Response data or error information
1781
+ """
1782
+ try:
1783
+ import time
1784
+
1785
+ requests = []
1786
+ final_slide_id = slide_id
1787
+
1788
+ # Step 1: Create slide if requested
1789
+ if create_slide:
1790
+ if not final_slide_id:
1791
+ final_slide_id = f"slide_{int(time.time() * 1000)}"
1792
+
1793
+ slide_request = {
1794
+ "createSlide": {
1795
+ "objectId": final_slide_id,
1796
+ "slideLayoutReference": {"predefinedLayout": layout},
1797
+ }
1798
+ }
1799
+
1800
+ if insert_at_index is not None:
1801
+ slide_request["createSlide"]["insertionIndex"] = insert_at_index
1802
+
1803
+ requests.append(slide_request)
1804
+ logger.info(f"Added createSlide request for slide ID: {final_slide_id}")
1805
+ elif not final_slide_id:
1806
+ raise ValueError("slide_id is required when create_slide=False")
1807
+
1808
+ # Step 2: Set background image or color if specified
1809
+ if final_slide_id and (background_image_url or background_color):
1810
+ if background_image_url:
1811
+ logger.info(
1812
+ f"Setting slide background image: {background_image_url}"
1813
+ )
1814
+ requests.append(
1815
+ {
1816
+ "updatePageProperties": {
1817
+ "objectId": final_slide_id,
1818
+ "pageProperties": {
1819
+ "pageBackgroundFill": {
1820
+ "stretchedPictureFill": {
1821
+ "contentUrl": background_image_url
1822
+ }
1823
+ }
1824
+ },
1825
+ "fields": "pageBackgroundFill",
1826
+ }
1827
+ }
1828
+ )
1829
+ elif background_color:
1830
+ logger.info(f"Setting slide background color: {background_color}")
1831
+ requests.append(
1832
+ {
1833
+ "updatePageProperties": {
1834
+ "objectId": final_slide_id,
1835
+ "pageProperties": {
1836
+ "pageBackgroundFill": {
1837
+ "solidFill": {
1838
+ "color": {
1839
+ "rgbColor": self._hex_to_rgb(
1840
+ background_color
1841
+ )
1842
+ }
1843
+ }
1844
+ }
1845
+ },
1846
+ "fields": "pageBackgroundFill.solidFill.color",
1847
+ }
1848
+ }
1849
+ )
1850
+
1851
+ # Step 3: Process each element
1852
+ if elements and final_slide_id:
1853
+ for i, element in enumerate(elements):
1854
+ element_id = f"element_{int(time.time() * 1000)}_{i}"
1855
+
1856
+ if element["type"] == "textbox":
1857
+ requests.extend(
1858
+ self._build_textbox_requests_generic(
1859
+ element_id, final_slide_id, element
1860
+ )
1861
+ )
1862
+ elif element["type"] == "image":
1863
+ requests.append(
1864
+ self._build_image_request_generic(
1865
+ element_id, final_slide_id, element
1866
+ )
1867
+ )
1868
+ elif element["type"] == "table":
1869
+ requests.extend(
1870
+ self._build_table_request_generic(
1871
+ element_id, final_slide_id, element
1872
+ )
1873
+ )
1874
+
1875
+ logger.info(
1876
+ f"Built {len(requests)} requests for slide (create_slide={create_slide}, elements={len(elements or [])})"
1877
+ )
1878
+
1879
+ # Execute batch update
1880
+ if requests:
1881
+ batch_result = self.batch_update(presentation_id, requests)
1882
+
1883
+ # Extract slide ID from response if we created a new slide
1884
+ if create_slide and batch_result.get("replies"):
1885
+ # The first reply should be the createSlide response
1886
+ create_slide_reply = batch_result["replies"][0].get(
1887
+ "createSlide", {}
1888
+ )
1889
+ if create_slide_reply:
1890
+ final_slide_id = create_slide_reply.get(
1891
+ "objectId", final_slide_id
1892
+ )
1893
+
1894
+ return {
1895
+ "presentationId": presentation_id,
1896
+ "slideId": final_slide_id,
1897
+ "operation": (
1898
+ "create_slide_with_elements"
1899
+ if create_slide
1900
+ else "update_slide_with_elements"
1901
+ ),
1902
+ "result": "success",
1903
+ "slideCreated": create_slide,
1904
+ "elementsAdded": len(elements or []),
1905
+ "totalRequests": len(requests),
1906
+ "batchResult": batch_result,
1907
+ }
1908
+ else:
1909
+ return {
1910
+ "presentationId": presentation_id,
1911
+ "slideId": final_slide_id,
1912
+ "operation": "no_operation",
1913
+ "result": "success",
1914
+ "message": "No requests generated",
1915
+ }
1916
+
1917
+ except Exception as e:
1918
+ return self.handle_api_error("create_slide_with_elements", e)
1919
+
1920
+ def _build_textbox_requests_generic(
1921
+ self, object_id: str, slide_id: str, element: dict[str, Any]
1922
+ ) -> list[dict[str, Any]]:
1923
+ """Generic helper to build textbox creation requests with support for mixed text formatting"""
1924
+ pos = element["position"]
1925
+ style = element.get("style", {})
1926
+ text_ranges = element.get("textRanges", None)
1927
+
1928
+ requests = [
1929
+ # Create shape
1930
+ {
1931
+ "createShape": {
1932
+ "objectId": object_id,
1933
+ "shapeType": "TEXT_BOX",
1934
+ "elementProperties": {
1935
+ "pageObjectId": slide_id,
1936
+ "size": {
1937
+ "width": {"magnitude": pos["width"], "unit": "PT"},
1938
+ "height": {"magnitude": pos["height"], "unit": "PT"},
1939
+ },
1940
+ "transform": {
1941
+ "scaleX": 1,
1942
+ "scaleY": 1,
1943
+ "translateX": pos["x"],
1944
+ "translateY": pos["y"],
1945
+ "unit": "PT",
1946
+ },
1947
+ },
1948
+ }
1949
+ },
1950
+ # Insert text
1951
+ {"insertText": {"objectId": object_id, "text": element["content"]}},
1952
+ ]
1953
+
1954
+ # Handle mixed text formatting with textRanges
1955
+ if text_ranges:
1956
+ # Convert content-based ranges to index-based ranges automatically
1957
+ processed_ranges = self._process_text_ranges(
1958
+ element["content"], text_ranges
1959
+ )
1960
+
1961
+ for text_range in processed_ranges:
1962
+ range_style = text_range.get("style", {})
1963
+ start_index = text_range.get("startIndex", 0)
1964
+ end_index = text_range.get("endIndex", len(element["content"]))
1965
+
1966
+ if range_style:
1967
+ format_request = {
1968
+ "updateTextStyle": {
1969
+ "objectId": object_id,
1970
+ "textRange": {
1971
+ "type": "FIXED_RANGE",
1972
+ "startIndex": start_index,
1973
+ "endIndex": end_index,
1974
+ },
1975
+ "style": {},
1976
+ "fields": "",
1977
+ }
1978
+ }
1979
+
1980
+ if "fontSize" in range_style:
1981
+ format_request["updateTextStyle"]["style"]["fontSize"] = {
1982
+ "magnitude": range_style["fontSize"],
1983
+ "unit": "PT",
1984
+ }
1985
+ format_request["updateTextStyle"]["fields"] += "fontSize,"
1986
+
1987
+ if "fontFamily" in range_style:
1988
+ format_request["updateTextStyle"]["style"]["fontFamily"] = (
1989
+ range_style["fontFamily"]
1990
+ )
1991
+ format_request["updateTextStyle"]["fields"] += "fontFamily,"
1992
+
1993
+ if range_style.get("bold"):
1994
+ format_request["updateTextStyle"]["style"]["bold"] = True
1995
+ format_request["updateTextStyle"]["fields"] += "bold,"
1996
+
1997
+ # Add foreground color support
1998
+ if (
1999
+ "color" in range_style
2000
+ or "foregroundColor" in range_style
2001
+ or "textColor" in range_style
2002
+ ):
2003
+ color_value = (
2004
+ range_style.get("color")
2005
+ or range_style.get("foregroundColor")
2006
+ or range_style.get("textColor")
2007
+ )
2008
+ color_obj = self._parse_color(color_value)
2009
+ if color_obj:
2010
+ format_request["updateTextStyle"]["style"][
2011
+ "foregroundColor"
2012
+ ] = color_obj
2013
+ format_request["updateTextStyle"][
2014
+ "fields"
2015
+ ] += "foregroundColor,"
2016
+
2017
+ # Clean up trailing comma and add format request
2018
+ format_request["updateTextStyle"]["fields"] = format_request[
2019
+ "updateTextStyle"
2020
+ ]["fields"].rstrip(",")
2021
+
2022
+ if format_request["updateTextStyle"]["fields"]:
2023
+ requests.append(format_request)
2024
+
2025
+ # Add formatting for the entire text if specified and no textRanges
2026
+ elif style:
2027
+ format_request = {
2028
+ "updateTextStyle": {
2029
+ "objectId": object_id,
2030
+ "textRange": {"type": "ALL"},
2031
+ "style": {},
2032
+ "fields": "",
2033
+ }
2034
+ }
2035
+
2036
+ if "fontSize" in style:
2037
+ format_request["updateTextStyle"]["style"]["fontSize"] = {
2038
+ "magnitude": style["fontSize"],
2039
+ "unit": "PT",
2040
+ }
2041
+ format_request["updateTextStyle"]["fields"] += "fontSize,"
2042
+
2043
+ if "fontFamily" in style:
2044
+ format_request["updateTextStyle"]["style"]["fontFamily"] = style[
2045
+ "fontFamily"
2046
+ ]
2047
+ format_request["updateTextStyle"]["fields"] += "fontFamily,"
2048
+
2049
+ if style.get("bold"):
2050
+ format_request["updateTextStyle"]["style"]["bold"] = True
2051
+ format_request["updateTextStyle"]["fields"] += "bold,"
2052
+
2053
+ # Add foreground color support
2054
+ if "color" in style or "foregroundColor" in style or "textColor" in style:
2055
+ color_value = (
2056
+ style.get("color")
2057
+ or style.get("foregroundColor")
2058
+ or style.get("textColor")
2059
+ )
2060
+ color_obj = self._parse_color(color_value)
2061
+ if color_obj:
2062
+ format_request["updateTextStyle"]["style"][
2063
+ "foregroundColor"
2064
+ ] = color_obj
2065
+ format_request["updateTextStyle"]["fields"] += "foregroundColor,"
2066
+
2067
+ # Clean up trailing comma and add format request
2068
+ format_request["updateTextStyle"]["fields"] = format_request[
2069
+ "updateTextStyle"
2070
+ ]["fields"].rstrip(",")
2071
+
2072
+ if format_request["updateTextStyle"]["fields"]:
2073
+ requests.append(format_request)
2074
+
2075
+ # Add text alignment (paragraph-level)
2076
+ if style and style.get("textAlignment"):
2077
+ alignment_map = {
2078
+ "LEFT": "START",
2079
+ "CENTER": "CENTER",
2080
+ "RIGHT": "END",
2081
+ "JUSTIFY": "JUSTIFIED",
2082
+ }
2083
+ api_alignment = alignment_map.get(style["textAlignment"].upper(), "START")
2084
+ requests.append(
2085
+ {
2086
+ "updateParagraphStyle": {
2087
+ "objectId": object_id,
2088
+ "textRange": {"type": "ALL"},
2089
+ "style": {"alignment": api_alignment},
2090
+ "fields": "alignment",
2091
+ }
2092
+ }
2093
+ )
2094
+
2095
+ # Add vertical alignment (shape-level)
2096
+ if style and style.get("verticalAlignment"):
2097
+ valign_map = {"TOP": "TOP", "MIDDLE": "MIDDLE", "BOTTOM": "BOTTOM"}
2098
+ api_valign = valign_map.get(style["verticalAlignment"].upper(), "TOP")
2099
+ requests.append(
2100
+ {
2101
+ "updateShapeProperties": {
2102
+ "objectId": object_id,
2103
+ "shapeProperties": {"contentAlignment": api_valign},
2104
+ "fields": "contentAlignment",
2105
+ }
2106
+ }
2107
+ )
2108
+
2109
+ # Add text box background color (shape-level) - this is the key fix!
2110
+ if style and style.get("backgroundColor"):
2111
+ color_obj, alpha = self._parse_color_with_alpha(style["backgroundColor"])
2112
+ if color_obj:
2113
+ requests.append(
2114
+ {
2115
+ "updateShapeProperties": {
2116
+ "objectId": object_id,
2117
+ "shapeProperties": {
2118
+ "shapeBackgroundFill": {
2119
+ "solidFill": {"color": color_obj, "alpha": alpha}
2120
+ }
2121
+ },
2122
+ "fields": "shapeBackgroundFill",
2123
+ }
2124
+ }
2125
+ )
2126
+
2127
+ return requests
2128
+
2129
+ def _process_text_ranges(self, content: str, text_ranges: list[dict]) -> list[dict]:
2130
+ """
2131
+ Process textRanges to support both content-based and index-based ranges.
2132
+
2133
+ Args:
2134
+ content: The full text content
2135
+ text_ranges: List of textRange objects that can be either:
2136
+ - Index-based: {"startIndex": 0, "endIndex": 5, "style": {...}}
2137
+ - Content-based: {"content": "43.4M", "style": {...}}
2138
+
2139
+ Returns:
2140
+ List of index-based textRange objects
2141
+ """
2142
+ processed_ranges = []
2143
+
2144
+ for text_range in text_ranges:
2145
+ if "content" in text_range:
2146
+ # Content-based range - find the text in the content
2147
+ target_content = text_range["content"]
2148
+ start_index = content.find(target_content)
2149
+
2150
+ if start_index >= 0:
2151
+ end_index = start_index + len(target_content)
2152
+ processed_ranges.append(
2153
+ {
2154
+ "startIndex": start_index,
2155
+ "endIndex": end_index,
2156
+ "style": text_range.get("style", {}),
2157
+ }
2158
+ )
2159
+ else:
2160
+ # Content not found - log warning but continue
2161
+ logger.warning(
2162
+ f"Content '{target_content}' not found in text: '{content}'"
2163
+ )
2164
+ else:
2165
+ # Index-based range - use as-is but validate indices
2166
+ start_index = text_range.get("startIndex", 0)
2167
+ end_index = text_range.get("endIndex", len(content))
2168
+
2169
+ # Auto-fix common off-by-one errors
2170
+ if end_index == len(content) - 1:
2171
+ end_index = len(content)
2172
+ logger.info(
2173
+ f"Auto-corrected endIndex from {len(content) - 1} to {len(content)}"
2174
+ )
2175
+
2176
+ # Validate indices
2177
+ if (
2178
+ start_index >= 0
2179
+ and end_index <= len(content)
2180
+ and start_index < end_index
2181
+ ):
2182
+ processed_ranges.append(
2183
+ {
2184
+ "startIndex": start_index,
2185
+ "endIndex": end_index,
2186
+ "style": text_range.get("style", {}),
2187
+ }
2188
+ )
2189
+ else:
2190
+ logger.warning(
2191
+ f"Invalid text range indices: start={start_index}, end={end_index}, content_length={len(content)}"
2192
+ )
2193
+
2194
+ return processed_ranges
2195
+
2196
+ def _parse_color(self, color_value: str | dict) -> dict | None:
2197
+ """Parse color value into Google Slides API format.
2198
+
2199
+ Args:
2200
+ color_value: Color as hex string (e.g., "#ffffff"), RGB dict, or theme color
2201
+
2202
+ Returns:
2203
+ Color object in Google Slides API format or None if invalid
2204
+ """
2205
+ if not color_value:
2206
+ return None
2207
+
2208
+ # Handle hex color strings
2209
+ if isinstance(color_value, str):
2210
+ if color_value.lower() == "white":
2211
+ color_value = "#ffffff"
2212
+ elif color_value.lower() == "black":
2213
+ color_value = "#000000"
2214
+
2215
+ if color_value.startswith("#"):
2216
+ # Convert hex to RGB
2217
+ try:
2218
+ hex_color = color_value.lstrip("#")
2219
+ if len(hex_color) == 6:
2220
+ r = int(hex_color[0:2], 16) / 255.0
2221
+ g = int(hex_color[2:4], 16) / 255.0
2222
+ b = int(hex_color[4:6], 16) / 255.0
2223
+
2224
+ return {
2225
+ "opaqueColor": {
2226
+ "rgbColor": {"red": r, "green": g, "blue": b}
2227
+ }
2228
+ }
2229
+ elif len(hex_color) == 8:
2230
+ # Handle 8-character hex with alpha (RRGGBBAA)
2231
+ r = int(hex_color[0:2], 16) / 255.0
2232
+ g = int(hex_color[2:4], 16) / 255.0
2233
+ b = int(hex_color[4:6], 16) / 255.0
2234
+ a = int(hex_color[6:8], 16) / 255.0
2235
+
2236
+ return {
2237
+ "opaqueColor": {
2238
+ "rgbColor": {"red": r, "green": g, "blue": b}
2239
+ }
2240
+ }
2241
+ # Note: Google Slides API doesn't support alpha in text colors directly
2242
+ # The alpha would need to be handled differently (e.g., using SolidFill with alpha)
2243
+ except ValueError:
2244
+ logger.warning(f"Invalid hex color format: {color_value}")
2245
+ return None
2246
+
2247
+ # Handle RGB dict format
2248
+ elif isinstance(color_value, dict):
2249
+ if "r" in color_value and "g" in color_value and "b" in color_value:
2250
+ return {
2251
+ "opaqueColor": {
2252
+ "rgbColor": {
2253
+ "red": color_value["r"] / 255.0,
2254
+ "green": color_value["g"] / 255.0,
2255
+ "blue": color_value["b"] / 255.0,
2256
+ }
2257
+ }
2258
+ }
2259
+ elif (
2260
+ "red" in color_value
2261
+ and "green" in color_value
2262
+ and "blue" in color_value
2263
+ ):
2264
+ return {
2265
+ "opaqueColor": {
2266
+ "rgbColor": {
2267
+ "red": color_value["red"],
2268
+ "green": color_value["green"],
2269
+ "blue": color_value["blue"],
2270
+ }
2271
+ }
2272
+ }
2273
+
2274
+ logger.warning(f"Unsupported color format: {color_value}")
2275
+ return None
2276
+
2277
+ def _build_image_request_generic(
2278
+ self, object_id: str, slide_id: str, element: dict[str, Any]
2279
+ ) -> dict[str, Any]:
2280
+ """Generic helper to build image creation request with smart sizing support"""
2281
+ pos = element["position"]
2282
+
2283
+ request = {
2284
+ "createImage": {
2285
+ "objectId": object_id,
2286
+ "url": element["content"], # For images, content is the URL
2287
+ "elementProperties": {
2288
+ "pageObjectId": slide_id,
2289
+ "transform": {
2290
+ "scaleX": 1,
2291
+ "scaleY": 1,
2292
+ "translateX": pos["x"],
2293
+ "translateY": pos["y"],
2294
+ "unit": "PT",
2295
+ },
2296
+ },
2297
+ }
2298
+ }
2299
+
2300
+ # Smart sizing: handle different dimension specifications
2301
+ # IMPORTANT: Google Slides API limitation - createImage requires BOTH width and height
2302
+ # if size is specified, or omit size entirely. Single dimension causes UNIT_UNSPECIFIED error.
2303
+
2304
+ if "width" in pos and "height" in pos and pos["width"] and pos["height"]:
2305
+ # Both dimensions specified - exact sizing
2306
+ request["createImage"]["elementProperties"]["size"] = {
2307
+ "width": {"magnitude": pos["width"], "unit": "PT"},
2308
+ "height": {"magnitude": pos["height"], "unit": "PT"},
2309
+ }
2310
+ elif "height" in pos and pos["height"]:
2311
+ # Only height specified - assume this is for portrait/vertical images
2312
+ # Use a reasonable aspect ratio (3:4 portrait) to calculate width
2313
+ height = pos["height"]
2314
+ width = height * (3.0 / 4.0) # 3:4 aspect ratio (portrait)
2315
+ logger.info(
2316
+ f"Image height specified ({height}pt), calculating proportional width ({width:.1f}pt) using 3:4 aspect ratio"
2317
+ )
2318
+ request["createImage"]["elementProperties"]["size"] = {
2319
+ "width": {"magnitude": width, "unit": "PT"},
2320
+ "height": {"magnitude": height, "unit": "PT"},
2321
+ }
2322
+ elif "width" in pos and pos["width"]:
2323
+ # Only width specified - assume this is for landscape images
2324
+ # Use a reasonable aspect ratio (16:9 landscape) to calculate height
2325
+ width = pos["width"]
2326
+ height = width * (9.0 / 16.0) # 16:9 aspect ratio (landscape)
2327
+ logger.info(
2328
+ f"Image width specified ({width}pt), calculating proportional height ({height:.1f}pt) using 16:9 aspect ratio"
2329
+ )
2330
+ request["createImage"]["elementProperties"]["size"] = {
2331
+ "width": {"magnitude": width, "unit": "PT"},
2332
+ "height": {"magnitude": height, "unit": "PT"},
2333
+ }
2334
+ # If neither width nor height specified, omit size - image uses natural dimensions
2335
+
2336
+ return request
2337
+
2338
+ def _build_table_request_generic(
2339
+ self, object_id: str, slide_id: str, element: dict[str, Any]
2340
+ ) -> list[dict[str, Any]]:
2341
+ """Generic helper to build table creation requests with data population"""
2342
+ pos = element["position"]
2343
+ content = element["content"]
2344
+
2345
+ # Extract table data
2346
+ headers = content.get("headers", [])
2347
+ rows_data = content.get("rows", [])
2348
+
2349
+ # Calculate table dimensions
2350
+ num_rows = len(rows_data) + (
2351
+ 1 if headers else 0
2352
+ ) # Add 1 for headers if present
2353
+ num_columns = max(
2354
+ len(headers) if headers else 0,
2355
+ max(len(row) for row in rows_data) if rows_data else 0,
2356
+ )
2357
+
2358
+ if num_columns == 0 or num_rows == 0:
2359
+ logger.warning("Table has no data, skipping creation")
2360
+ return []
2361
+
2362
+ requests = []
2363
+
2364
+ # Create table request
2365
+ create_table_request = {
2366
+ "createTable": {
2367
+ "objectId": object_id,
2368
+ "elementProperties": {
2369
+ "pageObjectId": slide_id,
2370
+ "size": {
2371
+ "width": {"magnitude": pos["width"], "unit": "PT"},
2372
+ "height": {"magnitude": pos["height"], "unit": "PT"},
2373
+ },
2374
+ "transform": {
2375
+ "scaleX": 1,
2376
+ "scaleY": 1,
2377
+ "translateX": pos["x"],
2378
+ "translateY": pos["y"],
2379
+ "unit": "PT",
2380
+ },
2381
+ },
2382
+ "rows": num_rows,
2383
+ "columns": num_columns,
2384
+ }
2385
+ }
2386
+ requests.append(create_table_request)
2387
+
2388
+ # Populate table with data
2389
+ current_row = 0
2390
+
2391
+ # Insert headers if present
2392
+ if headers:
2393
+ for col_index, header_text in enumerate(headers):
2394
+ if col_index < num_columns and header_text:
2395
+ requests.append(
2396
+ {
2397
+ "insertText": {
2398
+ "objectId": object_id,
2399
+ "cellLocation": {
2400
+ "rowIndex": current_row,
2401
+ "columnIndex": col_index,
2402
+ },
2403
+ "text": str(header_text),
2404
+ "insertionIndex": 0,
2405
+ }
2406
+ }
2407
+ )
2408
+ current_row += 1
2409
+
2410
+ # Insert row data
2411
+ for row_index, row in enumerate(rows_data):
2412
+ table_row_index = current_row + row_index
2413
+ for col_index, cell_text in enumerate(row):
2414
+ if col_index < num_columns and cell_text and table_row_index < num_rows:
2415
+ requests.append(
2416
+ {
2417
+ "insertText": {
2418
+ "objectId": object_id,
2419
+ "cellLocation": {
2420
+ "rowIndex": table_row_index,
2421
+ "columnIndex": col_index,
2422
+ },
2423
+ "text": str(cell_text),
2424
+ "insertionIndex": 0,
2425
+ }
2426
+ }
2427
+ )
2428
+
2429
+ # Add table styling based on style configuration
2430
+ style = element.get("style", {})
2431
+
2432
+ # Add styling based on prompt rules
2433
+ if style:
2434
+ # Make first column bold if specified (prompt rule)
2435
+ if style.get("firstColumnBold", False):
2436
+ for row_idx in range(num_rows):
2437
+ requests.append(
2438
+ {
2439
+ "updateTextStyle": {
2440
+ "objectId": object_id,
2441
+ "cellLocation": {
2442
+ "rowIndex": row_idx,
2443
+ "columnIndex": 0,
2444
+ },
2445
+ "style": {"bold": True},
2446
+ "fields": "bold",
2447
+ }
2448
+ }
2449
+ )
2450
+ # Add header row styling if headers are present
2451
+ if headers and style.get("headerStyle"):
2452
+ header_style = style["headerStyle"]
2453
+ for col_index in range(len(headers)):
2454
+ if header_style.get("bold"):
2455
+ requests.append(
2456
+ {
2457
+ "updateTextStyle": {
2458
+ "objectId": object_id,
2459
+ "cellLocation": {
2460
+ "rowIndex": 0,
2461
+ "columnIndex": col_index,
2462
+ },
2463
+ "style": {"bold": True},
2464
+ "fields": "bold",
2465
+ }
2466
+ }
2467
+ )
2468
+
2469
+ if header_style.get("backgroundColor"):
2470
+ color_obj, alpha = self._parse_color_with_alpha(
2471
+ header_style["backgroundColor"]
2472
+ )
2473
+ if color_obj:
2474
+ requests.append(
2475
+ {
2476
+ "updateTableCellProperties": {
2477
+ "objectId": object_id,
2478
+ "tableRange": {
2479
+ "location": {
2480
+ "rowIndex": 0,
2481
+ "columnIndex": col_index,
2482
+ },
2483
+ "rowSpan": 1,
2484
+ "columnSpan": 1,
2485
+ },
2486
+ "tableCellProperties": {
2487
+ "tableCellBackgroundFill": {
2488
+ "solidFill": {
2489
+ "color": color_obj,
2490
+ "alpha": alpha,
2491
+ }
2492
+ }
2493
+ },
2494
+ "fields": "tableCellBackgroundFill",
2495
+ }
2496
+ }
2497
+ )
2498
+
2499
+ return requests
2500
+
2501
+ def _hex_to_rgb(self, hex_color: str) -> dict[str, float]:
2502
+ """Convert hex color to RGB values (0-1 range)"""
2503
+ # Remove # if present
2504
+ hex_color = hex_color.lstrip("#")
2505
+
2506
+ # Handle 8-character hex (with alpha) by taking first 6 characters
2507
+ if len(hex_color) == 8:
2508
+ hex_color = hex_color[:6]
2509
+
2510
+ # Convert to RGB
2511
+ try:
2512
+ r = int(hex_color[0:2], 16) / 255.0
2513
+ g = int(hex_color[2:4], 16) / 255.0
2514
+ b = int(hex_color[4:6], 16) / 255.0
2515
+ return {"red": r, "green": g, "blue": b}
2516
+ except ValueError:
2517
+ # Default to light pink if conversion fails
2518
+ return {"red": 0.97, "green": 0.8, "blue": 0.8}
2519
+
2520
+ def update_text_formatting(
2521
+ self,
2522
+ presentation_id: str,
2523
+ element_id: str,
2524
+ formatted_text: str,
2525
+ font_size: float | None = None,
2526
+ font_family: str | None = None,
2527
+ text_alignment: str | None = None,
2528
+ vertical_alignment: str | None = None,
2529
+ start_index: int | None = None,
2530
+ end_index: int | None = None,
2531
+ ) -> dict[str, Any]:
2532
+ """
2533
+ Update formatting of text in an existing text box with support for font and alignment parameters.
2534
+
2535
+ Args:
2536
+ presentation_id: The ID of the presentation
2537
+ element_id: The ID of the text box element
2538
+ formatted_text: Text with formatting markers (**, *, etc.)
2539
+ font_size: Optional font size in points (e.g., 25, 7.5)
2540
+ font_family: Optional font family (e.g., "Playfair Display", "Roboto", "Arial")
2541
+ text_alignment: Optional horizontal alignment ("LEFT", "CENTER", "RIGHT", "JUSTIFY")
2542
+ vertical_alignment: Optional vertical alignment ("TOP", "MIDDLE", "BOTTOM")
2543
+ start_index: Optional start index for applying formatting to specific range (0-based)
2544
+ end_index: Optional end index for applying formatting to specific range (exclusive)
2545
+
2546
+ Returns:
2547
+ Response data or error information
2548
+ """
2549
+ try:
2550
+ import re
2551
+
2552
+ # First, replace the text content if needed
2553
+ plain_text = formatted_text
2554
+ # Remove bold markers
2555
+ plain_text = re.sub(r"\*\*(.*?)\*\*", r"\1", plain_text)
2556
+ # Remove italic markers
2557
+ plain_text = re.sub(r"\*(.*?)\*", r"\1", plain_text)
2558
+ # Remove code markers if present
2559
+ plain_text = re.sub(r"`(.*?)`", r"\1", plain_text)
2560
+
2561
+ # Update the text content first if it has formatting markers
2562
+ if plain_text != formatted_text:
2563
+ update_text_request = {
2564
+ "deleteText": {"objectId": element_id, "textRange": {"type": "ALL"}}
2565
+ }
2566
+
2567
+ insert_text_request = {
2568
+ "insertText": {
2569
+ "objectId": element_id,
2570
+ "insertionIndex": 0,
2571
+ "text": plain_text,
2572
+ }
2573
+ }
2574
+
2575
+ # Execute text replacement
2576
+ self.service.presentations().batchUpdate(
2577
+ presentationId=presentation_id,
2578
+ body={"requests": [update_text_request, insert_text_request]},
2579
+ ).execute()
2580
+
2581
+ # Generate style requests
2582
+ style_requests = []
2583
+
2584
+ # If font_size or font_family are specified, apply them to the specified range or entire text
2585
+ if font_size is not None or font_family is not None:
2586
+ style = {}
2587
+ fields = []
2588
+
2589
+ if font_size is not None:
2590
+ style["fontSize"] = {"magnitude": font_size, "unit": "PT"}
2591
+ fields.append("fontSize")
2592
+
2593
+ if font_family is not None:
2594
+ style["fontFamily"] = font_family
2595
+ fields.append("fontFamily")
2596
+
2597
+ if style:
2598
+ text_range = {"type": "ALL"}
2599
+ if start_index is not None and end_index is not None:
2600
+ text_range = {
2601
+ "type": "FIXED_RANGE",
2602
+ "startIndex": start_index,
2603
+ "endIndex": end_index,
2604
+ }
2605
+
2606
+ style_requests.append(
2607
+ {
2608
+ "updateTextStyle": {
2609
+ "objectId": element_id,
2610
+ "textRange": text_range,
2611
+ "style": style,
2612
+ "fields": ",".join(fields),
2613
+ }
2614
+ }
2615
+ )
2616
+
2617
+ # Handle text alignment (paragraph-level formatting)
2618
+ if text_alignment is not None:
2619
+ # Map alignment values to Google Slides API format
2620
+ alignment_map = {
2621
+ "LEFT": "START",
2622
+ "CENTER": "CENTER",
2623
+ "RIGHT": "END",
2624
+ "JUSTIFY": "JUSTIFIED",
2625
+ }
2626
+
2627
+ api_alignment = alignment_map.get(text_alignment.upper(), "START")
2628
+ if api_alignment:
2629
+ text_range = {"type": "ALL"}
2630
+ if start_index is not None and end_index is not None:
2631
+ text_range = {
2632
+ "type": "FIXED_RANGE",
2633
+ "startIndex": start_index,
2634
+ "endIndex": end_index,
2635
+ }
2636
+
2637
+ style_requests.append(
2638
+ {
2639
+ "updateParagraphStyle": {
2640
+ "objectId": element_id,
2641
+ "textRange": text_range,
2642
+ "style": {"alignment": api_alignment},
2643
+ "fields": "alignment",
2644
+ }
2645
+ }
2646
+ )
2647
+
2648
+ # Handle vertical alignment (content alignment for the entire text box)
2649
+ if vertical_alignment is not None:
2650
+ # Map vertical alignment values to Google Slides API format
2651
+ valign_map = {"TOP": "TOP", "MIDDLE": "MIDDLE", "BOTTOM": "BOTTOM"}
2652
+
2653
+ api_valign = valign_map.get(vertical_alignment.upper(), "TOP")
2654
+ if api_valign:
2655
+ style_requests.append(
2656
+ {
2657
+ "updateShapeProperties": {
2658
+ "objectId": element_id,
2659
+ "shapeProperties": {"contentAlignment": api_valign},
2660
+ "fields": "contentAlignment",
2661
+ }
2662
+ }
2663
+ )
2664
+
2665
+ # Process bold text formatting
2666
+ bold_pattern = r"\*\*(.*?)\*\*"
2667
+ bold_matches = list(re.finditer(bold_pattern, formatted_text))
2668
+
2669
+ text_offset = 0 # Track offset due to removed markers
2670
+ for match in bold_matches:
2671
+ content = match.group(1)
2672
+ # Calculate position in plain text
2673
+ start_pos = match.start() - text_offset
2674
+ end_pos = start_pos + len(content)
2675
+
2676
+ style_requests.append(
2677
+ {
2678
+ "updateTextStyle": {
2679
+ "objectId": element_id,
2680
+ "textRange": {
2681
+ "type": "FIXED_RANGE",
2682
+ "startIndex": start_pos,
2683
+ "endIndex": end_pos,
2684
+ },
2685
+ "style": {"bold": True},
2686
+ "fields": "bold",
2687
+ }
2688
+ }
2689
+ )
2690
+
2691
+ # Update offset (removed 4 characters: **)
2692
+ text_offset += 4
2693
+
2694
+ # Process italic text formatting
2695
+ italic_pattern = r"\*(.*?)\*"
2696
+ italic_matches = list(re.finditer(italic_pattern, formatted_text))
2697
+
2698
+ text_offset = 0 # Reset offset for italic processing
2699
+ for match in italic_matches:
2700
+ content = match.group(1)
2701
+ # Skip if this is part of a bold pattern
2702
+ if any(
2703
+ bold_match.start() <= match.start() < bold_match.end()
2704
+ for bold_match in bold_matches
2705
+ ):
2706
+ continue
2707
+
2708
+ start_pos = match.start() - text_offset
2709
+ end_pos = start_pos + len(content)
2710
+
2711
+ style_requests.append(
2712
+ {
2713
+ "updateTextStyle": {
2714
+ "objectId": element_id,
2715
+ "textRange": {
2716
+ "type": "FIXED_RANGE",
2717
+ "startIndex": start_pos,
2718
+ "endIndex": end_pos,
2719
+ },
2720
+ "style": {"italic": True},
2721
+ "fields": "italic",
2722
+ }
2723
+ }
2724
+ )
2725
+
2726
+ # Update offset (removed 2 characters: *)
2727
+ text_offset += 2
2728
+
2729
+ # Process code text formatting
2730
+ code_pattern = r"`(.*?)`"
2731
+ code_matches = list(re.finditer(code_pattern, formatted_text))
2732
+
2733
+ text_offset = 0 # Reset offset for code processing
2734
+ for match in code_matches:
2735
+ content = match.group(1)
2736
+ start_pos = match.start() - text_offset
2737
+ end_pos = start_pos + len(content)
2738
+
2739
+ style_requests.append(
2740
+ {
2741
+ "updateTextStyle": {
2742
+ "objectId": element_id,
2743
+ "textRange": {
2744
+ "type": "FIXED_RANGE",
2745
+ "startIndex": start_pos,
2746
+ "endIndex": end_pos,
2747
+ },
2748
+ "style": {
2749
+ "fontFamily": "Courier New",
2750
+ "backgroundColor": {
2751
+ "opaqueColor": {
2752
+ "rgbColor": {
2753
+ "red": 0.95,
2754
+ "green": 0.95,
2755
+ "blue": 0.95,
2756
+ }
2757
+ }
2758
+ },
2759
+ },
2760
+ "fields": "fontFamily,backgroundColor",
2761
+ }
2762
+ }
2763
+ )
2764
+
2765
+ # Update offset (removed 2 characters: `)
2766
+ text_offset += 2
2767
+
2768
+ # Execute all style requests
2769
+ if style_requests:
2770
+ logger.info(f"Applying {len(style_requests)} formatting requests")
2771
+ response = (
2772
+ self.service.presentations()
2773
+ .batchUpdate(
2774
+ presentationId=presentation_id,
2775
+ body={"requests": style_requests},
2776
+ )
2777
+ .execute()
2778
+ )
2779
+
2780
+ return {
2781
+ "presentationId": presentation_id,
2782
+ "elementId": element_id,
2783
+ "appliedFormats": {
2784
+ "fontSize": font_size,
2785
+ "fontFamily": font_family,
2786
+ "textAlignment": text_alignment,
2787
+ "verticalAlignment": vertical_alignment,
2788
+ "textRange": (
2789
+ {"startIndex": start_index, "endIndex": end_index}
2790
+ if start_index is not None and end_index is not None
2791
+ else "ALL"
2792
+ ),
2793
+ },
2794
+ "operation": "update_text_formatting",
2795
+ "result": "success",
2796
+ }
2797
+
2798
+ return {"result": "no_formatting_applied"}
2799
+
2800
+ except Exception as e:
2801
+ return self.handle_api_error("update_text_formatting", e)
2802
+
2803
+ def convert_template_zones_to_pt(
2804
+ self, template_zones: dict[str, Any]
2805
+ ) -> dict[str, Any]:
2806
+ """
2807
+ Convert template zones coordinates from EMU to PT for easier slide element creation.
2808
+
2809
+ Args:
2810
+ template_zones: Template zones from extract_template_zones_only
2811
+
2812
+ Returns:
2813
+ Template zones with additional PT coordinates (x_pt, y_pt, width_pt, height_pt)
2814
+ """
2815
+ try:
2816
+ return convert_template_zones(template_zones, target_unit="PT")
2817
+ except Exception as e:
2818
+ return self.handle_api_error("convert_template_zones_to_pt", e)
2819
+
2820
+ def _parse_color_with_alpha(
2821
+ self, color_value: str | dict
2822
+ ) -> tuple[dict | None, float]:
2823
+ """Parse color value with alpha support for Google Slides API format.
2824
+
2825
+ Args:
2826
+ color_value: Color as hex string (e.g., "#ffffff", "#ffffff80"), RGB dict, rgba() string, or theme color
2827
+
2828
+ Returns:
2829
+ Tuple of (color_object, alpha_value) where alpha is 0.0-1.0
2830
+ """
2831
+ if not color_value:
2832
+ return None, 1.0
2833
+
2834
+ alpha = 1.0 # Default to fully opaque
2835
+
2836
+ # Handle hex color strings
2837
+ if isinstance(color_value, str):
2838
+ if color_value.lower() == "white":
2839
+ color_value = "#ffffff"
2840
+ elif color_value.lower() == "black":
2841
+ color_value = "#000000"
2842
+
2843
+ # Handle rgba() CSS format
2844
+ if color_value.startswith("rgba("):
2845
+ import re
2846
+
2847
+ # Parse rgba(r, g, b, a) format
2848
+ match = re.match(
2849
+ r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)",
2850
+ color_value,
2851
+ )
2852
+ if match:
2853
+ r = int(match.group(1)) / 255.0
2854
+ g = int(match.group(2)) / 255.0
2855
+ b = int(match.group(3)) / 255.0
2856
+ alpha = float(match.group(4))
2857
+
2858
+ color_obj = {"rgbColor": {"red": r, "green": g, "blue": b}}
2859
+ return color_obj, alpha
2860
+ else:
2861
+ logger.warning(f"Invalid rgba format: {color_value}")
2862
+ return None, 1.0
2863
+
2864
+ # Handle rgb() CSS format (no alpha)
2865
+ elif color_value.startswith("rgb("):
2866
+ import re
2867
+
2868
+ # Parse rgb(r, g, b) format
2869
+ match = re.match(
2870
+ r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", color_value
2871
+ )
2872
+ if match:
2873
+ r = int(match.group(1)) / 255.0
2874
+ g = int(match.group(2)) / 255.0
2875
+ b = int(match.group(3)) / 255.0
2876
+
2877
+ color_obj = {"rgbColor": {"red": r, "green": g, "blue": b}}
2878
+ return color_obj, alpha
2879
+ else:
2880
+ logger.warning(f"Invalid rgb format: {color_value}")
2881
+ return None, 1.0
2882
+
2883
+ elif color_value.startswith("#"):
2884
+ # Convert hex to RGB
2885
+ try:
2886
+ hex_color = color_value.lstrip("#")
2887
+ if len(hex_color) == 6:
2888
+ r = int(hex_color[0:2], 16) / 255.0
2889
+ g = int(hex_color[2:4], 16) / 255.0
2890
+ b = int(hex_color[4:6], 16) / 255.0
2891
+
2892
+ color_obj = {"rgbColor": {"red": r, "green": g, "blue": b}}
2893
+ return color_obj, alpha
2894
+ elif len(hex_color) == 8:
2895
+ # Handle 8-character hex with alpha (RRGGBBAA)
2896
+ r = int(hex_color[0:2], 16) / 255.0
2897
+ g = int(hex_color[2:4], 16) / 255.0
2898
+ b = int(hex_color[4:6], 16) / 255.0
2899
+ alpha = int(hex_color[6:8], 16) / 255.0 # Extract alpha
2900
+
2901
+ color_obj = {"rgbColor": {"red": r, "green": g, "blue": b}}
2902
+ return color_obj, alpha
2903
+ except ValueError:
2904
+ logger.warning(f"Invalid hex color format: {color_value}")
2905
+ return None, 1.0
2906
+
2907
+ # Handle RGB dict format
2908
+ elif isinstance(color_value, dict):
2909
+ if "r" in color_value and "g" in color_value and "b" in color_value:
2910
+ color_obj = {
2911
+ "rgbColor": {
2912
+ "red": color_value["r"] / 255.0,
2913
+ "green": color_value["g"] / 255.0,
2914
+ "blue": color_value["b"] / 255.0,
2915
+ }
2916
+ }
2917
+ alpha = color_value.get("a", 255) / 255.0 # Handle alpha if present
2918
+ return color_obj, alpha
2919
+ elif (
2920
+ "red" in color_value
2921
+ and "green" in color_value
2922
+ and "blue" in color_value
2923
+ ):
2924
+ color_obj = {
2925
+ "rgbColor": {
2926
+ "red": color_value["red"],
2927
+ "green": color_value["green"],
2928
+ "blue": color_value["blue"],
2929
+ }
2930
+ }
2931
+ alpha = color_value.get("alpha", 1.0) # Handle alpha if present
2932
+ return color_obj, alpha
2933
+
2934
+ logger.warning(f"Unsupported color format: {color_value}")
2935
+ return None, 1.0