appscript.dev
Automation Intermediate Forms Docs Drive

Generate a PDF from each form response

Turn Northwind submissions into formatted documents stored under the right client folder.

Published Jul 5, 2025

Northwind’s brief-intake form gathers all the right answers, but a row in a sheet is not what designers want to read at the start of a project. They want a one-page document — branded header, headings in the right places, an archive copy in the client’s Drive folder.

This script turns every new submission into exactly that. It copies a Google Doc template, swaps in the answers using {{placeholders}}, and drops the finished file in a shared output folder. The original spreadsheet row stays put as the system of record; the doc is the thing the team actually opens.

What you’ll need

  • A Google Doc template containing the placeholders {{name}}, {{date}}, and {{body}} somewhere in the body. Style it however you like — fonts, logo, page numbers — the placeholders are the only contract.
  • A Drive folder to receive the generated docs. Use a single shared folder, or extend the script to look up a client-specific folder by name.
  • The form’s response sheet, since the trigger runs on the sheet. The form must capture at least a Name field; everything else is auto-included in the body.

The script

// IDs for the template Doc and the output folder. Both come from
// the URL of the file in Drive.
const TEMPLATE = '1abcResponseTemplateId';
const OUTPUT = '1abcResponseDocsId';

/**
 * Installable form-submit trigger. Copies the template, fills in the
 * placeholders from the submission, and saves the result to OUTPUT.
 *
 * @param {GoogleAppsScript.Events.SheetsOnFormSubmit} e The submit event.
 */
function onFormSubmit(e) {
  // Guard against being run by hand from the editor — without the event
  // object there are no answers to copy in.
  if (!e || !e.namedValues) {
    Logger.log('No event payload — run this from a form-submit trigger.');
    return;
  }

  // 1. Pull the submitter's name. Default to "anon" so a missing answer
  //    still produces a file rather than crashing the trigger.
  const name = e.namedValues.Name?.[0] || 'anon';
  const date = new Date();
  const isoDate = date.toISOString().slice(0, 10);

  // 2. Build the body block. One line per answer, in submission order.
  //    Object.entries preserves the order the form delivers, which is the
  //    order the questions appear on the form.
  const body = Object.entries(e.namedValues)
    .map(([k, v]) => k + ': ' + v[0])
    .join('\n');

  // 3. Copy the template into the output folder. The filename is
  //    sortable-date + name, so newest-by-name is one click in Drive.
  const fileName = isoDate + ' — ' + name;
  const copy = DriveApp.getFileById(TEMPLATE)
    .makeCopy(fileName, DriveApp.getFolderById(OUTPUT));

  // 4. Open the copy and swap each placeholder for the real value.
  //    replaceText takes a regex, but plain strings work too.
  const doc = DocumentApp.openById(copy.getId());
  const docBody = doc.getBody();
  docBody.replaceText('{{name}}', name);
  docBody.replaceText('{{date}}', isoDate);
  docBody.replaceText('{{body}}', body);

  // 5. Save and close so the file is fully written before the trigger
  //    returns. Apps Script will flush eventually, but explicit is safer.
  doc.saveAndClose();
  Logger.log('Generated doc: ' + fileName + ' (' + copy.getId() + ')');
}

How it works

  1. The trigger fires on every form submission and receives the event object, with answers in e.namedValues.
  2. It pulls the submitter’s Name (defaulting to anon) and today’s date in ISO form. The ISO date is both used inside the doc and prefixed onto the filename so files sort chronologically.
  3. It flattens every answer into a body block — one key: value line per question — that drops into the {{body}} placeholder.
  4. DriveApp.makeCopy clones the template into the output folder. Keeping the original template untouched means you can edit styling without affecting past runs.
  5. The copy is opened with DocumentApp and replaceText swaps each {{placeholder}} for its real value. The placeholders can appear anywhere in the body — heading, table cell, footer.
  6. saveAndClose() flushes the changes immediately so the file is ready as soon as the trigger returns.

Example run

A form submission like this:

FieldValue
NameAcme Joinery
ProjectSpring catalogue redesign
Deadline2026-06-30
Brief12-page A4, photography supplied.

Produces a doc named 2026-05-27 — Acme Joinery in the output folder, with the template’s placeholders filled in:

Project brief — Acme Joinery Date: 2026-05-27

Name: Acme Joinery Project: Spring catalogue redesign Deadline: 2026-06-30 Brief: 12-page A4, photography supplied.

The original sheet row stays intact for tracking; the doc is what the team opens when the project kicks off.

Trigger it

Use the installable form-submit trigger on the response sheet. Simple triggers can’t open Docs, so this has to be installable:

  1. From the response sheet, open Extensions → Apps Script.
  2. In the editor, open Triggers (the clock icon).
  3. Add trigger, choose onFormSubmit, event source From spreadsheet, event type On form submit.
  4. Approve the authorisation prompt — it will ask for Docs and Drive scopes.
  5. Submit a test response and confirm the doc lands in the output folder with the placeholders replaced.

Watch out for

  • Placeholders are matched as plain text. {{Name}} and {{name}} are different to replaceText. Keep the casing consistent between the template and the script.
  • The script writes only {{name}}, {{date}} and {{body}}. Leave a placeholder unset in the script and it shows up literally in the doc — add a replaceText line for every placeholder in your template.
  • One doc per submission can fill a folder quickly. Either rotate the output folder monthly, or sub-folder by client by looking up a folder by name before the makeCopy call.
  • DriveApp.makeCopy counts against the daily Drive quota. A few hundred submissions a day is fine; tens of thousands needs batching or moving to a background job.
  • If your template uses tables, replaceText works inside cells, but only on flat text — a placeholder split across runs (mid-bold, for example) won’t be replaced. Type each placeholder as plain unformatted text and style around it.

Related