Skip to content
Skip to chapter

Chapter 19

Workspace services

Build a two-slide garden briefing

Build a two-slide Google Slides briefing with SlidesApp that places text, shapes, a line, and a PNG map, then resizes the map by its saved object ID.

By the end

Generate and inspect a two-slide briefing for a fictional garden, resize its map without replacing it, and recover an interrupted run using the saved presentation ID.

Chapter navigation

Make a two-slide briefing with a map

Create a two-slide readiness briefing for a fictional community garden. The overview slide places a supplied garden map beside three readiness cards. The second slide lists one next-visit action for each zone.

You need runtime and permissions, working with arrays, and working with Drive files first. SlidesApp creates and edits the presentation, and DriveApp reads the PNG you upload. Google's Slides guide gives an overview of the built-in Slides service.

Each script project saves the ID of one briefing presentation. After the first run completes, running createGardenSlides again reports the saved presentation ID and creates nothing. Two more functions change the recorded presentation: resizeGardenMap resizes the existing map element in place, and rebuildGardenSlides redraws the two slides. Both run only after you confirm the presentation ID in confirmedOutputId.

Identify slides and elements by object ID

A Presentation is the Google Slides file. It holds slides, layouts, and masters. Each Slide holds PageElement objects such as shapes, images, and lines. A text box is a Shape, and its getText() method returns a TextRange.

Source
Presentation: a file ID
  Slide: an object ID
    Shape: an object ID
      TextRange: characters and paragraphs inside the shape
    Image: an object ID
    Line: an object ID

The Presentation and Slide references separate the presentation's Drive file ID from the object IDs inside it. Reordering slides in the thumbnail strip does not change a slide's object ID. Moving or resizing an image keeps its object ID. Deleting and reinserting an image gives it a new object ID.

The script saves the two slide IDs in role order (overview, then actions) and finds them with presentation.getSlideById(id), never by thumbnail position.

It also saves the overview map's object ID, so resizeGardenMap can call overview.getPageElementById(id), check the element type, and call asImage(). If that element is missing, the function stops instead of resizing whichever image is left on the slide. See the PageElement reference. An object ID identifies the element on the slide, not the image bytes inside it. For example, Image.replace swaps the picture and keeps the same ID.

This is a standalone script, so SlidesApp.getActivePresentation() returns null. The creation run uses the Presentation that SlidesApp.create returns, and later runs open the saved file ID with SlidesApp.openById. See the SlidesApp reference.

Supply the image without making it public

The project includes site-map.png, a 640-by-400-pixel PNG schematic. It shows six planter positions in North bed, two along Path edge, four in Courtyard, and a gate at the lower end of the path. The brown rectangles show how many planters each zone can hold, not how many are ready. Readiness counts can therefore change without redrawing the image. The create-site-map.mjs file is the Node script that produced the PNG. Do not add it to Apps Script.

  1. Download site-map.png from the slides-workflows project files and open it to see the three zones and twelve planter positions.

  2. In a private My Drive folder, upload that exact file with New > File upload. Do not convert or edit it.

  3. Open the uploaded file's Drive preview and copy the file ID from its URL, between /file/d/ and the next slash. Keep General access set to Restricted.

Before it creates a presentation, the script checks that the uploaded file is a PNG of at most 100,000 bytes and that its SHA-256 digest matches the supplied site-map.png. The script rejects a different or re-encoded file before it touches any slide.

file.getBlob() loads the PNG bytes into an Apps Script Blob, and slide.insertImage(blob, left, top, width, height) copies those bytes into the presentation. No public image URL is needed. Never call getDataAsString() on image bytes, and never relabel another format's MIME type as image/png. See the Blob reference and Slide.insertImage.

Install the complete project

  1. At script.google.com, create a standalone project named Garden Slides practice.

  2. Replace Code.gs with the complete source below, then add a second script file named Data (Data.gs) containing the supplied zone data.

  3. In Project Settings, enable Show "appsscript.json" manifest file in editor, replace appsscript.json with the supplied manifest, and save all files.

  4. In Code.gs, set GARDEN_SLIDES.imageFileId to your uploaded PNG's file ID. Keep the default outputName and leave confirmedOutputId as ''.

Code.gs
/**
 * @type {{imageFileId: string, outputName: string, confirmedOutputId: string}}
 */
const GARDEN_SLIDES = {
  imageFileId: 'REPLACE_WITH_UPLOADED_SITE_MAP_PNG_ID',
  outputName: 'Harbor garden - readiness briefing',
  // Required only for reconciliation, rebuilding, or deliberate image resizing.
  confirmedOutputId: ''
};

/**
 * @typedef {{name: string, capacity: number, ready: number, action: string}} GardenZone
 */
/**
 * @typedef {{heading: string, dateLabel: string, mapWidthPoints: number, zones: GardenZone[]}} GardenSlidesPlan
 */
/**
 * @typedef {{
 *   status: 'creating'|'editing'|'complete',
 *   attemptedAt: string,
 *   outputName: string,
 *   imageFileId: string,
 *   plan: GardenSlidesPlan,
 *   outputId: string,
 *   slideIds: string[],
 *   mapElementId: string
 * }} GardenSlidesRecord
 */

const GARDEN_SLIDES_RECORD_KEY = 'GARDEN_SLIDES_REPORT';
const GARDEN_MAP_SHA256 = '515251e150bc4b330e8cd1c3a415fbc58d39a088629459be09a2fd3c3772c9e2';

function inspectGardenSlides() {
  validateGardenSettings_();
  validateGardenPlan_(GARDEN_SLIDES_DATA);
  const blob = loadGardenMap_(GARDEN_SLIDES.imageFileId);
  Logger.log('Supplied PNG loaded: ' + blob.getName() + '; source ID: ' + GARDEN_SLIDES.imageFileId);
  Logger.log('Planned data: ' + JSON.stringify(GARDEN_SLIDES_DATA));
  Logger.log('Planned canvas: 720 x 405 pt; map: ' + GARDEN_SLIDES_DATA.mapWidthPoints +
    ' x ' + (GARDEN_SLIDES_DATA.mapWidthPoints * 5 / 8) + ' pt.');
  showGardenSlidesRecord();
  Logger.log('Inspection only. No presentation or property was changed.');
}

function createGardenSlides() {
  validateGardenSettings_();
  withGardenSlidesLock_(() => {
    const previous = readGardenSlidesRecord_();
    if (previous !== null) {
      requireGardenSettingsMatch_(previous);
      if (previous.status !== 'complete') {
        throw new Error('An attempt is unresolved. Inspect the record; do not create another presentation.');
      }
      Logger.log('Already complete. No presentation writes. Output ID: ' + previous.outputId);
      Logger.log('Output URL: ' + SlidesApp.openById(previous.outputId).getUrl());
      return;
    }
    validateGardenPlan_(GARDEN_SLIDES_DATA);
    const mapBlob = loadGardenMap_(GARDEN_SLIDES.imageFileId);
    /** @type {GardenSlidesRecord} */
    const record = {
      status: 'creating',
      attemptedAt: new Date().toISOString(),
      outputName: GARDEN_SLIDES.outputName,
      imageFileId: GARDEN_SLIDES.imageFileId,
      plan: GARDEN_SLIDES_DATA,
      outputId: '',
      slideIds: [],
      mapElementId: ''
    };
    // Save intent and data before the external creation request.
    saveGardenSlidesRecord_(record);
    const presentation = SlidesApp.create(record.outputName);
    record.outputId = presentation.getId();
    Logger.log('Slides returned output ID: ' + record.outputId);
    record.status = 'editing';
    // Preserve presentation identity before any slide/element mutation.
    saveGardenSlidesRecord_(record);
    renderGardenSlides_(record, presentation, mapBlob);
  });
}

function showGardenSlidesRecord() {
  const value = PropertiesService.getScriptProperties().getProperty(GARDEN_SLIDES_RECORD_KEY);
  Logger.log(GARDEN_SLIDES_RECORD_KEY + ': ' + (value === null ? 'No recorded attempt.' : value));
}

