/** * .xlsx import/export via SheetJS (the *only* place a spreadsheet library is * used, and never for formula evaluation -- that stays in our engine). * * export: one worksheet per table. Formula cells are written as real Excel * formulas when they translate cleanly (`;` -> `,`, `AVG` -> * `AVERAGE`), always accompanied by the cached value from our engine. * import: one table per worksheet. `cell.f` becomes a formula when our parser * accepts it, otherwise the cached value is taken. */ import type * as XLSXNS from 'xlsx'; import { parseFormula } from '../engine'; import type { DocumentStore } from '../store/document'; import type { Sheet, TableObject } from '../store/types'; import { defaultTableView } from '../store/types'; import { rawString } from '../store/format'; /** SheetJS is the only spreadsheet library and it is loaded on demand. */ let xlsxModule: typeof XLSXNS | null = null; async function loadXLSX(): Promise { if (!xlsxModule) xlsxModule = await import('xlsx'); return xlsxModule; } /** Swap top-level separators while respecting "..." and '...' quoting. */ function swapOutsideQuotes(src: string, from: string, to: string): string { let out = ''; let q: '"' | "'" | null = null; for (const ch of src) { if (q) { out += ch; if (ch === q) q = null; } else if (ch === '"' || ch === "'") { q = ch; out += ch; } else if (ch === from) { out += to; } else { out += ch; } } return out; } function toExcelFormula(input: string): string | null { // input starts with '=' let body = input.slice(1); body = swapOutsideQuotes(body, ';', ','); body = body.replace(/\bAVG\s*\(/gi, 'AVERAGE('); return body; } function fromExcelFormula(f: string): string | null { const body = swapOutsideQuotes(f, ',', ';'); const candidate = '=' + body; try { parseFormula(candidate); return candidate; } catch { return null; } } function sanitizeSheetName(name: string, taken: Set): string { const clean = name.replace(/[[\]:*?/\\]/g, ' ').trim().slice(0, 31) || 'Tabelle'; let candidate = clean; let i = 2; while (taken.has(candidate.toLowerCase())) { candidate = `${clean.slice(0, 28)} ${i++}`; } taken.add(candidate.toLowerCase()); return candidate; } // ---- export ----------------------------------------------------------- export async function documentToXlsx(store: DocumentStore): Promise { const XLSX = await loadXLSX(); const wb = XLSX.utils.book_new(); const taken = new Set(); // one worksheet per table object, across all sheets const tables = store.doc.sheets.flatMap((s) => s.objects.filter((o): o is TableObject => o.kind === 'table'), ); for (const table of tables) { const ws: XLSXNS.WorkSheet = {}; let maxC = 0; let maxR = 0; for (const key of Object.keys(table.cells)) { const [c, r] = key.split(',').map(Number); const raw = table.cells[key]; const addr = XLSX.utils.encode_cell({ c, r }); const value = store.cellValue(table.id, c, r); if (raw.startsWith('=')) { const f = toExcelFormula(raw); const num = typeof value === 'number' ? value : undefined; ws[addr] = num !== undefined ? { t: 'n', v: num, f: f ?? undefined } : { t: 's', v: rawString(value), f: f ?? undefined }; } else if (typeof value === 'number') { ws[addr] = { t: 'n', v: value }; } else { ws[addr] = { t: 's', v: raw }; } if (c > maxC) maxC = c; if (r > maxR) maxR = r; } ws['!ref'] = XLSX.utils.encode_range({ s: { c: 0, r: 0 }, e: { c: maxC, r: maxR } }); XLSX.utils.book_append_sheet(wb, ws, sanitizeSheetName(table.name, taken)); } const out = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer; return new Blob([out], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); } // ---- import ----------------------------------------------------------- export interface ImportedSheet { name: string; rows: number; cols: number; cells: Record; } export async function xlsxToSheets(buffer: ArrayBuffer): Promise { const XLSX = await loadXLSX(); const wb = XLSX.read(buffer, { type: 'array', cellFormula: true, cellNF: false }); const result: ImportedSheet[] = []; for (const name of wb.SheetNames) { const ws = wb.Sheets[name]; const cells: Record = {}; let rows = 1; let cols = 1; if (ws && ws['!ref']) { const range = XLSX.utils.decode_range(ws['!ref']); for (let r = range.s.r; r <= range.e.r; r++) { for (let c = range.s.c; c <= range.e.c; c++) { const cell = ws[XLSX.utils.encode_cell({ r, c })] as XLSXNS.CellObject | undefined; if (!cell) continue; let raw: string | null = null; if (cell.f) raw = fromExcelFormula(String(cell.f)); if (raw == null) { if (cell.v == null) continue; raw = cell.t === 'n' ? String(cell.v) : String(cell.w ?? cell.v); } if (raw === '') continue; cells[`${c},${r}`] = raw; rows = Math.max(rows, r + 1); cols = Math.max(cols, c + 1); } } } result.push({ name, rows: Math.max(rows, 20), cols: Math.max(cols, 8), cells }); } return result; } /** Wrap an imported worksheet as a canvas sheet holding one table object. */ export function importedToSheet(imported: ImportedSheet, idx: number): Sheet { const stamp = Date.now().toString(36); const table: TableObject = { kind: 'table', id: `xlsx-tbl-${stamp}-${idx}`, name: imported.name, x: 40, y: 40, rows: imported.rows, cols: imported.cols, colWidths: {}, cells: imported.cells, view: defaultTableView(), }; return { id: `xlsx-sheet-${stamp}-${idx}`, name: imported.name, objects: [table] }; }