import { inflateRawSync } from 'node:zlib'; export const MAX_IMPORT_FILE_BYTES = 5 * 1024 * 1024; export const MAX_IMPORT_ROWS = 2_000; export const MAX_IMPORT_COLUMNS = 100; export const MAX_IMPORT_CELL_CHARS = 10_000; const MAX_XLSX_ENTRIES = 256; const MAX_XLSX_UNCOMPRESSED_BYTES = 20 * 1024 * 1024; export interface ParsedTable { fileName: string; sheetName: string | null; headers: string[]; rows: string[][]; warnings: string[]; } export function parseTabularFile(input: { fileName: string; mimeType?: string; bytes: Uint8Array; }): ParsedTable { if (input.bytes.byteLength === 0) throw new Error('The selected file is empty.'); if (input.bytes.byteLength > MAX_IMPORT_FILE_BYTES) { throw new Error('Import files may not exceed 5 MB.'); } const fileName = input.fileName.trim().slice(0, 255); const lowerName = fileName.toLowerCase(); if (lowerName.endsWith('.csv') || input.mimeType === 'text/csv') { const text = new TextDecoder('utf-8', { fatal: true }).decode(input.bytes); const { headers, rows, warnings } = parseCsv(text); return { fileName, sheetName: null, headers, rows, warnings }; } if ( lowerName.endsWith('.xlsx') || input.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) { const { sheetName, headers, rows, warnings } = parseXlsx(input.bytes); return { fileName, sheetName, headers, rows, warnings }; } throw new Error('Use a UTF-8 CSV or .xlsx workbook. Legacy .xls files are not supported.'); } export function parseCsv(source: string): Omit { if (source.includes('\0')) throw new Error('The CSV contains invalid null bytes.'); const table: string[][] = []; let row: string[] = []; let field = ''; let quoted = false; const pushField = () => { if (field.length > MAX_IMPORT_CELL_CHARS) throw new Error('A cell exceeds 10,000 characters.'); row.push(field); field = ''; if (row.length > MAX_IMPORT_COLUMNS) throw new Error('Imports may not exceed 100 columns.'); }; const pushRow = () => { pushField(); table.push(row); row = []; if (table.length > MAX_IMPORT_ROWS + 1) throw new Error('Imports may not exceed 2,000 data rows.'); }; const text = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source; for (let index = 0; index < text.length; index += 1) { const character = text[index]!; if (quoted) { if (character === '"') { if (text[index + 1] === '"') { field += '"'; index += 1; } else { quoted = false; } } else { field += character; } continue; } if (character === '"' && field.length === 0) quoted = true; else if (character === ',') pushField(); else if (character === '\n') pushRow(); else if (character === '\r' && text[index + 1] === '\n') continue; else if (character === '\r') pushRow(); else field += character; } if (quoted) throw new Error('The CSV ends inside a quoted cell.'); if (field.length > 0 || row.length > 0) pushRow(); return normaliseTabularRows(table, []); } function parseXlsx(bytes: Uint8Array): { sheetName: string; headers: string[]; rows: string[][]; warnings: string[]; } { const entries = readZip(bytes); const workbook = readXml(entries, 'xl/workbook.xml'); const relationships = readXml(entries, 'xl/_rels/workbook.xml.rels'); rejectActiveXml(workbook); rejectActiveXml(relationships); const sheets = [...workbook.matchAll(/]*>/gi)] .map((match) => ({ name: xmlAttribute(match[0], 'name'), relationshipId: xmlAttribute(match[0], 'r:id'), hidden: ['hidden', 'veryHidden'].includes(xmlAttribute(match[0], 'state') ?? ''), })) .filter((sheet) => sheet.name && sheet.relationshipId && !sheet.hidden); const firstSheet = sheets[0]; if (!firstSheet?.name || !firstSheet.relationshipId) { throw new Error('The workbook has no visible worksheet.'); } const relationship = [...relationships.matchAll(/]*>/gi)] .map((match) => match[0]) .find((tag) => xmlAttribute(tag, 'Id') === firstSheet.relationshipId); const target = relationship ? xmlAttribute(relationship, 'Target') : null; if (!target) throw new Error('The workbook worksheet relationship is invalid.'); const sheetPath = normaliseZipPath(target.startsWith('/') ? target.slice(1) : `xl/${target}`); const sharedStrings = entries.has('xl/sharedStrings.xml') ? parseSharedStrings(readXml(entries, 'xl/sharedStrings.xml')) : []; const worksheet = readXml(entries, sheetPath); const parsed = parseWorksheetXml(worksheet, sharedStrings); return { sheetName: decodeXml(firstSheet.name), ...parsed }; } export function parseWorksheetXml( worksheet: string, sharedStrings: readonly string[] = [], ): Omit { rejectActiveXml(worksheet); const table: string[][] = []; const warnings: string[] = []; let sawFormula = false; for (const rowMatch of worksheet.matchAll(/]*>([\s\S]*?)<\/row>/gi)) { const row: string[] = []; let sequentialColumn = 0; for (const cellMatch of rowMatch[1]!.matchAll(/]*)>([\s\S]*?)<\/c>/gi)) { const attributes = cellMatch[1]!; const body = cellMatch[2]!; const reference = xmlAttribute(attributes, 'r'); const column = reference ? columnFromReference(reference) : sequentialColumn; if (column >= MAX_IMPORT_COLUMNS) throw new Error('Imports may not exceed 100 columns.'); const type = xmlAttribute(attributes, 't') ?? 'n'; const rawValue = body.match(/]*>([\s\S]*?)<\/v>/i)?.[1] ?? ''; if (/ MAX_IMPORT_CELL_CHARS) throw new Error('A cell exceeds 10,000 characters.'); row[column] = value; sequentialColumn = column + 1; } if (row.some((value) => value !== undefined && value !== '')) table.push(row); if (table.length > MAX_IMPORT_ROWS + 1) throw new Error('Imports may not exceed 2,000 data rows.'); } if (sawFormula) { warnings.push('Formula cells were not executed; only cached values stored in the workbook were read.'); } return normaliseTabularRows(table, warnings); } export function normaliseTabularRows( table: string[][], warnings: string[], ): Omit { while (table.length > 0 && table.at(-1)!.every((cell) => !cell?.trim())) table.pop(); const headerRow = table.shift(); if (!headerRow) throw new Error('The file has no header row.'); let width = headerRow.length; while (width > 0 && !headerRow[width - 1]?.trim()) width -= 1; if (width === 0) throw new Error('The file has no named columns.'); const headers = headerRow.slice(0, width).map((header) => header.trim()); if (headers.some((header) => !header)) throw new Error('Every imported column needs a header.'); const normalised = headers.map((header) => header.toLocaleLowerCase()); if (new Set(normalised).size !== normalised.length) { throw new Error('Column headers must be unique, ignoring letter case.'); } const rows = table .map((sourceRow) => Array.from({ length: width }, (_, index) => sourceRow[index] ?? '')) .filter((sourceRow) => sourceRow.some((cell) => cell.trim() !== '')); if (rows.length === 0) throw new Error('The file has headers but no data rows.'); if (rows.length > MAX_IMPORT_ROWS) throw new Error('Imports may not exceed 2,000 data rows.'); if (rows.some((sourceRow) => sourceRow.some((cell) => /^[=+@]/.test(cell.trim())))) { warnings.push('Formula-like text from the source remains inert text and is never executed.'); } return { headers, rows, warnings }; } function readZip(bytes: Uint8Array): Map { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); let end = -1; for (let offset = bytes.byteLength - 22; offset >= Math.max(0, bytes.byteLength - 65_557); offset -= 1) { if (view.getUint32(offset, true) === 0x06054b50) { end = offset; break; } } if (end < 0) throw new Error('The .xlsx ZIP directory is invalid.'); const count = view.getUint16(end + 10, true); const centralOffset = view.getUint32(end + 16, true); if (count > MAX_XLSX_ENTRIES || count === 0xffff || centralOffset === 0xffffffff) { throw new Error('The workbook archive is too large or uses unsupported ZIP64 metadata.'); } const entries = new Map(); let offset = centralOffset; let totalUncompressed = 0; for (let index = 0; index < count; index += 1) { if (offset + 46 > bytes.byteLength || view.getUint32(offset, true) !== 0x02014b50) { throw new Error('The .xlsx ZIP directory is corrupt.'); } const flags = view.getUint16(offset + 8, true); const method = view.getUint16(offset + 10, true); const compressedSize = view.getUint32(offset + 20, true); const uncompressedSize = view.getUint32(offset + 24, true); const nameLength = view.getUint16(offset + 28, true); const extraLength = view.getUint16(offset + 30, true); const commentLength = view.getUint16(offset + 32, true); const localOffset = view.getUint32(offset + 42, true); const name = new TextDecoder().decode(bytes.subarray(offset + 46, offset + 46 + nameLength)); const safeName = normaliseZipPath(name); if ((flags & 1) !== 0) throw new Error('Encrypted workbooks are not supported.'); if (method !== 0 && method !== 8) throw new Error('The workbook uses unsupported ZIP compression.'); totalUncompressed += uncompressedSize; if (totalUncompressed > MAX_XLSX_UNCOMPRESSED_BYTES) { throw new Error('The expanded workbook may not exceed 20 MB.'); } if (localOffset + 30 > bytes.byteLength || view.getUint32(localOffset, true) !== 0x04034b50) { throw new Error('The workbook contains an invalid ZIP entry.'); } const localNameLength = view.getUint16(localOffset + 26, true); const localExtraLength = view.getUint16(localOffset + 28, true); const dataOffset = localOffset + 30 + localNameLength + localExtraLength; const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize); if (compressed.byteLength !== compressedSize) throw new Error('The workbook ZIP entry is truncated.'); const output = method === 0 ? Uint8Array.from(compressed) : inflateRawSync(compressed, { maxOutputLength: MAX_XLSX_UNCOMPRESSED_BYTES }); if (output.byteLength !== uncompressedSize) throw new Error('The workbook ZIP entry size is inconsistent.'); entries.set(safeName, output); offset += 46 + nameLength + extraLength + commentLength; } return entries; } function readXml(entries: Map, name: string): string { const bytes = entries.get(name); if (!bytes) throw new Error(`The workbook is missing ${name}.`); return new TextDecoder('utf-8', { fatal: true }).decode(bytes); } function rejectActiveXml(xml: string): void { if (/]*>([\s\S]*?)<\/si>/gi)].map((match) => extractTextNodes(match[1]!), ); } function extractTextNodes(xml: string): string { return [...xml.matchAll(/]*>([\s\S]*?)<\/t>/gi)] .map((match) => decodeXml(match[1]!)) .join(''); } function decodeXml(value: string): string { return value.replace(/&(?:#x[\da-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (entity) => { if (entity === '&') return '&'; if (entity === '<') return '<'; if (entity === '>') return '>'; if (entity === '"') return '"'; if (entity === ''') return "'"; const numeric = entity.startsWith('&#x') ? Number.parseInt(entity.slice(3, -1), 16) : Number.parseInt(entity.slice(2, -1), 10); return Number.isFinite(numeric) ? String.fromCodePoint(numeric) : entity; }); } function columnFromReference(reference: string): number { const letters = reference.match(/^[A-Za-z]+/)?.[0]; if (!letters) throw new Error('The worksheet contains an invalid cell reference.'); let column = 0; for (const letter of letters.toUpperCase()) column = column * 26 + letter.charCodeAt(0) - 64; return column - 1; }