Cloud function to google slides

Here’s a similar write-up for our Google Slides image update function, based on the similar framework provided by Russ for writing to Google Sheets without Zapier:

Cloud Function to Update Google Slides Images

Do you need to automatically update images in Google Slides? If you have GCP and the ability to create a cloud function, this article is for you. We’ll show you how to create a function that can update images in your Google Slides presentations with proper positioning and sizing.

Usage

Let’s start with how to use it. Full initial setup instructions are below under Initial Technical Setup.

Step 1) Collect the following info from your Google Slides presentation:

  • presentation_id: Found in your Google Slides URL (e.g., 105tmY-5-v_MWKkKWy0duEc78hpVgHwhmBRlb4VhvwcU)

  • slide_id: Usually ‘p’ for the first slide

Step 2) Assemble the URL and arguments

Your endpoint for the function will have a URL associated with it. Use that and the information to assemble the full URL with arguments:

https://{{cloud function endpoint url}}/omni_to_slides?
presentation_id=YOUR_PRESENTATION_ID
&slide_id=YOUR_SLIDE_ID # typically p for first slide
&replace_image=first  # Options: 'first', 'last', or index like '0', '1'

Optional parameters:

  • width: Width in points (default: 768 - 80% of slide width)

  • height: Height in points (default: 432 - 80% of slide height)

  • x_position: X position in points (default: 96 - centered)

  • y_position: Y position in points (default: 54 - centered)

Step 3) Give the service account editor access to the Google Slides presentation

Step 4) Send your image

Send a POST request to the URL with your image file. The function will:

  • Upload the image to Google Drive

  • Replace the specified image on the slide

  • Center and size it appropriately

  • Clean up the temporary file

Initial Technical Setup (GCP console)

This is the easiest recipe for installing a cloud function on GCP and applying the right logic. This assumes you have owner/admin privileges and are in a GCP project with billing enabled.

Step 1) Enable the required APIs

Search for and enable these APIs in the Google Cloud Console:

  • Google Slides API

  • Google Drive API

  • Cloud Functions API

  • Cloud Build API

Step 2) Create a service account

  1. Search in the console for “Service Accounts” (IAM & Admin)

  2. Click “Create Service Account”

  3. Give it a name and description

  4. Grant these roles:

  • Cloud Functions Invoker

  • Slides API User

  • Drive API User

Step 3) Create the cloud function

  1. Search for “Cloud Functions” in the console

  2. Click “Create Function”

  3. Choose:

  • Runtime: Python 3.9

  • Trigger: HTTP

  • Authentication: Allow unauthenticated invocations

  • Entry point: omni_to_slides

Replace main.py with the below code:

import functions_framework
import os
import json
from datetime import datetime
import logging
import google.auth.credentials
from google.oauth2 import service_account
from googleapiclient.http import MediaFileUpload
from googleapiclient.http import MediaIoBaseUpload
from random import randint
import time
import base64
import io
import google.auth
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from typing import Optional

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Configuration
DEFAULT_PRESENTATION_ID = os.getenv('DEFAULT_PRESENTATION_ID', '105tmY-5-v_MWKkKWy0duEc78hpVgHwhmBRlb4VhvwcU')
DEFAULT_SLIDE_ID = os.getenv('DEFAULT_SLIDE_ID', 'p')
DEFAULT_IMAGE_WIDTH = int(os.getenv('DEFAULT_IMAGE_WIDTH', '768'))  # 80% of 960 points (slide width)
DEFAULT_IMAGE_HEIGHT = int(os.getenv('DEFAULT_IMAGE_HEIGHT', '432'))  # 80% of 540 points (slide height)
DEFAULT_X_POSITION = int(os.getenv('DEFAULT_X_POSITION', '96'))  # (960 - 768) / 2 = 96 points from left
DEFAULT_Y_POSITION = int(os.getenv('DEFAULT_Y_POSITION', '54'))  # (540 - 432) / 2 = 54 points from top

def get_slides_client(rw_vs_ro: str):
    """
    Generate the service credentials to be used to query a google slide

    Args:
        rw_vs_ro (str): A string, 'r_o' or 'r_w', representing whether creds should be readonly or readwrite

    Returns:
        tuple: A tuple containing (slides_service, drive_service)
    """
    if rw_vs_ro == 'r_o':
        scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly']
    if rw_vs_ro == 'r_w':
        scopes = ['https://www.googleapis.com/auth/spreadsheets','https://www.googleapis.com/auth/presentations','https://www.googleapis.com/auth/drive']

    try:
        creds, project = google.auth.default(scopes=scopes)
        drive_service = build('drive', 'v3', credentials=creds)
        service = build('slides', 'v1', credentials=creds)
        return service, drive_service
    except Exception as e:
        logger.error(f"Failed to initialize Google services: {str(e)}")
        raise

