/** * Minimal zero-dependency XLSX writer. * * Produces a valid OOXML workbook by emitting the required parts and storing * them with a hand-rolled ZIP (stored/no-compression entries + CRC32). No * external library, no `zip` binary needed. * * Scope: multiple sheets, string/number cells, bold header row. That is all the * estimator needs — a client-pokeable model, not a formatting exercise. */ export type Cell = string | number | null; export interface Sheet { name: string; rows: Cell[][]; } 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(data: Uint8Array): number { let c = 0xffffffff; for (let i = 0; i < data.length; i++) c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function escapeXml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') // Strip control chars XML forbids. .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ''); } /** Convert a zero-based column index to an A1 column name. */ function columnName(index: number): string { let name = ''; let n = index; do { name = String.fromCharCode(65 + (n % 26)) + name; n = Math.floor(n / 26) - 1; } while (n >= 0); return name; } function sheetXml(sheet: Sheet): string { const rows = sheet.rows .map((row, rowIndex) => { const cells = row .map((cell, colIndex) => { if (cell === null || cell === undefined || cell === '') return ''; const ref = `${columnName(colIndex)}${rowIndex + 1}`; const style = rowIndex === 0 ? ' s="1"' : ''; if (typeof cell === 'number' && Number.isFinite(cell)) { return `${cell}`; } return `${escapeXml(String(cell))}`; }) .join(''); return `${cells}`; }) .join(''); return `${rows}`; } /** Excel forbids : \\ / ? * [ ] in sheet names and caps them at 31 chars. */ function safeSheetName(name: string, index: number): string { const cleaned = name.replace(/[:\\/?*[\]]/g, '-').slice(0, 31); return cleaned || `Sheet${index + 1}`; } /** Build an .xlsx file as bytes. */ export function buildXlsx(sheets: Sheet[]): Uint8Array { const names = sheets.map((s, i) => safeSheetName(s.name, i)); const files: Array<{ path: string; content: string }> = []; files.push({ path: '[Content_Types].xml', content: '' + '' + '' + '' + '' + '' + sheets .map( (_, i) => ``, ) .join('') + '', }); files.push({ path: '_rels/.rels', content: '' + '' + '' + '', }); files.push({ path: 'xl/workbook.xml', content: '' + '' + names.map((n, i) => ``).join('') + '', }); files.push({ path: 'xl/_rels/workbook.xml.rels', content: '' + '' + sheets .map( (_, i) => ``, ) .join('') + `` + '', }); files.push({ path: 'xl/styles.xml', content: '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '', }); sheets.forEach((sheet, i) => { files.push({ path: `xl/worksheets/sheet${i + 1}.xml`, content: sheetXml(sheet) }); }); return zip(files); } /** Build a ZIP archive with stored (uncompressed) entries. */ function zip(files: Array<{ path: string; content: string }>): Uint8Array { const encoder = new TextEncoder(); const locals: Uint8Array[] = []; const centrals: Uint8Array[] = []; let offset = 0; for (const file of files) { const nameBytes = encoder.encode(file.path); const data = encoder.encode(file.content); const crc = crc32(data); const local = new Uint8Array(30 + nameBytes.length + data.length); const lv = new DataView(local.buffer); lv.setUint32(0, 0x04034b50, true); lv.setUint16(4, 20, true); // version needed lv.setUint16(6, 0, true); // flags lv.setUint16(8, 0, true); // stored lv.setUint16(10, 0, true); // time lv.setUint16(12, 0x21, true); // date (1996-01-01) lv.setUint32(14, crc, true); lv.setUint32(18, data.length, true); lv.setUint32(22, data.length, true); lv.setUint16(26, nameBytes.length, true); lv.setUint16(28, 0, true); local.set(nameBytes, 30); local.set(data, 30 + nameBytes.length); locals.push(local); const central = new Uint8Array(46 + nameBytes.length); const cv = new DataView(central.buffer); cv.setUint32(0, 0x02014b50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true); cv.setUint16(8, 0, true); cv.setUint16(10, 0, true); cv.setUint16(12, 0, true); cv.setUint16(14, 0x21, true); cv.setUint32(16, crc, true); cv.setUint32(20, data.length, true); cv.setUint32(24, data.length, true); cv.setUint16(28, nameBytes.length, true); cv.setUint32(42, offset, true); central.set(nameBytes, 46); centrals.push(central); offset += local.length; } const centralSize = centrals.reduce((s, c) => s + c.length, 0); const end = new Uint8Array(22); const ev = new DataView(end.buffer); ev.setUint32(0, 0x06054b50, true); ev.setUint16(8, files.length, true); ev.setUint16(10, files.length, true); ev.setUint32(12, centralSize, true); ev.setUint32(16, offset, true); const total = offset + centralSize + end.length; const out = new Uint8Array(total); let pos = 0; for (const chunk of [...locals, ...centrals, end]) { out.set(chunk, pos); pos += chunk.length; } return out; }