// Record an operator-identified existing output; never search or create by name.
function reconcileGardenSlides() {
  validateGardenSettings_();
  requireGardenId_(GARDEN_SLIDES.confirmedOutputId, 'confirmedOutputId');
  withGardenSlidesLock_(() => {
    const record = requireGardenSlidesRecord_();
    requireGardenSettingsMatch_(record);
    if (record.status !== 'creating') {
      if (record.outputId !== GARDEN_SLIDES.confirmedOutputId) {
        throw new Error('Another output ID is already recorded. Do not substitute a different file.');
      }
      Logger.log('That output is already recorded. No change.');
      return;
    }
    const presentation = SlidesApp.openById(GARDEN_SLIDES.confirmedOutputId);
    if (presentation.getName() !== record.outputName || presentation.getSlides().length > 2) {
      throw new Error('The candidate does not match this small report. Preserve the uncertain attempt.');
    }
    record.outputId = presentation.getId();
    record.status = 'editing';
    saveGardenSlidesRecord_(record);
    Logger.log('Existing output recorded, without editing slides. Review before rebuilding.');
  });
}

// OVERWRITES all page elements and backgrounds on the two report slides.
// Notes, masters, and layouts are not rewritten. Human slide content is not preserved.
function rebuildGardenSlides() {
  validateGardenSettings_();
  withGardenSlidesLock_(() => {
    const record = requireConfirmedGardenRecord_();
    const mapBlob = loadGardenMap_(record.imageFileId);
    const presentation = openGardenOutput_(record);
    renderGardenSlides_(record, presentation, mapBlob);
  });
}

// Change only the recorded map element, preserving its ID and the other elements.
function resizeGardenMap() {
  validateGardenSettings_();
  requireGardenMapWidth_(GARDEN_SLIDES_DATA.mapWidthPoints);
  withGardenSlidesLock_(() => {
    const record = requireConfirmedGardenRecord_();
    if (record.status !== 'complete') throw new Error('Finish or rebuild the recorded report before resizing.');
    const presentation = openGardenOutput_(record);
    requireGardenCanvas_(presentation);
    const overview = presentation.getSlideById(record.slideIds[0]);
    const element = overview === null ? null : overview.getPageElementById(record.mapElementId);
    if (element === null || element.getPageElementType() !== SlidesApp.PageElementType.IMAGE ||
        element.getParentGroup() !== null) {
      throw new Error('The recorded map element is missing, is not an image, or is grouped. Do not select another image by position.');
    }
    record.plan.mapWidthPoints = GARDEN_SLIDES_DATA.mapWidthPoints;
    record.status = 'editing';
    // A failed resize remains attached to this output and this new intended width.
    saveGardenSlidesRecord_(record);
    const image = element.asImage();
    image.setWidth(record.plan.mapWidthPoints).setHeight(record.plan.mapWidthPoints * 5 / 8)
      .setLeft(36).setTop(110).setRotation(0);
    presentation.saveAndClose();
    record.status = 'complete';
    saveGardenSlidesRecord_(record);
    Logger.log('Same map object ID: ' + record.mapElementId + '; width: ' + record.plan.mapWidthPoints + ' pt.');
  });
}

/**
 * @param {GardenSlidesRecord} record
 * @param {SlidesApp.Presentation} presentation
 * @param {Blob} mapBlob
 * @returns {void}
 */
function renderGardenSlides_(record, presentation, mapBlob) {
  requireGardenCanvas_(presentation);
  /** @type {SlidesApp.Slide[]} */
  let slides = presentation.getSlides();
  if (slides.length > 2) throw new Error('This output has extra slides. Stop rather than remove them.');
  record.status = 'editing';
  record.mapElementId = '';
  saveGardenSlidesRecord_(record);
  if (record.slideIds.length === 0) {
    // The new or reconciled output may have a default slide or an interrupted append.
    // At most two appends, never an unbounded loop over input data.
    for (let count = slides.length; count < 2; count += 1) {
      slides.push(presentation.appendSlide(SlidesApp.PredefinedLayout.BLANK));
    }
    record.slideIds = slides.map((slide) => slide.getObjectId());
    saveGardenSlidesRecord_(record);
  } else {
    /** @type {SlidesApp.Slide[]} */
    const recordedSlides = [];
    for (const id of record.slideIds) {
      const slide = presentation.getSlideById(id);
      if (slide === null) {
        throw new Error('A recorded slide was removed or replaced. Preserve the output and investigate.');
      }
      recordedSlides.push(slide);
    }
    slides = recordedSlides;
  }
  const elements = slides.map((slide) => slide.getPageElements());
  if (elements.some((items) => items.length > 30)) {
    throw new Error('A report slide contains more than 30 elements. Review its human content before proceeding.');
  }
  // The exact two selected slides are script-owned. Do not use this on a shared deck.
  elements.forEach((items) => items.forEach((element) => element.remove()));
  slides.forEach((slide) => slide.getBackground().setSolidFill('#FFFFFF'));

  const plan = record.plan;
  const overview = slides[0];
  gardenText_(overview, plan.heading, 36, 26, 648, 46, 26, true);
  gardenText_(overview, plan.dateLabel + ' | Fictional practice report', 36, 76, 648, 28, 14, false);
  const image = overview.insertImage(mapBlob, 36, 110, plan.mapWidthPoints, plan.mapWidthPoints * 5 / 8);
  image.setTitle('Harbor garden planter positions');
  image.setDescription('Original schematic: six planters in North bed, two beside the path from the gate, and four in Courtyard. Positions and capacity only, not readiness; not to scale.');
  gardenText_(overview, 'Planter positions, not readiness.\nOriginal schematic; not to scale.', 36, 318, 320, 36, 10, false);
  const ready = plan.zones.reduce((sum, zone) => sum + zone.ready, 0);
  const capacity = plan.zones.reduce((sum, zone) => sum + zone.capacity, 0);
  plan.zones.forEach((zone, index) => {
    const text = zone.name + '\n' + zone.ready + ' / ' + zone.capacity + ' ready';
    const shape = gardenCard_(overview, text, 388, 110 + index * 72, 296, 60);
    const shapeText = requireGardenShapeText_(shape);
    requireGardenRangeStyle_(shapeText, 0, zone.name.length).setBold(true).setFontSize(17);
    requireGardenRangeStyle_(shapeText, zone.name.length + 1, text.length).setFontSize(22);
  });
  gardenFooter_(overview, ready + ' of ' + capacity + ' planters ready | Overview');

  const actions = slides[1];
  gardenText_(actions, 'Next visit: three actions', 36, 26, 648, 46, 26, true);
  gardenText_(actions, 'Match each action to the labelled zone on the map.', 36, 76, 648, 28, 14, false);
  plan.zones.forEach((zone, index) => {
    const heading = zone.name + ': ' + zone.ready + ' / ' + zone.capacity + ' ready';
    const shape = gardenCard_(actions, heading + '\n' + zone.action, 36, 116 + index * 76, 648, 66);
    requireGardenRangeStyle_(requireGardenShapeText_(shape), 0, heading.length).setBold(true).setFontSize(17);
  });
  gardenFooter_(actions, plan.dateLabel + ' | Actions for the next visit');
  record.mapElementId = image.getObjectId();
  const url = presentation.getUrl();
  presentation.saveAndClose();
  record.status = 'complete';
  saveGardenSlidesRecord_(record);
  Logger.log('Saved report URL: ' + url);
  Logger.log('Output ID: ' + record.outputId + '; overview/actions slide IDs: ' + record.slideIds.join(', '));
  Logger.log('Map element ID: ' + record.mapElementId);
}

/**
 * @param {SlidesApp.Slide} slide
 * @param {string} value
 * @param {number} left
 * @param {number} top
 * @param {number} width
 * @param {number} height
 * @param {number} size
 * @param {boolean} bold
 * @returns {SlidesApp.Shape}
 */
function gardenText_(slide, value, left, top, width, height, size, bold) {
  const shape = slide.insertTextBox(value, left, top, width, height);
  shape.setContentAlignment(SlidesApp.ContentAlignment.MIDDLE);
  const text = requireGardenShapeText_(shape);
  requireGardenTextStyle_(text).setFontFamily('Arial').setFontSize(size).setBold(bold).setForegroundColor('#264536');
  text.getParagraphStyle().setParagraphAlignment(SlidesApp.ParagraphAlignment.START)
    .setSpaceAbove(0).setSpaceBelow(0).setLineSpacing(110);
  return shape;
}

