google-workspace-mcp 1.0.4__py3-none-any.whl → 1.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- google_workspace_mcp/__main__.py +6 -5
- google_workspace_mcp/services/drive.py +39 -12
- google_workspace_mcp/services/slides.py +2033 -57
- google_workspace_mcp/tools/add_image.py +1781 -0
- google_workspace_mcp/tools/calendar.py +12 -17
- google_workspace_mcp/tools/docs_tools.py +24 -32
- google_workspace_mcp/tools/drive.py +264 -21
- google_workspace_mcp/tools/gmail.py +27 -36
- google_workspace_mcp/tools/sheets_tools.py +18 -25
- google_workspace_mcp/tools/slides.py +774 -55
- google_workspace_mcp/utils/unit_conversion.py +201 -0
- {google_workspace_mcp-1.0.4.dist-info → google_workspace_mcp-1.1.5.dist-info}/METADATA +2 -2
- {google_workspace_mcp-1.0.4.dist-info → google_workspace_mcp-1.1.5.dist-info}/RECORD +15 -13
- {google_workspace_mcp-1.0.4.dist-info → google_workspace_mcp-1.1.5.dist-info}/WHEEL +0 -0
- {google_workspace_mcp-1.0.4.dist-info → google_workspace_mcp-1.1.5.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1781 @@
|
|
1
|
+
"""
|
2
|
+
Precise Google Slides Image Positioning - Python Implementation
|
3
|
+
Uses your existing BaseGoogleService infrastructure for authentication.
|
4
|
+
"""
|
5
|
+
|
6
|
+
import logging
|
7
|
+
import re
|
8
|
+
from dataclasses import dataclass
|
9
|
+
from typing import Any, Dict, List, Optional
|
10
|
+
|
11
|
+
from google_workspace_mcp.app import mcp # Import from central app module
|
12
|
+
from google_workspace_mcp.services.base import BaseGoogleService
|
13
|
+
from google_workspace_mcp.utils.unit_conversion import convert_template_zone_coordinates
|
14
|
+
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
@dataclass
|
19
|
+
class ImageZone:
|
20
|
+
"""Represents a positioning zone for images on a slide."""
|
21
|
+
|
22
|
+
x: int # EMU coordinates
|
23
|
+
y: int
|
24
|
+
width: int
|
25
|
+
height: int
|
26
|
+
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
28
|
+
"""Convert to Google Slides API format."""
|
29
|
+
return {
|
30
|
+
"size": {
|
31
|
+
"width": {"magnitude": self.width, "unit": "EMU"},
|
32
|
+
"height": {"magnitude": self.height, "unit": "EMU"},
|
33
|
+
},
|
34
|
+
"transform": {
|
35
|
+
"scaleX": 1,
|
36
|
+
"scaleY": 1,
|
37
|
+
"translateX": self.x,
|
38
|
+
"translateY": self.y,
|
39
|
+
"unit": "EMU",
|
40
|
+
},
|
41
|
+
}
|
42
|
+
|
43
|
+
|
44
|
+
class PreciseSlidesPositioning(BaseGoogleService):
|
45
|
+
"""
|
46
|
+
Precise image positioning for Google Slides using EMU coordinates.
|
47
|
+
Extends your existing BaseGoogleService for consistent authentication.
|
48
|
+
"""
|
49
|
+
|
50
|
+
def __init__(self):
|
51
|
+
super().__init__("slides", "v1")
|
52
|
+
|
53
|
+
@staticmethod
|
54
|
+
def inches_to_emu(inches: float) -> int:
|
55
|
+
"""Convert inches to EMU (English Metric Units). 1 inch = 914,400 EMU."""
|
56
|
+
return int(inches * 914400)
|
57
|
+
|
58
|
+
@staticmethod
|
59
|
+
def emu_to_inches(emu: int) -> float:
|
60
|
+
"""Convert EMU to inches for human-readable dimensions."""
|
61
|
+
return emu / 914400
|
62
|
+
|
63
|
+
def get_template_zones(self) -> Dict[str, ImageZone]:
|
64
|
+
"""
|
65
|
+
Define the positioning zones based on your template layout.
|
66
|
+
Standard Google Slides: 10" x 5.625" (widescreen 16:9)
|
67
|
+
"""
|
68
|
+
slide_width = self.inches_to_emu(10)
|
69
|
+
slide_height = self.inches_to_emu(5.625)
|
70
|
+
|
71
|
+
zones = {
|
72
|
+
# Full slide background
|
73
|
+
"background": ImageZone(x=0, y=0, width=slide_width, height=slide_height),
|
74
|
+
# Title area (top section)
|
75
|
+
"title": ImageZone(
|
76
|
+
x=self.inches_to_emu(0.5),
|
77
|
+
y=self.inches_to_emu(0.3),
|
78
|
+
width=self.inches_to_emu(9),
|
79
|
+
height=self.inches_to_emu(0.8),
|
80
|
+
),
|
81
|
+
# Left content area (copy block)
|
82
|
+
"left_content": ImageZone(
|
83
|
+
x=self.inches_to_emu(0.5),
|
84
|
+
y=self.inches_to_emu(1.3),
|
85
|
+
width=self.inches_to_emu(4),
|
86
|
+
height=self.inches_to_emu(3.8),
|
87
|
+
),
|
88
|
+
# Right image block (your main focus)
|
89
|
+
"right_image_block": ImageZone(
|
90
|
+
x=self.inches_to_emu(5),
|
91
|
+
y=self.inches_to_emu(1.3),
|
92
|
+
width=self.inches_to_emu(4.5),
|
93
|
+
height=self.inches_to_emu(3.8),
|
94
|
+
),
|
95
|
+
}
|
96
|
+
|
97
|
+
return zones
|
98
|
+
|
99
|
+
def add_background_image(
|
100
|
+
self, presentation_id: str, slide_id: str, image_url: str
|
101
|
+
) -> Dict[str, Any]:
|
102
|
+
"""
|
103
|
+
Add a background image that fills the entire slide.
|
104
|
+
|
105
|
+
Args:
|
106
|
+
presentation_id: The ID of the presentation
|
107
|
+
slide_id: The ID of the slide
|
108
|
+
image_url: Publicly accessible URL of the background image
|
109
|
+
|
110
|
+
Returns:
|
111
|
+
API response or error details
|
112
|
+
"""
|
113
|
+
try:
|
114
|
+
zones = self.get_template_zones()
|
115
|
+
background_zone = zones["background"]
|
116
|
+
|
117
|
+
object_id = f"background_{int(__import__('time').time() * 1000)}"
|
118
|
+
|
119
|
+
requests = [
|
120
|
+
{
|
121
|
+
"createImage": {
|
122
|
+
"objectId": object_id,
|
123
|
+
"url": image_url,
|
124
|
+
"elementProperties": {
|
125
|
+
"pageObjectId": slide_id,
|
126
|
+
**background_zone.to_dict(),
|
127
|
+
},
|
128
|
+
}
|
129
|
+
}
|
130
|
+
]
|
131
|
+
|
132
|
+
response = (
|
133
|
+
self.service.presentations()
|
134
|
+
.batchUpdate(
|
135
|
+
presentationId=presentation_id, body={"requests": requests}
|
136
|
+
)
|
137
|
+
.execute()
|
138
|
+
)
|
139
|
+
|
140
|
+
logger.info(f"Background image added successfully: {object_id}")
|
141
|
+
return {
|
142
|
+
"success": True,
|
143
|
+
"object_id": object_id,
|
144
|
+
"zone": "background",
|
145
|
+
"response": response,
|
146
|
+
}
|
147
|
+
|
148
|
+
except Exception as error:
|
149
|
+
return self.handle_api_error("add_background_image", error)
|
150
|
+
|
151
|
+
def add_right_side_image(
|
152
|
+
self, presentation_id: str, slide_id: str, image_url: str
|
153
|
+
) -> Dict[str, Any]:
|
154
|
+
"""
|
155
|
+
Add an image to the right image block with precise positioning.
|
156
|
+
|
157
|
+
Args:
|
158
|
+
presentation_id: The ID of the presentation
|
159
|
+
slide_id: The ID of the slide
|
160
|
+
image_url: Publicly accessible URL of the portrait image
|
161
|
+
|
162
|
+
Returns:
|
163
|
+
API response or error details
|
164
|
+
"""
|
165
|
+
try:
|
166
|
+
zones = self.get_template_zones()
|
167
|
+
right_zone = zones["right_image_block"]
|
168
|
+
|
169
|
+
object_id = f"right_image_{int(__import__('time').time() * 1000)}"
|
170
|
+
|
171
|
+
requests = [
|
172
|
+
{
|
173
|
+
"createImage": {
|
174
|
+
"objectId": object_id,
|
175
|
+
"url": image_url,
|
176
|
+
"elementProperties": {
|
177
|
+
"pageObjectId": slide_id,
|
178
|
+
**right_zone.to_dict(),
|
179
|
+
},
|
180
|
+
}
|
181
|
+
}
|
182
|
+
]
|
183
|
+
|
184
|
+
response = (
|
185
|
+
self.service.presentations()
|
186
|
+
.batchUpdate(
|
187
|
+
presentationId=presentation_id, body={"requests": requests}
|
188
|
+
)
|
189
|
+
.execute()
|
190
|
+
)
|
191
|
+
|
192
|
+
logger.info(f"Right side image added successfully: {object_id}")
|
193
|
+
return {
|
194
|
+
"success": True,
|
195
|
+
"object_id": object_id,
|
196
|
+
"zone": "right_image_block",
|
197
|
+
"response": response,
|
198
|
+
}
|
199
|
+
|
200
|
+
except Exception as error:
|
201
|
+
return self.handle_api_error("add_right_side_image", error)
|
202
|
+
|
203
|
+
def add_image_to_zone(
|
204
|
+
self,
|
205
|
+
presentation_id: str,
|
206
|
+
slide_id: str,
|
207
|
+
image_url: str,
|
208
|
+
zone_name: str,
|
209
|
+
custom_zone: Optional[ImageZone] = None,
|
210
|
+
) -> Dict[str, Any]:
|
211
|
+
"""
|
212
|
+
Add an image to any specified zone with precise positioning.
|
213
|
+
|
214
|
+
Args:
|
215
|
+
presentation_id: The ID of the presentation
|
216
|
+
slide_id: The ID of the slide
|
217
|
+
image_url: Publicly accessible URL of the image
|
218
|
+
zone_name: Name of predefined zone or 'custom'
|
219
|
+
custom_zone: Custom ImageZone if zone_name is 'custom'
|
220
|
+
|
221
|
+
Returns:
|
222
|
+
API response or error details
|
223
|
+
"""
|
224
|
+
try:
|
225
|
+
if zone_name == "custom" and custom_zone:
|
226
|
+
zone = custom_zone
|
227
|
+
else:
|
228
|
+
zones = self.get_template_zones()
|
229
|
+
if zone_name not in zones:
|
230
|
+
raise ValueError(
|
231
|
+
f"Unknown zone: {zone_name}. Available: {list(zones.keys())}"
|
232
|
+
)
|
233
|
+
zone = zones[zone_name]
|
234
|
+
|
235
|
+
object_id = f"{zone_name}_{int(__import__('time').time() * 1000)}"
|
236
|
+
|
237
|
+
requests = [
|
238
|
+
{
|
239
|
+
"createImage": {
|
240
|
+
"objectId": object_id,
|
241
|
+
"url": image_url,
|
242
|
+
"elementProperties": {
|
243
|
+
"pageObjectId": slide_id,
|
244
|
+
**zone.to_dict(),
|
245
|
+
},
|
246
|
+
}
|
247
|
+
}
|
248
|
+
]
|
249
|
+
|
250
|
+
response = (
|
251
|
+
self.service.presentations()
|
252
|
+
.batchUpdate(
|
253
|
+
presentationId=presentation_id, body={"requests": requests}
|
254
|
+
)
|
255
|
+
.execute()
|
256
|
+
)
|
257
|
+
|
258
|
+
logger.info(f"Image added to {zone_name} successfully: {object_id}")
|
259
|
+
return {
|
260
|
+
"success": True,
|
261
|
+
"object_id": object_id,
|
262
|
+
"zone": zone_name,
|
263
|
+
"response": response,
|
264
|
+
}
|
265
|
+
|
266
|
+
except Exception as error:
|
267
|
+
return self.handle_api_error("add_image_to_zone", error)
|
268
|
+
|
269
|
+
def get_existing_element_positions(
|
270
|
+
self, presentation_id: str, slide_id: str
|
271
|
+
) -> Dict[str, Any]:
|
272
|
+
"""
|
273
|
+
Extract exact positions, dimensions, and content of existing elements from a template slide.
|
274
|
+
Use this to reverse-engineer your template coordinates and understand element content.
|
275
|
+
|
276
|
+
Args:
|
277
|
+
presentation_id: The ID of the presentation
|
278
|
+
slide_id: The ID of the slide
|
279
|
+
|
280
|
+
Returns:
|
281
|
+
Dictionary of element positions, content, and metadata or error details
|
282
|
+
"""
|
283
|
+
try:
|
284
|
+
response = (
|
285
|
+
self.service.presentations()
|
286
|
+
.pages()
|
287
|
+
.get(presentationId=presentation_id, pageObjectId=slide_id)
|
288
|
+
.execute()
|
289
|
+
)
|
290
|
+
|
291
|
+
elements = response.get("pageElements", [])
|
292
|
+
positions = {}
|
293
|
+
|
294
|
+
for element in elements:
|
295
|
+
if "objectId" in element:
|
296
|
+
obj_id = element["objectId"]
|
297
|
+
|
298
|
+
# Get basic positioning info
|
299
|
+
size = element.get("size", {})
|
300
|
+
transform = element.get("transform", {})
|
301
|
+
|
302
|
+
# Determine element type and extract content
|
303
|
+
element_info = self._extract_element_content(element)
|
304
|
+
|
305
|
+
# Get scaling factors
|
306
|
+
scale_x = transform.get("scaleX", 1)
|
307
|
+
scale_y = transform.get("scaleY", 1)
|
308
|
+
|
309
|
+
# Calculate actual visual positions
|
310
|
+
actual_pos = self.calculate_actual_position(
|
311
|
+
transform.get("translateX", 0),
|
312
|
+
transform.get("translateY", 0),
|
313
|
+
size.get("width", {}).get("magnitude", 0),
|
314
|
+
size.get("height", {}).get("magnitude", 0),
|
315
|
+
scale_x,
|
316
|
+
scale_y,
|
317
|
+
)
|
318
|
+
|
319
|
+
positions[obj_id] = {
|
320
|
+
# Raw position and size data
|
321
|
+
"x_emu": transform.get("translateX", 0),
|
322
|
+
"y_emu": transform.get("translateY", 0),
|
323
|
+
"width_emu": size.get("width", {}).get("magnitude", 0),
|
324
|
+
"height_emu": size.get("height", {}).get("magnitude", 0),
|
325
|
+
"x_inches": self.emu_to_inches(transform.get("translateX", 0)),
|
326
|
+
"y_inches": self.emu_to_inches(transform.get("translateY", 0)),
|
327
|
+
"width_inches": self.emu_to_inches(
|
328
|
+
size.get("width", {}).get("magnitude", 0)
|
329
|
+
),
|
330
|
+
"height_inches": self.emu_to_inches(
|
331
|
+
size.get("height", {}).get("magnitude", 0)
|
332
|
+
),
|
333
|
+
"scaleX": scale_x,
|
334
|
+
"scaleY": scale_y,
|
335
|
+
# Actual visual positions (accounting for scaling)
|
336
|
+
**actual_pos,
|
337
|
+
# Content and type information
|
338
|
+
**element_info,
|
339
|
+
}
|
340
|
+
|
341
|
+
logger.info(
|
342
|
+
f"Retrieved positions and content for {len(positions)} elements"
|
343
|
+
)
|
344
|
+
return {"success": True, "elements": positions, "slide_id": slide_id}
|
345
|
+
|
346
|
+
except Exception as error:
|
347
|
+
return self.handle_api_error("get_existing_element_positions", error)
|
348
|
+
|
349
|
+
def _extract_element_content(self, element: Dict[str, Any]) -> Dict[str, Any]:
|
350
|
+
"""
|
351
|
+
Extract content and metadata from a slide element.
|
352
|
+
|
353
|
+
Args:
|
354
|
+
element: Raw element data from Google Slides API
|
355
|
+
|
356
|
+
Returns:
|
357
|
+
Dictionary with element type, content, and metadata
|
358
|
+
"""
|
359
|
+
element_info = {
|
360
|
+
"element_type": "unknown",
|
361
|
+
"content": None,
|
362
|
+
"content_type": None,
|
363
|
+
"metadata": {},
|
364
|
+
"raw_element_keys": list(element.keys()), # Debug: show all available keys
|
365
|
+
}
|
366
|
+
|
367
|
+
# Text box or shape with text
|
368
|
+
if "shape" in element:
|
369
|
+
element_info["element_type"] = "shape"
|
370
|
+
shape = element["shape"]
|
371
|
+
|
372
|
+
# Get shape type
|
373
|
+
shape_type = shape.get("shapeType", "UNSPECIFIED")
|
374
|
+
element_info["metadata"]["shape_type"] = shape_type
|
375
|
+
|
376
|
+
# Extract text content
|
377
|
+
if "text" in shape:
|
378
|
+
text_content = self._extract_text_content(shape["text"])
|
379
|
+
element_info["content"] = text_content["text"]
|
380
|
+
element_info["content_type"] = "text"
|
381
|
+
element_info["metadata"]["text_formatting"] = text_content["formatting"]
|
382
|
+
else:
|
383
|
+
element_info["content_type"] = "shape_no_text"
|
384
|
+
|
385
|
+
# Image element
|
386
|
+
elif "image" in element:
|
387
|
+
element_info["element_type"] = "image"
|
388
|
+
element_info["content_type"] = "image"
|
389
|
+
image = element["image"]
|
390
|
+
|
391
|
+
# Get image properties
|
392
|
+
element_info["content"] = image.get("sourceUrl", "No URL available")
|
393
|
+
element_info["metadata"] = {
|
394
|
+
"image_properties": image.get("imageProperties", {}),
|
395
|
+
"content_url": image.get("contentUrl", None),
|
396
|
+
}
|
397
|
+
|
398
|
+
# Line element
|
399
|
+
elif "line" in element:
|
400
|
+
element_info["element_type"] = "line"
|
401
|
+
element_info["content_type"] = "line"
|
402
|
+
line = element["line"]
|
403
|
+
element_info["metadata"] = {
|
404
|
+
"line_type": line.get("lineType", "UNKNOWN"),
|
405
|
+
"line_properties": line.get("lineProperties", {}),
|
406
|
+
}
|
407
|
+
|
408
|
+
# Video element
|
409
|
+
elif "video" in element:
|
410
|
+
element_info["element_type"] = "video"
|
411
|
+
element_info["content_type"] = "video"
|
412
|
+
video = element["video"]
|
413
|
+
element_info["content"] = video.get("url", "No URL available")
|
414
|
+
element_info["metadata"] = {
|
415
|
+
"video_properties": video.get("videoProperties", {})
|
416
|
+
}
|
417
|
+
|
418
|
+
# Table element
|
419
|
+
elif "table" in element:
|
420
|
+
element_info["element_type"] = "table"
|
421
|
+
element_info["content_type"] = "table"
|
422
|
+
table = element["table"]
|
423
|
+
|
424
|
+
# Extract table structure and content
|
425
|
+
table_content = self._extract_table_content(table)
|
426
|
+
element_info["content"] = table_content
|
427
|
+
element_info["metadata"] = {
|
428
|
+
"rows": table.get("rows", 0),
|
429
|
+
"columns": table.get("columns", 0),
|
430
|
+
}
|
431
|
+
|
432
|
+
# Group element
|
433
|
+
elif "elementGroup" in element:
|
434
|
+
element_info["element_type"] = "group"
|
435
|
+
element_info["content_type"] = "group"
|
436
|
+
group = element["elementGroup"]
|
437
|
+
element_info["metadata"] = {
|
438
|
+
"children_count": len(group.get("children", [])),
|
439
|
+
"children_ids": [
|
440
|
+
child.get("objectId") for child in group.get("children", [])
|
441
|
+
],
|
442
|
+
}
|
443
|
+
|
444
|
+
# Placeholder or other special elements
|
445
|
+
if "placeholder" in element:
|
446
|
+
placeholder = element["placeholder"]
|
447
|
+
element_info["metadata"]["placeholder"] = {
|
448
|
+
"type": placeholder.get("type", "UNKNOWN"),
|
449
|
+
"index": placeholder.get("index", 0),
|
450
|
+
}
|
451
|
+
|
452
|
+
return element_info
|
453
|
+
|
454
|
+
def _extract_text_content(self, text_element: Dict[str, Any]) -> Dict[str, Any]:
|
455
|
+
"""
|
456
|
+
Extract text content and formatting from a text element.
|
457
|
+
|
458
|
+
Args:
|
459
|
+
text_element: Text element from Google Slides API
|
460
|
+
|
461
|
+
Returns:
|
462
|
+
Dictionary with text content and formatting information
|
463
|
+
"""
|
464
|
+
text_content = {"text": "", "formatting": []}
|
465
|
+
|
466
|
+
text_runs = text_element.get("textElements", [])
|
467
|
+
|
468
|
+
for text_run in text_runs:
|
469
|
+
if "textRun" in text_run:
|
470
|
+
run = text_run["textRun"]
|
471
|
+
content = run.get("content", "")
|
472
|
+
text_content["text"] += content
|
473
|
+
|
474
|
+
# Extract formatting if available
|
475
|
+
style = run.get("style", {})
|
476
|
+
if style:
|
477
|
+
text_content["formatting"].append(
|
478
|
+
{
|
479
|
+
"content": content,
|
480
|
+
"font_family": style.get("fontFamily", ""),
|
481
|
+
"font_size": style.get("fontSize", {}).get("magnitude", 0),
|
482
|
+
"bold": style.get("bold", False),
|
483
|
+
"italic": style.get("italic", False),
|
484
|
+
"foreground_color": style.get("foregroundColor", {}),
|
485
|
+
"background_color": style.get("backgroundColor", {}),
|
486
|
+
}
|
487
|
+
)
|
488
|
+
|
489
|
+
elif "autoText" in text_run:
|
490
|
+
# Handle auto text (like slide numbers, dates, etc.)
|
491
|
+
auto_text = text_run["autoText"]
|
492
|
+
text_content["text"] += f"[AUTO: {auto_text.get('type', 'UNKNOWN')}]"
|
493
|
+
|
494
|
+
return text_content
|
495
|
+
|
496
|
+
def _extract_table_content(self, table_element: Dict[str, Any]) -> List[List[str]]:
|
497
|
+
"""
|
498
|
+
Extract content from table cells.
|
499
|
+
|
500
|
+
Args:
|
501
|
+
table_element: Table element from Google Slides API
|
502
|
+
|
503
|
+
Returns:
|
504
|
+
2D list representing table content
|
505
|
+
"""
|
506
|
+
table_content = []
|
507
|
+
|
508
|
+
table_rows = table_element.get("tableRows", [])
|
509
|
+
for row in table_rows:
|
510
|
+
row_content = []
|
511
|
+
table_cells = row.get("tableCells", [])
|
512
|
+
|
513
|
+
for cell in table_cells:
|
514
|
+
cell_text = ""
|
515
|
+
if "text" in cell:
|
516
|
+
cell_text_data = self._extract_text_content(cell["text"])
|
517
|
+
cell_text = cell_text_data["text"].strip()
|
518
|
+
row_content.append(cell_text)
|
519
|
+
|
520
|
+
table_content.append(row_content)
|
521
|
+
|
522
|
+
return table_content
|
523
|
+
|
524
|
+
def calculate_actual_position(
|
525
|
+
self,
|
526
|
+
x_emu: int,
|
527
|
+
y_emu: int,
|
528
|
+
width_emu: int,
|
529
|
+
height_emu: int,
|
530
|
+
scale_x: float = 1.0,
|
531
|
+
scale_y: float = 1.0,
|
532
|
+
) -> Dict[str, float]:
|
533
|
+
"""
|
534
|
+
Calculate the actual visual position and size considering scaling.
|
535
|
+
|
536
|
+
Args:
|
537
|
+
x_emu, y_emu: Position in EMU
|
538
|
+
width_emu, height_emu: Size in EMU
|
539
|
+
scale_x, scale_y: Scaling factors
|
540
|
+
|
541
|
+
Returns:
|
542
|
+
Dictionary with actual positions and sizes in inches
|
543
|
+
"""
|
544
|
+
return {
|
545
|
+
"actual_x_inches": self.emu_to_inches(x_emu),
|
546
|
+
"actual_y_inches": self.emu_to_inches(y_emu),
|
547
|
+
"actual_width_inches": self.emu_to_inches(width_emu) * scale_x,
|
548
|
+
"actual_height_inches": self.emu_to_inches(height_emu) * scale_y,
|
549
|
+
"visual_right_edge_inches": self.emu_to_inches(x_emu)
|
550
|
+
+ (self.emu_to_inches(width_emu) * scale_x),
|
551
|
+
"visual_bottom_edge_inches": self.emu_to_inches(y_emu)
|
552
|
+
+ (self.emu_to_inches(height_emu) * scale_y),
|
553
|
+
}
|
554
|
+
|
555
|
+
def implement_complete_template(
|
556
|
+
self,
|
557
|
+
presentation_id: str,
|
558
|
+
slide_id: str,
|
559
|
+
background_url: str,
|
560
|
+
portrait_url: str,
|
561
|
+
) -> Dict[str, Any]:
|
562
|
+
"""
|
563
|
+
Implement your complete template with background and portrait images.
|
564
|
+
|
565
|
+
Args:
|
566
|
+
presentation_id: The ID of the presentation
|
567
|
+
slide_id: The ID of the slide
|
568
|
+
background_url: URL for background image (https://i.ibb.co/4RXQbYGB/IMG-7774.jpg)
|
569
|
+
portrait_url: URL for portrait image (https://i.ibb.co/HLWpZmPS/20250122-KEVI4992-kevinostaj.jpg)
|
570
|
+
|
571
|
+
Returns:
|
572
|
+
Combined results or error details
|
573
|
+
"""
|
574
|
+
try:
|
575
|
+
results = {"success": True, "operations": []}
|
576
|
+
|
577
|
+
# Step 1: Add background image
|
578
|
+
bg_result = self.add_background_image(
|
579
|
+
presentation_id, slide_id, background_url
|
580
|
+
)
|
581
|
+
results["operations"].append(bg_result)
|
582
|
+
|
583
|
+
if not bg_result.get("success", False):
|
584
|
+
logger.error(
|
585
|
+
"Background image failed, stopping template implementation"
|
586
|
+
)
|
587
|
+
return bg_result
|
588
|
+
|
589
|
+
# Step 2: Add portrait image to right block
|
590
|
+
portrait_result = self.add_right_side_image(
|
591
|
+
presentation_id, slide_id, portrait_url
|
592
|
+
)
|
593
|
+
results["operations"].append(portrait_result)
|
594
|
+
|
595
|
+
if not portrait_result.get("success", False):
|
596
|
+
logger.error("Portrait image failed")
|
597
|
+
results["success"] = False
|
598
|
+
|
599
|
+
# Step 3: Return zone information for reference
|
600
|
+
zones = self.get_template_zones()
|
601
|
+
results["template_zones"] = {
|
602
|
+
name: {
|
603
|
+
"x_inches": self.emu_to_inches(zone.x),
|
604
|
+
"y_inches": self.emu_to_inches(zone.y),
|
605
|
+
"width_inches": self.emu_to_inches(zone.width),
|
606
|
+
"height_inches": self.emu_to_inches(zone.height),
|
607
|
+
}
|
608
|
+
for name, zone in zones.items()
|
609
|
+
}
|
610
|
+
|
611
|
+
logger.info("Template implementation completed successfully")
|
612
|
+
return results
|
613
|
+
|
614
|
+
except Exception as error:
|
615
|
+
return self.handle_api_error("implement_complete_template", error)
|
616
|
+
|
617
|
+
def extract_template_zones_by_text(
|
618
|
+
self, presentation_id: str, slide_id: str, unit: str = "EMU"
|
619
|
+
) -> Dict[str, Any]:
|
620
|
+
"""
|
621
|
+
Extract positioning zones from a template slide by finding placeholder text elements.
|
622
|
+
This gives us the exact coordinates where content should be placed.
|
623
|
+
|
624
|
+
Args:
|
625
|
+
presentation_id: The ID of the presentation
|
626
|
+
slide_id: The ID of the template slide
|
627
|
+
unit: Target unit for coordinates ("EMU", "PT", or "INCHES"). Default is "EMU".
|
628
|
+
|
629
|
+
Returns:
|
630
|
+
Dictionary with zone information including coordinates in both EMU and specified unit
|
631
|
+
"""
|
632
|
+
try:
|
633
|
+
# Validate unit parameter
|
634
|
+
if unit not in ["EMU", "PT", "INCHES"]:
|
635
|
+
raise ValueError("unit must be 'EMU', 'PT', or 'INCHES'")
|
636
|
+
|
637
|
+
elements_result = self.get_existing_element_positions(
|
638
|
+
presentation_id, slide_id
|
639
|
+
)
|
640
|
+
|
641
|
+
if not elements_result.get("success"):
|
642
|
+
return {"error": "Failed to read template slide"}
|
643
|
+
|
644
|
+
elements = elements_result["elements"]
|
645
|
+
template_zones = {}
|
646
|
+
|
647
|
+
# Keywords to look for in template text (case-insensitive)
|
648
|
+
zone_keywords = {
|
649
|
+
"image block": "image_block",
|
650
|
+
"background image": "background_image",
|
651
|
+
"full-bleed background": "background_image",
|
652
|
+
"copy block": "copy_block",
|
653
|
+
"slide copy block": "slide_copy",
|
654
|
+
"press recap slide title": "press_recap_slide_title",
|
655
|
+
"slide title": "slide_title",
|
656
|
+
"logo": "logo_area",
|
657
|
+
"brand": "logo_area",
|
658
|
+
# Add more keywords for stat blocks and phone images
|
659
|
+
"stat a": "stat_a",
|
660
|
+
"stat b": "stat_b",
|
661
|
+
"stat c": "stat_c",
|
662
|
+
"stat d": "stat_d",
|
663
|
+
"phone image a": "phone_image_a",
|
664
|
+
"phone image b": "phone_image_b",
|
665
|
+
"phone image c": "phone_image_c",
|
666
|
+
"image title": "image_title",
|
667
|
+
"data table a": "data_table_a",
|
668
|
+
"data summary copy block": "data_summary_copy_block",
|
669
|
+
"table title": "table_title",
|
670
|
+
"summary slide title": "summary_slide_title",
|
671
|
+
"summary slide copy block": "summary_slide_copy_block",
|
672
|
+
"thank you copy": "thank_you_copy",
|
673
|
+
}
|
674
|
+
|
675
|
+
# Scan all text elements for template keywords
|
676
|
+
for element_id, element_info in elements.items():
|
677
|
+
if element_info.get("content_type") == "text" and element_info.get(
|
678
|
+
"content"
|
679
|
+
):
|
680
|
+
content = element_info["content"].lower().strip()
|
681
|
+
|
682
|
+
# Check if this text element matches any template zone
|
683
|
+
for keyword, zone_name in zone_keywords.items():
|
684
|
+
if keyword in content:
|
685
|
+
# Use actual visual dimensions if element is scaled
|
686
|
+
x_inches = element_info.get("x_inches", 0)
|
687
|
+
y_inches = element_info.get("y_inches", 0)
|
688
|
+
|
689
|
+
if element_info.get(
|
690
|
+
"actual_width_inches"
|
691
|
+
) and element_info.get("actual_height_inches"):
|
692
|
+
width_inches = element_info["actual_width_inches"]
|
693
|
+
height_inches = element_info["actual_height_inches"]
|
694
|
+
else:
|
695
|
+
width_inches = element_info.get("width_inches", 0)
|
696
|
+
height_inches = element_info.get("height_inches", 0)
|
697
|
+
|
698
|
+
# Base zone data with EMU and inches coordinates
|
699
|
+
zone_data = {
|
700
|
+
"zone_name": zone_name,
|
701
|
+
"original_text": element_info["content"],
|
702
|
+
"element_id": element_id,
|
703
|
+
# Coordinates in inches (human readable)
|
704
|
+
"x_inches": x_inches,
|
705
|
+
"y_inches": y_inches,
|
706
|
+
"width_inches": width_inches,
|
707
|
+
"height_inches": height_inches,
|
708
|
+
# Coordinates in EMU (for API calls)
|
709
|
+
"x_emu": self.inches_to_emu(x_inches),
|
710
|
+
"y_emu": self.inches_to_emu(y_inches),
|
711
|
+
"width_emu": self.inches_to_emu(width_inches),
|
712
|
+
"height_emu": self.inches_to_emu(height_inches),
|
713
|
+
# Scaling info
|
714
|
+
"scale_x": element_info.get("scaleX", 1),
|
715
|
+
"scale_y": element_info.get("scaleY", 1),
|
716
|
+
# ImageZone object for easy use
|
717
|
+
"image_zone": ImageZone(
|
718
|
+
x=self.inches_to_emu(x_inches),
|
719
|
+
y=self.inches_to_emu(y_inches),
|
720
|
+
width=self.inches_to_emu(width_inches),
|
721
|
+
height=self.inches_to_emu(height_inches),
|
722
|
+
),
|
723
|
+
}
|
724
|
+
|
725
|
+
# Add coordinates in the requested unit if not EMU
|
726
|
+
if unit != "EMU":
|
727
|
+
zone_data = convert_template_zone_coordinates(
|
728
|
+
zone_data, unit
|
729
|
+
)
|
730
|
+
|
731
|
+
template_zones[zone_name] = zone_data
|
732
|
+
|
733
|
+
unit_suffix = unit.lower() if unit != "EMU" else "emu"
|
734
|
+
width_key = (
|
735
|
+
f"width_{unit_suffix}" if unit != "EMU" else "width_emu"
|
736
|
+
)
|
737
|
+
height_key = (
|
738
|
+
f"height_{unit_suffix}"
|
739
|
+
if unit != "EMU"
|
740
|
+
else "height_emu"
|
741
|
+
)
|
742
|
+
x_key = f"x_{unit_suffix}" if unit != "EMU" else "x_emu"
|
743
|
+
y_key = f"y_{unit_suffix}" if unit != "EMU" else "y_emu"
|
744
|
+
|
745
|
+
width_val = zone_data.get(width_key, width_inches)
|
746
|
+
height_val = zone_data.get(height_key, height_inches)
|
747
|
+
x_val = zone_data.get(x_key, x_inches)
|
748
|
+
y_val = zone_data.get(y_key, y_inches)
|
749
|
+
|
750
|
+
logger.info(
|
751
|
+
f"🎯 Found template zone '{zone_name}' from text '{content}': {width_val:.2f} {unit}×{height_val:.2f} {unit} at ({x_val:.2f} {unit}, {y_val:.2f} {unit})"
|
752
|
+
)
|
753
|
+
break # Found a match, move to next element
|
754
|
+
|
755
|
+
return {
|
756
|
+
"success": True,
|
757
|
+
"zones": template_zones,
|
758
|
+
"slide_id": slide_id,
|
759
|
+
"unit": unit,
|
760
|
+
}
|
761
|
+
|
762
|
+
except Exception as error:
|
763
|
+
return self.handle_api_error("extract_template_zones_by_text", error)
|
764
|
+
|
765
|
+
def place_image_in_template_zone(
|
766
|
+
self,
|
767
|
+
presentation_id: str,
|
768
|
+
slide_id: str,
|
769
|
+
zone_name: str,
|
770
|
+
image_url: str,
|
771
|
+
template_zones: Dict[str, Any],
|
772
|
+
) -> Dict[str, Any]:
|
773
|
+
"""
|
774
|
+
Place an image in a specific template zone, replacing the placeholder text.
|
775
|
+
|
776
|
+
Args:
|
777
|
+
presentation_id: The ID of the presentation
|
778
|
+
slide_id: The ID of the slide
|
779
|
+
zone_name: Name of the template zone (e.g., "image_block")
|
780
|
+
image_url: URL of the image to place
|
781
|
+
template_zones: Template zones extracted from extract_template_zones_by_text()
|
782
|
+
|
783
|
+
Returns:
|
784
|
+
API response or error details
|
785
|
+
"""
|
786
|
+
try:
|
787
|
+
zones = template_zones.get("zones", {})
|
788
|
+
if zone_name not in zones:
|
789
|
+
raise ValueError(
|
790
|
+
f"Zone '{zone_name}' not found in template. Available zones: {list(zones.keys())}"
|
791
|
+
)
|
792
|
+
|
793
|
+
zone_info = zones[zone_name]
|
794
|
+
image_zone = zone_info["image_zone"]
|
795
|
+
|
796
|
+
object_id = f"{zone_name}_{int(__import__('time').time() * 1000)}"
|
797
|
+
|
798
|
+
requests = [
|
799
|
+
{
|
800
|
+
"createImage": {
|
801
|
+
"objectId": object_id,
|
802
|
+
"url": image_url,
|
803
|
+
"elementProperties": {
|
804
|
+
"pageObjectId": slide_id,
|
805
|
+
**image_zone.to_dict(),
|
806
|
+
},
|
807
|
+
}
|
808
|
+
}
|
809
|
+
]
|
810
|
+
|
811
|
+
response = (
|
812
|
+
self.service.presentations()
|
813
|
+
.batchUpdate(
|
814
|
+
presentationId=presentation_id, body={"requests": requests}
|
815
|
+
)
|
816
|
+
.execute()
|
817
|
+
)
|
818
|
+
|
819
|
+
logger.info(f"✅ Image placed in '{zone_name}' zone: {object_id}")
|
820
|
+
logger.info(
|
821
|
+
f"📍 Position: {zone_info['width_inches']:.2f}\"×{zone_info['height_inches']:.2f}\" at ({zone_info['x_inches']:.2f}\", {zone_info['y_inches']:.2f}\")"
|
822
|
+
)
|
823
|
+
|
824
|
+
return {
|
825
|
+
"success": True,
|
826
|
+
"object_id": object_id,
|
827
|
+
"zone_name": zone_name,
|
828
|
+
"zone_info": zone_info,
|
829
|
+
"response": response,
|
830
|
+
}
|
831
|
+
|
832
|
+
except Exception as error:
|
833
|
+
return self.handle_api_error("place_image_in_template_zone", error)
|
834
|
+
|
835
|
+
def create_presentation(self, title: str) -> Dict[str, Any]:
|
836
|
+
"""
|
837
|
+
Create a new Google Slides presentation.
|
838
|
+
|
839
|
+
Args:
|
840
|
+
title: The title of the presentation
|
841
|
+
|
842
|
+
Returns:
|
843
|
+
Presentation details or error information
|
844
|
+
"""
|
845
|
+
try:
|
846
|
+
presentation = {"title": title}
|
847
|
+
|
848
|
+
response = self.service.presentations().create(body=presentation).execute()
|
849
|
+
|
850
|
+
presentation_id = response["presentationId"]
|
851
|
+
logger.info(f"Presentation created successfully: {presentation_id}")
|
852
|
+
|
853
|
+
return {
|
854
|
+
"success": True,
|
855
|
+
"presentation_id": presentation_id,
|
856
|
+
"title": title,
|
857
|
+
"url": f"https://docs.google.com/presentation/d/{presentation_id}/edit",
|
858
|
+
"response": response,
|
859
|
+
}
|
860
|
+
|
861
|
+
except Exception as error:
|
862
|
+
return self.handle_api_error("create_presentation", error)
|
863
|
+
|
864
|
+
def create_slide(
|
865
|
+
self, presentation_id: str, layout: str = "BLANK"
|
866
|
+
) -> Dict[str, Any]:
|
867
|
+
"""
|
868
|
+
Create a new slide in an existing presentation.
|
869
|
+
|
870
|
+
Args:
|
871
|
+
presentation_id: The ID of the presentation
|
872
|
+
layout: Layout type ('BLANK', 'TITLE_AND_BODY', 'TITLE_ONLY', etc.)
|
873
|
+
|
874
|
+
Returns:
|
875
|
+
Slide details or error information
|
876
|
+
"""
|
877
|
+
try:
|
878
|
+
slide_id = f"slide_{int(__import__('time').time() * 1000)}"
|
879
|
+
|
880
|
+
requests = [
|
881
|
+
{
|
882
|
+
"createSlide": {
|
883
|
+
"objectId": slide_id,
|
884
|
+
"slideLayoutReference": {"predefinedLayout": layout},
|
885
|
+
}
|
886
|
+
}
|
887
|
+
]
|
888
|
+
|
889
|
+
response = (
|
890
|
+
self.service.presentations()
|
891
|
+
.batchUpdate(
|
892
|
+
presentationId=presentation_id, body={"requests": requests}
|
893
|
+
)
|
894
|
+
.execute()
|
895
|
+
)
|
896
|
+
|
897
|
+
logger.info(f"Slide created successfully: {slide_id}")
|
898
|
+
|
899
|
+
return {
|
900
|
+
"success": True,
|
901
|
+
"slide_id": slide_id,
|
902
|
+
"layout": layout,
|
903
|
+
"response": response,
|
904
|
+
}
|
905
|
+
|
906
|
+
except Exception as error:
|
907
|
+
return self.handle_api_error("create_slide", error)
|
908
|
+
|
909
|
+
def create_presentation_with_slides(
|
910
|
+
self, title: str, slide_count: int = 2
|
911
|
+
) -> Dict[str, Any]:
|
912
|
+
"""
|
913
|
+
Create a presentation with multiple blank slides.
|
914
|
+
|
915
|
+
Args:
|
916
|
+
title: The title of the presentation
|
917
|
+
slide_count: Number of slides to create (default: 2)
|
918
|
+
|
919
|
+
Returns:
|
920
|
+
Complete presentation setup details
|
921
|
+
"""
|
922
|
+
try:
|
923
|
+
# Create presentation
|
924
|
+
pres_result = self.create_presentation(title)
|
925
|
+
if not pres_result.get("success"):
|
926
|
+
return pres_result
|
927
|
+
|
928
|
+
presentation_id = pres_result["presentation_id"]
|
929
|
+
slides = []
|
930
|
+
|
931
|
+
# Create additional slides (presentation starts with one slide)
|
932
|
+
for i in range(
|
933
|
+
slide_count - 1
|
934
|
+
): # -1 because presentation has one slide already
|
935
|
+
slide_result = self.create_slide(presentation_id, "BLANK")
|
936
|
+
if slide_result.get("success"):
|
937
|
+
slides.append(slide_result)
|
938
|
+
else:
|
939
|
+
logger.warning(f"Failed to create slide {i+2}: {slide_result}")
|
940
|
+
|
941
|
+
# Get the first slide ID (already exists)
|
942
|
+
pres_details = (
|
943
|
+
self.service.presentations()
|
944
|
+
.get(presentationId=presentation_id)
|
945
|
+
.execute()
|
946
|
+
)
|
947
|
+
|
948
|
+
first_slide_id = pres_details["slides"][0]["objectId"]
|
949
|
+
slides.insert(
|
950
|
+
0, {"success": True, "slide_id": first_slide_id, "layout": "BLANK"}
|
951
|
+
)
|
952
|
+
|
953
|
+
logger.info(f"Presentation with {len(slides)} slides created successfully")
|
954
|
+
|
955
|
+
return {
|
956
|
+
"success": True,
|
957
|
+
"presentation_id": presentation_id,
|
958
|
+
"title": title,
|
959
|
+
"url": f"https://docs.google.com/presentation/d/{presentation_id}/edit",
|
960
|
+
"slides": slides,
|
961
|
+
"slide_ids": [
|
962
|
+
slide["slide_id"] for slide in slides if slide.get("success")
|
963
|
+
],
|
964
|
+
}
|
965
|
+
|
966
|
+
except Exception as error:
|
967
|
+
return self.handle_api_error("create_presentation_with_slides", error)
|
968
|
+
|
969
|
+
def implement_multi_slide_template(
|
970
|
+
self,
|
971
|
+
presentation_id: str,
|
972
|
+
slide_ids: list,
|
973
|
+
background_url: str,
|
974
|
+
portrait_url: str,
|
975
|
+
) -> Dict[str, Any]:
|
976
|
+
"""
|
977
|
+
Implement template across multiple slides - background on first slide, portrait on second.
|
978
|
+
|
979
|
+
Args:
|
980
|
+
presentation_id: The ID of the presentation
|
981
|
+
slide_ids: List of slide IDs [background_slide_id, portrait_slide_id]
|
982
|
+
background_url: URL for background image
|
983
|
+
portrait_url: URL for portrait image
|
984
|
+
|
985
|
+
Returns:
|
986
|
+
Results for all slide operations
|
987
|
+
"""
|
988
|
+
try:
|
989
|
+
if len(slide_ids) < 2:
|
990
|
+
raise ValueError("Need at least 2 slide IDs for multi-slide template")
|
991
|
+
|
992
|
+
results = {"success": True, "operations": []}
|
993
|
+
|
994
|
+
# Slide 1: Background image (full slide)
|
995
|
+
bg_result = self.add_background_image(
|
996
|
+
presentation_id, slide_ids[0], background_url
|
997
|
+
)
|
998
|
+
results["operations"].append(
|
999
|
+
{
|
1000
|
+
"slide": 1,
|
1001
|
+
"slide_id": slide_ids[0],
|
1002
|
+
"type": "background",
|
1003
|
+
"result": bg_result,
|
1004
|
+
}
|
1005
|
+
)
|
1006
|
+
|
1007
|
+
# Slide 2: Portrait image in right block
|
1008
|
+
portrait_result = self.add_right_side_image(
|
1009
|
+
presentation_id, slide_ids[1], portrait_url
|
1010
|
+
)
|
1011
|
+
results["operations"].append(
|
1012
|
+
{
|
1013
|
+
"slide": 2,
|
1014
|
+
"slide_id": slide_ids[1],
|
1015
|
+
"type": "portrait_right",
|
1016
|
+
"result": portrait_result,
|
1017
|
+
}
|
1018
|
+
)
|
1019
|
+
|
1020
|
+
# Check if any operations failed
|
1021
|
+
failed_ops = [
|
1022
|
+
op for op in results["operations"] if not op["result"].get("success")
|
1023
|
+
]
|
1024
|
+
if failed_ops:
|
1025
|
+
results["success"] = False
|
1026
|
+
results["failed_operations"] = failed_ops
|
1027
|
+
|
1028
|
+
logger.info(
|
1029
|
+
f"Multi-slide template implemented across {len(slide_ids)} slides"
|
1030
|
+
)
|
1031
|
+
return results
|
1032
|
+
|
1033
|
+
except Exception as error:
|
1034
|
+
return self.handle_api_error("implement_multi_slide_template", error)
|
1035
|
+
|
1036
|
+
def create_complete_presentation_workflow(
|
1037
|
+
self, title: str, background_url: str, portrait_url: str
|
1038
|
+
) -> Dict[str, Any]:
|
1039
|
+
"""
|
1040
|
+
Complete workflow: Create presentation, create slides, add images to separate slides.
|
1041
|
+
|
1042
|
+
Args:
|
1043
|
+
title: The title of the presentation
|
1044
|
+
background_url: URL for background image (first slide)
|
1045
|
+
portrait_url: URL for portrait image (second slide)
|
1046
|
+
|
1047
|
+
Returns:
|
1048
|
+
Complete workflow results
|
1049
|
+
"""
|
1050
|
+
try:
|
1051
|
+
# Step 1: Create presentation with 2 slides
|
1052
|
+
pres_result = self.create_presentation_with_slides(title, slide_count=2)
|
1053
|
+
if not pres_result.get("success"):
|
1054
|
+
return pres_result
|
1055
|
+
|
1056
|
+
presentation_id = pres_result["presentation_id"]
|
1057
|
+
slide_ids = pres_result["slide_ids"]
|
1058
|
+
|
1059
|
+
# Step 2: Implement template across slides
|
1060
|
+
template_result = self.implement_multi_slide_template(
|
1061
|
+
presentation_id, slide_ids, background_url, portrait_url
|
1062
|
+
)
|
1063
|
+
|
1064
|
+
# Step 3: Combine results
|
1065
|
+
final_result = {
|
1066
|
+
"success": template_result.get("success", False),
|
1067
|
+
"presentation_id": presentation_id,
|
1068
|
+
"title": pres_result["title"],
|
1069
|
+
"url": pres_result["url"],
|
1070
|
+
"slides": {
|
1071
|
+
"slide_1": {
|
1072
|
+
"slide_id": slide_ids[0],
|
1073
|
+
"content": "background_image",
|
1074
|
+
"url": f"{pres_result['url']}#slide=id.{slide_ids[0]}",
|
1075
|
+
},
|
1076
|
+
"slide_2": {
|
1077
|
+
"slide_id": slide_ids[1],
|
1078
|
+
"content": "portrait_image",
|
1079
|
+
"url": f"{pres_result['url']}#slide=id.{slide_ids[1]}",
|
1080
|
+
},
|
1081
|
+
},
|
1082
|
+
"template_operations": template_result.get("operations", []),
|
1083
|
+
}
|
1084
|
+
|
1085
|
+
logger.info(f"Complete presentation workflow finished: {presentation_id}")
|
1086
|
+
return final_result
|
1087
|
+
|
1088
|
+
except Exception as error:
|
1089
|
+
return self.handle_api_error("create_complete_presentation_workflow", error)
|
1090
|
+
|
1091
|
+
@staticmethod
|
1092
|
+
def extract_presentation_id_from_url(url: str) -> str:
|
1093
|
+
"""
|
1094
|
+
Extract presentation ID from Google Slides URL.
|
1095
|
+
|
1096
|
+
Args:
|
1097
|
+
url: Google Slides URL
|
1098
|
+
|
1099
|
+
Returns:
|
1100
|
+
Presentation ID
|
1101
|
+
|
1102
|
+
Raises:
|
1103
|
+
ValueError: If URL format is invalid
|
1104
|
+
"""
|
1105
|
+
# Pattern to match Google Slides URLs
|
1106
|
+
pattern = r"/presentation/d/([a-zA-Z0-9-_]+)"
|
1107
|
+
match = re.search(pattern, url)
|
1108
|
+
|
1109
|
+
if not match:
|
1110
|
+
raise ValueError(f"Invalid Google Slides URL format: {url}")
|
1111
|
+
|
1112
|
+
return match.group(1)
|
1113
|
+
|
1114
|
+
def get_presentation_slides(self, presentation_id: str) -> Dict[str, Any]:
|
1115
|
+
"""
|
1116
|
+
Get all slides from a presentation with their IDs and basic info.
|
1117
|
+
|
1118
|
+
Args:
|
1119
|
+
presentation_id: The ID of the presentation
|
1120
|
+
|
1121
|
+
Returns:
|
1122
|
+
Dictionary with slide information or error details
|
1123
|
+
"""
|
1124
|
+
try:
|
1125
|
+
response = (
|
1126
|
+
self.service.presentations()
|
1127
|
+
.get(presentationId=presentation_id)
|
1128
|
+
.execute()
|
1129
|
+
)
|
1130
|
+
|
1131
|
+
slides_info = []
|
1132
|
+
for i, slide in enumerate(response.get("slides", [])):
|
1133
|
+
slides_info.append(
|
1134
|
+
{
|
1135
|
+
"slide_number": i + 1,
|
1136
|
+
"slide_id": slide["objectId"],
|
1137
|
+
"layout": slide.get("slideProperties", {}).get(
|
1138
|
+
"masterObjectId", "unknown"
|
1139
|
+
),
|
1140
|
+
}
|
1141
|
+
)
|
1142
|
+
|
1143
|
+
logger.info(f"Retrieved {len(slides_info)} slides from presentation")
|
1144
|
+
return {
|
1145
|
+
"success": True,
|
1146
|
+
"presentation_id": presentation_id,
|
1147
|
+
"presentation_title": response.get("title", "Unknown"),
|
1148
|
+
"slides": slides_info,
|
1149
|
+
"total_slides": len(slides_info),
|
1150
|
+
}
|
1151
|
+
|
1152
|
+
except Exception as error:
|
1153
|
+
return self.handle_api_error("get_presentation_slides", error)
|
1154
|
+
|
1155
|
+
def get_elements_from_specific_slides(
|
1156
|
+
self, presentation_id: str, slide_numbers: List[int]
|
1157
|
+
) -> Dict[str, Any]:
|
1158
|
+
"""
|
1159
|
+
Get element positions from specific slides by slide number.
|
1160
|
+
|
1161
|
+
Args:
|
1162
|
+
presentation_id: The ID of the presentation
|
1163
|
+
slide_numbers: List of slide numbers (1-indexed)
|
1164
|
+
|
1165
|
+
Returns:
|
1166
|
+
Dictionary with elements from specified slides or error details
|
1167
|
+
"""
|
1168
|
+
try:
|
1169
|
+
# First get all slides to map numbers to IDs
|
1170
|
+
slides_result = self.get_presentation_slides(presentation_id)
|
1171
|
+
if not slides_result.get("success"):
|
1172
|
+
return slides_result
|
1173
|
+
|
1174
|
+
slides_info = slides_result["slides"]
|
1175
|
+
results = {
|
1176
|
+
"success": True,
|
1177
|
+
"presentation_id": presentation_id,
|
1178
|
+
"slides_analyzed": [],
|
1179
|
+
}
|
1180
|
+
|
1181
|
+
for slide_num in slide_numbers:
|
1182
|
+
if slide_num < 1 or slide_num > len(slides_info):
|
1183
|
+
logger.warning(
|
1184
|
+
f"Slide number {slide_num} is out of range (1-{len(slides_info)})"
|
1185
|
+
)
|
1186
|
+
continue
|
1187
|
+
|
1188
|
+
slide_info = slides_info[slide_num - 1] # Convert to 0-indexed
|
1189
|
+
slide_id = slide_info["slide_id"]
|
1190
|
+
|
1191
|
+
# Get elements from this slide
|
1192
|
+
elements_result = self.get_existing_element_positions(
|
1193
|
+
presentation_id, slide_id
|
1194
|
+
)
|
1195
|
+
|
1196
|
+
slide_data = {
|
1197
|
+
"slide_number": slide_num,
|
1198
|
+
"slide_id": slide_id,
|
1199
|
+
"elements_found": (
|
1200
|
+
len(elements_result.get("elements", {}))
|
1201
|
+
if elements_result.get("success")
|
1202
|
+
else 0
|
1203
|
+
),
|
1204
|
+
"elements": elements_result.get("elements", {}),
|
1205
|
+
"success": elements_result.get("success", False),
|
1206
|
+
}
|
1207
|
+
|
1208
|
+
if elements_result.get("success"):
|
1209
|
+
logger.info(
|
1210
|
+
f"Retrieved {slide_data['elements_found']} elements from slide {slide_num}"
|
1211
|
+
)
|
1212
|
+
else:
|
1213
|
+
logger.error(
|
1214
|
+
f"Failed to get elements from slide {slide_num}: {elements_result}"
|
1215
|
+
)
|
1216
|
+
|
1217
|
+
results["slides_analyzed"].append(slide_data)
|
1218
|
+
|
1219
|
+
return results
|
1220
|
+
|
1221
|
+
except Exception as error:
|
1222
|
+
return self.handle_api_error("get_elements_from_specific_slides", error)
|
1223
|
+
|
1224
|
+
|
1225
|
+
# @mcp.tool(
|
1226
|
+
# name="analyze_presentation_layout",
|
1227
|
+
# )
|
1228
|
+
async def analyze_presentation_layout(
|
1229
|
+
presentation_url: str = "https://docs.google.com/presentation/d/1tdBZ0MH-CGiV2VmEptS7h0PfIyXOp3_yXN_AkNzgpTc/edit?slide=id.g360952048d5_0_86#slide=id.g360952048d5_0_86",
|
1230
|
+
) -> Dict[str, Any]:
|
1231
|
+
"""
|
1232
|
+
Get a comprehensive overview of all slides in a presentation.
|
1233
|
+
|
1234
|
+
Args:
|
1235
|
+
presentation_url: Google Slides URL (defaults to the provided template)
|
1236
|
+
|
1237
|
+
Returns:
|
1238
|
+
Dictionary with all slides and their basic information
|
1239
|
+
"""
|
1240
|
+
try:
|
1241
|
+
# Initialize the service
|
1242
|
+
positioner = PreciseSlidesPositioning()
|
1243
|
+
|
1244
|
+
# Extract presentation ID from URL
|
1245
|
+
presentation_id = positioner.extract_presentation_id_from_url(presentation_url)
|
1246
|
+
logger.info(f"Analyzing presentation: {presentation_id}")
|
1247
|
+
|
1248
|
+
# Get all slides
|
1249
|
+
slides_result = positioner.get_presentation_slides(presentation_id)
|
1250
|
+
|
1251
|
+
if slides_result.get("success"):
|
1252
|
+
logger.info(
|
1253
|
+
f"✅ Found {slides_result['total_slides']} slides in presentation"
|
1254
|
+
)
|
1255
|
+
logger.info(f"📝 Presentation: '{slides_result['presentation_title']}'")
|
1256
|
+
|
1257
|
+
# Get basic content from each slide
|
1258
|
+
for slide_info in slides_result["slides"]:
|
1259
|
+
slide_num = slide_info["slide_number"]
|
1260
|
+
slide_id = slide_info["slide_id"]
|
1261
|
+
|
1262
|
+
# Get first few elements to understand slide content
|
1263
|
+
elements_result = positioner.get_existing_element_positions(
|
1264
|
+
presentation_id, slide_id
|
1265
|
+
)
|
1266
|
+
|
1267
|
+
if elements_result.get("success"):
|
1268
|
+
elements = elements_result["elements"]
|
1269
|
+
text_elements = []
|
1270
|
+
|
1271
|
+
# Extract text content from elements to identify slide purpose
|
1272
|
+
for element_id, element_info in list(elements.items())[
|
1273
|
+
:3
|
1274
|
+
]: # First 3 elements
|
1275
|
+
if element_info.get(
|
1276
|
+
"content_type"
|
1277
|
+
) == "text" and element_info.get("content"):
|
1278
|
+
text_content = element_info["content"].strip()
|
1279
|
+
if (
|
1280
|
+
text_content and len(text_content) < 100
|
1281
|
+
): # Short text likely to be titles
|
1282
|
+
text_elements.append(text_content)
|
1283
|
+
|
1284
|
+
slide_info["sample_text"] = text_elements
|
1285
|
+
slide_info["total_elements"] = len(elements)
|
1286
|
+
|
1287
|
+
logger.info(
|
1288
|
+
f"📄 Slide {slide_num}: {slide_info['total_elements']} elements"
|
1289
|
+
)
|
1290
|
+
if text_elements:
|
1291
|
+
logger.info(
|
1292
|
+
f" 📝 Sample text: {', '.join(text_elements[:2])}"
|
1293
|
+
)
|
1294
|
+
|
1295
|
+
return slides_result
|
1296
|
+
else:
|
1297
|
+
logger.error(f"❌ Failed to analyze presentation: {slides_result}")
|
1298
|
+
raise ValueError(f"Failed to analyze presentation: {slides_result}")
|
1299
|
+
|
1300
|
+
except Exception as error:
|
1301
|
+
logger.error(f"Error in analyze_presentation_layout: {error}")
|
1302
|
+
raise ValueError(f"Error analyzing presentation: {error}")
|
1303
|
+
|
1304
|
+
|
1305
|
+
# @mcp.tool(
|
1306
|
+
# name="get_template_elements_from_slides",
|
1307
|
+
# )
|
1308
|
+
async def get_template_elements_from_slides(
|
1309
|
+
presentation_url: str = "https://docs.google.com/presentation/d/1tdBZ0MH-CGiV2VmEptS7h0PfIyXOp3_yXN_AkNzgpTc/edit?slide=id.g360952048d5_0_86#slide=id.g360952048d5_0_86",
|
1310
|
+
slide_numbers: str = "4,5",
|
1311
|
+
) -> Dict[str, Any]:
|
1312
|
+
"""
|
1313
|
+
Extract element positions from specific slides in a template presentation.
|
1314
|
+
|
1315
|
+
Args:
|
1316
|
+
presentation_url: Google Slides URL (defaults to the provided template)
|
1317
|
+
slide_numbers: Comma-separated slide numbers to analyze (e.g., "4,5")
|
1318
|
+
|
1319
|
+
Returns:
|
1320
|
+
Dictionary with element positions and dimensions from specified slides
|
1321
|
+
"""
|
1322
|
+
try:
|
1323
|
+
# Initialize the service
|
1324
|
+
positioner = PreciseSlidesPositioning()
|
1325
|
+
|
1326
|
+
# Extract presentation ID from URL
|
1327
|
+
presentation_id = positioner.extract_presentation_id_from_url(presentation_url)
|
1328
|
+
logger.info(f"Extracted presentation ID: {presentation_id}")
|
1329
|
+
|
1330
|
+
# Parse slide numbers
|
1331
|
+
slide_nums = [int(num.strip()) for num in slide_numbers.split(",")]
|
1332
|
+
logger.info(f"Analyzing slides: {slide_nums}")
|
1333
|
+
|
1334
|
+
# Get elements from specified slides
|
1335
|
+
result = positioner.get_elements_from_specific_slides(
|
1336
|
+
presentation_id, slide_nums
|
1337
|
+
)
|
1338
|
+
|
1339
|
+
if result.get("success"):
|
1340
|
+
logger.info(
|
1341
|
+
f"✅ Successfully analyzed {len(result['slides_analyzed'])} slides"
|
1342
|
+
)
|
1343
|
+
|
1344
|
+
# Log detailed information about elements found
|
1345
|
+
for slide_data in result["slides_analyzed"]:
|
1346
|
+
slide_num = slide_data["slide_number"]
|
1347
|
+
elements_count = slide_data["elements_found"]
|
1348
|
+
logger.info(f"📄 Slide {slide_num}: Found {elements_count} elements")
|
1349
|
+
|
1350
|
+
# Log element details in human-readable format with content
|
1351
|
+
for element_id, element_info in slide_data["elements"].items():
|
1352
|
+
# Get content preview
|
1353
|
+
content_preview = ""
|
1354
|
+
if element_info.get("content"):
|
1355
|
+
content = str(element_info["content"])
|
1356
|
+
if element_info["content_type"] == "text":
|
1357
|
+
# Show first 50 characters of text
|
1358
|
+
content_preview = f" - Text: '{content[:50]}{'...' if len(content) > 50 else ''}'"
|
1359
|
+
elif element_info["content_type"] == "image":
|
1360
|
+
content_preview = f" - Image URL: {content[:50]}{'...' if len(content) > 50 else ''}"
|
1361
|
+
elif element_info["content_type"] == "table":
|
1362
|
+
rows = len(content) if isinstance(content, list) else 0
|
1363
|
+
content_preview = f" - Table: {rows} rows"
|
1364
|
+
|
1365
|
+
# Check for placeholder information
|
1366
|
+
placeholder_info = ""
|
1367
|
+
if "placeholder" in element_info.get("metadata", {}):
|
1368
|
+
placeholder = element_info["metadata"]["placeholder"]
|
1369
|
+
placeholder_info = (
|
1370
|
+
f" [Placeholder: {placeholder.get('type', 'UNKNOWN')}]"
|
1371
|
+
)
|
1372
|
+
|
1373
|
+
# Show both raw and actual visual positions if they differ
|
1374
|
+
raw_pos = f"({element_info['x_inches']:.2f}\", {element_info['y_inches']:.2f}\")"
|
1375
|
+
raw_size = f"{element_info['width_inches']:.2f}\" × {element_info['height_inches']:.2f}\""
|
1376
|
+
|
1377
|
+
if (
|
1378
|
+
element_info.get("scaleX", 1) != 1
|
1379
|
+
or element_info.get("scaleY", 1) != 1
|
1380
|
+
):
|
1381
|
+
# Show actual visual dimensions when scaled
|
1382
|
+
actual_size = f"{element_info.get('actual_width_inches', 0):.2f}\" × {element_info.get('actual_height_inches', 0):.2f}\""
|
1383
|
+
scale_info = f" [Scaled: {element_info.get('scaleX', 1):.2f}x, {element_info.get('scaleY', 1):.2f}x → {actual_size}]"
|
1384
|
+
else:
|
1385
|
+
scale_info = ""
|
1386
|
+
|
1387
|
+
logger.info(
|
1388
|
+
f" 🔲 {element_id} ({element_info['element_type']}): "
|
1389
|
+
f"{raw_size} at {raw_pos}{scale_info}"
|
1390
|
+
f"{content_preview}{placeholder_info}"
|
1391
|
+
)
|
1392
|
+
|
1393
|
+
# Log shape type for shapes
|
1394
|
+
if element_info[
|
1395
|
+
"element_type"
|
1396
|
+
] == "shape" and "shape_type" in element_info.get("metadata", {}):
|
1397
|
+
shape_type = element_info["metadata"]["shape_type"]
|
1398
|
+
logger.info(f" └─ Shape type: {shape_type}")
|
1399
|
+
|
1400
|
+
# Log debug info about element structure
|
1401
|
+
if element_info.get("raw_element_keys"):
|
1402
|
+
logger.info(
|
1403
|
+
f" └─ Raw element keys: {element_info['raw_element_keys']}"
|
1404
|
+
)
|
1405
|
+
|
1406
|
+
return result
|
1407
|
+
else:
|
1408
|
+
logger.error(f"❌ Failed to analyze slides: {result}")
|
1409
|
+
raise ValueError(f"Failed to analyze slides: {result}")
|
1410
|
+
|
1411
|
+
except Exception as error:
|
1412
|
+
logger.error(f"Error in get_template_elements_from_slides: {error}")
|
1413
|
+
raise ValueError(f"Error analyzing template slides: {error}")
|
1414
|
+
|
1415
|
+
|
1416
|
+
# @mcp.tool(
|
1417
|
+
# name="create_presentation_with_positioned_images",
|
1418
|
+
# )
|
1419
|
+
async def create_presentation_with_positioned_images(
|
1420
|
+
title: str = "Press Recap - Paris x Motorola",
|
1421
|
+
background_url: str = "https://i.ibb.co/4RXQbYGB/IMG-7774.jpg",
|
1422
|
+
portrait_url: str = "https://i.ibb.co/HLWpZmPS/20250122-KEVI4992-kevinostaj.jpg",
|
1423
|
+
) -> Dict[str, Any]:
|
1424
|
+
"""
|
1425
|
+
Creates a Google Slides presentation with precisely positioned background and portrait images.
|
1426
|
+
|
1427
|
+
Args:
|
1428
|
+
title: Title for the new presentation
|
1429
|
+
background_url: URL for the background image (full slide)
|
1430
|
+
portrait_url: URL for the portrait image (right block)
|
1431
|
+
|
1432
|
+
Returns:
|
1433
|
+
Dictionary with presentation details and operation results
|
1434
|
+
"""
|
1435
|
+
try:
|
1436
|
+
# Initialize the service
|
1437
|
+
positioner = PreciseSlidesPositioning()
|
1438
|
+
|
1439
|
+
logger.info("Creating complete presentation workflow...")
|
1440
|
+
result = positioner.create_complete_presentation_workflow(
|
1441
|
+
title=title,
|
1442
|
+
background_url=background_url,
|
1443
|
+
portrait_url=portrait_url,
|
1444
|
+
)
|
1445
|
+
|
1446
|
+
if result.get("success"):
|
1447
|
+
logger.info(f"✅ Presentation created successfully!")
|
1448
|
+
logger.info(f"📝 Presentation URL: {result['url']}")
|
1449
|
+
logger.info(
|
1450
|
+
f"📄 Slide 1 (Background): {result['slides']['slide_1']['url']}"
|
1451
|
+
)
|
1452
|
+
logger.info(f"📄 Slide 2 (Portrait): {result['slides']['slide_2']['url']}")
|
1453
|
+
return result
|
1454
|
+
else:
|
1455
|
+
logger.error(f"❌ Workflow failed: {result}")
|
1456
|
+
raise ValueError(f"Workflow failed: {result}")
|
1457
|
+
|
1458
|
+
except Exception as error:
|
1459
|
+
logger.error(f"Error in create_presentation_with_positioned_images: {error}")
|
1460
|
+
raise ValueError(f"Error creating presentation: {error}")
|
1461
|
+
|
1462
|
+
|
1463
|
+
# @mcp.tool(
|
1464
|
+
# name="create_slide_from_template_zones",
|
1465
|
+
# )
|
1466
|
+
async def create_slide_from_template_zones(
|
1467
|
+
template_presentation_url: str = "https://docs.google.com/presentation/d/1tdBZ0MH-CGiV2VmEptS7h0PfIyXOp3_yXN_AkNzgpTc/edit?slide=id.g360952048d5_0_86#slide=id.g360952048d5_0_86",
|
1468
|
+
template_slide_number: int = 5,
|
1469
|
+
new_presentation_title: str = "Template-Based Slides",
|
1470
|
+
image_block_url: str = "https://i.ibb.co/HLWpZmPS/20250122-KEVI4992-kevinostaj.jpg",
|
1471
|
+
background_image_url: str = "https://i.ibb.co/4RXQbYGB/IMG-7774.jpg",
|
1472
|
+
) -> Dict[str, Any]:
|
1473
|
+
"""
|
1474
|
+
Extract template zones from a template slide and create a new slide with images placed exactly where the placeholder text indicates.
|
1475
|
+
|
1476
|
+
Args:
|
1477
|
+
template_presentation_url: URL of the template presentation
|
1478
|
+
template_slide_number: Slide number to extract template zones from (e.g., 5 for "Image Block")
|
1479
|
+
new_presentation_title: Title for the new presentation
|
1480
|
+
image_block_url: URL for image to place in "Image Block" zone
|
1481
|
+
background_image_url: URL for background image (if template has background zone)
|
1482
|
+
|
1483
|
+
Returns:
|
1484
|
+
Dictionary with template extraction results and new slide creation details
|
1485
|
+
"""
|
1486
|
+
try:
|
1487
|
+
# Initialize the service
|
1488
|
+
positioner = PreciseSlidesPositioning()
|
1489
|
+
|
1490
|
+
# Extract template presentation ID
|
1491
|
+
template_presentation_id = positioner.extract_presentation_id_from_url(
|
1492
|
+
template_presentation_url
|
1493
|
+
)
|
1494
|
+
logger.info(f"🔍 Analyzing template presentation: {template_presentation_id}")
|
1495
|
+
|
1496
|
+
# Get template slide ID
|
1497
|
+
slides_result = positioner.get_presentation_slides(template_presentation_id)
|
1498
|
+
if not slides_result.get("success") or template_slide_number > len(
|
1499
|
+
slides_result["slides"]
|
1500
|
+
):
|
1501
|
+
raise ValueError(f"Template slide {template_slide_number} not found")
|
1502
|
+
|
1503
|
+
template_slide_id = slides_result["slides"][template_slide_number - 1][
|
1504
|
+
"slide_id"
|
1505
|
+
]
|
1506
|
+
logger.info(
|
1507
|
+
f"📄 Using template slide {template_slide_number} (ID: {template_slide_id})"
|
1508
|
+
)
|
1509
|
+
|
1510
|
+
# Extract template zones from the template slide
|
1511
|
+
template_zones = positioner.extract_template_zones_by_text(
|
1512
|
+
template_presentation_id, template_slide_id
|
1513
|
+
)
|
1514
|
+
if not template_zones.get("success"):
|
1515
|
+
raise ValueError(f"Failed to extract template zones: {template_zones}")
|
1516
|
+
|
1517
|
+
zones = template_zones["zones"]
|
1518
|
+
logger.info(f"🎯 Found {len(zones)} template zones: {list(zones.keys())}")
|
1519
|
+
|
1520
|
+
# Create new presentation with one slide
|
1521
|
+
new_pres_result = positioner.create_presentation_with_slides(
|
1522
|
+
new_presentation_title, slide_count=1
|
1523
|
+
)
|
1524
|
+
if not new_pres_result.get("success"):
|
1525
|
+
raise ValueError(f"Failed to create new presentation: {new_pres_result}")
|
1526
|
+
|
1527
|
+
new_presentation_id = new_pres_result["presentation_id"]
|
1528
|
+
new_slide_id = new_pres_result["slide_ids"][0]
|
1529
|
+
|
1530
|
+
logger.info(f"✅ Created new presentation: {new_presentation_id}")
|
1531
|
+
|
1532
|
+
# Place images in template zones
|
1533
|
+
placement_results = []
|
1534
|
+
|
1535
|
+
# Place background image if background zone exists
|
1536
|
+
if "background_image" in zones and background_image_url:
|
1537
|
+
bg_result = positioner.place_image_in_template_zone(
|
1538
|
+
new_presentation_id,
|
1539
|
+
new_slide_id,
|
1540
|
+
"background_image",
|
1541
|
+
background_image_url,
|
1542
|
+
template_zones,
|
1543
|
+
)
|
1544
|
+
placement_results.append({"zone": "background_image", "result": bg_result})
|
1545
|
+
|
1546
|
+
# Place image in image block zone
|
1547
|
+
if "image_block" in zones and image_block_url:
|
1548
|
+
img_result = positioner.place_image_in_template_zone(
|
1549
|
+
new_presentation_id,
|
1550
|
+
new_slide_id,
|
1551
|
+
"image_block",
|
1552
|
+
image_block_url,
|
1553
|
+
template_zones,
|
1554
|
+
)
|
1555
|
+
placement_results.append({"zone": "image_block", "result": img_result})
|
1556
|
+
|
1557
|
+
# Summary
|
1558
|
+
successful_placements = [
|
1559
|
+
p for p in placement_results if p["result"].get("success")
|
1560
|
+
]
|
1561
|
+
failed_placements = [
|
1562
|
+
p for p in placement_results if not p["result"].get("success")
|
1563
|
+
]
|
1564
|
+
|
1565
|
+
final_result = {
|
1566
|
+
"success": len(failed_placements) == 0,
|
1567
|
+
"template_analysis": {
|
1568
|
+
"template_presentation_id": template_presentation_id,
|
1569
|
+
"template_slide_number": template_slide_number,
|
1570
|
+
"template_slide_id": template_slide_id,
|
1571
|
+
"zones_found": list(zones.keys()),
|
1572
|
+
"zones_details": zones,
|
1573
|
+
},
|
1574
|
+
"new_presentation": {
|
1575
|
+
"presentation_id": new_presentation_id,
|
1576
|
+
"presentation_url": new_pres_result["url"],
|
1577
|
+
"slide_id": new_slide_id,
|
1578
|
+
"title": new_presentation_title,
|
1579
|
+
},
|
1580
|
+
"image_placements": {
|
1581
|
+
"successful": len(successful_placements),
|
1582
|
+
"failed": len(failed_placements),
|
1583
|
+
"details": placement_results,
|
1584
|
+
},
|
1585
|
+
}
|
1586
|
+
|
1587
|
+
logger.info(f"🎉 Template-based slide creation completed!")
|
1588
|
+
logger.info(f"📝 New presentation URL: {new_pres_result['url']}")
|
1589
|
+
logger.info(f"🖼️ Successfully placed {len(successful_placements)} images")
|
1590
|
+
|
1591
|
+
if failed_placements:
|
1592
|
+
logger.warning(f"⚠️ {len(failed_placements)} image placements failed")
|
1593
|
+
|
1594
|
+
return final_result
|
1595
|
+
|
1596
|
+
except Exception as error:
|
1597
|
+
logger.error(f"Error in create_slide_from_template_zones: {error}")
|
1598
|
+
raise ValueError(f"Error creating slide from template: {error}")
|
1599
|
+
|
1600
|
+
|
1601
|
+
@mcp.tool(
|
1602
|
+
name="extract_template_zones_only",
|
1603
|
+
)
|
1604
|
+
async def extract_template_zones_only(
|
1605
|
+
template_presentation_url: str = "https://docs.google.com/presentation/d/1tdBZ0MH-CGiV2VmEptS7h0PfIyXOp3_yXN_AkNzgpTc/edit?slide=id.g360952048d5_0_86#slide=id.g360952048d5_0_86",
|
1606
|
+
slide_numbers: str = "4,5",
|
1607
|
+
unit: str = "PT",
|
1608
|
+
) -> Dict[str, Any]:
|
1609
|
+
"""
|
1610
|
+
Extract positioning zones and coordinates from specific slides by finding and analyzing placeholder text elements.
|
1611
|
+
Returns precise coordinates and dimensions for LLM prompting with configurable units.
|
1612
|
+
|
1613
|
+
Args:
|
1614
|
+
template_presentation_url: URL of the template presentation
|
1615
|
+
slide_numbers: Comma-separated slide numbers to analyze (e.g., "4,5")
|
1616
|
+
unit: Target unit for coordinates ("EMU", "PT", or "INCHES"). Default is "PT".
|
1617
|
+
|
1618
|
+
Returns:
|
1619
|
+
Dictionary with extracted template zones, coordinates, and dimensions for each slide
|
1620
|
+
"""
|
1621
|
+
try:
|
1622
|
+
# Initialize the service
|
1623
|
+
positioner = PreciseSlidesPositioning()
|
1624
|
+
|
1625
|
+
# Extract presentation ID from URL
|
1626
|
+
presentation_id = positioner.extract_presentation_id_from_url(
|
1627
|
+
template_presentation_url
|
1628
|
+
)
|
1629
|
+
logger.info(
|
1630
|
+
f"🔍 Extracting template zones from presentation: {presentation_id}"
|
1631
|
+
)
|
1632
|
+
|
1633
|
+
# Parse slide numbers
|
1634
|
+
slide_nums = [int(num.strip()) for num in slide_numbers.split(",")]
|
1635
|
+
logger.info(f"📄 Analyzing slides: {slide_nums}")
|
1636
|
+
|
1637
|
+
# Get all slides info
|
1638
|
+
slides_result = positioner.get_presentation_slides(presentation_id)
|
1639
|
+
if not slides_result.get("success"):
|
1640
|
+
raise ValueError(f"Failed to get presentation slides: {slides_result}")
|
1641
|
+
|
1642
|
+
slides_info = slides_result["slides"]
|
1643
|
+
results = {
|
1644
|
+
"success": True,
|
1645
|
+
"presentation_id": presentation_id,
|
1646
|
+
"presentation_title": slides_result.get("presentation_title", "Unknown"),
|
1647
|
+
"slides_analyzed": [],
|
1648
|
+
}
|
1649
|
+
|
1650
|
+
# Extract template zones from each requested slide
|
1651
|
+
for slide_num in slide_nums:
|
1652
|
+
if slide_num < 1 or slide_num > len(slides_info):
|
1653
|
+
logger.warning(
|
1654
|
+
f"Slide number {slide_num} is out of range (1-{len(slides_info)})"
|
1655
|
+
)
|
1656
|
+
continue
|
1657
|
+
|
1658
|
+
slide_info = slides_info[slide_num - 1] # Convert to 0-indexed
|
1659
|
+
slide_id = slide_info["slide_id"]
|
1660
|
+
|
1661
|
+
logger.info(f"🎯 Extracting template zones from slide {slide_num}")
|
1662
|
+
|
1663
|
+
# Extract template zones from this slide
|
1664
|
+
template_zones = positioner.extract_template_zones_by_text(
|
1665
|
+
presentation_id, slide_id, unit
|
1666
|
+
)
|
1667
|
+
|
1668
|
+
slide_data = {
|
1669
|
+
"slide_number": slide_num,
|
1670
|
+
"slide_id": slide_id,
|
1671
|
+
"zones_found": len(template_zones.get("zones", {})),
|
1672
|
+
"template_zones": template_zones.get("zones", {}),
|
1673
|
+
"extraction_success": template_zones.get("success", False),
|
1674
|
+
}
|
1675
|
+
|
1676
|
+
if template_zones.get("success"):
|
1677
|
+
zones = template_zones["zones"]
|
1678
|
+
logger.info(f"✅ Slide {slide_num}: Found {len(zones)} template zones")
|
1679
|
+
|
1680
|
+
# Log each zone for easy reference
|
1681
|
+
for zone_name, zone_info in zones.items():
|
1682
|
+
logger.info(
|
1683
|
+
f" 🎯 {zone_name}: {zone_info['width_inches']:.2f}\"×{zone_info['height_inches']:.2f}\" at ({zone_info['x_inches']:.2f}\", {zone_info['y_inches']:.2f}\")"
|
1684
|
+
)
|
1685
|
+
logger.info(
|
1686
|
+
f" 📝 Original text: '{zone_info['original_text']}'"
|
1687
|
+
)
|
1688
|
+
logger.info(
|
1689
|
+
f" 📐 EMU coordinates: x={zone_info['x_emu']}, y={zone_info['y_emu']}, w={zone_info['width_emu']}, h={zone_info['height_emu']}"
|
1690
|
+
)
|
1691
|
+
else:
|
1692
|
+
logger.warning(f"❌ Failed to extract zones from slide {slide_num}")
|
1693
|
+
|
1694
|
+
results["slides_analyzed"].append(slide_data)
|
1695
|
+
|
1696
|
+
# Summary logging
|
1697
|
+
total_zones = sum(slide["zones_found"] for slide in results["slides_analyzed"])
|
1698
|
+
logger.info(f"🎉 Template zone extraction completed!")
|
1699
|
+
logger.info(f"📊 Total zones found across all slides: {total_zones}")
|
1700
|
+
|
1701
|
+
return results
|
1702
|
+
|
1703
|
+
except Exception as error:
|
1704
|
+
logger.error(f"Error in extract_template_zones_only: {error}")
|
1705
|
+
raise ValueError(f"Error extracting template zones: {error}")
|
1706
|
+
|
1707
|
+
|
1708
|
+
def main_with_creation():
|
1709
|
+
"""Standalone main function for testing without MCP."""
|
1710
|
+
import asyncio
|
1711
|
+
|
1712
|
+
async def test_both_tools():
|
1713
|
+
# Test template analysis
|
1714
|
+
print("=== Testing Template Analysis ===")
|
1715
|
+
template_result = await get_template_elements_from_slides()
|
1716
|
+
print("Template analysis result:", template_result)
|
1717
|
+
|
1718
|
+
print("\n=== Testing Presentation Creation ===")
|
1719
|
+
# Test presentation creation
|
1720
|
+
creation_result = await create_presentation_with_positioned_images()
|
1721
|
+
print("Creation result:", creation_result)
|
1722
|
+
|
1723
|
+
return template_result, creation_result
|
1724
|
+
|
1725
|
+
results = asyncio.run(test_both_tools())
|
1726
|
+
print("All results:", results)
|
1727
|
+
|
1728
|
+
|
1729
|
+
if __name__ == "__main__":
|
1730
|
+
main_with_creation()
|
1731
|
+
|
1732
|
+
|
1733
|
+
# # Usage example function
|
1734
|
+
# def main():
|
1735
|
+
# """Example usage of the PreciseSlidesPositioning class."""
|
1736
|
+
|
1737
|
+
# # Initialize the service (uses your existing auth setup)
|
1738
|
+
# positioner = PreciseSlidesPositioning()
|
1739
|
+
|
1740
|
+
# # Your slide details
|
1741
|
+
# presentation_id = "your-presentation-id-here"
|
1742
|
+
# slide_id = "your-slide-id-here"
|
1743
|
+
|
1744
|
+
# # Your image URLs
|
1745
|
+
# background_url = "https://i.ibb.co/4RXQbYGB/IMG-7774.jpg"
|
1746
|
+
# portrait_url = "https://i.ibb.co/HLWpZmPS/20250122-KEVI4992-kevinostaj.jpg"
|
1747
|
+
|
1748
|
+
# # Method 1: Complete template implementation
|
1749
|
+
# print("Implementing complete template...")
|
1750
|
+
# result = positioner.implement_complete_template(
|
1751
|
+
# presentation_id, slide_id, background_url, portrait_url
|
1752
|
+
# )
|
1753
|
+
# print("Result:", result)
|
1754
|
+
|
1755
|
+
# # Method 2: Individual operations
|
1756
|
+
# print("\nAlternative: Adding images individually...")
|
1757
|
+
|
1758
|
+
# # Add background
|
1759
|
+
# bg_result = positioner.add_background_image(
|
1760
|
+
# presentation_id, slide_id, background_url
|
1761
|
+
# )
|
1762
|
+
# print("Background result:", bg_result)
|
1763
|
+
|
1764
|
+
# # Add portrait to right side
|
1765
|
+
# portrait_result = positioner.add_right_side_image(
|
1766
|
+
# presentation_id, slide_id, portrait_url
|
1767
|
+
# )
|
1768
|
+
# print("Portrait result:", portrait_result)
|
1769
|
+
|
1770
|
+
# # Method 3: Analyze existing template (helpful for fine-tuning)
|
1771
|
+
# print("\nAnalyzing existing template elements...")
|
1772
|
+
# positions = positioner.get_existing_element_positions(presentation_id, slide_id)
|
1773
|
+
# if positions.get("success"):
|
1774
|
+
# for element_id, pos in positions["elements"].items():
|
1775
|
+
# print(
|
1776
|
+
# f"{element_id}: {pos['width_inches']:.2f}\" x {pos['height_inches']:.2f}\" at ({pos['x_inches']:.2f}\", {pos['y_inches']:.2f}\")"
|
1777
|
+
# )
|
1778
|
+
|
1779
|
+
|
1780
|
+
# if __name__ == "__main__":
|
1781
|
+
# main()
|