def transform_data(webhook_data, header_row=False):
    """
    Transform the incoming webhook data into a format suitable for Google Sheets.
    
    Args:
        webhook_data (dict): The incoming webhook data.
    
    Returns:
        list: A list of transformed data rows.
    """
    new_values = []
    if header_row:
        new_values = [[str(key) for key in webhook_data[0].keys()]]
        new_values.extend( [[val for val in row.values()] for row in webhook_data])
    else:
        new_values = [[val for val in row.values()] for row in webhook_data]
        
    return new_values
    

def get_slide_images(service, presentation_id: str, slide_id: str) -> list:
    """
    Get all images on a specific slide.
    
    Args:
        service: Google Slides service
        presentation_id (str): ID of the presentation
        slide_id (str): ID of the slide
        
    Returns:
        list: List of image objects on the slide
    """
    try:
        logger.info(f"Attempting to get slide with ID: {slide_id}")
        # Get the slide
        slide = service.presentations().pages().get(
            presentationId=presentation_id,
            pageObjectId=slide_id
        ).execute()
        
        logger.info(f"Successfully retrieved slide. Found {len(slide.get('pageElements', []))} elements")
        
        # Extract all image elements
        images = []
        for element in slide.get('pageElements', []):
            if 'image' in element:
                images.append({
                    'objectId': element['objectId'],
                    'transform': element.get('transform', {}),
                    'size': element.get('size', {})
                })
        
        logger.info(f"Found {len(images)} images on the slide")
        if images:
            logger.info(f"First image objectId: {images[0]['objectId']}")
        
        return images
    except HttpError as e:
        logger.error(f"Failed to get slide images: {str(e)}")
        raise