/**
 * @param {SlidesApp.Slide} slide
 * @param {string} value
 * @param {number} left
 * @param {number} top
 * @param {number} width
 * @param {number} height
 * @returns {SlidesApp.Shape}
 */
function gardenCard_(slide, value, left, top, width, height) {
  const shape = slide.insertShape(SlidesApp.ShapeType.RECTANGLE, left, top, width, height);
  shape.getFill().setSolidFill('#E5EDDC');
  shape.getBorder().setTransparent();
  shape.setContentAlignment(SlidesApp.ContentAlignment.MIDDLE);
  const text = requireGardenShapeText_(shape).setText(value);
  requireGardenTextStyle_(text).setFontFamily('Arial').setFontSize(14).setBold(false).setForegroundColor('#264536');
  text.getParagraphStyle().setParagraphAlignment(SlidesApp.ParagraphAlignment.START)
    .setSpaceAbove(0).setSpaceBelow(0).setLineSpacing(110);
  return shape;
}

/**
 * @param {SlidesApp.Shape} shape
 * @returns {SlidesApp.TextRange}
 */
function requireGardenShapeText_(shape) {
  const text = shape.getText();
  if (text === null) throw new Error('A generated text shape is unexpectedly missing its text range.');
  return text;
}
/**
 * @param {SlidesApp.TextRange} text
 * @param {number} start
 * @param {number} end
 * @returns {SlidesApp.TextStyle}
 */
function requireGardenRangeStyle_(text, start, end) {
  const range = text.getRange(start, end);
  if (range === null) throw new Error('A generated text range is unexpectedly missing part of its text.');
  const style = range.getTextStyle();
  if (style === null) throw new Error('A generated text range is unexpectedly missing its text style.');
  return style;
}

/**
 * @param {SlidesApp.TextRange} text
 * @returns {SlidesApp.TextStyle}
 */
function requireGardenTextStyle_(text) {
  const style = text.getTextStyle();
  if (style === null) throw new Error('A generated text range is unexpectedly missing its text style.');
  return style;
}

/**
 * @param {SlidesApp.Slide} slide
 * @param {string} text
 * @returns {void}
 */
function gardenFooter_(slide, text) {
  const line = slide.insertLine(SlidesApp.LineCategory.STRAIGHT, 36, 362, 684, 362);
  line.setWeight(1);
  line.getLineFill().setSolidFill('#264536');
  gardenText_(slide, text, 36, 369, 648, 24, 10, false);
}

/**
 * @param {string} id
 * @returns {Blob}
 */
function loadGardenMap_(id) {
  requireGardenId_(id, 'imageFileId');
  const file = DriveApp.getFileById(id);
  if (file.isTrashed() || file.getMimeType() !== 'image/png' || file.getSize() > 100000) {
    throw new Error('Upload the supplied small site-map.png as an actual PNG, not a shortcut or converted file.');
  }
  const blob = file.getBlob();
  const digest = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, blob.getBytes());
  const hex = digest.map((byte) => (byte < 0 ? byte + 256 : byte).toString(16).padStart(2, '0')).join('');
  if (hex !== GARDEN_MAP_SHA256) throw new Error('The uploaded bytes differ from the supplied site-map.png.');
  return blob;
}

/**
 * @param {SlidesApp.Presentation} presentation
 * @returns {void}
 */
function requireGardenCanvas_(presentation) {
  const width = presentation.getPageWidth();
  const height = presentation.getPageHeight();
  if (Math.abs(width - 720) > 0.1 || Math.abs(height - 405) > 0.1) {
    throw new Error('Recorded output canvas is ' + width + ' x ' + height +
      ' pt. Set that file to 10 x 5.625 inches in File > Page setup > Custom, then confirm its ID and rebuild.');
  }
}

/**
 * @param {GardenSlidesRecord} record
 * @returns {SlidesApp.Presentation}
 */
function openGardenOutput_(record) {
  const presentation = SlidesApp.openById(record.outputId);
  if (presentation.getName() !== record.outputName) {
    throw new Error('The recorded output name changed. Inspect the exact ID and restore its name before editing.');
  }
  return presentation;
}

function validateGardenSettings_() {
  requireGardenId_(GARDEN_SLIDES.imageFileId, 'imageFileId');
  requireGardenText_(GARDEN_SLIDES.outputName, 'outputName', 80);
}

/**
 * @param {GardenSlidesPlan} plan
 * @returns {void}
 */
function validateGardenPlan_(plan) {
  if (!plan || typeof plan !== 'object') throw new Error('Missing garden report data.');
  requireGardenText_(plan.heading, 'heading', 40);
  requireGardenText_(plan.dateLabel, 'dateLabel', 24);
  requireGardenMapWidth_(plan.mapWidthPoints);
  const mapZones = [['North bed', 6], ['Path edge', 2], ['Courtyard', 4]];
  if (!Array.isArray(plan.zones) || plan.zones.length !== 3) throw new Error('Keep the three supplied map zones.');
  plan.zones.forEach((zone, index) => {
    if (!zone || zone.name !== mapZones[index][0] || zone.capacity !== mapZones[index][1]) {
      throw new Error('Zone names, capacities, and order must match the supplied map. Change ready or action instead.');
    }
    if (!Number.isInteger(zone.ready) || zone.ready < 0 || zone.ready > zone.capacity) {
      throw new Error('ready must be an integer from zero to the zone capacity.');
    }
    requireGardenText_(zone.action, 'action', 64);
  });
}

/**
 * @param {number} width
 * @returns {void}
 */
function requireGardenMapWidth_(width) {
  if (!Number.isFinite(width) || width < 240 || width > 320) {
    throw new Error('mapWidthPoints must be a number from 240 to 320 for this layout.');
  }
}

/**
 * @param {string} value
 * @param {string} label
 * @returns {void}
 */
function requireGardenId_(value, label) {
  if (typeof value !== 'string' || value.startsWith('REPLACE_') || value.length > 200 || !/^[A-Za-z0-9_-]+$/.test(value)) {
    throw new Error('Set ' + label + ' to the actual file ID, not a name, blank, or whole URL.');
  }
}

/**
 * @param {string} value
 * @param {string} label
 * @param {number} maximum
 * @returns {void}
 */
function requireGardenText_(value, label, maximum) {
  if (typeof value !== 'string' || value.trim().length === 0 || value.length > maximum || /[\r\n\t]/.test(value)) {
    throw new Error(label + ' must be nonempty single-line text of at most ' + maximum + ' characters.');
  }
}

/**
 * @param {function(): void} action
 * @returns {void}
 */
function withGardenSlidesLock_(action) {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) throw new Error('Another report operation is running. Wait and inspect its record.');
  try {
    action();
  } finally {
    lock.releaseLock();
  }
}

/**
 * @param {GardenSlidesRecord} record
 * @returns {void}
 */
function saveGardenSlidesRecord_(record) {
  PropertiesService.getScriptProperties().setProperty(GARDEN_SLIDES_RECORD_KEY, JSON.stringify(record));
}

function readGardenSlidesRecord_() {
  const value = PropertiesService.getScriptProperties().getProperty(GARDEN_SLIDES_RECORD_KEY);
  if (value === null) return null;
  /** @type {GardenSlidesRecord} */
  let record;
  try {
    record = JSON.parse(value);
  } catch (error) {
    throw new Error('Unreadable garden Slides record. Preserve it instead of deleting it to retry.');
  }
  if (!record || typeof record !== 'object' || !['creating', 'editing', 'complete'].includes(record.status) ||
      typeof record.outputId !== 'string' || typeof record.mapElementId !== 'string' ||
      typeof record.attemptedAt !== 'string' || !Array.isArray(record.slideIds) ||
      (record.slideIds.length !== 0 && record.slideIds.length !== 2) ||
      record.slideIds.some((id) => typeof id !== 'string' || id.length === 0 || id.length > 200)) {
    throw new Error('Unexpected garden Slides record. Preserve it and investigate.');
  }
  requireGardenId_(record.imageFileId, 'record.imageFileId');
  requireGardenText_(record.outputName, 'record.outputName', 80);
  validateGardenPlan_(record.plan);
  if (record.slideIds.length === 2 && record.slideIds[0] === record.slideIds[1]) {
    throw new Error('The two recorded slides must have different object IDs.');
  }
  if (record.status === 'creating') {
    if (record.outputId !== '' || record.slideIds.length !== 0 || record.mapElementId !== '') {
      throw new Error('Unexpected identity in a pending creation record.');
    }
  } else {
    requireGardenId_(record.outputId, 'record.outputId');
    if (record.status === 'complete' && (record.slideIds.length !== 2 || record.mapElementId === '')) {
      throw new Error('The completed report is missing slide or image identity.');
    }
  }
  return record;
}

