Excel Office Script Examples

Content

Introduction

Since July 2020, Microsoft 365 users can record their actions in Excel on-line into Office Script macros. Earlier, I've posted an article that shows you how to get started. On this page, I will list some example scripts which you cannot record but might come in handy.

Some of these scripts contain literal values, (the values between quotes). This means things like worksheet names are hard-coded into the example scripts: "ToC". So if you need a different worksheet name, edit the script accordingly. Because Microsoft has not yet given us a way to prompt the user for a value, we must edit the script to change what sheet it affects.

Download the full code (zip file with all scripts) here.

Video

I've presented parts of this page during Excel Virtually Global 2022. Here's the Youtube recording:

Office script video presented at Excel virtually Global (links to youtube)

Entering zeroes in empty cells

Suppose you want to enter a zero in each empty cell in the selection:

Range of cells with empty cells

This code does exactly that.

function main(workbook: ExcelScript.Workbook) {
    // Your code here
    let emptyAreas = workbook.getSelectedRange().getSpecialCells(ExcelScript.SpecialCellType.blanks);
  emptyAreas.getAreas().forEach(r => {
    r.setValue(0);
  });

}

Last Used Row

The function getLastRow shown below, returns the last used row of any range (assuming the cell on row 1048576 is empty!)

function main(workbook: ExcelScript.Workbook) {
  const sht = workbook.getWorksheet("Sheet2");
  const lastRow = getLastRow(sht, sht.getRange("H:H"));
  console.log(lastRow);
  sht.getRange("H2:H" + lastRow).select();
}

function getLastRow(sht: ExcelScript.Worksheet, rng: ExcelScript.Range) {
  let lastCell = sht.getCell(rng.getEntireColumn().getCellCount() - 1, rng.getColumnIndex());
  if (lastCell.getValue().toString().length > 0) {
    return "Last row on sheet filled"
  } else {
  return lastCell.getExtendedRange(ExcelScript.KeyboardDirection.up).getRowIndex() + 1;
  }
}

Notice that the script will return a string instead of a number if the last cell of the sheet contains something. This will cause an error in the calling routine which you must handle.

Adding a table of Contents to your workbook

Working in Excel Online and need a table of Contents for your workbook quickly? Here's an Office Script that produces one. It generates a list for all worksheets and all charts, with hyperlinks pointing to their locations.

First let me show the main function and explain it step by step.

function main(workbook: ExcelScript.Workbook) {
  
  if (workbook.getWorksheet("ToC")) {
    let temp = workbook.getWorksheet("ToC")
    temp.delete();
  }

  let allSheets = workbook.getWorksheets();
  let tocSheet = workbook.addWorksheet("ToC");
  let reportCell = tocSheet.getRange("B2");
  let newTable = addTocTable("Worksheets", workbook, reportCell);
  reportCell = reportCell.getOffsetRange(20);
  reportCell.getResizedRange(01).setValues([["Worksheet""Link"]]);

  for (let i = 0; i < allSheets.length; i++) {
    reportCell.getOffsetRange(i + 10).setValue(allSheets[i].getName());
  }
  reportCell.getOffsetRange(11).setFormulaR1C1("=HYPERLINK(\"#'\"&RC[-1]&\"'!A1\",RC[-1])");

  reportCell = reportCell.getOffsetRange(newTable.getRowCount() + 20)
  newTable = addTocTable("Charts", workbook, reportCell);
  reportCell = reportCell.getOffsetRange(20)
  reportCell.getResizedRange(0,3).setValues([["Worksheet""Name""Location""Link"]]);
  let k = 0;
  for (let i = 0; i < allSheets.length; i++) {
    let allCharts = allSheets[i].getCharts();
    for (let j = 0; j < allCharts.length; j++) {
      k++;
      let theSheet = allSheets[i];
      reportCell.getOffsetRange(k, 0).setValue(allSheets[i].getName());
      reportCell.getOffsetRange(k, 1).setValue(allCharts[j].getName());
      let topLeftCell = getCellUnderChart(allCharts[j], allSheets[i]);
      let topLeftAddress = topLeftCell.getAddress();
      if( topLeftAddress.charAt(0)=="'"){
        topLeftAddress="'" + topLeftAddress;
      }
      reportCell.getOffsetRange(k, 2).setFormula(topLeftAddress);
    }
  }
  reportCell.getOffsetRange(13).setFormulaR1C1("=HYPERLINK(\"#\"&RC[-1],\"Link\")");

  tocSheet.activate();
}

