// PaperType for Google Sheets — Apps Script (MIT licence, https://papertype.dev/guides/pdf-from-google-sheets)
//
// Setup (once):
// 1. In the spreadsheet: Extensions → Apps Script. Replace the editor contents with this file. Save.
// 2. Project Settings (gear icon) → Script properties → Add script property:
// PAPERTYPE_API_KEY = dp_your_key (free key from https://papertype.dev/signup)
// 3. Reload the spreadsheet. A "PDF" menu appears next to Help.
//
// The functions reportBody() and invoiceBody() build the request; renderPdf_() sends it.
// Every request body these functions produce was posted to the live API before this file was published.
const PAPERTYPE_URL = 'https://papertype.dev/v1/pdf';
// ---------- menu ----------
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('PDF')
.addItem('Save selected row as report (Drive)', 'exportSelectedRowAsReport')
.addItem('Save selected row as invoice (Drive)', 'exportSelectedRowAsInvoice')
.addToUi();
}
function exportSelectedRowAsReport() {
const row = selectedRow_();
const pdf = renderPdf_(reportBody(row.headers, row.values));
const file = DriveApp.createFile(pdf);
SpreadsheetApp.getUi().alert('Saved to Drive: ' + file.getUrl());
}
function exportSelectedRowAsInvoice() {
const row = selectedRow_();
const pdf = renderPdf_(invoiceBody(row.headers, row.values));
const file = DriveApp.createFile(pdf);
SpreadsheetApp.getUi().alert('Saved to Drive: ' + file.getUrl());
}
// Installable trigger (Triggers → Add Trigger → function onFormSubmit, source "From spreadsheet", type "On form submit").
// Emails the submitted row as a PDF to the address in the form's "Email" question.
function onFormSubmit(e) {
const sheet = e.range.getSheet();
const lastCol = sheet.getLastColumn();
const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
const values = sheet.getRange(e.range.getRow(), 1, 1, lastCol).getValues()[0];
const pdf = renderPdf_(reportBody(headers, values));
const to = (e.namedValues['Email'] || [])[0];
if (!to) throw new Error('No "Email" question in this form');
GmailApp.sendEmail(to, 'Your submission as a PDF', 'Attached is a copy of what you submitted.', { attachments: [pdf] });
}
// ---------- request bodies (plain functions, no Google services, so they can be tested outside Sheets) ----------
// One row → a one-page Markdown report: the first column is the title, the other columns become a Field | Value table.
function reportBody(headers, values) {
const title = cellText(values[0]) || 'Row report';
const lines = ['# ' + title, '', '| Field | Value |', '|---|---|'];
for (let i = 1; i < headers.length; i++) {
if (cellText(headers[i]) === '') continue;
lines.push('| ' + mdCell(headers[i]) + ' | ' + mdCell(values[i]) + ' |');
}
return {
markdown: lines.join('\n'),
title: title,
theme: 'clean',
page_numbers: true,
filename: safeName(title) + '.pdf',
};
}
// One row → an invoice with the hosted "invoice" template.
// Expected headers (case-insensitive): Number, Date, Due date, Client, Client address, Items, Tax rate, Currency.
// "Items" holds one line per item: description, quantity, unit price (e.g. "Design, October, 10, 120").
function invoiceBody(headers, values) {
const row = {};
headers.forEach(function (h, i) { row[cellText(h).toLowerCase()] = values[i]; });
const items = cellText(row['items']).split(/\r?\n/).filter(function (l) { return l.trim() !== ''; }).map(function (l) {
const parts = l.split(',').map(function (s) { return s.trim(); });
const unitPrice = Number(parts.pop());
const quantity = Number(parts.pop());
return { description: parts.join(', '), quantity: quantity, unit_price: unitPrice };
});
const number = cellText(row['number']);
return {
template: 'invoice',
data: {
number: number,
date: isoDate(row['date']),
due_date: isoDate(row['due date']),
currency: cellText(row['currency']) || 'USD',
from: { name: 'Northwind Studio', address_lines: ['12 Example Road', 'Springfield'] }, // ← your details
to: { name: cellText(row['client']), address_lines: cellText(row['client address']).split(/\r?\n/) },
items: items,
tax_rate: Number(row['tax rate'] || 0),
tax_label: 'Sales tax',
},
page_size: 'Letter',
filename: (number || 'invoice') + '.pdf',
};
}
// ---------- helpers ----------
function renderPdf_(body) {
const key = PropertiesService.getScriptProperties().getProperty('PAPERTYPE_API_KEY');
if (!key) throw new Error('Add PAPERTYPE_API_KEY under Project Settings → Script properties');
const res = UrlFetchApp.fetch(PAPERTYPE_URL, {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + key },
payload: JSON.stringify(body),
muteHttpExceptions: true, // so a 4xx returns the JSON error instead of throwing a generic exception
});
if (res.getResponseCode() !== 200) {
throw new Error('PaperType ' + res.getResponseCode() + ': ' + res.getContentText());
}
return res.getBlob().setName(body.filename || 'document.pdf');
}
function selectedRow_() {
const sheet = SpreadsheetApp.getActiveSheet();
const r = sheet.getActiveRange().getRow();
if (r === 1) throw new Error('Select a data row, not the header row');
const lastCol = sheet.getLastColumn();
return {
headers: sheet.getRange(1, 1, 1, lastCol).getValues()[0],
values: sheet.getRange(r, 1, 1, lastCol).getValues()[0],
};
}
function isDate(v) {
return Object.prototype.toString.call(v) === '[object Date]';
}
function cellText(v) {
if (v === null || v === undefined) return '';
if (isDate(v)) return isoDate(v);
return String(v);
}
// Sheets hands date cells to the script as Date objects; the invoice template wants yyyy-MM-dd strings.
function isoDate(v) {
if (isDate(v)) return Utilities.formatDate(v, Session.getScriptTimeZone(), 'yyyy-MM-dd');
return cellText(v);
}
// Markdown table cells cannot contain "|" or line breaks; escape the pipe, turn breaks into
.
function mdCell(v) {
return cellText(v).replace(/\|/g, '\\|').replace(/\r?\n/g, '
');
}
function safeName(s) {
return String(s).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'document';
}