function requireGardenSlidesRecord_() {
  const record = readGardenSlidesRecord_();
  if (record === null) throw new Error('No recorded attempt. Recovery cannot invent one.');
  return record;
}

/**
 * @param {GardenSlidesRecord} record
 * @returns {void}
 */
function requireGardenSettingsMatch_(record) {
  if (record.imageFileId !== GARDEN_SLIDES.imageFileId || record.outputName !== GARDEN_SLIDES.outputName) {
    throw new Error('Settings differ from the recorded attempt. Restore its image ID and output name.');
  }
}

function requireConfirmedGardenRecord_() {
  requireGardenId_(GARDEN_SLIDES.confirmedOutputId, 'confirmedOutputId');
  const record = requireGardenSlidesRecord_();
  requireGardenSettingsMatch_(record);
  if (record.status === 'creating' || record.outputId !== GARDEN_SLIDES.confirmedOutputId) {
    throw new Error('Confirm the exact recorded output ID; reconcile an unknown creation before editing.');
  }
  return record;
}

Download Code.gs
Data.gs
// Fictional readiness observations. The supplied map shows positions, not readiness.
const GARDEN_SLIDES_DATA = {
  heading: 'Harbor garden readiness',
  dateLabel: '11 September 2026',
  mapWidthPoints: 320,
  // Names, capacities, and order describe the fixed supplied site-map.png.
  // Change ready counts and actions, not the map's physical layout.
  zones: [
    { name: 'North bed', capacity: 6, ready: 4, action: 'Bring seedlings for two planters.' },
    { name: 'Path edge', capacity: 2, ready: 1, action: 'Add soil to the planter beside the gate.' },
    { name: 'Courtyard', capacity: 4, ready: 2, action: 'Water and label the two remaining planters.' }
  ]
};

Download Data.gs
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/presentations",
    "https://www.googleapis.com/auth/drive.readonly"
  ]
}

Download appsscript.json

Data.gs holds the three zone records. Their names, order, and capacities (6, 2, and 4) must match site-map.png. Each zone's ready value must be an integer from 0 to its capacity, and each action must be one line of at most 64 characters.

The manifest requests https://www.googleapis.com/auth/presentations to create and edit presentations and https://www.googleapis.com/auth/drive.readonly to read the uploaded PNG. Both scopes cover every file the authorizing account can open. The single file ID in Code.gs is a check inside the script and does not narrow the OAuth scope. See the authorization guide.

Inspect the plan before creating anything

Select inspectGardenSlides and click Run. The execution log shows the uploaded image ID, the planned zone data, the planned 720-by-405-point canvas with a 320-by-200-point map, and No recorded attempt.

inspectGardenSlides checks imageFileId, reads the Drive file, checks its SHA-256 digest with Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, blob.getBytes()), and logs the plan. It creates no presentation and writes no Script Properties. See Utilities.computeDigest.

With the supplied data, four ready planters in North bed, one at Path edge, and two in Courtyard add up to the footer text 7 of 12 planters ready. Change North bed's ready count to 5 and its action to Bring seedlings for one planter., then run inspectGardenSlides again. The logged data changes, and the briefing would read 8 of 12 planters ready on the same 12-planter map. Restore both values before you create the presentation.

Place elements in points

The briefing targets the standard widescreen canvas of 720 by 405 points (10 by 5.625 inches at 72 points per inch). Coordinates start at the slide's upper-left corner. left grows to the right, and top grows downward. Element sizes, font sizes, and line weights are all in points, not image or CSS pixels. See Size and position page elements.

Before it draws, the script reads getPageWidth() and getPageHeight() and stops if the canvas is not 720 by 405 points.

Overview elementLeft / top in pointsWidth / height in points
Title text box36 / 26648 / 46
Map, initially36 / 110320 / 200
First readiness card388 / 110296 / 60
Second and third cardsSame left, 72 points farther down each timeSame size
Footer dividerStart 36 / 362, end 684 / 362Endpoints define length, and weight is 1 point

Each zone's array index (0, 1, 2) sets the top of its card: 110 + index * 72 on the overview slide and 116 + index * 76 on the actions slide. reduce adds up the ready and capacity values for the 7 of 12 planters ready footer.

Shapes and images take a bounding box. slide.insertLine takes start and end coordinates instead: (36, 362, 684, 362). line.setWeight(1) sets the stroke thickness in points, and line.getLineFill().setSolidFill(...) sets its color. A Line has no border property. See the Line reference.

site-map.png is 640 by 400 pixels, an 8-to-5 ratio, so the code computes height = width * 5 / 8. The map is 200 points high at 320 points wide, or 150 points high at 240 points wide. resizeGardenMap sets width and height together, so the image keeps its proportions. See the Image reference.

Generate and inspect the two slides

  1. With the original Data.gs values restored and no execution running, run createGardenSlides once.

  2. Open the presentation URL from the execution log. Confirm that its title is Harbor garden - readiness briefing and that its sharing is still private.

  3. Check the two slides. The overview slide has the map on the left, three readiness cards on the right, and 7 of 12 planters ready in the footer. The actions slide has three action cards.

  4. Select the map image in Slides and open its alt text. The title and description name the three zones and their planter capacities.

  5. Run showGardenSlidesRecord. Confirm that status is complete, and note the saved outputId, slideIds, and mapElementId.

If createGardenSlides stops with a canvas-size error after it creates the file, open the recorded presentation and set File > Page setup > Custom to 10 x 5.625 inches. Then read the rebuild warning below, copy the presentation ID into confirmedOutputId, and run rebuildGardenSlides.

On the first creation, the script calls appendSlide(SlidesApp.PredefinedLayout.BLANK) until the presentation has two slides, then saves their object IDs. On a later rebuild, it finds those same two slides with getSlideById. See Presentation.appendSlide.

Before it draws on the two recorded slides, the script removes every page element on them and sets their background to white. Each readiness card is a rectangle shape with a pale green solid fill, a transparent border, and ContentAlignment.MIDDLE vertical alignment. See the Shape, Fill, and Border references.

Inside each shape, getTextStyle() sets the font family (Arial), point size, bold weight, and foreground color. getParagraphStyle() sets line spacing and ParagraphAlignment.START, the Slides enum for left-aligned left-to-right text.

To bold only the zone name on a card's first line, the script inserts the whole string first and then styles textRange.getRange(0, zone.name.length). In getRange, the start index is inclusive and the end index is exclusive. See the TextRange, TextStyle, and ParagraphStyle references.

Resize the same object and restore it

Run createGardenSlides a second time to check that it changes nothing. It logs the saved outputId and exits without creating a presentation or changing an element.

Now resize the existing map image in place:

  1. Copy the presentation ID from the URL, between /presentation/d/ and the next slash. Check that it matches outputId from showGardenSlidesRecord, then paste it into GARDEN_SLIDES.confirmedOutputId.

  2. In Data.gs, change GARDEN_SLIDES_DATA.mapWidthPoints from 320 to 240.

  3. Run resizeGardenMap. The map becomes 240 by 150 points with the same top-left corner at (36, 110). The readiness cards, footer, and second slide do not change.

  4. Check the logged mapElementId. It is unchanged, because resizeGardenMap changes the existing Image element instead of deleting and reinserting it.

  5. Set mapWidthPoints back to 320 and run resizeGardenMap again to return the image to 320 by 200 points. Then set confirmedOutputId back to ''.

resizeGardenMap also resets the map's left, top, and rotation, and refuses to resize an image that is inside a group. It finds the overview slide by its saved ID, so reordering the two slide thumbnails in the editor does not change which slide it edits. It changes only the map and the saved mapWidthPoints. To redraw the cards after an interrupted run, use rebuildGardenSlides.

Save the presentation and element IDs between runs

The script keeps its JSON record between runs under the key GARDEN_SLIDES_REPORT in PropertiesService.getScriptProperties(). See the Properties service guide.