These are the important steps in this code:

The main function uses two helper functions, here's the addTocTable one:

function addTocTable(caption: String, workbook: ExcelScript.Workbook, location: ExcelScript.Range): ExcelScript.Table {
//1. adds a caption in reportCell and formats that as Heading 1
//2. Adds a table below reportCell to hold ToC entries
//Returns: the table
  location.setValue(caption);
  location.getResizedRange(01).setPredefinedCellStyle(ExcelScript.BuiltInStyle.heading1);
  location = location.getOffsetRange(20);
  let newTable = workbook.addTable(location, true);
  return newTable;
}

All it does is add the caption in the location cell, style it as "Heading 1" and add a new one-column table. The function returns the table as an object so we can use that object in our calling routine.

Note that you can also address a style by its name. But be aware that built-in style names are language-dependent. So only assign a style by its name if it is a custom style.

And here is the getCellUnderChart function:

function getCellUnderChart(cht: ExcelScript.Chart, ws: ExcelScript.Worksheet): ExcelScript.Range {
  let topLeftCell = ws.getRange("A1");
  do {
    topLeftCell = topLeftCell.getOffsetRange(0, 1)
  }
  while (topLeftCell.getLeft() < cht.getLeft());
  do {
    topLeftCell = topLeftCell.getOffsetRange(1, 0)
  }
  while (topLeftCell.getTop() < cht.getTop());

  return topLeftCell.getOffsetRange(-1, -1);
}

The basic principle is simple: the function first loops through the cells of the worksheet horizontally, starting from cell A1, until it has found a cell with a Left value that is greater than or equal to the chart's Left position. After that it does the same vertically to find the Top position. Because the two loops take us one column too far to the right and one row too far down, we offset the found cell by (-1, -1) so we return the correct cell which denotes where the top-left corner of the chart is located.

Adding a list of Range names

Desktop Excel has a simple feature to add a list of range names to your workbook (Formulas tab, Use in Formula drop-down, Paste List). But Excel on-line does not (yet) have that feature. Here is an Office script which adds a worksheet called "Names List" to your workbook, listing all range names and some of their properties.

function main(workbook: ExcelScript.Workbook) {
  var RowCt = 1;
  var sheetName = "";
  let MyNames = workbook.getNames();
  let selectedSheet = workbook.getWorksheet("Names list");
  if (selectedSheet) {
    selectedSheet.delete();
  }
  let worksheets = workbook.getWorksheets();
  selectedSheet = workbook.addWorksheet("Names list");
  selectedSheet.getRange("B2:E2").setValues([["Scope""Name""RefersTo""Visible"]]);
  for (let i = 0; i < MyNames.length; i++) {
    RowCt++
    selectedSheet.getCell(RowCt1).getResizedRange(03).setValues([
      ["Workbook"MyNames[i].getName(), "'" + MyNames[i].getFormula(), "'" + MyNames[i].getVisible()]]);
  }
  for (let i = 0; i < worksheets.length; i++) {
    sheetName = worksheets[i].getName();
    var myNames = worksheets[i].getNames();
    for (let j = 0; j < myNames.length; j++) {
      RowCt++;
      selectedSheet.getCell(RowCt1).getResizedRange(03).setValues([
        [sheetName, myNames[j].getName(), "'" + myNames[j].getFormula(), "'" + myNames[j].getVisible()]
      ]);
    }
  }
  let newTable = workbook.addTable(selectedSheet.getRange("B2").getSurroundingRegion(), true);
  newTable.getRange().getFormat().autofitColumns();
}

