Recipes
Working code for the tasks people ask about most. Every snippet is a real file under site/src/lib/examples/ that is type-checked against the library on every build, so
an API rename breaks this page before it ships. For a one-line lookup use the cheatsheet; for a specific function, the API reference.
Basics
Open a workbook and read every cell
Load an existing xlsx, narrow the first sheet to a worksheet, and walk every cell.
// Open a workbook and walk every cell on the first sheet.
import { loadWorkbook } from '@office-kit/xlsx/io';
import { fromFile } from '@office-kit/xlsx/node';
const wb = await loadWorkbook(fromFile('input.xlsx'));
const first = wb.sheets[0];
if (first?.kind === 'worksheet') {
for (const row of first.sheet.rows.values()) {
for (const cell of row.values()) {
console.log(`${cell.row},${cell.col}: ${String(cell.value)}`);
}
}
}
- wb.sheets is a discriminated union — narrow on `kind === "worksheet"` to reach the Worksheet shape (chartsheets have a different surface).
- For huge sheets, prefer `loadWorkbookStream` + `iterRows` instead — see the streaming recipe below.
Read a workbook somebody else produced
Load bytes you did not write, skip the blank sheets, and read each cell as the text Excel shows.
// Read a workbook somebody else produced: take the bytes as a Blob, find the
// first worksheet that has anything in it, and turn its rows into the text a
// person would see in Excel.
import { fromBlob, loadWorkbook } from '@office-kit/xlsx/io';
import { getCellDate, getCellDisplayText } from '@office-kit/xlsx/styles';
import type { Workbook } from '@office-kit/xlsx/workbook';
import { isWorksheetEmpty, iterRows, type Worksheet } from '@office-kit/xlsx/worksheet';
export async function readSheetAsText(file: Blob): Promise<string[][]> {
const wb = await loadWorkbook(fromBlob(file));
// wb.sheets mixes worksheets and chartsheets, and a producer often leaves a
// blank sheet in front of the data.
const first = wb.sheets.find((s) => s.kind === 'worksheet' && !isWorksheetEmpty(s.sheet));
if (first?.kind !== 'worksheet') return [];
return [...iterRows(first.sheet)].map((row) =>
row.map((cell) => (cell === undefined ? '' : getCellDisplayText(wb, cell))),
);
}
// `getCellDisplayText` gives you the text, which is what a CSV or an HTML table
// wants. When you need the value, ask for the value: a date cell holds a plain
// serial number, and its number format is the only thing that says so.
export function readDueDates(wb: Workbook, ws: Worksheet, column: number): Date[] {
const due: Date[] = [];
for (const row of iterRows(ws, { minCol: column, maxCol: column })) {
const cell = row[0];
if (cell === undefined) continue;
const date = getCellDate(wb, cell);
if (date !== undefined) due.push(date);
}
return due;
}
- `getCellDisplayText` puts the value through the number format the cell points at, so `0.5` under `0.0%` reads `50.0%` and a date serial reads as a date. `cellValueAsString` never sees the stylesheet and would give you `0.5` and `45365`.
- Excel stores a date as a plain number of days since the workbook epoch, so nothing in the value says it is a date: the number format is the only evidence, and `getCellDate` is what reads it.
- A producer that writes text into a numeric column leaves you strings next to numbers; `getCellDisplayText` returns text either way.
Edit a single cell and save
The canonical round-trip: load → mutate → write back. Same as the Quick start.
// Read an xlsx, mutate one cell, write it back.
//
// This file is imported as ?raw into the docs site so the snippet shown to
// readers is exactly what svelte-check / tsc compiled — if an API rename
// breaks this import, the docs build fails before deploy.
import { loadWorkbook, workbookToBytes } from '@office-kit/xlsx/io';
import { fromBuffer } from '@office-kit/xlsx/node';
import { setCell } from '@office-kit/xlsx/worksheet';
import { readFile, writeFile } from 'node:fs/promises';
const wb = await loadWorkbook(fromBuffer(await readFile('input.xlsx')));
const ref = wb.sheets[0];
if (ref?.kind === 'worksheet') {
setCell(ref.sheet, 1, 1, 'Hello from @office-kit/xlsx');
}
await writeFile('output.xlsx', await workbookToBytes(wb));
Build a workbook from scratch
No input file — start with `createWorkbook`, add a sheet, write cells, save.
// Build a one-sheet workbook from scratch and write it to disk.
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { setCell } from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Quarterly');
setCell(ws, 1, 1, 'Quarter');
setCell(ws, 1, 2, 'Revenue');
setCell(ws, 2, 1, 'Q1');
setCell(ws, 2, 2, 12_400);
setCell(ws, 3, 1, 'Q2');
setCell(ws, 3, 2, 15_900);
await saveWorkbook(wb, toFile('quarterly.xlsx'));
Multiple sheets + named ranges
Add several worksheets, define names that span them, and reference them in a formula.
// Build several worksheets in one workbook and use named ranges
// to refer between them.
import { makeFormula } from '@office-kit/xlsx/cell';
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import { addDefinedName, addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { setCell } from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const inputs = addWorksheet(wb, 'Inputs');
const summary = addWorksheet(wb, 'Summary');
setCell(inputs, 1, 1, 'Revenue');
setCell(inputs, 1, 2, 100_000);
setCell(inputs, 2, 1, 'Cost');
setCell(inputs, 2, 2, 65_000);
addDefinedName(wb, { name: 'Revenue', value: 'Inputs!$B$1' });
addDefinedName(wb, { name: 'Cost', value: 'Inputs!$B$2' });
setCell(summary, 1, 1, 'Margin');
setCell(summary, 1, 2, makeFormula('(Revenue - Cost) / Revenue', { cachedValue: 0.35 }));
await saveWorkbook(wb, toFile('multi-sheet.xlsx'));
Direct fs helpers (Node)
`fromFile` / `toFile` skip the manual `readFile` / `writeFile` glue.
// One-shot read + save direct from / to disk via the @office-kit/xlsx/node
// helpers, no manual fs glue needed.
import { loadWorkbook, saveWorkbook } from '@office-kit/xlsx/io';
import { fromFile, toFile } from '@office-kit/xlsx/node';
const wb = await loadWorkbook(fromFile('input.xlsx'));
// ...mutate wb...
await saveWorkbook(wb, toFile('output.xlsx'));
Cells & values
Style a header cell
Bold, font size, fill color, center alignment, and a thin border in five lines.
// Apply font, fill, alignment, and a thin border to a header row.
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import {
centerCell,
setBold,
setCellBackgroundColor,
setCellBorderAll,
setFontSize,
} from '@office-kit/xlsx/styles';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { setCell } from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Report');
const header = setCell(ws, 1, 1, 'Total revenue');
setBold(wb, header);
setFontSize(wb, header, 12);
setCellBackgroundColor(wb, header, 'FFE0E7FF');
centerCell(wb, header);
setCellBorderAll(wb, header, { style: 'thin' });
await saveWorkbook(wb, toFile('styled.xlsx'));
- These helpers are *cell-level* shortcuts. For range-wide changes, look at `setRangeFont`, `setRangeAlignment`, `setRangeBorderBox`, etc.
- `setBold` and friends merge into the existing font. `setCellFont` replaces it whole, which drops the workbook default (Calibri 11) unless the `Font` you pass is complete. Use `patchCellFont` to change several fields at once.
- Styling a whole report cell by cell adds up. `registerCellStyle` (next recipe) builds each look once and hands you an id the write itself carries.
- Background colors are hex `AARRGGBB` strings — leading `FF` is opaque alpha.
Style a whole report by style id
`registerCellStyle` returns a `styleId`; `setCell` and `appendRow` take it, so formatting arrives with the value instead of in a second pass.
// Style a report by registering each look once, then writing cells with the
// id. There is no second pass over the rows, so nothing can overwrite what the
// first pass wrote.
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import {
makeAlignment,
makeBorder,
makeColor,
makeFont,
makePatternFill,
makeSide,
patchCellFont,
registerCellStyle,
} from '@office-kit/xlsx/styles';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { appendRow, appendRows, setCell, setColumnWidths, setFreezePanes } from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Leverage');
const thin = makeSide({ style: 'thin' });
const box = makeBorder({ left: thin, right: thin, top: thin, bottom: thin });
const HEADER = registerCellStyle(wb, {
font: makeFont({ name: 'Calibri', size: 11, bold: true }),
fill: makePatternFill({ patternType: 'solid', fgColor: makeColor({ rgb: 'FFEFEFEF' }) }),
border: box,
alignment: makeAlignment({ wrapText: true, vertical: 'center' }),
});
const TEXT = registerCellStyle(wb, { border: box });
const INT = registerCellStyle(wb, { border: box, numberFormat: '#,##0' });
// A title needs one field changed, not a whole font: patchCellFont keeps the
// workbook default (Calibri 11) for everything it doesn't mention.
patchCellFont(wb, setCell(ws, 1, 1, 'Translation memory leverage'), { bold: true, size: 13 });
const headers = ['Language', 'Total words', 'Leveraged', 'New'];
appendRow(ws, headers, { styleIds: headers.map(() => HEADER) });
setFreezePanes(ws, 'A3');
const rows: ReadonlyArray<readonly [string, number, number, number]> = [
['de', 71_579, 52_310, 19_269],
['fr', 12_004, 9_880, 2_124],
];
appendRows(ws, rows, { styleIds: [TEXT, INT, INT, INT] });
setColumnWidths(ws, [18, 14, 14, 14]);
await saveWorkbook(wb, toFile('leverage.xlsx'));
- A `styleId` is a complete style, not a patch: an axis you leave out of the spec renders as the workbook default even if the target cell had something there.
- Equal specs dedup to one xf, so reusing three ids across a thousand rows costs three records.
- `styleIds` are column-indexed, and `appendRows` reuses them for every row. A column with an id is written even when its value is empty, which is how a bordered-but-blank input column survives the append; it also means ids past a row's last value widen the sheet.
Number formats: currency, percent, dates
`setCellAsCurrency` and `setCellAsPercent` are one-shot; everything else goes through `setCellNumberFormat` + a built-in or custom format code.
// Apply number formats: currency, percentage, and a date-time.
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import {
FORMAT_DATE_DATETIME,
setCellAsCurrency,
setCellAsPercent,
setCellNumberFormat,
} from '@office-kit/xlsx/styles';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { setCell } from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Numbers');
setCellAsCurrency(wb, setCell(ws, 1, 1, 12_400), { symbol: '$' });
setCellAsPercent(wb, setCell(ws, 1, 2, 0.187), 1);
const dateCell = setCell(ws, 1, 3, new Date('2026-05-08T09:30:00Z'));
setCellNumberFormat(wb, dateCell, FORMAT_DATE_DATETIME);
await saveWorkbook(wb, toFile('numbers.xlsx'));
Formulas in a generated workbook
Cache the values you can compute, and set `fullCalcOnLoad` for the ones you cannot.
// Formulas in a generated workbook: cache the value you already know, and
// ask Excel to recalculate the rest.
import { makeFormula } from '@office-kit/xlsx/cell';
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import { setCellNumberFormat } from '@office-kit/xlsx/styles';
import { addWorksheet, createWorkbook, setFullCalcOnLoad } from '@office-kit/xlsx/workbook';
import { appendRows, setCell } from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Sheet1');
const units = [12, 18, 30];
units.forEach((n, i) => setCell(ws, i + 1, 1, n));
// The producer can add these up, so cache the result: viewers that never
// calculate show the number instead of a blank cell.
const total = units.reduce((a, b) => a + b, 0);
setCellNumberFormat(wb, setCell(ws, 4, 1, makeFormula('SUM(A1:A3)', { cachedValue: total })), '#,##0');
// Every sheet a formula references has to exist in the workbook, or Excel
// resolves the reference to #REF! and offers to repair the file.
const other = addWorksheet(wb, 'Other');
appendRows(other, [
[12, 4],
[18, 7],
]);
// This library never evaluates formulas, so there is no value to cache for the
// cross-sheet lookup. `fullCalcOnLoad` makes a calculating app work it out on
// open rather than trust the cache, which is also what you want when the
// cached values you did write may have gone stale.
setCell(ws, 5, 1, makeFormula('SUMIFS(Other!B:B, Other!A:A, A1)'));
setFullCalcOnLoad(wb, true);
await saveWorkbook(wb, toFile('with-formulas.xlsx'));
- A `cachedValue` is the only thing a viewer that never calculates (Quick Look, Outlook and SharePoint previews, most thumbnailers) can show, so supply one wherever the producer can compute it. Excel, LibreOffice and Google Sheets compute an uncached formula on open regardless.
- `setFullCalcOnLoad(wb, true)` asks a calculating app to recompute the whole workbook on open instead of trusting the cache. That is what you want when this library wrote formulas it cannot evaluate, or when the cached values may be stale; it does nothing for the viewers above, which is why both matter.
- `makeFormula` builds the value for a `setCell` write, so placing a formula is one call that composes with the `styleId` argument. `makeArrayFormula`, `makeSharedFormula` and `makeDataTableFormula` cover the other `<f>` kinds, and `setFormula` and friends apply the same values to a cell you already hold.
- A leading `=` is stripped, so `'=SUM(A1:A3)'` and `'SUM(A1:A3)'` are interchangeable. OOXML stores `<f>` without it, and Excel calls a file that has one damaged.
- Every sheet a formula names has to exist in the workbook, or the reference resolves to `#REF!`.
Merge cells + freeze the header row
Merge a title across columns, then freeze row 1 so it stays put while scrolling.
// Merge a header range across the top row and freeze the first row so
// it stays visible while scrolling.
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import { centerCell, setBold } from '@office-kit/xlsx/styles';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { mergeCells, setCell, setFreezePanes } from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Report');
const title = setCell(ws, 1, 1, 'Q2 financial summary');
setBold(wb, title);
centerCell(wb, title);
mergeCells(ws, 'A1:E1');
setFreezePanes(ws, { rows: 1, cols: 0 });
await saveWorkbook(wb, toFile('merged-frozen.xlsx'));
Make a cell clickable
Hyperlinks live separately from cell values — set the text, attach the URL.
// Make a cell clickable. The text is whatever you set on the cell;
// hyperlink wires up the URL underneath.
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { setCell, setHyperlink } from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Links');
setCell(ws, 1, 1, 'Project home');
setHyperlink(ws, 'A1', {
target: 'https://github.com/office-kit/xlsx',
tooltip: 'View on GitHub',
});
await saveWorkbook(wb, toFile('with-links.xlsx'));
Tables, validation, conditional formatting
Promote a range to an Excel Table
Excel Tables get banded styling, a built-in autoFilter on every header, and a name you can reference in formulas.
// Promote a range to an Excel Table (named range with banded styling and
// a built-in filter dropdown on every header).
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { addExcelTable, setCell } from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Inventory');
const headers = ['SKU', 'Name', 'Price'];
headers.forEach((h, i) => setCell(ws, 1, i + 1, h));
const rows: ReadonlyArray<readonly [string, string, number]> = [
['A-001', 'Widget', 19.95],
['A-002', 'Gadget', 24.5],
['A-003', 'Doohickey', 7.25],
];
rows.forEach((row, r) => row.forEach((v, c) => setCell(ws, r + 2, c + 1, v)));
addExcelTable(wb, ws, {
name: 'Inventory',
ref: 'A1:C4',
columns: headers,
style: 'TableStyleMedium2',
});
await saveWorkbook(wb, toFile('inventory.xlsx'));
- Pass `style` for one-arg style selection or `styleInfo` for full control over banded rows / columns.
- Write the header row first: `addExcelTable` checks the definition against the sheet, because Excel repairs a file where the two disagree by dropping the table. The column count has to match the range width, the ref has to contain the header and totals rows, column names have to be unique, and every header cell has to hold its column name as text. Pass `headerRowCount: 0` for a genuinely header-less table.
- For just a filter without table styling, use `setAutoFilter(ws, makeAutoFilter({ ref: "A1:C4" }))`.
Dropdown data validation
Restrict a range to a list of allowed values. Excel renders a dropdown arrow on each cell.
// Add a list-type data validation — gives the user a dropdown of
// allowed values when they click into the range.
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { addDataValidation, makeDataValidation, setCell } from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Form');
setCell(ws, 1, 1, 'Status');
addDataValidation(
ws,
makeDataValidation({
type: 'list',
sqref: 'B1:B100',
formula1: '"Open,In progress,Closed"',
prompt: 'Pick a status',
errorTitle: 'Invalid value',
error: 'Pick one of the listed values.',
}),
);
await saveWorkbook(wb, toFile('with-dropdown.xlsx'));
- Pass a sheet-relative formula (`=Sheet1!$A$1:$A$10`) instead of a literal array if the choices come from another range.
A column the recipient fills in
Excel's built-in "Input" style marks a column as editable; a decimal validation keeps what they type usable.
// A column the recipient is meant to fill in: Excel's "Input" style marks it
// as editable, and a decimal validation keeps what they type usable.
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import { applyBuiltinStyle } from '@office-kit/xlsx/styles';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { addDataValidation, appendRows, ensureCell, makeDataValidation } from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Quote');
// C2 carries a rate that is already agreed; C3 is the one to fill in.
appendRows(ws, [
['Language', 'Words', 'Rate per word'],
['de', 71_579, 0.12],
['fr', 12_004],
]);
// ensureCell styles both rows the same way: it allocates the blank C3 and
// hands back C2 with its 0.12 intact. setCell would need the existing value
// passed back in to avoid wiping it.
for (let row = 2; row <= 3; row++) {
applyBuiltinStyle(wb, ensureCell(ws, row, 3), 'Input');
}
addDataValidation(
ws,
makeDataValidation({
type: 'decimal',
operator: 'between',
sqref: 'C2:C3',
formula1: '0',
formula2: '10',
// Both flags default to false in ECMA-376: leave them out and Excel shows
// neither the prompt nor the error, and takes any entry.
showInputMessage: true,
prompt: 'Rate per word, 0 to 10',
showErrorMessage: true,
errorTitle: 'Out of range',
error: 'Enter a rate between 0 and 10.',
}),
);
await saveWorkbook(wb, toFile('quote.xlsx'));
- `ensureCell` styles the whole column the same way whether a row is already filled in or still blank; `setCell` would have to know each existing value to avoid wiping it.
- `showInputMessage` and `showErrorMessage` default to false in ECMA-376. Without them Excel shows neither the prompt nor the error and accepts any entry.
- Pair this with `setRangeProtection(wb, ws, "C2:C3", { locked: false })` and a sheet protection if the rest of the sheet should be read-only.
Heat-map with a 3-color scale
Build a `colorScale` rule with `makeCfRule` + inner XML and attach it via `addConditionalFormatting`.
// Color-scale rule: red for low values, yellow for the middle, green
// for high. Excel's classic 3-color heat-map.
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import {
addConditionalFormatting,
makeCfRule,
makeConditionalFormatting,
setCell,
} from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Heat');
for (let r = 1; r <= 10; r++) setCell(ws, r, 1, Math.round(Math.random() * 100));
addConditionalFormatting(
ws,
makeConditionalFormatting({
sqref: 'A1:A10',
rules: [
makeCfRule({
type: 'colorScale',
priority: 1,
formulas: [],
innerXml:
'<colorScale>' +
'<cfvo type="min"/><cfvo type="percentile" val="50"/><cfvo type="max"/>' +
'<color rgb="FFF8696B"/><color rgb="FFFFEB84"/><color rgb="FF63BE7B"/>' +
'</colorScale>',
}),
],
}),
);
await saveWorkbook(wb, toFile('heatmap.xlsx'));
Charts & images
Add a clustered column chart
Wire a `BarChart` to a data range and anchor it to a cell with `addChartAt`.
// Add a clustered column chart driven by a data range on the same sheet.
import { makeBarChart, makeBarSeries, makeChartSpace } from '@office-kit/xlsx/chart';
import { addChartAt } from '@office-kit/xlsx/drawing';
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { setCell } from '@office-kit/xlsx/worksheet';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Sales');
setCell(ws, 1, 1, 'Region');
setCell(ws, 1, 2, 'Revenue');
setCell(ws, 2, 1, 'NA');
setCell(ws, 2, 2, 12_400);
setCell(ws, 3, 1, 'EU');
setCell(ws, 3, 2, 9_800);
setCell(ws, 4, 1, 'APAC');
setCell(ws, 4, 2, 7_300);
const chart = makeBarChart({
barDir: 'col',
grouping: 'clustered',
series: [
makeBarSeries({
idx: 0,
tx: { kind: 'literal', value: 'Revenue' },
cat: { ref: 'Sales!$A$2:$A$4' },
val: { ref: 'Sales!$B$2:$B$4' },
}),
],
});
const space = makeChartSpace({
plotArea: { chart },
title: 'Revenue by region',
legend: { position: 'r' },
});
addChartAt(ws, 'D2', { space }, { widthPx: 480, heightPx: 320 });
await saveWorkbook(wb, toFile('chart.xlsx'));
- Same pattern works for `makeLineChart`, `makePieChart`, `makeScatterChart` and friends — wrap them in a `PlotArea` and pass to `makeChartSpace`.
- For modern chart kinds (Sunburst, Treemap, Waterfall, Histogram, Pareto, Funnel, BoxWhisker, RegionMap), use the `makeSunburstChart` / `makeTreemapChart` / ... helpers from the chartex family — they emit `cx:` chart space.
Insert an image at a cell
Drop a PNG / JPEG / GIF / BMP / WebP / TIFF / SVG anchored to a cell — format and dimensions are auto-detected.
// Insert a PNG / JPEG image at a cell anchor. Format and dimensions
// are auto-detected from the bytes, so loadImage is the only call.
import { addImageAt, loadImage } from '@office-kit/xlsx/drawing';
import { saveWorkbook } from '@office-kit/xlsx/io';
import { toFile } from '@office-kit/xlsx/node';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { readFile } from 'node:fs/promises';
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Cover');
const image = loadImage(await readFile('logo.png'));
addImageAt(ws, 'B2', image, { widthPx: 200, heightPx: 80 });
await saveWorkbook(wb, toFile('with-image.xlsx'));
Generating files you have to trust
Assert on a workbook you just generated
Load the bytes back and read them with the same API you wrote them with.
// Assert on the bytes a renderer produced, by loading them back.
//
// `fromArrayBuffer` takes the Uint8Array directly, so there is no Buffer or
// temp file between the renderer and the assertions.
import { getCoordinate, getFormulaText, makeFormula } from '@office-kit/xlsx/cell';
import { fromArrayBuffer, loadWorkbook, workbookToBytes } from '@office-kit/xlsx/io';
import { addWorksheet, createWorkbook, getSheet, sheetNames } from '@office-kit/xlsx/workbook';
import {
appendRows,
getAutoFilter,
getRangeValues,
iterCells,
makeAutoFilter,
setAutoFilter,
setCell,
} from '@office-kit/xlsx/worksheet';
const render = async (): Promise<Uint8Array> => {
const wb = createWorkbook();
const ws = addWorksheet(wb, 'Leverage');
appendRows(ws, [
['Language', 'Words'],
['de', 71_579],
['fr', 12_004],
]);
setCell(ws, 4, 2, makeFormula('SUM(B2:B3)', { cachedValue: 83_583 }));
setAutoFilter(ws, makeAutoFilter({ ref: 'A1:B3' }));
return workbookToBytes(wb);
};
const wb = await loadWorkbook(fromArrayBuffer(await render()));
// getSheet narrows past the worksheet / chartsheet union for you.
const ws = getSheet(wb, 'Leverage');
if (!ws) throw new Error(`no Leverage sheet in ${sheetNames(wb).join(', ')}`);
console.log(getRangeValues(ws, 'A1:B3')); // [['Language','Words'], ['de',71579], ['fr',12004]]
console.log(getAutoFilter(ws)?.ref); // 'A1:B3'
// Every formula the renderer placed, keyed by address.
const formulas = new Map<string, string>();
for (const cell of iterCells(ws)) {
const text = getFormulaText(cell);
if (text !== undefined) formulas.set(getCoordinate(cell), text);
}
console.log(formulas); // Map { 'B4' => 'SUM(B2:B3)' }
- `fromArrayBuffer` accepts a `Uint8Array` as well as an `ArrayBuffer`, so a renderer's output goes straight into `loadWorkbook` with no copy and no temp file.
- `getSheet(wb, title)` narrows past the worksheet / chartsheet union, so there is no `kind === "worksheet"` check to write.
- `addWorksheet` already validates the title (31-character limit, `[]:*?/\` and the reserved name `History`), so a test of your own for those is testing this library.
Byte-identical output for identical input
Pin `mtime` and the core properties, and the same payload always renders the same bytes.
// Byte-identical output for identical input.
//
// Two things in an xlsx move on their own: the per-entry ZIP timestamp and the
// core properties. Pin both and the same payload always renders the same bytes,
// which is what a golden-file test or a content-addressed cache needs.
//
// The ZIP stamp holds the date's UTC wall time to a two-second resolution, and
// its year has to be in 1980-2099, so the bytes are the same on every machine.
import { workbookToBytes } from '@office-kit/xlsx/io';
import { addWorksheet, createWorkbook } from '@office-kit/xlsx/workbook';
import { appendRows } from '@office-kit/xlsx/worksheet';
interface Payload {
readonly generatedAt: string;
readonly rows: ReadonlyArray<readonly [string, number]>;
}
const render = async (payload: Payload): Promise<Uint8Array> => {
const wb = createWorkbook();
// Stamps come from the payload, never from the clock.
wb.properties = {
creator: 'leverage-report',
created: payload.generatedAt,
modified: payload.generatedAt,
};
const ws = addWorksheet(wb, 'Leverage');
appendRows(ws, [['Language', 'Words'], ...payload.rows.map((r) => [...r])]);
return workbookToBytes(wb, { mtime: new Date(payload.generatedAt) });
};
const payload: Payload = {
generatedAt: '2026-01-02T03:04:00.000Z',
rows: [
['de', 71_579],
['fr', 12_004],
],
};
const first = await render(payload);
const second = await render(payload);
console.log(Buffer.compare(Buffer.from(first), Buffer.from(second)) === 0); // true
- ZIP has no "no timestamp" encoding: each entry carries a DOS mtime, and without `mtime` it comes from the wall clock. That alone makes two renders of the same payload differ.
- `createWriteOnlyWorkbook` takes the same option, as does `compressionLevel` (0 skips compression, 9 is smallest) on both paths.
- The stamp is recorded as the date’s UTC wall time, to a two-second resolution, so the bytes do not change with the machine’s timezone. Its year has to fall in 1980-2099, the range supported by the ZIP backend: `new Date(0)` is rejected.
- Core properties are the other moving part. Set `created` / `modified` from your payload, not from `new Date()`.
Streaming (huge sheets)
Write a million rows without holding them in memory
`createWriteOnlyWorkbook` deflates each row as it arrives, so row buffering stays near 64 KiB however long the sheet gets. Excel caps a sheet at 1,048,576 rows; split anything longer across sheets.
// Stream millions of rows to disk in a fixed memory budget. Each row is
// deflated as it arrives — no intermediate workbook in memory.
import { toFile } from '@office-kit/xlsx/node';
import { createWriteOnlyWorkbook } from '@office-kit/xlsx/streaming';
const sink = toFile('big.xlsx');
const wb = await createWriteOnlyWorkbook(sink);
const ws = await wb.addWorksheet('Data');
ws.setColumnWidth(1, 24); // must precede the first appendRow
for (let r = 0; r < 1_000_000; r++) {
await ws.appendRow([r, `row-${r}`, r * Math.PI]);
}
await ws.close();
await wb.finalize();
- `setColumnWidth` must run *before* the first `appendRow` — once any row is written, `<cols>` is locked.
- `ws.close()` and `wb.finalize()` are required — that's when the central directory is written.
Iterate a huge sheet without loading it
`loadWorkbookStream` + `iterRows` walks the file once and yields rows as they're parsed.
// Iterate huge sheets without loading the full workbook. iterRows is a SAX
// pass — it walks the file once and yields each row's cells.
import { fromFile } from '@office-kit/xlsx/node';
import { loadWorkbookStream } from '@office-kit/xlsx/streaming';
const wb = await loadWorkbookStream(fromFile('big.xlsx'));
const sheet = wb.openWorksheet(wb.sheetNames[0] ?? '');
for await (const row of sheet.iterRows({ minRow: 1, maxRow: 100 })) {
console.log(row.map((c) => c.value));
}
await wb.close();
- Bound the walk with `iterRows({ minRow, maxRow, minCol, maxCol })` — the parser skips ahead via tag-scan.
Browser
Browser: read xlsx from a fetch response
`fromResponse` is streaming, so the workbook starts parsing while bytes are still arriving.
// Browser: pipe a fetch Response straight into the loader. fromResponse is
// streaming, so the workbook starts parsing before the download is done.
import { fromResponse, loadWorkbook } from '@office-kit/xlsx/io';
const response = await fetch('/sheet.xlsx');
const wb = await loadWorkbook(fromResponse(response));
const ref = wb.sheets[0];
if (ref?.kind === 'worksheet') {
console.log(ref.sheet.title);
}
Browser: read xlsx from <input type="file">
`fromBlob` consumes the File the user just picked, no full buffer.
// Browser: parse the xlsx the user just selected via <input type="file">.
// fromBlob is streaming, so the workbook starts parsing while the file
// is still being read.
import { fromBlob, loadWorkbook } from '@office-kit/xlsx/io';
export async function loadFromInput(input: HTMLInputElement) {
const file = input.files?.[0];
if (!file) return null;
const wb = await loadWorkbook(fromBlob(file));
return wb;
}
To drive a workbook end to end, walk through Getting started. To experiment without installing anything, open the REPL; to inspect a real file, open the playground.