Read and write Excel .xlsx files in Deno. On JSR. Source:
github.com/HoldenMalinchock/excel.
exceljs is the usual way to write .xlsx from JavaScript, but 4.4.0 is unmaintained and carries
prototype-pollution, zip-path, and zip-bomb CVEs. This package is not a port of that API. It covers
the jobs people actually use exceljs for: turn rows of data into a workbook, style it, make a table,
read it back.
deno add jsr:@hmalinchock/excelimport { Workbook } from "@hmalinchock/excel";
const wb = new Workbook();
wb.sheet("Sales").from([
{ id: 1, name: "Ada", total: 42.5 },
{ id: 2, name: "Grace", total: 17 },
]).table({ name: "Sales", style: "TableStyleMedium9" });
await wb.write("./sales.xlsx");Zero runtime dependencies. Names follow Excel’s own model: a workbook holds sheets, sheets hold cells, and a range of cells can be an Excel table.
Runnable copies of these snippets live in examples/.
const ws = wb.sheet("People");
ws.from([
{ id: 1, name: "Ada", role: "Engineer" },
{ id: 2, name: "Grace", role: "Admiral" },
]);
ws.width("name", 16);from() writes a header row, bolds it, and adds an autofilter. Pass { headers: ["name", "id"] }
to pick column order. Arrays write a raw grid instead:
wb.sheet("Raw").from([
["Id", "Name"],
[1, "Ada"],
]);
ws.add(["next", "row"]);Read it back:
const loaded = await Workbook.read("./sales.xlsx");
loaded.sheet("Sales").objects(); // records using row 1 as keys
loaded.sheet("Sales").values(); // 2d array
loaded.sheet("Sales").get("B2");An Excel table is the formatted, filterable object you get from Insert → Table. Call table() after
from():
const records = [
{ region: "East", product: "Gadget", units: 10, revenue: 1200 },
{ region: "West", product: "Widget", units: 4, revenue: 360 },
];
wb.sheet("Sales")
.from(records)
.column("revenue", { width: 14, numFmt: "$#,##0.00", align: "right" })
.table({
name: "Sales",
style: "TableStyleMedium9",
totals: { units: "sum", revenue: "sum" },
});Or in one step: ws.from(records, { table: true }).
style is any built-in name (TableStyleLight1…21, TableStyleMedium1…28,
TableStyleDark1…11). totals: true labels the first column “Total” and sums the rest.
See examples/as_table.ts.
style takes an A1 cell, a range, a whole column (C:C), or a whole row (2:2). Colors are hex.
ws.set("A1", "Q1 report");
ws.merge("A1:D1");
ws.style("A1:D1", {
bold: true,
size: 16,
font: "Calibri",
color: "#FFFFFF",
fill: "#1F4E79",
align: "center",
border: true,
});
ws.style("A2:D2", { bold: true, fill: "#D6DCE4", border: "thin" });
ws.style("C:C", { numFmt: "$#,##0.00", align: "right" });border: true is a thin box. Use "thin" | "medium" | "thick" | "dashed" and optional
borderColor. vertical is "top" | "center" | "bottom". wrap: true wraps text.
ws.column("revenue", { width: 14, numFmt: "$#,##0.00", align: "right" });
ws.column("C", { width: 12, hidden: false });
ws.column("note", { width: 24, wrap: true });Keys from from() work ("revenue"), as do letters ("C"). Column style applies to existing cells
in that column and is stored as the column default.
ws.sheetStyle({
tabColor: "#1F4E79",
defaultRowHeight: 18,
defaultColWidth: 12,
showGridLines: false,
});
ws.freeze(1); // freeze the header rowws.set("D2", { formula: "SUM(C2:C4)", result: 42.5 });
ws.set("E2", { hyperlink: "https://example.com", text: "link" });
ws.set("F2", new Date("2026-01-15T00:00:00Z"));
ws.comment("A2", "check this");
ws.csv(); // formula-like cells are quoted so they cannot executeconst wb = Workbook.fromCsv("id,name\n1,Ada\n");See examples/styling.ts and examples/from_records.ts.
There is no addImage({ filename }), no lodash-style note merge, and no unbounded zip inflate.
| Issue | exceljs 4.4.0 | this package |
|---|---|---|
Prototype pollution (CVE-2026-78207) |
deepMerge on cell notes copies __proto__ |
from() / set() copy only own, allow-listed keys onto prototype-free objects |
Path traversal (CVE-2026-78208) |
addImage({ filename }) reads any path |
that API does not exist; zip entry names with .. are rejected on load |
Zip bomb (CVE-2026-78206) |
every entry inflated, no cap | declared size, ratio, and actual inflate are capped before XML parse |
Load limits (overridable on Workbook.parse / Workbook.read): 10 000 entries, 128 MiB per entry,
512 MiB total uncompressed, compression ratio 1000:1.
MIT