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.
@@ -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 (FIX: Actually assign the variable!)
787
- image_id = f"image_{slide_id}_{hash(image_url) % 10000}"
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", # Could use "EMU" to match docs
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"] = str(
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 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(
1074
+ def embed_sheets_chart(
1229
1075
  self,
1230
1076
  presentation_id: str,
1231
1077
  slide_id: str,
1232
- text: str,
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
- Create a text box with text, font formatting, and alignment.
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 (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)
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
- Response data or error information
1095
+ The API response from the batchUpdate call.
1262
1096
  """
1263
1097
  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
- )
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
- "createShape": {
1105
+ "createSheetsChart": {
1291
1106
  "objectId": element_id,
1292
- "shapeType": "TEXT_BOX",
1107
+ "spreadsheetId": spreadsheet_id,
1108
+ "chartId": chart_id,
1109
+ "linkingMode": "LINKED",
1293
1110
  "elementProperties": {
1294
1111
  "pageObjectId": slide_id,
1295
- "size": {"height": height, "width": width},
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": 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
- 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}
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
- logger.info(
1442
- f"Batch update completed successfully. Response: {json.dumps(response, indent=2)}"
1134
+ created_element_id = (
1135
+ response.get("replies", [{}])[0]
1136
+ .get("createSheetsChart", {})
1137
+ .get("objectId")
1443
1138
  )
1444
1139
 
1445
1140
  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", {}),
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("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
1148
+ return self.handle_api_error("embed_sheets_chart", e)