Every function that changes the presentation or the record wraps its work in withGardenSlidesLock_. That helper takes LockService.getScriptLock() with tryLock(1000) and releases it in finally. Two executions of the same project therefore cannot interleave their edits. See the Lock reference.

The record's status tells you what is known and what to do next:

StatusWhat is knownNext step
No recordNo attempt is recorded in this project.Run inspectGardenSlides, then createGardenSlides.
creatingThe plan was saved before SlidesApp.create, without a confirmed outputId.Check Executions and Drive, then reconcile the created file ID with reconcileGardenSlides.
editingoutputId is saved, but drawing, resizing, or the final record write did not finish.Inspect the recorded presentation, set confirmedOutputId, and run rebuildGardenSlides. resizeGardenMap refuses to run until the status is complete.
completeThe Slides changes were saved and the final record write succeeded.A rerun of createGardenSlides reports outputId. Resizing or rebuilding needs confirmedOutputId.

During creation, the script saves outputId right after SlidesApp.create and saves slideIds before it clears or draws page elements. Each Slides edit is a separate call, so a failure partway through drawing can leave some elements on a slide. Apps Script also applies pending Slides changes at the end of an execution, even one that throws later (see Presentation.saveAndClose). A failed run can therefore leave a created or partly drawn presentation in Drive while the record still says creating or editing.

Reconcile an uncertain creation

If a run fails while the status is creating, wait for active executions to finish, then run showGardenSlidesRecord. Next, check the run's entry in Executions. createGardenSlides logs Slides returned output ID right before it saves the record, so the log may already name the file.

If the log does not show the ID, look in Drive for a Harbor garden - readiness briefing presentation created at the time of the run. Copy its file ID into confirmedOutputId and run reconcileGardenSlides. It checks the presentation's title and slide count, then sets the record to editing. It creates no file and changes no slide.

Rebuild only the confirmed generated slides

When the record is editing, or when you want to redraw the two recorded slides on purpose, inspect the presentation first and set confirmedOutputId to its outputId.

After rebuildGardenSlides runs, inspect the presentation, run showGardenSlidesRecord, and clear confirmedOutputId. Because the rebuild recreated every shape, line, and image on the two slides, mapElementId now holds a new object ID. Later resizeGardenMap runs use that new ID.

To see the difference between resizing and rebuilding, add a text box reading Practice annotation to remove to the overview slide of a completed presentation. resizeGardenMap leaves the annotation in place, because it changes only the element recorded as mapElementId. rebuildGardenSlides removes every element on both recorded slides, including the annotation, and redraws the briefing.

Interpret errors without losing the output

SymptomAction
Invalid file ID, SHA-256 mismatch, out-of-range ready count, or overlong textThe script stops before SlidesApp.create. Fix imageFileId or Data.gs and run inspectGardenSlides.
Authorization or Drive access failureCheck that the running account can read imageFileId in Drive and has approved the manifest scopes.
Canvas size mismatchSet the page setup of the recorded outputId to 10 by 5.625 inches, set confirmedOutputId, and run rebuildGardenSlides.
Slide count, element count (more than 30), or missing slide or element ID errorrebuildGardenSlides needs the two recorded slide IDs and no extra slides. resizeGardenMap needs the recorded mapElementId to be an ungrouped Image.
Service, quota, or lock timeout during an editKeep GARDEN_SLIDES_REPORT as it is. Inspect the presentation at outputId. Once active runs finish, set confirmedOutputId and run rebuildGardenSlides (the status is editing).

To make a second presentation with different zone data, use a new standalone project, so the existing GARDEN_SLIDES_REPORT record stays paired with its presentation.

Clean up the practice files

When you are done and no execution is running, clear confirmedOutputId. If you no longer need the generated presentation or the uploaded site-map.png, find each file by its recorded ID (outputId and imageFileId) before you move it to Trash.

Keep site-map.png in Drive until your last rebuildGardenSlides run. The presentation holds its own copy of the image, but a rebuild reads the PNG from Drive again. You can also revoke the practice project's OAuth grant from your Google Account connections page, as the authorization guide describes.

Next, read Inspect a folder with Advanced Drive v3 to see what to use when the built-in classes do not expose the API fields you need.

Project files

Complete source files for this chapter's examples.

Build a two-slide garden readiness briefing
Code.gs
/**
 * @type {{imageFileId: string, outputName: string, confirmedOutputId: string}}
 */
const GARDEN_SLIDES = {
  imageFileId: 'REPLACE_WITH_UPLOADED_SITE_MAP_PNG_ID',
  outputName: 'Harbor garden - readiness briefing',
  // Required only for reconciliation, rebuilding, or deliberate image resizing.
  confirmedOutputId: ''
};

/**
 * @typedef {{name: string, capacity: number, ready: number, action: string}} GardenZone
 */
/**
 * @typedef {{heading: string, dateLabel: string, mapWidthPoints: number, zones: GardenZone[]}} GardenSlidesPlan
 */
/**
 * @typedef {{
 *   status: 'creating'|'editing'|'complete',
 *   attemptedAt: string,
 *   outputName: string,
 *   imageFileId: string,
 *   plan: GardenSlidesPlan,
 *   outputId: string,
 *   slideIds: string[],
 *   mapElementId: string
 * }} GardenSlidesRecord
 */

const GARDEN_SLIDES_RECORD_KEY = 'GARDEN_SLIDES_REPORT';
const GARDEN_MAP_SHA256 = '515251e150bc4b330e8cd1c3a415fbc58d39a088629459be09a2fd3c3772c9e2';

function inspectGardenSlides() {
  validateGardenSettings_();
  validateGardenPlan_(GARDEN_SLIDES_DATA);
  const blob = loadGardenMap_(GARDEN_SLIDES.imageFileId);
  Logger.log('Supplied PNG loaded: ' + blob.getName() + '; source ID: ' + GARDEN_SLIDES.imageFileId);
  Logger.log('Planned data: ' + JSON.stringify(GARDEN_SLIDES_DATA));
  Logger.log('Planned canvas: 720 x 405 pt; map: ' + GARDEN_SLIDES_DATA.mapWidthPoints +
    ' x ' + (GARDEN_SLIDES_DATA.mapWidthPoints * 5 / 8) + ' pt.');
  showGardenSlidesRecord();
  Logger.log('Inspection only. No presentation or property was changed.');
}

function createGardenSlides() {
  validateGardenSettings_();
  withGardenSlidesLock_(() => {
    const previous = readGardenSlidesRecord_();
    if (previous !== null) {
      requireGardenSettingsMatch_(previous);
      if (previous.status !== 'complete') {
        throw new Error('An attempt is unresolved. Inspect the record; do not create another presentation.');
      }
      Logger.log('Already complete. No presentation writes. Output ID: ' + previous.outputId);
      Logger.log('Output URL: ' + SlidesApp.openById(previous.outputId).getUrl());
      return;
    }
    validateGardenPlan_(GARDEN_SLIDES_DATA);
    const mapBlob = loadGardenMap_(GARDEN_SLIDES.imageFileId);
    /** @type {GardenSlidesRecord} */
    const record = {
      status: 'creating',
      attemptedAt: new Date().toISOString(),
      outputName: GARDEN_SLIDES.outputName,
      imageFileId: GARDEN_SLIDES.imageFileId,
      plan: GARDEN_SLIDES_DATA,
      outputId: '',
      slideIds: [],
      mapElementId: ''
    };
    // Save intent and data before the external creation request.
    saveGardenSlidesRecord_(record);
    const presentation = SlidesApp.create(record.outputName);
    record.outputId = presentation.getId();
    Logger.log('Slides returned output ID: ' + record.outputId);
    record.status = 'editing';
    // Preserve presentation identity before any slide/element mutation.
    saveGardenSlidesRecord_(record);
    renderGardenSlides_(record, presentation, mapBlob);
  });
}

function showGardenSlidesRecord() {
  const value = PropertiesService.getScriptProperties().getProperty(GARDEN_SLIDES_RECORD_KEY);
  Logger.log(GARDEN_SLIDES_RECORD_KEY + ': ' + (value === null ? 'No recorded attempt.' : value));
}