The resulting table looks like this:
List of range names

Prompting for user input

OfficeScript does not have a feature like the VBA InputBox, nor can you design input screens.

Recently, Microsoft has provided a way to solve that issue. If you add input parameters to your function definition, Excel will show a little prompt window asking for their values. It will even add space characters before each capitalized letter of your variable name. Take this script for example:

function main(workbook: ExcelScript.WorkbookStartDate:stringEndDate:string) {
    console.log(StartDate);
    console.log(EndDate);
}

If you execute this script, this is what is shown:

A prompt for input

There is some intelligence to this. If a parameter is a boolean, it will present a drop-down with true/false. And you can set default values too. In addition, I wanted to define the StartDate and EndDate parameters as "Date", but apparently that isn't a valid data type for TypeScript. I was hoping it'd recognize this and offer me a date picker. O well, at least we can prompt the user for input values.

Alternative to using arguments

If you need more elaborate input, perhaps this offers some help.

The script below checks for presence of a worksheet called "Script_Input". If that sheet is not in the workbook, it gets added. Next a function is called which allows you to pass it a prompt and an array of inputs, which will be added to the Script_Input sheet. The resulting input sheet looks like this:

An input sheet for Office Script

If the script detects that the "Script_Input" sheet is available, it assumes all input has been completed and runs an action with that input (writes to the console).

This is the entire script:

function main(workbook: ExcelScript.Workbook) {
  if (workbook.getWorksheet("Script_Input")) {
    //we assume input is there, here are your script's main actions
    let inputSheet = getInputSheet(workbook, "Script_Input");
    let name = inputSheet.getRange("B3").getValue();
    let dob = inputSheet.getRange("B4").getValue();
    console.log(name + " " + dob);
  }
  else {
    createPromptSheet(workbook, "Please enter your details below", ["Name""DoB"])
    //It may be a good idea to delete the Script_Input sheet now
  }
}

function createPromptSheet(workbook: ExcelScript.Workbook, prompt: string, questions: string[]) {
  let inputSheet = getInputSheet(workbook, "Script_Input");
  let titleCell = inputSheet.getRange("A1")
  titleCell.setValue(prompt);
  titleCell.setPredefinedCellStyle("Heading 1");
  titleCell.getFormat().autofitColumns();
  let questionCell = inputSheet.getRange("A3");
  let rw = 0;
  for (let i = 0; i < questions.length; i++) {
    rw++;
    questionCell.getOffsetRange(i, 0).setValue(questions[i]);
    questionCell.getOffsetRange(i, 0).setPredefinedCellStyle("Heading 4");
    questionCell.getOffsetRange(i, 1).setPredefinedCellStyle("Input");
  }
  inputSheet.activate();
  questionCell.getOffsetRange(rw + 20).setValue("After entering all data, click the script button again.");
  questionCell.getOffsetRange(rw + 20).setPredefinedCellStyle("Heading 4");
  questionCell.getOffsetRange(01).select();
}

function getInputSheet(workbook: ExcelScript.Workbook, name: string) {
  if (workbook.getWorksheet(name)) {
    return workbook.getWorksheet(name);
  }
  else {
    return workbook.addWorksheet(name);
  }

}

Filter a table by date

Suppose you have a table with a date column and you want to filter that table by dates prior to today.

A date column in a table

To make this happen, you need the proper date format which applies to your Excel settings; is it mm/dd/yyyy or dd-mm-jjjj? For this you use the so-called cultureInfo of the Excel application:

workbook.getApplication().getCultureInfo().getName()

This returns a string in the shape en-US or (in my case) nl-NL. This string can then be used to convert the current system date to the proper locale string.

To filter this table for dates prior to today, you use code like this:

