import * as XLSX from 'xlsx-js-style'; export type ExcelCellValue = string | number | boolean | Date | null | undefined; export interface StyledExcelColumn { title: string; key?: string; width?: number; minWidth?: number; maxWidth?: number; align?: 'left' | 'center' | 'right'; numFmt?: string; render: (row: T, rowIndex: number) => ExcelCellValue; } export interface StyledExcelExportOptions { filename: string; sheetName: string; title?: string; metadataRows?: ExcelCellValue[][]; summaryRows?: ExcelCellValue[][]; columns: StyledExcelColumn[]; rows: T[]; } const BLACK_BORDER = { top: { style: 'thin', color: { rgb: '000000' } }, right: { style: 'thin', color: { rgb: '000000' } }, bottom: { style: 'thin', color: { rgb: '000000' } }, left: { style: 'thin', color: { rgb: '000000' } }, }; const BASE_ALIGNMENT = { vertical: 'center', wrapText: true, }; const TITLE_STYLE = { font: { bold: true, sz: 16, color: { rgb: 'FFFFFF' } }, fill: { fgColor: { rgb: '111827' } }, alignment: { ...BASE_ALIGNMENT, horizontal: 'center' }, border: BLACK_BORDER, }; const SECTION_LABEL_STYLE = { font: { bold: true, color: { rgb: '111827' } }, fill: { fgColor: { rgb: 'F3F4F6' } }, alignment: { ...BASE_ALIGNMENT, horizontal: 'center' }, border: BLACK_BORDER, }; const SECTION_VALUE_STYLE = { font: { color: { rgb: '111827' } }, fill: { fgColor: { rgb: 'FFFFFF' } }, alignment: { ...BASE_ALIGNMENT, horizontal: 'left' }, border: BLACK_BORDER, }; const HEADER_STYLE = { font: { bold: true, color: { rgb: 'FFFFFF' } }, fill: { fgColor: { rgb: '374151' } }, alignment: { ...BASE_ALIGNMENT, horizontal: 'center' }, border: BLACK_BORDER, }; const BODY_STYLE = { font: { color: { rgb: '111827' } }, fill: { fgColor: { rgb: 'FFFFFF' } }, alignment: { ...BASE_ALIGNMENT, horizontal: 'left' }, border: BLACK_BORDER, }; function normalizeCellValue(value: ExcelCellValue): ExcelCellValue { if (value === null || value === undefined) return ''; return value; } function visualLength(value: ExcelCellValue): number { if (value === null || value === undefined) return 0; const text = value instanceof Date ? value.toISOString() : String(value); const lines = text.split(/\r?\n/); return Math.max(...lines.map((line) => Array.from(line).reduce((len, ch) => len + (ch.charCodeAt(0) > 255 ? 2 : 1), 0)), 0); } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function estimateColumnWidths(rows: ExcelCellValue[][], columns: StyledExcelColumn[]): { wch: number }[] { const totalColumns = Math.max(columns.length, ...rows.map((row) => row.length)); return Array.from({ length: totalColumns }, (_, index) => { const config = columns[index]; if (config?.width) return { wch: config.width }; const maxLength = rows.reduce((max, row) => Math.max(max, visualLength(row[index])), visualLength(config?.title)); const minWidth = config?.minWidth ?? 10; const maxWidth = config?.maxWidth ?? 42; return { wch: clamp(Math.ceil(maxLength * 1.15) + 2, minWidth, maxWidth) }; }); } function estimateRowHeight(row: ExcelCellValue[], colWidths: { wch: number }[], baseHeight = 20): number { const maxLines = row.reduce((max, cell, index) => { const width = Math.max(8, colWidths[index]?.wch || 12); const text = cell === null || cell === undefined ? '' : String(cell); const explicitLines = text.split(/\r?\n/); const wrappedLines = explicitLines.reduce((sum, line) => sum + Math.max(1, Math.ceil(visualLength(line) / Math.max(8, width - 2))), 0); return Math.max(max, wrappedLines); }, 1); return clamp(baseHeight + (maxLines - 1) * 16, baseHeight, 120); } function applyCellStyle(cell: any, style: any, numFmt?: string): void { cell.s = { ...style, alignment: { ...style.alignment }, border: BLACK_BORDER, }; if (numFmt) cell.z = numFmt; } function isNumericCell(value: ExcelCellValue): boolean { return typeof value === 'number' && Number.isFinite(value); } export function exportStyledExcel(options: StyledExcelExportOptions): void { const { filename, sheetName, title, metadataRows = [], summaryRows = [], columns, rows } = options; const totalColumns = Math.max(1, columns.length); const aoa: ExcelCellValue[][] = []; let titleRowIndex = -1; let headerRowIndex = -1; if (title) { titleRowIndex = aoa.length; aoa.push([title, ...Array.from({ length: totalColumns - 1 }, () => '')]); } metadataRows.forEach((row) => aoa.push(row.map(normalizeCellValue))); summaryRows.forEach((row) => aoa.push(row.map(normalizeCellValue))); if (metadataRows.length || summaryRows.length) { aoa.push(Array.from({ length: totalColumns }, () => '')); } headerRowIndex = aoa.length; aoa.push(columns.map((column) => column.title)); rows.forEach((row, rowIndex) => { aoa.push(columns.map((column) => normalizeCellValue(column.render(row, rowIndex)))); }); const worksheet = XLSX.utils.aoa_to_sheet(aoa); const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1'); const colWidths = estimateColumnWidths(aoa, columns); worksheet['!cols'] = colWidths; worksheet['!rows'] = aoa.map((row, index) => { if (index === titleRowIndex) return { hpt: 34 }; if (index === headerRowIndex) return { hpt: 28 }; if (row.every((cell) => cell === '')) return { hpt: 10 }; return { hpt: estimateRowHeight(row, colWidths) }; }); if (titleRowIndex >= 0 && totalColumns > 1) { worksheet['!merges'] = worksheet['!merges'] || []; worksheet['!merges'].push({ s: { r: titleRowIndex, c: 0 }, e: { r: titleRowIndex, c: totalColumns - 1 }, }); } worksheet['!autofilter'] = { ref: XLSX.utils.encode_range({ s: { r: headerRowIndex, c: 0 }, e: { r: Math.max(headerRowIndex, aoa.length - 1), c: totalColumns - 1 }, }), }; for (let rowIndex = range.s.r; rowIndex <= range.e.r; rowIndex += 1) { for (let colIndex = range.s.c; colIndex <= range.e.c; colIndex += 1) { const address = XLSX.utils.encode_cell({ r: rowIndex, c: colIndex }); const cell = worksheet[address] || { t: 's', v: '' }; worksheet[address] = cell; if (rowIndex === titleRowIndex) { applyCellStyle(cell, TITLE_STYLE); continue; } if (rowIndex === headerRowIndex) { applyCellStyle(cell, HEADER_STYLE); continue; } if (rowIndex < headerRowIndex) { applyCellStyle(cell, colIndex === 0 ? SECTION_LABEL_STYLE : SECTION_VALUE_STYLE, isNumericCell(cell.v) ? '#,##0.00' : undefined); continue; } const column = columns[colIndex]; const align = column?.align || (isNumericCell(cell.v) ? 'right' : 'left'); applyCellStyle(cell, { ...BODY_STYLE, alignment: { ...BASE_ALIGNMENT, horizontal: align }, }, column?.numFmt || (isNumericCell(cell.v) ? '#,##0.00' : undefined)); } } const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); XLSX.writeFile(workbook, filename); }