Skip to content
Skip to chapter

Chapter 19

Workspace services

Build a two-slide garden briefing

Use SlidesApp in Google Apps Script to create a Google Slides presentation once, place text, shapes, lines, and PNG images, and update by ID.

By the end

Generate and inspect a two-slide fictional report, resize its recorded map without replacing it, and recover on the identified output.

Chapter navigation

Make a briefing with a map and a decision

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

The project builds on runtime and permissions, working with arrays, and working with Drive files. SlidesApp creates and edits the presentation, and DriveApp reads the PNG you upload. See Google's Slides guide for an overview of the built-in Slides service.

Each script project tracks one briefing presentation. Running createGardenSlides again after completion reports the saved presentation ID without creating another deck or rewriting slides. A separate confirmed function (resizeGardenMap) resizes the existing map element in place, while rebuildGardenSlides reconstructs the two slides inside the recorded presentation.

A presentation contains objects, not slide numbers alone

A Presentation is the Google Slides file, containing slides along with layouts and masters. Each Slide contains PageElement objects such as shapes, images, and lines. A text box is a Shape whose 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 distinguish 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, and moving or resizing an image preserves its page-element ID, whereas deleting and reinserting an image assigns a new object ID.

The script saves two slide IDs in role order (overview, then actions) and looks them up with presentation.getSlideById(id) rather than by thumbnail index. It also saves the overview map's page-element ID so resizeGardenMap can fetch overview.getPageElementById(id), verify its element type, and call asImage(). If that element ID is missing, the function stops instead of resizing the first remaining image on the slide. See the PageElement reference. Note that an object ID identifies the element container rather than its bitmap bytes: calling Image.replace swaps the image content while keeping the same element ID.

Because this is a standalone script, SlidesApp.getActivePresentation() returns null. Creation retains the Presentation returned by SlidesApp.create, and subsequent 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 with six planter positions in North bed, two along Path edge, and four in Courtyard, plus a gate at the lower end of the path. The brown rectangles show physical capacity rather than current readiness, so readiness counts can change without redrawing the image. (The accompanying create-site-map.mjs script is the Node generator that produced the PNG and is not installed in Apps Script.)

  1. Download site-map.png from the slides-workflows project files and inspect the three zones and twelve planter positions locally.

  2. In a private My Drive folder, upload that exact site-map.png file via New > File upload without converting or editing it.

  3. Open the uploaded file's Drive preview and copy the file ID between /file/d/ and the next slash in its URL, keeping general access Restricted.

Before creating a presentation, the script checks the uploaded file's MIME type, byte size, and SHA-256 digest against the expected fixture fingerprint so an unrelated or re-encoded file is rejected before any Slides write.

file.getBlob() loads the raw PNG bytes into an Apps Script Blob, and slide.insertImage(blob, left, top, width, height) copies those bytes into the presentation without needing a public image URL. Never call getDataAsString() on binary image data or 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 whose names, order, and capacities (6, 2, and 4) match site-map.png. Each zone's ready value must be an integer between 0 and its capacity.

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 OAuth scopes apply across the authorizing account's accessible files; configuring a single file ID in Code.gs is an application guard, not a narrower OAuth scope. See the authorization guide.

Inspect the plan before creating anything

Select inspectGardenSlides and click Run. Review the execution log to confirm the uploaded image ID, the planned zone totals, the default 320-by-200-point map size, and No recorded attempt.

inspectGardenSlides validates imageFileId, reads the Drive file, verifies its SHA-256 fingerprint with Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, blob.getBytes()), and logs the planned layout without creating a presentation or writing Script Properties. See Utilities.computeDigest.

With the default data, four ready planters in North bed, one at Path edge, and two in Courtyard sum to 7 of 12 planters ready. Try changing North bed's ready count to 5 and its action to Bring seedlings for one planter., then run inspectGardenSlides again to see the updated summary (8 of 12 planters ready) against the same 12-planter map. Restore both values before creating the presentation.

Place elements in points

The briefing targets a standard widescreen 720-by-405-point canvas (10 by 5.625 inches at 72 typographic points per inch). Coordinates originate at the slide's upper-left corner, with left increasing to the right and top increasing downward. Element dimensions, font sizes, and line weights all use points rather than image or CSS pixels; see Size and position page elements.

Before drawing, the renderer checks getPageWidth() and getPageHeight() and stops if the presentation does not use a 720-by-405-point canvas.

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; weight is 1 point

Each zone's array index (0, 1, 2) offsets its readiness card to 110 + index * 72 on the overview slide and 116 + index * 76 on the actions slide, while reduce computes the 7 of 12 planters ready footer total. Because Slides mutations execute as individual calls rather than an atomic transaction, a failure mid-render can leave partial edits on the slide.

Unlike shapes and images, slide.insertLine takes start and end coordinates (36, 362, 684, 362) rather than a bounding box. Calling line.setWeight(1) sets the stroke thickness in points, and line.getLineFill().setSolidFill(...) sets its color; a Line has no shape border property. See the Line reference.

Because site-map.png is 640 by 400 pixels (an 8-by-5 aspect ratio), the code computes height = width * 5 / 8 (200 points high at 320 points wide, or 150 points high at 240 points wide). Updating both width and height together during resizeGardenMap preserves the aspect ratio without stretching the image. 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 and confirm its title is Harbor garden - readiness briefing and its sharing remains private.

  3. Verify that the presentation has two slides: the overview slide with the map on the left, three readiness cards on the right, and 7 of 12 planters ready in the footer, followed by the actions slide with three action cards.

  4. Select the map image in Slides and inspect its alt-text title and description, which record the three zones and planter capacities alongside the visual diagram.

  5. Run showGardenSlidesRecord and confirm status is complete, noting the saved outputId, slideIds, and mapElementId.

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

