google-workspace-mcp 1.1.5__py3-none-any.whl → 1.2.0__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 +5 -6
- google_workspace_mcp/models.py +486 -0
- google_workspace_mcp/services/calendar.py +14 -4
- google_workspace_mcp/services/drive.py +237 -14
- google_workspace_mcp/services/sheets_service.py +273 -35
- google_workspace_mcp/services/slides.py +42 -1829
- google_workspace_mcp/tools/calendar.py +116 -100
- google_workspace_mcp/tools/docs_tools.py +99 -57
- google_workspace_mcp/tools/drive.py +112 -92
- google_workspace_mcp/tools/gmail.py +131 -66
- google_workspace_mcp/tools/sheets_tools.py +137 -64
- google_workspace_mcp/tools/slides.py +295 -743
- {google_workspace_mcp-1.1.5.dist-info → google_workspace_mcp-1.2.0.dist-info}/METADATA +3 -2
- {google_workspace_mcp-1.1.5.dist-info → google_workspace_mcp-1.2.0.dist-info}/RECORD +16 -17
- google_workspace_mcp/tools/add_image.py +0 -1781
- google_workspace_mcp/utils/unit_conversion.py +0 -201
- {google_workspace_mcp-1.1.5.dist-info → google_workspace_mcp-1.2.0.dist-info}/WHEEL +0 -0
- {google_workspace_mcp-1.1.5.dist-info → google_workspace_mcp-1.2.0.dist-info}/entry_points.txt +0 -0
@@ -12,7 +12,6 @@ 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
|
16
15
|
|
17
16
|
logger = logging.getLogger(__name__)
|
18
17
|
|
@@ -783,13 +782,12 @@ class SlidesService(BaseGoogleService):
|
|
783
782
|
Response data or error information
|
784
783
|
"""
|
785
784
|
try:
|
786
|
-
# Create a unique element ID
|
787
|
-
|
785
|
+
# Create a unique element ID
|
786
|
+
f"image_{slide_id}_{hash(image_url) % 10000}"
|
788
787
|
|
789
788
|
# Define the base request
|
790
789
|
create_image_request = {
|
791
790
|
"createImage": {
|
792
|
-
"objectId": image_id, # FIX: Add the missing objectId
|
793
791
|
"url": image_url,
|
794
792
|
"elementProperties": {
|
795
793
|
"pageObjectId": slide_id,
|
@@ -798,7 +796,7 @@ class SlidesService(BaseGoogleService):
|
|
798
796
|
"scaleY": 1,
|
799
797
|
"translateX": position[0],
|
800
798
|
"translateY": position[1],
|
801
|
-
"unit": "PT",
|
799
|
+
"unit": "PT",
|
802
800
|
},
|
803
801
|
},
|
804
802
|
}
|
@@ -843,90 +841,6 @@ class SlidesService(BaseGoogleService):
|
|
843
841
|
except Exception as e:
|
844
842
|
return self.handle_api_error("add_image", e)
|
845
843
|
|
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
|
-
|
930
844
|
def add_table(
|
931
845
|
self,
|
932
846
|
presentation_id: str,
|
@@ -1120,9 +1034,7 @@ class SlidesService(BaseGoogleService):
|
|
1120
1034
|
|
1121
1035
|
# If insert location is specified
|
1122
1036
|
if insert_at_index is not None:
|
1123
|
-
duplicate_request["duplicateObject"]["insertionIndex"] =
|
1124
|
-
insert_at_index
|
1125
|
-
)
|
1037
|
+
duplicate_request["duplicateObject"]["insertionIndex"] = insert_at_index
|
1126
1038
|
|
1127
1039
|
logger.info(
|
1128
1040
|
f"Sending API request to duplicate slide: {json.dumps(duplicate_request, indent=2)}"
|
@@ -1159,1777 +1071,78 @@ class SlidesService(BaseGoogleService):
|
|
1159
1071
|
except Exception as e:
|
1160
1072
|
return self.handle_api_error("duplicate_slide", e)
|
1161
1073
|
|
1162
|
-
def
|
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(
|
1074
|
+
def embed_sheets_chart(
|
1229
1075
|
self,
|
1230
1076
|
presentation_id: str,
|
1231
1077
|
slide_id: str,
|
1232
|
-
|
1078
|
+
spreadsheet_id: str,
|
1079
|
+
chart_id: int,
|
1233
1080
|
position: tuple[float, float],
|
1234
1081
|
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
1082
|
) -> dict[str, Any]:
|
1243
1083
|
"""
|
1244
|
-
|
1084
|
+
Embeds a chart from Google Sheets into a Google Slides presentation.
|
1245
1085
|
|
1246
1086
|
Args:
|
1247
|
-
presentation_id: The ID of the presentation
|
1248
|
-
slide_id: The ID of the slide
|
1249
|
-
|
1250
|
-
|
1251
|
-
|
1252
|
-
|
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)
|
1087
|
+
presentation_id: The ID of the presentation.
|
1088
|
+
slide_id: The ID of the slide to add the chart to.
|
1089
|
+
spreadsheet_id: The ID of the Google Sheet containing the chart.
|
1090
|
+
chart_id: The ID of the chart within the sheet.
|
1091
|
+
position: Tuple of (x, y) coordinates for position in PT.
|
1092
|
+
size: Tuple of (width, height) for the chart size in PT.
|
1259
1093
|
|
1260
1094
|
Returns:
|
1261
|
-
|
1095
|
+
The API response from the batchUpdate call.
|
1262
1096
|
"""
|
1263
1097
|
try:
|
1264
|
-
|
1265
|
-
|
1266
|
-
|
1267
|
-
|
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
|
-
)
|
1098
|
+
element_id = f"chart_{chart_id}_{int(__import__('time').time() * 1000)}"
|
1099
|
+
logger.info(
|
1100
|
+
f"Embedding chart {chart_id} from sheet {spreadsheet_id} into slide {slide_id}"
|
1101
|
+
)
|
1285
1102
|
|
1286
|
-
# Build requests with proper sequence
|
1287
1103
|
requests = [
|
1288
|
-
# Step 1: Create text box shape (no autofit - API limitation)
|
1289
1104
|
{
|
1290
|
-
"
|
1105
|
+
"createSheetsChart": {
|
1291
1106
|
"objectId": element_id,
|
1292
|
-
"
|
1107
|
+
"spreadsheetId": spreadsheet_id,
|
1108
|
+
"chartId": chart_id,
|
1109
|
+
"linkingMode": "LINKED",
|
1293
1110
|
"elementProperties": {
|
1294
1111
|
"pageObjectId": slide_id,
|
1295
|
-
"size": {
|
1112
|
+
"size": {
|
1113
|
+
"width": {"magnitude": size[0], "unit": "PT"},
|
1114
|
+
"height": {"magnitude": size[1], "unit": "PT"},
|
1115
|
+
},
|
1296
1116
|
"transform": {
|
1297
1117
|
"scaleX": 1,
|
1298
1118
|
"scaleY": 1,
|
1299
1119
|
"translateX": position[0],
|
1300
1120
|
"translateY": position[1],
|
1301
|
-
"unit":
|
1121
|
+
"unit": "PT",
|
1302
1122
|
},
|
1303
1123
|
},
|
1304
1124
|
}
|
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
1125
|
}
|
1126
|
+
]
|
1344
1127
|
|
1345
|
-
|
1346
|
-
|
1347
|
-
|
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}
|
1128
|
+
response = self.batch_update(presentation_id, requests)
|
1129
|
+
if response.get("error"):
|
1130
|
+
raise ValueError(
|
1131
|
+
response.get("message", "Batch update for chart embedding failed")
|
1437
1132
|
)
|
1438
|
-
.execute()
|
1439
|
-
)
|
1440
1133
|
|
1441
|
-
|
1442
|
-
|
1134
|
+
created_element_id = (
|
1135
|
+
response.get("replies", [{}])[0]
|
1136
|
+
.get("createSheetsChart", {})
|
1137
|
+
.get("objectId")
|
1443
1138
|
)
|
1444
1139
|
|
1445
1140
|
return {
|
1446
|
-
"
|
1447
|
-
"
|
1448
|
-
"
|
1449
|
-
"
|
1450
|
-
"replies": response.get("replies", []),
|
1451
|
-
"writeControl": response.get("writeControl", {}),
|
1141
|
+
"success": True,
|
1142
|
+
"presentation_id": presentation_id,
|
1143
|
+
"slide_id": slide_id,
|
1144
|
+
"element_id": created_element_id or element_id,
|
1452
1145
|
}
|
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
1146
|
|
1557
1147
|
except Exception as e:
|
1558
|
-
return self.handle_api_error("
|
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
|
1148
|
+
return self.handle_api_error("embed_sheets_chart", e)
|