import { isNumericColumnType } from "@/lib/dataGrid/dataGridColumnType"; export type XlsxCellValue = string | number | boolean | null | undefined; export interface XlsxWorksheetData { sheetName?: string; columns: readonly string[]; columnTypes?: readonly string[]; columnComments?: readonly (string | null)[] | undefined; rows: readonly (readonly XlsxCellValue[])[]; numericColumnRightAlign?: boolean; } interface XlsxWorksheetSegment { worksheet: XlsxWorksheetData; sheetName?: string; rowStart: number; rowEnd: number; } type ZipEntry = { path: string; data: Uint8Array; crc: number; localHeaderOffset: number; }; const encoder = new TextEncoder(); const CRC_TABLE = buildCrcTable(); const XLSX_MAX_DATA_ROWS = 1_048_575; function buildCrcTable(): number[] { const table: number[] = []; for (let i = 0; i < 256; i++) { let c = i; for (let j = 0; j < 8; j++) { c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; } table.push(c >>> 0); } return table; } function crc32(data: Uint8Array): number { let crc = 0xffffffff; for (const byte of data) { crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); } return (crc ^ 0xffffffff) >>> 0; } function escapeXml(value: string): string { return [...value] .filter((char) => { const code = char.charCodeAt(0); return code === 9 || code === 10 || code === 13 || code >= 32; }) .join("") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function columnName(index: number): string { let value = ""; let n = index + 1; while (n > 0) { const rem = (n - 1) % 26; value = String.fromCharCode(65 + rem) + value; n = Math.floor((n - 1) / 26); } return value; } function cellRef(rowIndex: number, colIndex: number): string { return `${columnName(colIndex)}${rowIndex + 1}`; } function sheetRange(columnCount: number, rowCount: number): string { if (columnCount === 0 || rowCount === 0) return "A1"; return `A1:${columnName(columnCount - 1)}${rowCount}`; } function normalizeSheetName(value?: string): string { const invalidChars = new Set(["[", "]", ":", "*", "?", "/", "\\"]); const name = [...(value || "Sheet1")] .map((char) => (invalidChars.has(char) ? " " : char)) .join("") .trim() || "Sheet1"; return name.slice(0, 31); } function appendSheetSuffix(base: string, suffix: number): string { const suffixText = ` (${suffix})`; const maxBaseLength = Math.max(0, 31 - [...suffixText].length); return `${[...base].slice(0, maxBaseLength).join("")}${suffixText}`; } function normalizeUniqueSheetNames(sheets: readonly { sheetName?: string }[]): string[] { const names: string[] = []; sheets.forEach((sheet, index) => { const base = normalizeSheetName(sheet.sheetName || `Sheet${index + 1}`); let candidate = base; let suffix = 2; while (names.includes(candidate)) { candidate = appendSheetSuffix(base, suffix); suffix += 1; } names.push(candidate); }); return names; } function estimateColumnWidths(columns: readonly string[], rows: readonly (readonly XlsxCellValue[])[], rowStart: number, rowEnd: number, columnComments?: readonly (string | null)[]): number[] { return columns.map((column, colIndex) => { const headerText = columnComments?.[colIndex] || column; const values = Array.from({ length: Math.min(100, rowEnd - rowStart) }, (_, index) => rows[rowStart + index]?.[colIndex]); const maxLen = [headerText, ...values.map((value) => (value == null ? "" : String(value)))].map((value) => Math.min(value.length, 60)).reduce((max, length) => Math.max(max, length), 8); return Math.max(10, Math.min(60, maxLen + 2)); }); } function safeExcelNumber(value: string): string | undefined { const trimmed = value.trim(); if (!trimmed || !Number.isFinite(Number(trimmed))) return undefined; const [integerPart, fractionPart = ""] = trimmed.split(/[eE]/, 1)[0].split(".", 2); const significantDigits = `${integerPart}${fractionPart.replace(/0+$/, "")}`.replace(/\D/g, "").replace(/^0+/, "").length; return significantDigits <= 15 ? trimmed : undefined; } const NUMERIC_RIGHT_ALIGN_STYLE_INDEX = 2; const NUMERIC_LEFT_ALIGN_STYLE_INDEX = 3; function numericColumnStyle(columnType?: string, enabled = true): number | undefined { if (!isNumericColumnType(columnType)) return undefined; return enabled ? NUMERIC_RIGHT_ALIGN_STYLE_INDEX : NUMERIC_LEFT_ALIGN_STYLE_INDEX; } function cellXml(value: XlsxCellValue, rowIndex: number, colIndex: number, style?: number, columnType?: string): string { const ref = cellRef(rowIndex, colIndex); const styleAttr = style == null ? "" : ` s="${style}"`; if (value == null) return ``; if (typeof value === "number" && Number.isFinite(value)) { return `${value}`; } if (typeof value === "boolean") { return `${value ? 1 : 0}`; } if (typeof value === "string" && isNumericColumnType(columnType)) { // Excel keeps only 15 significant digits, so preserve higher-precision // database values as text rather than silently rounding them. const number = safeExcelNumber(value); if (number !== undefined) return `${number}`; } return `${escapeXml(String(value))}`; } function worksheetXml(segment: XlsxWorksheetSegment): string { const data = segment.worksheet; const columns = data.columns; const rows = data.rows; const totalRows = segment.rowEnd - segment.rowStart + 1; const range = sheetRange(columns.length, totalRows); const widths = estimateColumnWidths(columns, rows, segment.rowStart, segment.rowEnd, data.columnComments); const rightAlignEnabled = data.numericColumnRightAlign !== false; const colsXml = widths.map((width, index) => ``).join(""); const headerXml = `${columns.map((column, index) => cellXml(data.columnComments?.[index] || column, 0, index, 1)).join("")}`; const bodyXml = Array.from({ length: segment.rowEnd - segment.rowStart }, (_, rowIndex) => { const row = rows[segment.rowStart + rowIndex]!; const excelRowIndex = rowIndex + 2; const cells = columns.map((_, colIndex) => cellXml(row[colIndex], excelRowIndex - 1, colIndex, numericColumnStyle(data.columnTypes?.[colIndex], rightAlignEnabled), data.columnTypes?.[colIndex])).join(""); return `${cells}`; }).join(""); return ` ${colsXml} ${headerXml}${bodyXml} `; } function splitWorksheetsForMaxRows(sheets: readonly XlsxWorksheetData[], maxDataRowsPerSheet: number): XlsxWorksheetSegment[] { const maxRows = Number.isFinite(maxDataRowsPerSheet) ? Math.max(1, Math.floor(maxDataRowsPerSheet)) : XLSX_MAX_DATA_ROWS; const segments: XlsxWorksheetSegment[] = []; for (const worksheet of sheets) { if (worksheet.rows.length <= maxRows) { segments.push({ worksheet, sheetName: worksheet.sheetName, rowStart: 0, rowEnd: worksheet.rows.length }); continue; } const baseName = normalizeSheetName(worksheet.sheetName); for (let rowStart = 0, chunkIndex = 0; rowStart < worksheet.rows.length; rowStart += maxRows, chunkIndex += 1) { segments.push({ worksheet, sheetName: chunkIndex === 0 ? baseName : appendSheetSuffix(baseName, chunkIndex + 1), rowStart, rowEnd: Math.min(rowStart + maxRows, worksheet.rows.length), }); } } return segments; } function contentTypesXml(sheetCount = 1): string { const worksheetOverrides = Array.from({ length: sheetCount }, (_, index) => ` `).join("\n"); return ` ${worksheetOverrides} `; } function rootRelsXml(): string { return ` `; } function workbookXml(sheetNames: readonly string[]): string { const sheets = sheetNames.map((sheetName, index) => ``).join(""); return ` ${sheets} `; } function workbookRelsXml(sheetCount = 1): string { const worksheetRels = Array.from({ length: sheetCount }, (_, index) => ` `).join("\n"); return ` ${worksheetRels} `; } function stylesXml(): string { return ` `; } function uint16(value: number): Uint8Array { const bytes = new Uint8Array(2); const view = new DataView(bytes.buffer); view.setUint16(0, value, true); return bytes; } function uint32(value: number): Uint8Array { const bytes = new Uint8Array(4); const view = new DataView(bytes.buffer); view.setUint32(0, value >>> 0, true); return bytes; } function concatBytes(parts: Uint8Array[]): Uint8Array { const total = parts.reduce((sum, part) => sum + part.length, 0); const out = new Uint8Array(total); let offset = 0; for (const part of parts) { out.set(part, offset); offset += part.length; } return out; } function createZip(files: Array<{ path: string; content: string }>): Uint8Array { const entries: ZipEntry[] = []; const localParts: Uint8Array[] = []; let offset = 0; for (const file of files) { const path = encoder.encode(file.path); const data = encoder.encode(file.content); const crc = crc32(data); const localHeader = concatBytes([uint32(0x04034b50), uint16(20), uint16(0), uint16(0), uint16(0), uint16(0), uint32(crc), uint32(data.length), uint32(data.length), uint16(path.length), uint16(0), path]); entries.push({ path: file.path, data, crc, localHeaderOffset: offset }); localParts.push(localHeader, data); offset += localHeader.length + data.length; } const centralParts: Uint8Array[] = []; for (const entry of entries) { const path = encoder.encode(entry.path); centralParts.push( concatBytes([uint32(0x02014b50), uint16(20), uint16(20), uint16(0), uint16(0), uint16(0), uint16(0), uint32(entry.crc), uint32(entry.data.length), uint32(entry.data.length), uint16(path.length), uint16(0), uint16(0), uint16(0), uint16(0), uint32(0), uint32(entry.localHeaderOffset), path]), ); } const central = concatBytes(centralParts); const end = concatBytes([uint32(0x06054b50), uint16(0), uint16(0), uint16(entries.length), uint16(entries.length), uint32(central.length), uint32(offset), uint16(0)]); return concatBytes([...localParts, central, end]); } export function buildXlsxWorkbook(data: XlsxWorksheetData): Uint8Array { return buildXlsxWorkbookMulti([data]); } export function buildXlsxWorkbookMulti(sheets: readonly XlsxWorksheetData[]): Uint8Array { return buildXlsxWorkbookMultiWithMaxRows(sheets, XLSX_MAX_DATA_ROWS); } export function buildXlsxWorkbookMultiWithMaxRows(sheets: readonly XlsxWorksheetData[], maxDataRowsPerSheet: number): Uint8Array { if (sheets.length === 0) throw new Error("At least one worksheet is required"); const segments = splitWorksheetsForMaxRows(sheets, maxDataRowsPerSheet); const sheetNames = normalizeUniqueSheetNames(segments); return createZip([ { path: "[Content_Types].xml", content: contentTypesXml(segments.length) }, { path: "_rels/.rels", content: rootRelsXml() }, { path: "xl/workbook.xml", content: workbookXml(sheetNames) }, { path: "xl/_rels/workbook.xml.rels", content: workbookRelsXml(segments.length) }, { path: "xl/styles.xml", content: stylesXml() }, ...segments.map((segment, index) => ({ path: `xl/worksheets/sheet${index + 1}.xml`, content: worksheetXml(segment) })), ]); }