/** * CSV import/export -- hand-written, no library. * * Export follows RFC 4180: fields containing the delimiter, a double quote, CR * or LF are wrapped in double quotes and inner quotes are doubled. Line ending * is CRLF, optional UTF-8 BOM for Excel. Delimiter is "," or ";". * * Import: a small state-machine parser that understands quoted fields with * embedded delimiters / newlines, plus delimiter sniffing and de/en locale * number normalisation. A leading "=" makes the cell a formula. */ import type { DocumentStore } from '../store/document'; import { rawString } from '../store/format'; export type Delimiter = ',' | ';' | '\t'; export interface CsvExportOptions { delimiter: Delimiter; bom: boolean; crlf: boolean; } // ---- export -------------------------------------------------------------- function needsQuoting(field: string, delimiter: string): boolean { return ( field.includes(delimiter) || field.includes('"') || field.includes('\n') || field.includes('\r') ); } function quoteField(field: string, delimiter: string): string { if (!needsQuoting(field, delimiter)) return field; return `"${field.replace(/"/g, '""')}"`; } /** Bounding box of the used area of a sheet. */ function usedBounds(cells: Record): { rows: number; cols: number } { let rows = 0; let cols = 0; for (const key of Object.keys(cells)) { const [c, r] = key.split(',').map(Number); if (c + 1 > cols) cols = c + 1; if (r + 1 > rows) rows = r + 1; } return { rows, cols }; } export function tableToCsv( store: DocumentStore, tableId: string, opts: CsvExportOptions, ): string { const table = store.tableById(tableId); if (!table) return ''; const bounds = usedBounds(table.cells); const rows = Math.max(bounds.rows, 1); const cols = Math.max(bounds.cols, 1); const eol = opts.crlf ? '\r\n' : '\n'; const lines: string[] = []; for (let r = 0; r < rows; r++) { const fields: string[] = []; for (let c = 0; c < cols; c++) { const value = rawString(store.cellValue(tableId, c, r)); fields.push(quoteField(value, opts.delimiter)); } lines.push(fields.join(opts.delimiter)); } return (opts.bom ? '' : '') + lines.join(eol) + eol; } // ---- import -------------------------------------------------------------- export function sniffDelimiter(text: string): Delimiter { const firstLine = text.slice(0, text.search(/\r?\n/) >>> 0 || text.length); const counts: Record = { ',': 0, ';': 0, '\t': 0 }; let inQuotes = false; for (const ch of firstLine) { if (ch === '"') inQuotes = !inQuotes; else if (!inQuotes && (ch === ',' || ch === ';' || ch === '\t')) counts[ch]++; } const best = (Object.keys(counts) as Delimiter[]).sort( (a, b) => counts[b] - counts[a], )[0]; return counts[best] > 0 ? best : ','; } /** RFC-4180-ish parser. Returns a matrix of raw string cells. */ export function parseCsv(text: string, delimiter: Delimiter): string[][] { const rows: string[][] = []; let row: string[] = []; let field = ''; let inQuotes = false; let i = 0; const n = text.length; // strip BOM if (text.charCodeAt(0) === 0xfeff) i = 1; const endField = () => { row.push(field); field = ''; }; const endRow = () => { endField(); rows.push(row); row = []; }; while (i < n) { const ch = text[i]; if (inQuotes) { if (ch === '"') { if (text[i + 1] === '"') { field += '"'; i += 2; continue; } inQuotes = false; i++; continue; } field += ch; i++; continue; } if (ch === '"') { inQuotes = true; i++; continue; } if (ch === delimiter) { endField(); i++; continue; } if (ch === '\r') { if (text[i + 1] === '\n') i++; endRow(); i++; continue; } if (ch === '\n') { endRow(); i++; continue; } field += ch; i++; } // trailing field / row (unless the file ended exactly on a newline) if (field !== '' || row.length > 0) endRow(); return rows; } const DE_NUMBER = /^-?\d{1,3}(\.\d{3})+(,\d+)?$|^-?\d+,\d+$/; const EN_NUMBER = /^-?\d{1,3}(,\d{3})+(\.\d+)?$|^-?\d+\.\d+$/; /** Normalise a locale-formatted number to a plain JS number literal string. */ export function normalizeNumber(value: string): string { const v = value.trim(); if (v === '' || v.startsWith('=')) return value; if (/^-?\d+$/.test(v)) return v; if (DE_NUMBER.test(v)) return v.replace(/\./g, '').replace(',', '.'); if (EN_NUMBER.test(v)) return v.replace(/,/g, ''); return value; } export interface CsvToCellsResult { cells: Record; rows: number; cols: number; } export function matrixToCells( matrix: string[][], detectNumbers = true, ): CsvToCellsResult { const cells: Record = {}; let cols = 0; matrix.forEach((line, r) => { if (line.length > cols) cols = line.length; line.forEach((cell, c) => { let raw = cell; if (detectNumbers) raw = normalizeNumber(raw); if (raw !== '') cells[`${c},${r}`] = raw; }); }); return { cells, rows: matrix.length, cols: Math.max(cols, 1) }; }