// Record an operator-identified existing output; never search or create by name.
function reconcileGardenSlides() {
  validateGardenSettings_();
  requireGardenId_(GARDEN_SLIDES.confirmedOutputId, 'confirmedOutputId');
  withGardenSlidesLock_(() => {
    const record = requireGardenSlidesRecord_();
    requireGardenSettingsMatch_(record);
    if (record.status !== 'creating') {
      if (record.outputId !== GARDEN_SLIDES.confirmedOutputId) {
        throw new Error('Another output ID is already recorded. Do not substitute a different file.');
      }
      Logger.log('That output is already recorded. No change.');
      return;
    }
    const presentation = SlidesApp.openById(GARDEN_SLIDES.confirmedOutputId);
    if (presentation.getName() !== record.outputName || presentation.getSlides().length > 2) {
      throw new Error('The candidate does not match this small report. Preserve the uncertain attempt.');
    }
    record.outputId = presentation.getId();
    record.status = 'editing';
    saveGardenSlidesRecord_(record);
    Logger.log('Existing output recorded, without editing slides. Review before rebuilding.');
  });
}

// OVERWRITES all page elements and backgrounds on the two report slides.
// Notes, masters, and layouts are not rewritten. Human slide content is not preserved.
function rebuildGardenSlides() {
  validateGardenSettings_();
  withGardenSlidesLock_(() => {
    const record = requireConfirmedGardenRecord_();
    const mapBlob = loadGardenMap_(record.imageFileId);
    const presentation = openGardenOutput_(record);
    renderGardenSlides_(record, presentation, mapBlob);
  });
}

// Change only the recorded map element, preserving its ID and the other elements.
function resizeGardenMap() {
  validateGardenSettings_();
  requireGardenMapWidth_(GARDEN_SLIDES_DATA.mapWidthPoints);
  withGardenSlidesLock_(() => {
    const record = requireConfirmedGardenRecord_();
    if (record.status !== 'complete') throw new Error('Finish or rebuild the recorded report before resizing.');
    const presentation = openGardenOutput_(record);
    requireGardenCanvas_(presentation);
    const overview = presentation.getSlideById(record.slideIds[0]);
    const element = overview === null ? null : overview.getPageElementById(record.mapElementId);
    if (element === null || element.getPageElementType() !== SlidesApp.PageElementType.IMAGE ||
        element.getParentGroup() !== null) {
      throw new Error('The recorded map element is missing, is not an image, or is grouped. Do not select another image by position.');
    }
    record.plan.mapWidthPoints = GARDEN_SLIDES_DATA.mapWidthPoints;
    record.status = 'editing';
    // A failed resize remains attached to this output and this new intended width.
    saveGardenSlidesRecord_(record);
    const image = element.asImage();
    image.setWidth(record.plan.mapWidthPoints).setHeight(record.plan.mapWidthPoints * 5 / 8)
      .setLeft(36).setTop(110).setRotation(0);
    presentation.saveAndClose();
    record.status = 'complete';
    saveGardenSlidesRecord_(record);
    Logger.log('Same map object ID: ' + record.mapElementId + '; width: ' + record.plan.mapWidthPoints + ' pt.');
  });
}

/**
 * @param {GardenSlidesRecord} record
 * @param {SlidesApp.Presentation} presentation
 * @param {Blob} mapBlob
 * @returns {void}
 */
function renderGardenSlides_(record, presentation, mapBlob) {
  requireGardenCanvas_(presentation);
  /** @type {SlidesApp.Slide[]} */
  let slides = presentation.getSlides();
  if (slides.length > 2) throw new Error('This output has extra slides. Stop rather than remove them.');
  record.status = 'editing';
  record.mapElementId = '';
  saveGardenSlidesRecord_(record);
  if (record.slideIds.length === 0) {
    // The new or reconciled output may have a default slide or an interrupted append.
    // At most two appends, never an unbounded loop over input data.
    for (let count = slides.length; count < 2; count += 1) {
      slides.push(presentation.appendSlide(SlidesApp.PredefinedLayout.BLANK));
    }
    record.slideIds = slides.map((slide) => slide.getObjectId());
    saveGardenSlidesRecord_(record);
  } else {
    /** @type {SlidesApp.Slide[]} */
    const recordedSlides = [];
    for (const id of record.slideIds) {
      const slide = presentation.getSlideById(id);
      if (slide === null) {
        throw new Error('A recorded slide was removed or replaced. Preserve the output and investigate.');
      }
      recordedSlides.push(slide);
    }
    slides = recordedSlides;
  }
  const elements = slides.map((slide) => slide.getPageElements());
  if (elements.some((items) => items.length > 30)) {
    throw new Error('A report slide contains more than 30 elements. Review its human content before proceeding.');
  }
  // The exact two selected slides are script-owned. Do not use this on a shared deck.
  elements.forEach((items) => items.forEach((element) => element.remove()));
  slides.forEach((slide) => slide.getBackground().setSolidFill('#FFFFFF'));

  const plan = record.plan;
  const overview = slides[0];
  gardenText_(overview, plan.heading, 36, 26, 648, 46, 26, true);
  gardenText_(overview, plan.dateLabel + ' | Fictional practice report', 36, 76, 648, 28, 14, false);
  const image = overview.insertImage(mapBlob, 36, 110, plan.mapWidthPoints, plan.mapWidthPoints * 5 / 8);
  image.setTitle('Harbor garden planter positions');
  image.setDescription('Original schematic: six planters in North bed, two beside the path from the gate, and four in Courtyard. Positions and capacity only, not readiness; not to scale.');
  gardenText_(overview, 'Planter positions, not readiness.\nOriginal schematic; not to scale.', 36, 318, 320, 36, 10, false);
  const ready = plan.zones.reduce((sum, zone) => sum + zone.ready, 0);
  const capacity = plan.zones.reduce((sum, zone) => sum + zone.capacity, 0);
  plan.zones.forEach((zone, index) => {
    const text = zone.name + '\n' + zone.ready + ' / ' + zone.capacity + ' ready';
    const shape = gardenCard_(overview, text, 388, 110 + index * 72, 296, 60);
    const shapeText = requireGardenShapeText_(shape);
    requireGardenRangeStyle_(shapeText, 0, zone.name.length).setBold(true).setFontSize(17);
    requireGardenRangeStyle_(shapeText, zone.name.length + 1, text.length).setFontSize(22);
  });
  gardenFooter_(overview, ready + ' of ' + capacity + ' planters ready | Overview');

  const actions = slides[1];
  gardenText_(actions, 'Next visit: three actions', 36, 26, 648, 46, 26, true);
  gardenText_(actions, 'Match each action to the labelled zone on the map.', 36, 76, 648, 28, 14, false);
  plan.zones.forEach((zone, index) => {
    const heading = zone.name + ': ' + zone.ready + ' / ' + zone.capacity + ' ready';
    const shape = gardenCard_(actions, heading + '\n' + zone.action, 36, 116 + index * 76, 648, 66);
    requireGardenRangeStyle_(requireGardenShapeText_(shape), 0, heading.length).setBold(true).setFontSize(17);
  });
  gardenFooter_(actions, plan.dateLabel + ' | Actions for the next visit');
  record.mapElementId = image.getObjectId();
  const url = presentation.getUrl();
  presentation.saveAndClose();
  record.status = 'complete';
  saveGardenSlidesRecord_(record);
  Logger.log('Saved report URL: ' + url);
  Logger.log('Output ID: ' + record.outputId + '; overview/actions slide IDs: ' + record.slideIds.join(', '));
  Logger.log('Map element ID: ' + record.mapElementId);
}

/**
 * @param {SlidesApp.Slide} slide
 * @param {string} value
 * @param {number} left
 * @param {number} top
 * @param {number} width
 * @param {number} height
 * @param {number} size
 * @param {boolean} bold
 * @returns {SlidesApp.Shape}
 */
function gardenText_(slide, value, left, top, width, height, size, bold) {
  const shape = slide.insertTextBox(value, left, top, width, height);
  shape.setContentAlignment(SlidesApp.ContentAlignment.MIDDLE);
  const text = requireGardenShapeText_(shape);
  requireGardenTextStyle_(text).setFontFamily('Arial').setFontSize(size).setBold(bold).setForegroundColor('#264536');
  text.getParagraphStyle().setParagraphAlignment(SlidesApp.ParagraphAlignment.START)
    .setSpaceAbove(0).setSpaceBelow(0).setLineSpacing(110);
  return shape;
}

/**
 * @param {SlidesApp.Slide} slide
 * @param {string} value
 * @param {number} left
 * @param {number} top
 * @param {number} width
 * @param {number} height
 * @returns {SlidesApp.Shape}
 */
