* Parse CSV per RFC 4180. Handles: * - Quoted fields with embedded delimiter, embedded "", and embedded newlines * - CRLF, LF, CR line endings * - Optional BOM at file start * - Empty trailing fields (preserved)
(text: string, delimiter: string)
| 48 | * - Empty trailing fields (preserved) |
| 49 | */ |
| 50 | function parseCSV(text: string, delimiter: string): string[][] { |
| 51 | // Strip UTF-8 BOM if present |
| 52 | if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1); |
| 53 | |
| 54 | const rows: string[][] = []; |
| 55 | let row: string[] = []; |
| 56 | let field = ''; |
| 57 | let i = 0; |
| 58 | let inQuotes = false; |
| 59 | const len = text.length; |
| 60 | |
| 61 | while (i < len) { |
| 62 | const ch = text[i]!; |
| 63 | if (inQuotes) { |
| 64 | if (ch === '"') { |
| 65 | if (text[i + 1] === '"') { |
| 66 | field += '"'; |
| 67 | i += 2; |
| 68 | } else { |
| 69 | inQuotes = false; |
| 70 | i++; |
| 71 | } |
| 72 | } else { |
| 73 | field += ch; |
| 74 | i++; |
| 75 | } |
| 76 | } else { |
| 77 | if (ch === '"' && field === '') { |
| 78 | // Start of quoted field |
| 79 | inQuotes = true; |
| 80 | i++; |
| 81 | } else if (ch === delimiter) { |
| 82 | row.push(field); |
| 83 | field = ''; |
| 84 | i++; |
| 85 | } else if (ch === '\r' || ch === '\n') { |
| 86 | // End of row |
| 87 | row.push(field); |
| 88 | rows.push(row); |
| 89 | row = []; |
| 90 | field = ''; |
| 91 | if (ch === '\r' && text[i + 1] === '\n') i += 2; |
| 92 | else i++; |
| 93 | } else { |
| 94 | field += ch; |
| 95 | i++; |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | // Trailing record without newline |
| 100 | if (field !== '' || row.length > 0) { |
| 101 | row.push(field); |
| 102 | rows.push(row); |
| 103 | } |
| 104 | return rows; |
| 105 | } |
| 106 | |
| 107 | /** Quote a CSV field per RFC 4180 if it contains special chars. */ |