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.
@@ -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()