def update_google_slides(
    png_binary_data: bytes,
    presentation_id: str,
    slide_id: str,
    image_id: Optional[str] = None,
    replace_image: Optional[str] = None,
    width: Optional[int] = None,
    height: Optional[int] = None,
    x_position: Optional[int] = None,
    y_position: Optional[int] = None
):
    """
    Update a Google Slides presentation with a PNG image.
    
    Args:
        png_binary_data (bytes): The binary data of the PNG image
        presentation_id (str): The ID of the presentation (from the URL)
        slide_id (str): The ID of the slide to update (will be used as pageId)
        image_id (str, optional): ID of an existing image to replace
        replace_image (str, optional): Which image to replace ('first', 'last', or index like '0', '1', etc.)
        width (int, optional): Width of the image in points (default: 768 - 80% of slide width)
        height (int, optional): Height of the image in points (default: 432 - 80% of slide height)
        x_position (int, optional): X position of the image in points (default: 96 - centered)
        y_position (int, optional): Y position of the image in points (default: 54 - centered)

    Returns:
        dict: The response from the Google Slides API

    Raises:
        HttpError: If the Google Slides API request fails
        ValueError: If the image data is invalid or replace_image is invalid
    """
    if not png_binary_data:
        raise ValueError("No image data provided")

    # Use default values if not provided
    width = width or DEFAULT_IMAGE_WIDTH
    height = height or DEFAULT_IMAGE_HEIGHT
    x_position = x_position or DEFAULT_X_POSITION
    y_position = y_position or DEFAULT_Y_POSITION

    try:
        service, drive_service = get_slides_client('r_w')
        
        # Handle image replacement based on position
        if replace_image:
            logger.info(f"Attempting to replace image with replace_image={replace_image}")
            images = get_slide_images(service, presentation_id, slide_id)
            if not images:
                raise ValueError("No images found on the slide to replace")
            
            if replace_image == 'first':
                image_id = images[0]['objectId']
                logger.info(f"Selected first image with objectId: {image_id}")
            elif replace_image == 'last':
                image_id = images[-1]['objectId']
                logger.info(f"Selected last image with objectId: {image_id}")
            else:
                try:
                    index = int(replace_image)
                    if index < 0 or index >= len(images):
                        raise ValueError(f"Image index {index} is out of range. Slide has {len(images)} images.")
                    image_id = images[index]['objectId']
                    logger.info(f"Selected image at index {index} with objectId: {image_id}")
                except ValueError:
                    raise ValueError("replace_image must be 'first', 'last', or a valid index number")

        # Upload image to Google Drive
        logger.info("Uploading image to Google Drive...")
        image_data = base64.b64decode(base64.b64encode(png_binary_data).decode('UTF-8'))
        fh = io.BytesIO(image_data)
        
        # Create a unique filename
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        file_metadata = {
            'name': f'slide_image_{timestamp}_{randint(0,1000000)}.png',
            'mimeType': 'image/png'
        }
        
        media = MediaIoBaseUpload(
            fh, 
            mimetype='image/png',
            resumable=True
        )
        
        image_file_id = None
        try:
            file = drive_service.files().create(
                body=file_metadata,
                media_body=media,
                fields='id,webContentLink',
                supportsAllDrives=True
            ).execute()

            image_file_id = file.get('id')
            logger.info(f"Image uploaded successfully with ID: {image_file_id}")

            # Make the file publicly accessible
            logger.info("Setting file permissions...")
            drive_service.permissions().create(
                fileId=image_file_id,
                body={
                    'type': 'anyone',
                    'role': 'reader',
                    'allowFileDiscovery': False
                },
                fields='id'
            ).execute()
            
            # Wait for permissions to propagate
            logger.info("Waiting for permissions to propagate...")
            time.sleep(5)

            # Get the direct download link
            file = drive_service.files().get(
                fileId=image_file_id,
                fields='webContentLink'
            ).execute()
            
            image_url = file.get('webContentLink')
            if not image_url:
                raise ValueError("Failed to get public URL for the image")
            
            logger.info(f"Using image URL: {image_url}")

            if image_id:
                logger.info(f"Preparing to replace image with ID: {image_id}")
                requests = [
                    {
                        'replaceImage': {
                            'imageObjectId': image_id,
                            'imageReplaceMethod': 'CENTER_INSIDE',
                            'url': image_url
                        }
                    }
                ]
            else:
                logger.info("Creating new image")
                requests = [
                    {
                        'createImage': {
                            'url': image_url,
                            'elementProperties': {
                                'pageId': slide_id,
                                'size': {
                                    'width': {'magnitude': width, 'unit': 'PT'},
                                    'height': {'magnitude': height, 'unit': 'PT'}
                                },
                                'transform': {
                                    'scaleX': 1,
                                    'scaleY': 1,
                                    'translateX': x_position,
                                    'translateY': y_position,
                                    'unit': 'PT'
                                }
                            }
                        }
                    }
                ]
            
            logger.info("Updating Google Slides presentation...")
            response = service.presentations().batchUpdate(
                presentationId=presentation_id,
                body={'requests': requests}
            ).execute()
            
            logger.info("Successfully updated presentation")
            
            # Wait a bit to ensure Slides has accessed the image
            time.sleep(5)
            
            return response
            
        except HttpError as e:
            logger.error(f"Failed to create/upload image: {str(e)}")
            raise
        finally:
            # Clean up the temporary file
            if image_file_id:
                try:
                    logger.info(f"Cleaning up temporary file: {image_file_id}")
                    drive_service.files().delete(fileId=image_file_id).execute()
                except Exception as e:
                    logger.error(f"Failed to clean up temporary file: {str(e)}")

    except HttpError as e:
        logger.error(f"Google Slides API error: {str(e)}")
        raise
    except Exception as e:
        logger.error(f"Unexpected error: {str(e)}")
        raise

