213 lines
7.0 KiB
TypeScript
213 lines
7.0 KiB
TypeScript
import * as XLSX from 'xlsx-js-style';
|
|
|
|
export type ExcelCellValue = string | number | boolean | Date | null | undefined;
|
|
|
|
export interface StyledExcelColumn<T = any> {
|
|
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<T = any> {
|
|
filename: string;
|
|
sheetName: string;
|
|
title?: string;
|
|
metadataRows?: ExcelCellValue[][];
|
|
summaryRows?: ExcelCellValue[][];
|
|
columns: StyledExcelColumn<T>[];
|
|
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<number>((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<number>((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<T>(options: StyledExcelExportOptions<T>): 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);
|
|
}
|