function main(workbook: ExcelScript.Workbook) {
  let table1 = workbook.getTable("Table1");
  table1.getAutoFilter().clearCriteria();
  
  //Get date in local format
  const date = new Date().toLocaleDateString(workbook.getApplication().getCultureInfo().getName());

  //Apply filter to 'Table1' column 'date'
  table1.getColumnByName("date").getFilter().applyCustomFilter("<" + date);
}

You can also use the ISO date format, which should work too:

function main(workbook: ExcelScript.Workbook) {
  let table1 = workbook.getTable("Table1");
  table1.getAutoFilter().clearCriteria();
  
  //Get date in ISO format
  const date = new Date().toISOString().slice(0, 10);

  //Apply filter to 'Table1' column 'date'
  table1.getColumnByName("date").getFilter().applyCustomFilter("<" + date);
}

Sending Personalised PDF Reports with Office Scripts

In this example, we'll build an Excel workbook that automatically generates and emails a personalised PDF report to every recipient listed in a table. The solution uses the new Office Scripts PDF and email capabilities, allowing Excel to create PDF attachments and send emails without relying on Outlook VBA automation.

The workbook is driven by a table named tblAddresses. Each row represents a recipient. The table contains at least two columns:

Column Purpose
ID Unique identifier used to personalise the report.
email Recipient's email address.

For each row in the table, the script writes the value from the ID column into a workbook-level named range called Email_id. This named range acts as the driver for the report and can be used in formulas, or any other workbook logic needed to generate recipient-specific content.

The workbook also contains a worksheet named Email_attachment. This worksheet serves as the report template. Its contents can reference Email_id directly or indirectly through formulas, allowing the worksheet to automatically display the information relevant to the current recipient.

Two additional workbook-level named ranges control the email content:

Named Range Purpose
Email_subject Contains the email subject line.
Email_body Contains the email body text.
Email_id Contains the current recipient ID.

Because the script recalculates the workbook after updating Email_id, both Email_subject and Email_body may contain formulas that dynamically generate personalised text for each recipient.

The overall process works as follows:

  1. Read all records from tblAddresses.
  2. For each recipient:
    • Write the recipient's ID to Email_id.
    • Recalculate the workbook.
    • Read the contents of Email_subject and Email_body.
    • Convert the worksheet Email_attachment to PDF.
    • Send an email to the address stored in the email column using Email_subject as the subject line and Email_body for the body.
    • Attach the generated PDF report to the email message.
  3. Repeat until all recipients have received their personalised report.

This design keeps all business logic inside the workbook. Email recipients are controlled from tblAddresses, report generation is controlled by Email_attachment, and the email text is maintained through the named ranges Email_subject and Email_body. No hard-coded recipient lists, subject lines, or message text are required in the script itself, making the solution easy to maintain and update by workbook authors rather than developers.

Here is the entire script for your reference (also included in the download of course):

/**
 * Creates and emails a personalised PDF for each row in tblAddresses.
 *
 * Required workbook objects:
 *   Table:           tblAddresses
 *   Table columns:   email, ID
 *   Named ranges:    Email_id, Email_subject, Email_body
 *   Worksheet:       Email_attachment
 */
