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.
Generate and inspect a two-slide fictional report, resize its recorded map without replacing it, and recover on the identified output.
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.
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.)
Download
site-map.pngfrom theslides-workflowsproject files and inspect the three zones and twelve planter positions locally.In a private My Drive folder, upload that exact
site-map.pngfile via New > File upload without converting or editing it.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
At
script.google.com, create a standalone project namedGarden Slides practice.Replace
Code.gswith the complete source below, then add a second script file namedData(Data.gs) containing the supplied zone data.In Project Settings, enable Show "appsscript.json" manifest file in editor, replace
appsscript.jsonwith the supplied manifest, and save all files.In
Code.gs, setGARDEN_SLIDES.imageFileIdto your uploaded PNG's file ID. Keep the defaultoutputNameand leaveconfirmedOutputIdas''.
/**
* @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;
}
// 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.' }
]
};
{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/presentations",
"https://www.googleapis.com/auth/drive.readonly"
]
}
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 element | Left / top in points | Width / height in points |
|---|---|---|
| Title text box | 36 / 26 | 648 / 46 |
| Map, initially | 36 / 110 | 320 / 200 |
| First readiness card | 388 / 110 | 296 / 60 |
| Second and third cards | Same left, 72 points farther down each time | Same size |
| Footer divider | Start 36 / 362, end 684 / 362 | Endpoints 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
With the original
Data.gsvalues restored and no execution running, runcreateGardenSlidesonce.Open the presentation URL from the execution log and confirm its title is
Harbor garden - readiness briefingand its sharing remains private.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 readyin the footer, followed by the actions slide with three action cards.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.
Run
showGardenSlidesRecordand confirmstatusiscomplete, noting the savedoutputId,slideIds, andmapElementId.
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:
Copy the presentation ID from the URL between
/presentation/d/and the next slash, verify that it matchesoutputIdfromshowGardenSlidesRecord, and paste it intoGARDEN_SLIDES.confirmedOutputId.In
Data.gs, changeGARDEN_SLIDES_DATA.mapWidthPointsfrom320to240.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.Check the logged
mapElementId: it remains identical becauseresizeGardenMapmutates the existingImageelement rather than deleting and reinserting it.Restore
mapWidthPointsto320, runresizeGardenMapagain to return the image to 320 by 200 points, and clearconfirmedOutputIdback 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.
| Status | What is known | Safe next step |
|---|---|---|
| No record | No attempt is recorded in this project. | Run inspectGardenSlides, then createGardenSlides. |
creating | The plan was saved before SlidesApp.create, without a confirmed outputId. | Check Executions and Drive; reconcile the created file ID with reconcileGardenSlides. |
editing | outputId is saved, but slide construction, resizing, or the final checkpoint did not finish. | Inspect the recorded presentation, set confirmedOutputId, and run rebuildGardenSlides or resizeGardenMap. |
complete | Slides 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. FiximageFileIdorData.gsand runinspectGardenSlides.Authorization or Drive access failure: verify that the executing account can read
imageFileIdin Drive and has approved the manifest scopes.Canvas size mismatch: set page setup on the recorded
outputIdto 10 by 5.625 inches and runrebuildGardenSlideswithconfirmedOutputId.Slide count, element count (>30), or missing slide/element ID error:
rebuildGardenSlidesrequires the two recorded slide IDs and no extra slides, whileresizeGardenMaprequires the recordedmapElementIdto exist as an ungroupedImage.Service, quota, or lock timeout during an edit: keep
GARDEN_SLIDES_REPORTintact, inspect the presentation atoutputId, and resume withresizeGardenMaporrebuildGardenSlidesonce 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
Download file/**
* @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
Download file// 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{
"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 fileimage/png · 3795 bytes

create-site-map.mjs
Download file// 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.');