Excel Export: Builds .xlsx workbooks from supplied tables or grid panel data and writes/opens them in the plugin's exports folder.
| 1 | import { strToU8, zipSync } from 'fflate' |
| 2 | |
| 3 | /** One sheet ready to write: its sanitized name, headers, and cells. */ |
| 4 | export interface Sheet { |
| 5 | name: string |
| 6 | columns: string[] |
| 7 | rows: unknown[][] |
| 8 | } |
| 9 | |
| 10 | const XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' |
| 11 | const NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main' |
| 12 | const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' |
| 13 | |
| 14 | function esc(s: string): string { |
| 15 | return s |
| 16 | .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') |
| 17 | .replace(/&/g, '&') |
| 18 | .replace(/</g, '<') |
| 19 | .replace(/>/g, '>') |
| 20 | .replace(/"/g, '"') |
| 21 | } |
| 22 | |
| 23 | function colName(i: number): string { |
| 24 | let s = '' |
| 25 | for (let n = i + 1; n > 0; n = Math.floor((n - 1) / 26)) s = String.fromCharCode(65 + ((n - 1) % 26)) + s |
| 26 | return s |
| 27 | } |
| 28 | |
| 29 | /** A plain decimal written as text, like "4.77" or "-12", but not "007" or "1,000". */ |
| 30 | const NUMERIC = /^-?(0|[1-9]\d*)(\.\d+)?([eE][-+]?\d+)?$/ |
| 31 | |
| 32 | type Cell = { kind: 'n'; value: number } | { kind: 'b'; value: boolean } | { kind: 's'; value: string } | null |
| 33 | |
| 34 | function toCell(v: unknown): Cell { |
| 35 | if (v === null || v === undefined || v === '') return null |
| 36 | if (typeof v === 'number') return Number.isFinite(v) ? { kind: 'n', value: v } : { kind: 's', value: String(v) } |
| 37 | if (typeof v === 'boolean') return { kind: 'b', value: v } |
| 38 | if (typeof v === 'string') { |
| 39 | const t = v.trim() |
| 40 | if (NUMERIC.test(t) && t.length < 16) return { kind: 'n', value: Number(t) } |
| 41 | return { kind: 's', value: v.slice(0, 32767) } |
| 42 | } |
| 43 | return { kind: 's', value: JSON.stringify(v).slice(0, 32767) } |
| 44 | } |
| 45 | |
| 46 | function cellXml(ref: string, cell: Cell, style: number): string { |
| 47 | const s = style ? ` s="${style}"` : '' |
| 48 | if (!cell) return style ? `<c r="${ref}"${s}/>` : '' |
| 49 | if (cell.kind === 'n') return `<c r="${ref}"${s}><v>${cell.value}</v></c>` |
| 50 | if (cell.kind === 'b') return `<c r="${ref}"${s} t="b"><v>${cell.value ? 1 : 0}</v></c>` |
| 51 | return `<c r="${ref}"${s} t="inlineStr"><is><t xml:space="preserve">${esc(cell.value)}</t></is></c>` |
| 52 | } |
| 53 | |
| 54 | function displayLength(cell: Cell): number { |
| 55 | if (!cell) return 0 |
| 56 | if (cell.kind === 'n') return String(cell.value).length |
| 57 | if (cell.kind === 'b') return 5 |
| 58 | return Math.max(...cell.value.split('\n').map((l) => l.length)) |
| 59 | } |
| 60 | |
| 61 | function sheetXml(sheet: Sheet): string { |
| 62 | const width = Math.max(sheet.columns.length, ...sheet.rows.map((r) => r.length), 1) |
| 63 | const widths = Array.from({ length: width }, (_, i) => (sheet.columns[i] ?? '').length + 2) |
| 64 | const rows: string[] = [] |
| 65 | const header = Array.from({ length: width }, (_, i) => |
| 66 | cellXml(`${colName(i)}1`, { kind: 's', value: sheet.columns[i] ?? `Column ${i + 1}` }, 1), |
| 67 | ) |
| 68 | rows.push(`<row r="1">${header.join('')}</row>`) |
| 69 | sheet.rows.forEach((row, r) => { |
| 70 | const cells = row.map((v, c) => { |
| 71 | const cell = toCell(v) |
| 72 | widths[c] = Math.max(widths[c] ?? 0, displayLength(cell) + 2) |
| 73 | return cellXml(`${colName(c)}${r + 2}`, cell, 0) |
| 74 | }) |
| 75 | rows.push(`<row r="${r + 2}">${cells.join('')}</row>`) |
| 76 | }) |
| 77 | const cols = widths |
| 78 | .map((w, i) => `<col min="${i + 1}" max="${i + 1}" width="${Math.min(Math.max(w, 8), 60)}" customWidth="1"/>`) |
| 79 | .join('') |
| 80 | const last = `${colName(width - 1)}${sheet.rows.length + 1}` |
| 81 | return ( |
| 82 | XML + |
| 83 | `<worksheet xmlns="${NS}" xmlns:r="${REL}">` + |
| 84 | `<dimension ref="A1:${last}"/>` + |
| 85 | `<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>` + |
| 86 | `<selection pane="bottomLeft" activeCell="A2" sqref="A2"/></sheetView></sheetViews>` + |
| 87 | `<sheetFormatPr defaultRowHeight="15"/><cols>${cols}</cols>` + |
| 88 | `<sheetData>${rows.join('')}</sheetData>` + |
| 89 | `<autoFilter ref="A1:${last}"/>` + |
| 90 | `</worksheet>` |
| 91 | ) |
| 92 | } |
| 93 | |
| 94 | const STYLES = |
| 95 | XML + |
| 96 | `<styleSheet xmlns="${NS}">` + |
| 97 | `<fonts count="2"><font><sz val="11"/><name val="Calibri"/><family val="2"/></font>` + |
| 98 | `<font><b/><sz val="11"/><name val="Calibri"/><family val="2"/></font></fonts>` + |
| 99 | `<fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill>` + |
| 100 | `<fill><patternFill patternType="solid"><fgColor rgb="FFF2F2F2"/><bgColor indexed="64"/></patternFill></fill></fills>` + |
| 101 | `<borders count="2"><border><left/><right/><top/><bottom/><diagonal/></border>` + |
| 102 | `<border><left/><right/><top/><bottom style="thin"><color auto="1"/></bottom><diagonal/></border></borders>` + |
| 103 | `<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>` + |
| 104 | `<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>` + |
| 105 | `<xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1"/></cellXfs>` + |
| 106 | `<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>` + |
| 107 | `</styleSheet>` |
| 108 | |
| 109 | /** The .xlsx file's bytes for these sheets. */ |
| 110 | export function buildXlsx(sheets: Sheet[]): Uint8Array { |
| 111 | const files: Record<string, Uint8Array> = {} |
| 112 | const overrides = sheets |
| 113 | .map( |
| 114 | (_, i) => |
| 115 | `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`, |
| 116 | ) |
| 117 | .join('') |
| 118 | files['[Content_Types].xml'] = strToU8( |
| 119 | XML + |
| 120 | `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` + |
| 121 | `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` + |
| 122 | `<Default Extension="xml" ContentType="application/xml"/>` + |
| 123 | `<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` + |
| 124 | `<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>` + |
| 125 | overrides + |
| 126 | `</Types>`, |
| 127 | ) |
| 128 | files['_rels/.rels'] = strToU8( |
| 129 | XML + |
| 130 | `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` + |
| 131 | `<Relationship Id="rId1" Type="${REL}/officeDocument" Target="xl/workbook.xml"/></Relationships>`, |
| 132 | ) |
| 133 | files['xl/workbook.xml'] = strToU8( |
| 134 | XML + |
| 135 | `<workbook xmlns="${NS}" xmlns:r="${REL}"><bookViews><workbookView/></bookViews><sheets>` + |
| 136 | sheets.map((s, i) => `<sheet name="${esc(s.name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join('') + |
| 137 | `</sheets><definedNames>` + |
| 138 | sheets |
| 139 | .map((s, i) => { |
| 140 | const width = Math.max(s.columns.length, ...s.rows.map((r) => r.length), 1) |
| 141 | const quoted = `'${s.name.replace(/'/g, "''")}'` |
| 142 | return `<definedName name="_xlnm._FilterDatabase" localSheetId="${i}" hidden="1">${esc(quoted)}!$A$1:$${colName(width - 1)}$${s.rows.length + 1}</definedName>` |
| 143 | }) |
| 144 | .join('') + |
| 145 | `</definedNames></workbook>`, |
| 146 | ) |
| 147 | files['xl/_rels/workbook.xml.rels'] = strToU8( |
| 148 | XML + |
| 149 | `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` + |
| 150 | sheets |
| 151 | .map((_, i) => `<Relationship Id="rId${i + 1}" Type="${REL}/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`) |
| 152 | .join('') + |
| 153 | `<Relationship Id="rId${sheets.length + 1}" Type="${REL}/styles" Target="styles.xml"/></Relationships>`, |
| 154 | ) |
| 155 | files['xl/styles.xml'] = strToU8(STYLES) |
| 156 | sheets.forEach((s, i) => (files[`xl/worksheets/sheet${i + 1}.xml`] = strToU8(sheetXml(s)))) |
| 157 | return zipSync(files, { level: 6 }) |
| 158 | } |