/**
* xlsx.js — a minimal, dependency-free XLSX writer.
*
* An .xlsx file is a ZIP archive of XML parts. This module builds the few
* parts Excel actually requires and packs them with a small STORED (uncompressed)
* ZIP writer — readers accept stored entries, and an analysis table is far too
* small for compression to matter.
*
* Why not a library: SheetJS's npm package is stale and carries advisories,
* ExcelJS is heavy for an offline-first field app, and the JSZip in node_modules
* is only a transitive dependency of shp-write (so it could disappear). This is
* ~200 lines we control, adds nothing to the dependency tree, and works offline.
*
* buildXlsxBlob([{ name: 'Results', rows: [['Zone','Count'], ['North', 3]] }])
*
* Values are written as numbers when typeof === 'number' (and finite), otherwise
* as inline strings — which avoids needing a sharedStrings part. Passing a row
* as { cells: [...], bold: true } renders that row in bold.
*/
// ---------------------------------------------------------------------------
// XML helpers
// ---------------------------------------------------------------------------
function esc(s) {
return String(s)
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''')
// Strip control characters Excel rejects outside tab/newline.
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
}
/** 0 → A, 25 → Z, 26 → AA … */
function colName(index) {
let s = '';
let n = index;
while (n >= 0) {
s = String.fromCharCode((n % 26) + 65) + s;
n = Math.floor(n / 26) - 1;
}
return s;
}
function normaliseRow(row) {
if (row && !Array.isArray(row) && Array.isArray(row.cells)) {
return { cells: row.cells, bold: !!row.bold };
}
return { cells: Array.isArray(row) ? row : [row], bold: false };
}
function sheetXml(rows) {
const out = [];
rows.forEach((raw, r) => {
const { cells, bold } = normaliseRow(raw);
const style = bold ? ' s="1"' : '';
const cellXml = cells.map((v, c) => {
const ref = `${colName(c)}${r + 1}`;
if (v === null || v === undefined || v === '') return ``;
if (typeof v === 'number' && Number.isFinite(v)) {
return `${v}`;
}
return `${esc(v)}`;
}).join('');
out.push(`${cellXml}
`);
});
return `
${out.join('')}`;
}
/** Excel requires fills[0]=none and fills[1]=gray125; keep the rest minimal. */
const STYLES_XML = `
`;
// ---------------------------------------------------------------------------
// Minimal STORED-ZIP writer
// ---------------------------------------------------------------------------
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
/** Pack files (name → string) into an uncompressed ZIP Blob. */
function zipStore(files) {
const enc = new TextEncoder();
const entries = Object.entries(files).map(([name, text]) => ({
name, nameBytes: enc.encode(name), data: enc.encode(text),
}));
// DOS time/date — a fixed timestamp keeps output deterministic.
const time = 0, date = ((2020 - 1980) << 9) | (1 << 5) | 1;
const chunks = [];
const central = [];
let offset = 0;
const u16 = (v) => [v & 0xFF, (v >>> 8) & 0xFF];
const u32 = (v) => [v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF];
for (const e of entries) {
const crc = crc32(e.data);
const size = e.data.length;
const local = [
...u32(0x04034b50), ...u16(20), ...u16(0), ...u16(0),
...u16(time), ...u16(date), ...u32(crc), ...u32(size), ...u32(size),
...u16(e.nameBytes.length), ...u16(0),
];
chunks.push(new Uint8Array(local), e.nameBytes, e.data);
central.push([
...u32(0x02014b50), ...u16(20), ...u16(20), ...u16(0), ...u16(0),
...u16(time), ...u16(date), ...u32(crc), ...u32(size), ...u32(size),
...u16(e.nameBytes.length), ...u16(0), ...u16(0), ...u16(0), ...u16(0),
...u32(0), ...u32(offset),
]);
central.push(e.nameBytes);
offset += local.length + e.nameBytes.length + size;
}
const cdParts = [];
let cdSize = 0;
for (const part of central) {
const arr = part instanceof Uint8Array ? part : new Uint8Array(part);
cdParts.push(arr); cdSize += arr.length;
}
const end = new Uint8Array([
...u32(0x06054b50), ...u16(0), ...u16(0),
...u16(entries.length), ...u16(entries.length),
...u32(cdSize), ...u32(offset), ...u16(0),
]);
return new Blob([...chunks, ...cdParts, end],
{ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Build an .xlsx Blob from one or more sheets.
* @param {Array<{name: string, rows: Array}>} sheets
* @returns {Blob}
*/
export function buildXlsxBlob(sheets) {
const list = (sheets || []).filter((s) => s && Array.isArray(s.rows));
if (list.length === 0) throw new Error('No sheets to export.');
const files = {};
const sheetEntries = list.map((s, i) => {
const file = `xl/worksheets/sheet${i + 1}.xml`;
files[file] = sheetXml(s.rows);
// Excel sheet names: max 31 chars, and : \ / ? * [ ] are illegal.
const safe = String(s.name || `Sheet${i + 1}`).replace(/[:\\/?*[\]]/g, ' ').slice(0, 31);
return { id: i + 1, name: safe, file };
});
files['[Content_Types].xml'] =
`
${sheetEntries.map((s) => ``).join('\n')}
`;
files['_rels/.rels'] =
`
`;
files['xl/workbook.xml'] =
`
${sheetEntries.map((s) => ``).join('')}
`;
files['xl/_rels/workbook.xml.rels'] =
`
${sheetEntries.map((s) => ``).join('\n')}
`;
files['xl/styles.xml'] = STYLES_XML;
return zipStore(files);
}
/** Trigger a browser download of a Blob. */
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/** Convenience: build and download in one call. */
export function exportXlsx(sheets, filename) {
downloadBlob(buildXlsxBlob(sheets), filename);
}