function gardenCard_(slide, value, left, top, width, height) {
  const shape = slide.insertShape(SlidesApp.ShapeType.RECTANGLE, left, top, width, height);
  shape.getFill().setSolidFill('#E5EDDC');
  shape.getBorder().setTransparent();
  shape.setContentAlignment(SlidesApp.ContentAlignment.MIDDLE);
  const text = requireGardenShapeText_(shape).setText(value);
  requireGardenTextStyle_(text).setFontFamily('Arial').setFontSize(14).setBold(false).setForegroundColor('#264536');
  text.getParagraphStyle().setParagraphAlignment(SlidesApp.ParagraphAlignment.START)
    .setSpaceAbove(0).setSpaceBelow(0).setLineSpacing(110);
  return shape;
}

/**
 * @param {SlidesApp.Shape} shape
 * @returns {SlidesApp.TextRange}
 */
function requireGardenShapeText_(shape) {
  const text = shape.getText();
  if (text === null) throw new Error('A generated text shape is unexpectedly missing its text range.');
  return text;
}
/**
 * @param {SlidesApp.TextRange} text
 * @param {number} start
 * @param {number} end
 * @returns {SlidesApp.TextStyle}
 */
function requireGardenRangeStyle_(text, start, end) {
  const range = text.getRange(start, end);
  if (range === null) throw new Error('A generated text range is unexpectedly missing part of its text.');
  const style = range.getTextStyle();
  if (style === null) throw new Error('A generated text range is unexpectedly missing its text style.');
  return style;
}

/**
 * @param {SlidesApp.TextRange} text
 * @returns {SlidesApp.TextStyle}
 */
function requireGardenTextStyle_(text) {
  const style = text.getTextStyle();
  if (style === null) throw new Error('A generated text range is unexpectedly missing its text style.');
  return style;
}

/**
 * @param {SlidesApp.Slide} slide
 * @param {string} text
 * @returns {void}
 */
function gardenFooter_(slide, text) {
  const line = slide.insertLine(SlidesApp.LineCategory.STRAIGHT, 36, 362, 684, 362);
  line.setWeight(1);
  line.getLineFill().setSolidFill('#264536');
  gardenText_(slide, text, 36, 369, 648, 24, 10, false);
}

/**
 * @param {string} id
 * @returns {Blob}
 */
function loadGardenMap_(id) {
  requireGardenId_(id, 'imageFileId');
  const file = DriveApp.getFileById(id);
  if (file.isTrashed() || file.getMimeType() !== 'image/png' || file.getSize() > 100000) {
    throw new Error('Upload the supplied small site-map.png as an actual PNG, not a shortcut or converted file.');
  }
  const blob = file.getBlob();
  const digest = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, blob.getBytes());
  const hex = digest.map((byte) => (byte < 0 ? byte + 256 : byte).toString(16).padStart(2, '0')).join('');
  if (hex !== GARDEN_MAP_SHA256) throw new Error('The uploaded bytes differ from the supplied site-map.png.');
  return blob;
}

/**
 * @param {SlidesApp.Presentation} presentation
 * @returns {void}
 */
function requireGardenCanvas_(presentation) {
  const width = presentation.getPageWidth();
  const height = presentation.getPageHeight();
  if (Math.abs(width - 720) > 0.1 || Math.abs(height - 405) > 0.1) {
    throw new Error('Recorded output canvas is ' + width + ' x ' + height +
      ' pt. Set that file to 10 x 5.625 inches in File > Page setup > Custom, then confirm its ID and rebuild.');
  }
}

/**
 * @param {GardenSlidesRecord} record
 * @returns {SlidesApp.Presentation}
 */
function openGardenOutput_(record) {
  const presentation = SlidesApp.openById(record.outputId);
  if (presentation.getName() !== record.outputName) {
    throw new Error('The recorded output name changed. Inspect the exact ID and restore its name before editing.');
  }
  return presentation;
}

function validateGardenSettings_() {
  requireGardenId_(GARDEN_SLIDES.imageFileId, 'imageFileId');
  requireGardenText_(GARDEN_SLIDES.outputName, 'outputName', 80);
}

/**
 * @param {GardenSlidesPlan} plan
 * @returns {void}
 */
function validateGardenPlan_(plan) {
  if (!plan || typeof plan !== 'object') throw new Error('Missing garden report data.');
  requireGardenText_(plan.heading, 'heading', 40);
  requireGardenText_(plan.dateLabel, 'dateLabel', 24);
  requireGardenMapWidth_(plan.mapWidthPoints);
  const mapZones = [['North bed', 6], ['Path edge', 2], ['Courtyard', 4]];
  if (!Array.isArray(plan.zones) || plan.zones.length !== 3) throw new Error('Keep the three supplied map zones.');
  plan.zones.forEach((zone, index) => {
    if (!zone || zone.name !== mapZones[index][0] || zone.capacity !== mapZones[index][1]) {
      throw new Error('Zone names, capacities, and order must match the supplied map. Change ready or action instead.');
    }
    if (!Number.isInteger(zone.ready) || zone.ready < 0 || zone.ready > zone.capacity) {
      throw new Error('ready must be an integer from zero to the zone capacity.');
    }
    requireGardenText_(zone.action, 'action', 64);
  });
}

/**
 * @param {number} width
 * @returns {void}
 */
function requireGardenMapWidth_(width) {
  if (!Number.isFinite(width) || width < 240 || width > 320) {
    throw new Error('mapWidthPoints must be a number from 240 to 320 for this layout.');
  }
}

/**
 * @param {string} value
 * @param {string} label
 * @returns {void}
 */
function requireGardenId_(value, label) {
  if (typeof value !== 'string' || value.startsWith('REPLACE_') || value.length > 200 || !/^[A-Za-z0-9_-]+$/.test(value)) {
    throw new Error('Set ' + label + ' to the actual file ID, not a name, blank, or whole URL.');
  }
}

/**
 * @param {string} value
 * @param {string} label
 * @param {number} maximum
 * @returns {void}
 */
function requireGardenText_(value, label, maximum) {
  if (typeof value !== 'string' || value.trim().length === 0 || value.length > maximum || /[\r\n\t]/.test(value)) {
    throw new Error(label + ' must be nonempty single-line text of at most ' + maximum + ' characters.');
  }
}

/**
 * @param {function(): void} action
 * @returns {void}
 */
function withGardenSlidesLock_(action) {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) throw new Error('Another report operation is running. Wait and inspect its record.');
  try {
    action();
  } finally {
    lock.releaseLock();
  }
}

/**
 * @param {GardenSlidesRecord} record
 * @returns {void}
 */
function saveGardenSlidesRecord_(record) {
  PropertiesService.getScriptProperties().setProperty(GARDEN_SLIDES_RECORD_KEY, JSON.stringify(record));
}

function readGardenSlidesRecord_() {
  const value = PropertiesService.getScriptProperties().getProperty(GARDEN_SLIDES_RECORD_KEY);
  if (value === null) return null;
  /** @type {GardenSlidesRecord} */
  let record;
  try {
    record = JSON.parse(value);
  } catch (error) {
    throw new Error('Unreadable garden Slides record. Preserve it instead of deleting it to retry.');
  }
  if (!record || typeof record !== 'object' || !['creating', 'editing', 'complete'].includes(record.status) ||
      typeof record.outputId !== 'string' || typeof record.mapElementId !== 'string' ||
      typeof record.attemptedAt !== 'string' || !Array.isArray(record.slideIds) ||
      (record.slideIds.length !== 0 && record.slideIds.length !== 2) ||
      record.slideIds.some((id) => typeof id !== 'string' || id.length === 0 || id.length > 200)) {
    throw new Error('Unexpected garden Slides record. Preserve it and investigate.');
  }
  requireGardenId_(record.imageFileId, 'record.imageFileId');
  requireGardenText_(record.outputName, 'record.outputName', 80);
  validateGardenPlan_(record.plan);
  if (record.slideIds.length === 2 && record.slideIds[0] === record.slideIds[1]) {
    throw new Error('The two recorded slides must have different object IDs.');
  }
  if (record.status === 'creating') {
    if (record.outputId !== '' || record.slideIds.length !== 0 || record.mapElementId !== '') {
      throw new Error('Unexpected identity in a pending creation record.');
    }
  } else {
    requireGardenId_(record.outputId, 'record.outputId');
    if (record.status === 'complete' && (record.slideIds.length !== 2 || record.mapElementId === '')) {
      throw new Error('The completed report is missing slide or image identity.');
    }
  }
  return record;
}

