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