/**
* 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;
}