google-workspace-mcp 1.0.4__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.
- google_workspace_mcp/__main__.py +6 -5
- google_workspace_mcp/services/drive.py +39 -12
- google_workspace_mcp/services/slides.py +2033 -57
- google_workspace_mcp/tools/add_image.py +1781 -0
- google_workspace_mcp/tools/calendar.py +12 -17
- google_workspace_mcp/tools/docs_tools.py +24 -32
- google_workspace_mcp/tools/drive.py +264 -21
- google_workspace_mcp/tools/gmail.py +27 -36
- google_workspace_mcp/tools/sheets_tools.py +18 -25
- google_workspace_mcp/tools/slides.py +774 -55
- google_workspace_mcp/utils/unit_conversion.py +201 -0
- {google_workspace_mcp-1.0.4.dist-info → google_workspace_mcp-1.1.5.dist-info}/METADATA +2 -2
- {google_workspace_mcp-1.0.4.dist-info → google_workspace_mcp-1.1.5.dist-info}/RECORD +15 -13
- {google_workspace_mcp-1.0.4.dist-info → google_workspace_mcp-1.1.5.dist-info}/WHEEL +0 -0
- {google_workspace_mcp-1.0.4.dist-info → google_workspace_mcp-1.1.5.dist-info}/entry_points.txt +0 -0
@@ -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
|
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(
|
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(
|
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()
|
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(
|
165
|
-
|
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()
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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()
|
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(
|
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": {
|
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(
|
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(
|
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(
|
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(
|
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(
|
609
|
+
result = create_presentation(
|
610
|
+
markdown=markdown_content, title=title, credentials=credentials
|
611
|
+
)
|
549
612
|
|
550
|
-
logger.info(
|
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 =
|
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"][
|
659
|
+
for text_element in element["shape"]["text"][
|
660
|
+
"textElements"
|
661
|
+
]:
|
591
662
|
if "textRun" in text_element:
|
592
|
-
text_parts.append(
|
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
|
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"][
|
700
|
+
for text_element in element["shape"]["text"][
|
701
|
+
"textElements"
|
702
|
+
]:
|
627
703
|
if "textRun" in text_element:
|
628
|
-
note_parts.append(
|
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(
|
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()
|
744
|
+
self.service.presentations()
|
745
|
+
.batchUpdate(
|
746
|
+
presentationId=presentation_id, body={"requests": requests}
|
747
|
+
)
|
748
|
+
.execute()
|
665
749
|
)
|
666
750
|
|
667
|
-
logger.info(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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()
|
1083
|
+
self.service.presentations()
|
1084
|
+
.batchUpdate(
|
1085
|
+
presentationId=presentation_id, body={"requests": requests}
|
1086
|
+
)
|
1087
|
+
.execute()
|
899
1088
|
)
|
900
1089
|
|
901
|
-
logger.info(
|
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(
|
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"] =
|
1123
|
+
duplicate_request["duplicateObject"]["insertionIndex"] = str(
|
1124
|
+
insert_at_index
|
1125
|
+
)
|
931
1126
|
|
932
|
-
logger.info(
|
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(
|
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 =
|
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
|