function requireGardenSlidesRecord_() {
  const record = readGardenSlidesRecord_();
  if (record === null) throw new Error('No recorded attempt. Recovery cannot invent one.');
  return record;
}

/**
 * @param {GardenSlidesRecord} record
 * @returns {void}
 */
function requireGardenSettingsMatch_(record) {
  if (record.imageFileId !== GARDEN_SLIDES.imageFileId || record.outputName !== GARDEN_SLIDES.outputName) {
    throw new Error('Settings differ from the recorded attempt. Restore its image ID and output name.');
  }
}

function requireConfirmedGardenRecord_() {
  requireGardenId_(GARDEN_SLIDES.confirmedOutputId, 'confirmedOutputId');
  const record = requireGardenSlidesRecord_();
  requireGardenSettingsMatch_(record);
  if (record.status === 'creating' || record.outputId !== GARDEN_SLIDES.confirmedOutputId) {
    throw new Error('Confirm the exact recorded output ID; reconcile an unknown creation before editing.');
  }
  return record;
}

Data.gs
// Fictional readiness observations. The supplied map shows positions, not readiness.
const GARDEN_SLIDES_DATA = {
  heading: 'Harbor garden readiness',
  dateLabel: '11 September 2026',
  mapWidthPoints: 320,
  // Names, capacities, and order describe the fixed supplied site-map.png.
  // Change ready counts and actions, not the map's physical layout.
  zones: [
    { name: 'North bed', capacity: 6, ready: 4, action: 'Bring seedlings for two planters.' },
    { name: 'Path edge', capacity: 2, ready: 1, action: 'Add soil to the planter beside the gate.' },
    { name: 'Courtyard', capacity: 4, ready: 2, action: 'Water and label the two remaining planters.' }
  ]
};

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/presentations",
    "https://www.googleapis.com/auth/drive.readonly"
  ]
}

site-map.png

Download file

image/png · 3795 bytes

Preview of site-map.png

create-site-map.mjs

Download file
create-site-map.mjs
// Original fictional garden schematic for this lesson, drawn without external assets.
// Authoring utility only: Node built-ins create site-map.png beside this file.
// Readers use the supplied PNG; this is not an Apps Script file or a build/test gate.
import { writeFileSync } from 'node:fs';
import { deflateSync } from 'node:zlib';

/** @typedef {[number, number, number]} Rgb */

const width = 640;
const height = 400;
const pixels = Buffer.alloc(width * height * 3);
/** @type {Record<string, Rgb>} */
const colors = {
  paper: [247, 246, 240], ink: [38, 69, 54], panel: [229, 237, 220],
  path: [232, 221, 196], paving: [204, 191, 159], soil: [155, 103, 67],
  rim: [100, 63, 42], leaf: [64, 113, 63], light: [255, 255, 255]
};

/**
 * @param {number} x
 * @param {number} y
 * @param {number} w
 * @param {number} h
 * @param {Rgb} color
 * @returns {void}
 */
function rectangle(x, y, w, h, color) {
  for (let row = y; row < y + h; row += 1) {
    for (let col = x; col < x + w; col += 1) {
      const offset = (row * width + col) * 3;
      pixels[offset] = color[0];
      pixels[offset + 1] = color[1];
      pixels[offset + 2] = color[2];
    }
  }
}

// A tiny original bitmap alphabet, sufficient for the map's four labels.
/** @type {Record<string, string[]>} */
const glyphs = {
  ' ': ['00000','00000','00000','00000','00000','00000','00000'],
  A: ['01110','10001','10001','11111','10001','10001','10001'],
  B: ['11110','10001','10001','11110','10001','10001','11110'],
  C: ['01111','10000','10000','10000','10000','10000','01111'],
  D: ['11110','10001','10001','10001','10001','10001','11110'],
  E: ['11111','10000','10000','11110','10000','10000','11111'],
  G: ['01111','10000','10000','10111','10001','10001','01111'],
  H: ['10001','10001','10001','11111','10001','10001','10001'],
  N: ['10001','11001','11001','10101','10011','10011','10001'],
  O: ['01110','10001','10001','10001','10001','10001','01110'],
  P: ['11110','10001','10001','11110','10000','10000','10000'],
  R: ['11110','10001','10001','11110','10100','10010','10001'],
  T: ['11111','00100','00100','00100','00100','00100','00100'],
  U: ['10001','10001','10001','10001','10001','10001','01110'],
  Y: ['10001','10001','01010','00100','00100','00100','00100']
};

/**
 * @param {string} text
 * @param {number} x
 * @param {number} y
 * @returns {void}
 */
function label(text, x, y) {
  for (let index = 0; index < text.length; index += 1) {
    const glyph = glyphs[text[index]];
    if (!glyph) throw new Error('Missing map glyph: ' + text[index]);
    glyph.forEach((row, rowIndex) => {
      for (let column = 0; column < 5; column += 1) {
        if (row[column] === '1') {
          rectangle(x + index * 18 + column * 3, y + rowIndex * 3, 3, 3, colors.ink);
        }
      }
    });
  }
}

/**
 * @param {number} x
 * @param {number} y
 * @param {number} w
 * @param {number} h
 * @returns {void}
 */
function planter(x, y, w, h) {
  rectangle(x, y, w, h, colors.rim);
  rectangle(x + 3, y + 3, w - 6, h - 6, colors.soil);
  rectangle(x + Math.floor(w / 2) - 2, y + 7, 4, h - 14, colors.leaf);
  rectangle(x + Math.floor(w / 2) - 9, y + 8, 18, 5, colors.leaf);
}

rectangle(0, 0, width, height, colors.paper);
rectangle(12, 12, 616, 376, colors.ink);
rectangle(16, 16, 608, 368, colors.paper);
rectangle(28, 32, 326, 160, colors.panel);
rectangle(28, 216, 326, 156, colors.panel);
rectangle(378, 32, 232, 340, colors.path);
label('NORTH BED', 46, 48);
label('COURTYARD', 46, 232);
label('PATH EDGE', 392, 48);
rectangle(462, 88, 48, 248, colors.paving);
for (let y = 102; y < 328; y += 28) rectangle(481, y, 9, 15, colors.paper);
label('GATE', 452, 342);
for (const y of [88, 140]) {
  for (const x of [48, 150, 252]) planter(x, y, 72, 32);
}
for (const y of [274, 324]) {
  for (const x of [78, 228]) planter(x, y, 72, 28);
}
planter(404, 114, 36, 64);
planter(540, 246, 36, 64);

/**
 * @param {Buffer} bytes
 * @returns {number}
 */
function crc32(bytes) {
  let crc = 0xffffffff;
  for (const byte of bytes) {
    crc ^= byte;
    for (let bit = 0; bit < 8; bit += 1) {
      crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
    }
  }
  return (crc ^ 0xffffffff) >>> 0;
}

/**
 * @param {string} type
 * @param {Buffer} data
 * @returns {Buffer}
 */
function chunk(type, data) {
  const name = Buffer.from(type, 'ascii');
  const result = Buffer.alloc(data.length + 12);
  result.writeUInt32BE(data.length, 0);
  name.copy(result, 4);
  data.copy(result, 8);
  result.writeUInt32BE(crc32(Buffer.concat([name, data])), data.length + 8);
  return result;
}

const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 2; // Eight-bit truecolor RGB, without alpha or interlacing.
const scanlines = Buffer.alloc(height * (width * 3 + 1));
for (let row = 0; row < height; row += 1) {
  pixels.copy(scanlines, row * (width * 3 + 1) + 1, row * width * 3, (row + 1) * width * 3);
}
const png = Buffer.concat([
  Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
  chunk('IHDR', header),
  chunk('IDAT', deflateSync(scanlines, { level: 9 })),
  chunk('IEND', Buffer.alloc(0))
]);
writeFileSync(new URL('./site-map.png', import.meta.url), png, { flag: 'wx' });
console.log('Created original site-map.png: ' + width + ' x ' + height + ' pixels; ' + png.length + ' bytes.');