@functions_framework.http
def omni_to_slides(request):
    """
    Cloud Function to handle image uploads and update Google Slides.
    Accepts webhook requests with query parameters for customization.
    
    Required Query Parameters:
        presentation_id (str): ID of the Google Slides presentation
        slide_id (str): ID of the slide to update
    
    Optional Query Parameters:
        width (int): Width of the image in points (default: 300)
        height (int): Height of the image in points (default: 200)
        x_position (int): X position of the image in points (default: 100)
        y_position (int): Y position of the image in points (default: 100)
        image_id (str): ID of existing image to replace
        replace_image (str): Which image to replace ('first', 'last', or index like '0', '1', etc.)
    
    Args:
        request (flask.Request): The request object containing the image file
        
    Returns:
        tuple: A tuple containing (response_message, status_code)
        
    Expected request format:
        - Multipart form data with an image file
        - Required query parameters: presentation_id, slide_id
        - Optional query parameters for customization
    """
    try:
        # Get required query parameters
        presentation_id = request.args.get('presentation_id')
        slide_id = request.args.get('slide_id')
        
        # Validate required parameters
        if not presentation_id:
            return json.dumps({
                "status": "error",
                "message": "presentation_id is required"
            }), 400
        if not slide_id:
            return json.dumps({
                "status": "error",
                "message": "slide_id is required"
            }), 400

        # Get optional query parameters with defaults
        width = request.args.get('width', type=int, default=DEFAULT_IMAGE_WIDTH)
        height = request.args.get('height', type=int, default=DEFAULT_IMAGE_HEIGHT)
        x_position = request.args.get('x_position', type=int, default=DEFAULT_X_POSITION)
        y_position = request.args.get('y_position', type=int, default=DEFAULT_Y_POSITION)
        image_id = request.args.get('image_id')
        replace_image = request.args.get('replace_image')

        if not request.files:
            return json.dumps({
                "status": "error",
                "message": "No file found in the request"
            }), 400

        # Get the first file from the request
        file = next(iter(request.files.values()))
        
        # Read the binary data
        png_binary_data = file.stream.read()
        
        # Save a copy locally for backup/debugging
        with open('captured_image.png', 'wb') as f:
            f.write(png_binary_data)
        
        # Update the Google Slides presentation
        response = update_google_slides(
            png_binary_data=png_binary_data,
            presentation_id=presentation_id,
            slide_id=slide_id,
            image_id=image_id,
            replace_image=replace_image,
            width=width,
            height=height,
            x_position=x_position,
            y_position=y_position
        )
        
        logger.info(f"Successfully updated Google Slides presentation: {response}")
        return json.dumps({
            "status": "success",
            "message": "Image successfully added to presentation",
            "presentation_id": presentation_id,
            "slide_id": slide_id,
            "parameters": {
                "width": width,
                "height": height,
                "x_position": x_position,
                "y_position": y_position,
                "replace_image": replace_image
            }
        }), 200

    except HttpError as e:
        logger.error(f"Google API error: {str(e)}")
        return json.dumps({
            "status": "error",
            "message": f"Google API error: {str(e)}"
        }), 500
    except Exception as e:
        logger.error(f"Unexpected error: {str(e)}")
        return json.dumps({
            "status": "error",
            "message": f"Error processing request: {str(e)}"
        }), 500

@functions_framework.http
def error_handler(request):
    """
    Custom error handler to help with debugging deployment issues.
    
    Args:
        request (flask.Request): The request object
        
    Returns:
        tuple: A tuple containing (error_message, status_code)
    """
    try:
        raise Exception("Test error handling")
    except Exception as e:
        logger.error(f"Caught error: {str(e)}")
        return json.dumps({
            "status": "error",
            "message": str(e)
        }), 500

Replace requirements.txt with below

blinker==1.9.0
cachetools==5.5.2
certifi==2025.1.31
charset-normalizer==3.4.1
click==8.1.8
cloudevents==1.11.0
deprecation==2.1.0
Flask==3.1.0
functions-framework==3.*
google-api-core==2.24.2
google-api-python-client==2.*
google-auth==2.*
google-auth-httplib2==0.*
google-auth-oauthlib==0.*
googleapis-common-protos==1.69.2
gunicorn==23.0.0
httplib2==0.22.0
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
oauthlib==3.2.2
packaging==24.2
proto-plus==1.26.1
protobuf==6.30.2
pyasn1==0.6.1
pyasn1_modules==0.4.1
pyparsing==3.2.3
requests==2.32.3
requests-oauthlib==2.0.0
rsa==4.9
uritemplate==4.1.1
urllib3==2.3.0
watchdog==6.0.0
Werkzeug==3.1.3

Click the + icon on the upper right of the file selector and create a file called service_account.json
You will then paste the contents of your service_account.json file you downloaded when we created the service account key.

Click “Deploy”

The function will be built and deployed. Once complete, you’ll get the function URL that you can use to update your slides.

Features

  • Automatically centers images on slides

  • Maintains aspect ratio

  • Can replace existing images or add new ones

  • Configurable size and position

  • Handles large images efficiently

  • Cleans up temporary files automatically

Technical Details

The function uses:

  • Python 3.9 runtime

  • Google Cloud Functions

  • Google Slides API

  • Google Drive API

  • Application Default Credentials