function main(workbook: ExcelScript.Workbook) {
  const TABLE_NAME = "tblAddresses";
  const EMAIL_COLUMN_NAME = "email";
  const ID_COLUMN_NAME = "ID";
  const ID_RANGE_NAME = "Email_id";
  const SUBJECT_RANGE_NAME = "Email_subject";
  const BODY_RANGE_NAME = "Email_body";
  const ATTACHMENT_SHEET_NAME = "Email_attachment";
  const table = workbook.getTable(TABLE_NAME);
  const attachmentSheet = workbook.getWorksheet(ATTACHMENT_SHEET_NAME);
  const emailIdRange =
    workbook.getNamedItem(ID_RANGE_NAME).getRange();
  const emailSubjectRange =
    workbook.getNamedItem(SUBJECT_RANGE_NAME).getRange();
  const emailBodyRange =
    workbook.getNamedItem(BODY_RANGE_NAME).getRange();
  const emailAddresses =
    table
      .getColumnByName(EMAIL_COLUMN_NAME)
      .getRangeBetweenHeaderAndTotal()
      .getTexts();
  const ids =
    table
      .getColumnByName(ID_COLUMN_NAME)
      .getRangeBetweenHeaderAndTotal()
      .getTexts();
  const originalEmailId = emailIdRange.getValue();
  // Store worksheet names and visibility only
  const worksheetStates = workbook.getWorksheets().map(sheet => ({
    name: sheet.getName(),
    visibility: sheet.getVisibility().toString()
  }));
  let emailsSent = 0;
  let emailsSkipped = 0;
  let emailsFailed = 0;
  try {
    // Make attachment sheet visible
    attachmentSheet.setVisibility(
      ExcelScript.SheetVisibility.visible
    );
    attachmentSheet.activate();
    // Hide all other sheets
    workbook.getWorksheets().forEach(sheet => {
      if (sheet.getName() !== ATTACHMENT_SHEET_NAME) {
        sheet.setVisibility(
          ExcelScript.SheetVisibility.hidden
        );
      }
    });
    for (let rowIndex = 0; rowIndex < emailAddresses.length; rowIndex++) {
      const recipient =
        (emailAddresses[rowIndex][0] ?? "").trim();
      const id =
        (ids[rowIndex][0] ?? "").trim();
      if (recipient === "" || id === "") {
        emailsSkipped++;
        continue;
      }
      try {
        emailIdRange.setValue(id);
        workbook.getApplication().calculate(
          ExcelScript.CalculationType.full
        );
        const emailSubject =
          emailSubjectRange.getText();
        const emailBody =
          emailBodyRange.getText();
        const pdfContent =
          OfficeScript.convertToPdf();
        const pdfFile = {
          name: `Email_${makeSafeFileName(id)}.pdf`,
          content: pdfContent
        };
        OfficeScript.sendMail({
          to: recipient,
          subject: emailSubject,
          content: emailBody,
          attachments: [pdfFile]
        });
        emailsSent++;
      } catch (error) {
        console.log(
          `Failed for ${recipient}: ${String(error)}`
        );
        emailsFailed++;
      }
    }
  } finally {
    emailIdRange.setValue(originalEmailId);
    // First make all sheets visible
    workbook.getWorksheets().forEach(sheet => {
      sheet.setVisibility(
        ExcelScript.SheetVisibility.visible
      );
    });
    // Then restore original visibility
    worksheetStates.forEach(state => {
      const ws = workbook.getWorksheet(state.name);
      switch (state.visibility) {
        case "visible":
          ws.setVisibility(
            ExcelScript.SheetVisibility.visible
          );
          break;
        case "hidden":
          ws.setVisibility(
            ExcelScript.SheetVisibility.hidden
          );
          break;
        case "veryHidden":
          ws.setVisibility(
            ExcelScript.SheetVisibility.veryHidden
          );
          break;
      }
    });
    workbook.getApplication().calculate(
      ExcelScript.CalculationType.full
    );
  }
  console.log(
    `Completed. Sent: ${emailsSent}; skipped: ${emailsSkipped}; failed: ${emailsFailed}`
  );
}

function makeSafeFileName(value: string): string {
  const safeValue = value
    .replace(/[<>:"/\\|?*]/g, "_")
    .replace(/\.$/, "")
    .trim();
  return safeValue === ""
    ? "Attachment"
    : safeValue;
}

Download

Download the full code (zip file with all scripts) here.

Next article: Call a script from a Power Automate Flow

In my next article I describe how to apply the Table Of Content script to all Excel files in a Sharepoint folder using Power Automate

Frequently asked Questions


Comments

Loading comments...