During initial creation, the renderer appends blank slides via appendSlide(SlidesApp.PredefinedLayout.BLANK) until the presentation has two slides, then saves their object IDs. On a subsequent rebuild, it resolves those exact two slides with getSlideById. See Presentation.appendSlide.

Before placing elements on the two recorded slides, the renderer removes any existing page elements on those slides 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.

Within each shape, getTextStyle() sets the font family (Arial), point size, bold weight, and foreground color, while getParagraphStyle() sets line spacing and ParagraphAlignment.START (the Slides enum for left-aligned left-to-right text). To bold only the zone name on the first line of a card without styling the entire shape, the script inserts the full string first and then selects textRange.getRange(0, zone.name.length) (start inclusive, end exclusive). See the TextRange, TextStyle, and ParagraphStyle references.

Resize the same object and restore it

First test idempotency: run createGardenSlides a second time. It logs the saved outputId and exits without creating a new deck or modifying elements.

Now resize the existing map image in place:

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

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

  3. Run resizeGardenMap. The map resizes to 240 by 150 points at the same top-left anchor (36, 110), leaving the readiness cards, footer, and second slide untouched.

  4. Check the logged mapElementId: it remains identical because resizeGardenMap mutates the existing Image element rather than deleting and reinserting it.

  5. Restore mapWidthPoints to 320, run resizeGardenMap again to return the image to 320 by 200 points, and clear confirmedOutputId back to ''.

resizeGardenMap also resets the map's (left, top) position and 0 rotation, and refuses to resize an image that has been placed inside a group. Because it looks up the overview slide by saved slide ID, reordering the two slide thumbnails in the editor does not affect which slide it targets.

Note that resizeGardenMap updates only the map dimensions and saved mapWidthPoints field; to redraw cards after an interrupted run, use rebuildGardenSlides.

Keep identity across failures

The script property GARDEN_SLIDES_REPORT stores the JSON checkpoint across runs using PropertiesService.getScriptProperties(). See the Properties service guide.

Mutating entrypoints wrap their work in withGardenSlidesLock_, which acquires LockService.getScriptLock() via tryLock(1000) and releases it in finally so concurrent executions in the same script project do not interleave edits. See the Lock reference.

StatusWhat is knownSafe next 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; reconcile the created file ID with reconcileGardenSlides.
editingoutputId is saved, but slide construction, resizing, or the final checkpoint did not finish.Inspect the recorded presentation, set confirmedOutputId, and run rebuildGardenSlides or resizeGardenMap.
completeSlides flushed changes and the final record checkpoint succeeded.Ordinary reruns report outputId; resizing or rebuilding requires confirmedOutputId.

During creation, the script checkpoints outputId immediately after SlidesApp.create and checkpoints slideIds before clearing or drawing page elements. Because Presentation.saveAndClose() flushes pending Slides mutations at the end of an execution even if the script throws later (see Presentation.saveAndClose), a failed run can leave a created or partially drawn deck in Drive even when the property status is still creating or editing.

Reconcile an uncertain creation

If a run fails while status is creating, wait for active executions to finish and run showGardenSlidesRecord. Check Executions first: createGardenSlides logs Slides returned output ID immediately before writing the checkpoint.

If the log does not show the ID, check recent files in Drive for the Harbor garden - readiness briefing presentation created at that timestamp. Copy its file ID into confirmedOutputId and run reconcileGardenSlides, which verifies the presentation title and slide count and transitions the record to editing without creating another file or altering slide contents.

Rebuild only the confirmed generated slides

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

After running rebuildGardenSlides, inspect the presentation and showGardenSlidesRecord, then clear confirmedOutputId. Because every shape, line, and image on the two slides is recreated, mapElementId receives a new object ID that subsequent resizeGardenMap calls will use.

To see the difference between resizing and rebuilding, add a manual text box reading Practice annotation to remove on the overview slide of a completed deck. Running resizeGardenMap leaves your annotation intact because it modifies only the recorded mapElementId, whereas running rebuildGardenSlides removes all elements on both recorded slides and redraws the clean briefing.

Interpret errors without losing the output

  • Invalid file ID, SHA-256 mismatch, out-of-range ready count, or overlong text: stops before SlidesApp.create. Fix imageFileId or Data.gs and run inspectGardenSlides.

  • Authorization or Drive access failure: verify that the executing account can read imageFileId in Drive and has approved the manifest scopes.

  • Canvas size mismatch: set page setup on the recorded outputId to 10 by 5.625 inches and run rebuildGardenSlides with confirmedOutputId.

  • Slide count, element count (>30), or missing slide/element ID error: rebuildGardenSlides requires the two recorded slide IDs and no extra slides, while resizeGardenMap requires the recorded mapElementId to exist as an ungrouped Image.

  • Service, quota, or lock timeout during an edit: keep GARDEN_SLIDES_REPORT intact, inspect the presentation at outputId, and resume with resizeGardenMap or rebuildGardenSlides once active runs finish.

To generate a second independent deck with different zone data, use a new standalone project so the existing GARDEN_SLIDES_REPORT checkpoint stays paired with its presentation.

Clean up the identified practice files

When you are finished with the exercise and all executions have completed, clear confirmedOutputId. If you no longer need the generated presentation or the uploaded site-map.png, verify each file by its recorded ID (outputId and imageFileId) before moving it to Trash in Drive. Keep site-map.png in Drive until you have finished any rebuildGardenSlides runs, since rebuilding re-reads the PNG blob even though the presentation stores its own embedded copy. You can also revoke the practice project's OAuth grant from your Google Account connections page as described in the authorization guide.

Next, enable and call Google Workspace advanced services when 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.');