// Dependency-free .xlsx writer. An .xlsx is just a ZIP of XML parts, so we // build the parts by hand and pack them with STORE (no compression) — that // keeps us from needing a deflate implementation while still producing a file // Excel opens natively, with no "format and extension don't match" warning. type Cell = string | number | null | undefined; /* --------------------------------- ZIP ---------------------------------- */ const CRC_TABLE = (() => { const table = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[i] = c >>> 0; } return table; })(); function crc32(bytes: Uint8Array): number { let c = 0xffffffff; for (let i = 0; i < bytes.length; i++) { c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); } return (c ^ 0xffffffff) >>> 0; } interface ZipEntry { name: string; data: Uint8Array; crc: number; offset: number; } // Builds a ZIP archive with stored (uncompressed) entries. Fixed DOS timestamp // so repeated exports of the same data are byte-identical. function zip(files: { name: string; content: string }[]): Blob { const encoder = new TextEncoder(); const chunks: Uint8Array[] = []; const entries: ZipEntry[] = []; let offset = 0; const DOS_TIME = 0; // 00:00:00 const DOS_DATE = (2020 - 1980) << 9 | (1 << 5) | 1; // 2020-01-01 function push(bytes: Uint8Array) { chunks.push(bytes); offset += bytes.length; } function header(size: number) { const buf = new ArrayBuffer(size); return { view: new DataView(buf), bytes: new Uint8Array(buf) }; } for (const file of files) { const nameBytes = encoder.encode(file.name); const data = encoder.encode(file.content); const crc = crc32(data); const local = header(30 + nameBytes.length); local.view.setUint32(0, 0x04034b50, true); // local file header signature local.view.setUint16(4, 20, true); // version needed local.view.setUint16(6, 0x0800, true); // flags: UTF-8 names local.view.setUint16(8, 0, true); // method: stored local.view.setUint16(10, DOS_TIME, true); local.view.setUint16(12, DOS_DATE, true); local.view.setUint32(14, crc, true); local.view.setUint32(18, data.length, true); // compressed size local.view.setUint32(22, data.length, true); // uncompressed size local.view.setUint16(26, nameBytes.length, true); local.view.setUint16(28, 0, true); // extra field length local.bytes.set(nameBytes, 30); entries.push({ name: file.name, data, crc, offset }); push(local.bytes); push(data); } const centralStart = offset; for (const entry of entries) { const nameBytes = encoder.encode(entry.name); const central = header(46 + nameBytes.length); central.view.setUint32(0, 0x02014b50, true); // central directory signature central.view.setUint16(4, 20, true); // version made by central.view.setUint16(6, 20, true); // version needed central.view.setUint16(8, 0x0800, true); // flags: UTF-8 names central.view.setUint16(10, 0, true); // method: stored central.view.setUint16(12, DOS_TIME, true); central.view.setUint16(14, DOS_DATE, true); central.view.setUint32(16, entry.crc, true); central.view.setUint32(20, entry.data.length, true); central.view.setUint32(24, entry.data.length, true); central.view.setUint16(28, nameBytes.length, true); central.view.setUint16(30, 0, true); // extra central.view.setUint16(32, 0, true); // comment central.view.setUint16(34, 0, true); // disk number central.view.setUint16(36, 0, true); // internal attrs central.view.setUint32(38, 0, true); // external attrs central.view.setUint32(42, entry.offset, true); central.bytes.set(nameBytes, 46); push(central.bytes); } const end = header(22); end.view.setUint32(0, 0x06054b50, true); // end of central directory end.view.setUint16(8, entries.length, true); end.view.setUint16(10, entries.length, true); end.view.setUint32(12, offset - centralStart, true); end.view.setUint32(16, centralStart, true); push(end.bytes); return new Blob(chunks as BlobPart[], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }); } /* --------------------------------- XML ---------------------------------- */ // Built via RegExp so the source file stays free of literal control bytes. const CONTROL_CHARS = new RegExp( "[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]", "g", ); function escapeXml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) // Control characters are illegal in XML 1.0 and make Excel reject the file. .replace(CONTROL_CHARS, ""); } // 0 -> A, 25 -> Z, 26 -> AA ... function columnName(index: number): string { let name = ""; let n = index; while (n >= 0) { name = String.fromCharCode(65 + (n % 26)) + name; n = Math.floor(n / 26) - 1; } return name; } function cellXml(ref: string, value: Cell, style: number): string { if (value === null || value === undefined || value === "") { return ``; } if (typeof value === "number" && Number.isFinite(value)) { return `${value}`; } // Inline strings keep us from having to build a sharedStrings part. return `${escapeXml( String(value), )}`; } function sheetXml(headers: string[], rows: Cell[][]): string { const width = Math.max(headers.length, ...rows.map((r) => r.length), 1); const cols = `` + Array.from( { length: width }, (_, i) => ``, ).join("") + ``; const lines: string[] = []; lines.push( `${headers .map((h, i) => cellXml(`${columnName(i)}1`, h, 1)) .join("")}`, ); rows.forEach((row, r) => { const n = r + 2; lines.push( `${row .map((cell, i) => cellXml(`${columnName(i)}${n}`, cell, 0)) .join("")}`, ); }); return ( `` + `` + // rightToLeft so the sheet opens in RTL orientation, and a frozen header row. `` + `` + `` + cols + `${lines.join("")}` + `` + `` ); } // Two cell formats: 0 = body, 1 = bold header on a grey fill. const STYLES_XML = `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + ``; /* -------------------------------- Public -------------------------------- */ export function exportToExcel( filename: string, headers: string[], rows: Cell[][], sheetName = "Sheet1", ) { const safeSheet = escapeXml(sheetName.replace(/[[\]:*?/\\]/g, "").slice(0, 31)) || "Sheet1"; const blob = zip([ { name: "[Content_Types].xml", content: `` + `` + `` + `` + `` + `` + `` + ``, }, { name: "_rels/.rels", content: `` + `` + `` + ``, }, { name: "xl/workbook.xml", content: `` + `` + ``, }, { name: "xl/_rels/workbook.xml.rels", content: `` + `` + `` + `` + ``, }, { name: "xl/styles.xml", content: STYLES_XML }, { name: "xl/worksheets/sheet1.xml", content: sheetXml(headers, rows) }, ]); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename.endsWith(".xlsx") ? filename : `${filename}.xlsx`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } // Runs `worker` over `items` with a bounded number of in-flight promises so a // few thousand rows don't open a few thousand sockets at once. export async function mapWithConcurrency( items: T[], limit: number, worker: (item: T, index: number) => Promise, onProgress?: (done: number, total: number) => void, ): Promise { const results = new Array(items.length); let cursor = 0; let done = 0; async function run() { while (cursor < items.length) { const index = cursor++; results[index] = await worker(items[index], index); done++; onProgress?.(done, items.length); } } await Promise.all( Array.from({ length: Math.min(limit, items.length) }, run), ); return results; }