import * as XLSX from "xlsx-js-style"; import type { SheetData, FileUploadResult } from "../types/sheet"; import { analyzeSheetStyles } from "./styleTest"; /** * 파일 처리 관련 유틸리티 - xlsx-js-style 공식 API 활용 버전 * - 모든 파일 형식을 SheetJS를 통해 읽은 후 XLSX로 변환 * - 변환된 XLSX 파일을 LuckyExcel로 전달 * - xlsx-js-style의 공식 스타일 구조를 그대로 활용 */ // 지원되는 파일 타입 export const SUPPORTED_FILE_TYPES = { XLSX: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", XLS: "application/vnd.ms-excel", CSV: "text/csv", } as const; export const SUPPORTED_EXTENSIONS = [".xlsx", ".xls", ".csv"] as const; // 최대 파일 크기 (50MB) export const MAX_FILE_SIZE = 50 * 1024 * 1024; /** * xlsx-js-style 색상 객체를 Luckysheet 색상 문자열로 변환 * 공식 xlsx-js-style COLOR_STYLE 형식을 지원: rgb, theme, indexed */ function convertXlsxColorToLuckysheet(colorObj: any): string { if (!colorObj) return ""; // RGB 형태 - 공식 문서: {rgb: "FFCC00"} if (colorObj.rgb) { const rgb = colorObj.rgb.toUpperCase(); // ARGB 형태 (8자리) - 앞의 2자리(Alpha) 제거 if (rgb.length === 8) { const r = parseInt(rgb.substring(2, 4), 16); const g = parseInt(rgb.substring(4, 6), 16); const b = parseInt(rgb.substring(6, 8), 16); return `rgb(${r},${g},${b})`; } // RGB 형태 (6자리) else if (rgb.length === 6) { const r = parseInt(rgb.substring(0, 2), 16); const g = parseInt(rgb.substring(2, 4), 16); const b = parseInt(rgb.substring(4, 6), 16); return `rgb(${r},${g},${b})`; } } // Theme 색상 - 공식 문서: {theme: 4} 또는 {theme: 1, tint: 0.4} if (typeof colorObj.theme === "number") { // Excel 테마 색상 매핑 (공식 문서 예시 기반) const themeColors: { [key: number]: string } = { 0: "rgb(255,255,255)", // 배경 1 (흰색) 1: "rgb(0,0,0)", // 텍스트 1 (검정) 2: "rgb(238,236,225)", // 배경 2 (연회색) 3: "rgb(31,73,125)", // 텍스트 2 (어두운 파랑) 4: "rgb(79,129,189)", // 강조 1 (파랑) - 공식 문서 예시 5: "rgb(192,80,77)", // 강조 2 (빨강) 6: "rgb(155,187,89)", // 강조 3 (초록) 7: "rgb(128,100,162)", // 강조 4 (보라) 8: "rgb(75,172,198)", // 강조 5 (하늘색) 9: "rgb(247,150,70)", // 강조 6 (주황) }; let baseColor = themeColors[colorObj.theme] || "rgb(0,0,0)"; // Tint 적용 - 공식 문서: {theme: 1, tint: 0.4} ("Blue, Accent 1, Lighter 40%") if (typeof colorObj.tint === "number") { baseColor = applyTintToRgbColor(baseColor, colorObj.tint); } return baseColor; } // Indexed 색상 (Excel 기본 색상표) if (typeof colorObj.indexed === "number") { const indexedColors: { [key: number]: string } = { 0: "rgb(0,0,0)", // 검정 1: "rgb(255,255,255)", // 흰색 2: "rgb(255,0,0)", // 빨강 3: "rgb(0,255,0)", // 초록 4: "rgb(0,0,255)", // 파랑 5: "rgb(255,255,0)", // 노랑 6: "rgb(255,0,255)", // 마젠타 7: "rgb(0,255,255)", // 시안 8: "rgb(128,0,0)", // 어두운 빨강 9: "rgb(0,128,0)", // 어두운 초록 10: "rgb(0,0,128)", // 어두운 파랑 17: "rgb(128,128,128)", // 회색 }; return indexedColors[colorObj.indexed] || "rgb(0,0,0)"; } return ""; } /** * RGB 색상에 Excel tint 적용 */ function applyTintToRgbColor(rgbColor: string, tint: number): string { const match = rgbColor.match(/rgb\((\d+),(\d+),(\d+)\)/); if (!match) return rgbColor; const r = parseInt(match[1]); const g = parseInt(match[2]); const b = parseInt(match[3]); // Excel tint 공식 적용 const applyTint = (color: number, tint: number): number => { if (tint < 0) { return Math.round(color * (1 + tint)); } else { return Math.round(color * (1 - tint) + (255 - 255 * (1 - tint))); } }; const newR = Math.max(0, Math.min(255, applyTint(r, tint))); const newG = Math.max(0, Math.min(255, applyTint(g, tint))); const newB = Math.max(0, Math.min(255, applyTint(b, tint))); return `rgb(${newR},${newG},${newB})`; } /** * xlsx-js-style 테두리 스타일을 Luckysheet 번호로 변환 * 공식 문서의 BORDER_STYLE 값들을 지원 */ function convertBorderStyleToLuckysheet(borderStyle: string): number { const styleMap: { [key: string]: number } = { thin: 1, medium: 2, thick: 3, dotted: 4, dashed: 5, dashDot: 6, dashDotDot: 7, double: 8, hair: 1, mediumDashed: 5, mediumDashDot: 6, mediumDashDotDot: 7, slantDashDot: 6, }; return styleMap[borderStyle] || 1; } /** * xlsx-js-style 스타일 객체를 Luckysheet 스타일로 변환 * 공식 xlsx-js-style API 구조를 완전히 활용 */ function convertXlsxStyleToLuckysheet(xlsxStyle: any): any { if (!xlsxStyle) return {}; const luckyStyle: any = {}; // 🎨 폰트 스타일 변환 - 공식 문서 font 속성 if (xlsxStyle.font) { const font = xlsxStyle.font; // 폰트명 - 공식 문서: {name: "Courier"} if (font.name) { luckyStyle.ff = font.name; } // 폰트 크기 - 공식 문서: {sz: 24} if (font.sz) { luckyStyle.fs = font.sz; } // 굵게 - 공식 문서: {bold: true} if (font.bold) { luckyStyle.bl = 1; } // 기울임 - 공식 문서: {italic: true} if (font.italic) { luckyStyle.it = 1; } // 밑줄 - 공식 문서: {underline: true} if (font.underline) { luckyStyle.un = 1; } // 취소선 - 공식 문서: {strike: true} if (font.strike) { luckyStyle.st = 1; } // 폰트 색상 - 공식 문서: {color: {rgb: "FF0000"}} if (font.color) { const fontColor = convertXlsxColorToLuckysheet(font.color); if (fontColor) { luckyStyle.fc = fontColor; } } } // 🎨 배경 스타일 변환 - 공식 문서 fill 속성 if (xlsxStyle.fill) { const fill = xlsxStyle.fill; // 배경색 - 공식 문서: {fgColor: {rgb: "E9E9E9"}} if (fill.fgColor) { const bgColor = convertXlsxColorToLuckysheet(fill.fgColor); if (bgColor) { luckyStyle.bg = bgColor; } } // bgColor도 확인 (패턴 배경의 경우) else if (fill.bgColor) { const bgColor = convertXlsxColorToLuckysheet(fill.bgColor); if (bgColor) { luckyStyle.bg = bgColor; } } } // 🎨 정렬 스타일 변환 - 공식 문서 alignment 속성 if (xlsxStyle.alignment) { const alignment = xlsxStyle.alignment; // 수평 정렬 - 공식 문서: {horizontal: "center"} if (alignment.horizontal) { luckyStyle.ht = alignment.horizontal === "left" ? 1 : alignment.horizontal === "center" ? 2 : alignment.horizontal === "right" ? 3 : 1; } // 수직 정렬 - 공식 문서: {vertical: "center"} if (alignment.vertical) { luckyStyle.vt = alignment.vertical === "top" ? 1 : alignment.vertical === "center" ? 2 : alignment.vertical === "bottom" ? 3 : 2; } // 텍스트 줄바꿈 - 공식 문서: {wrapText: true} if (alignment.wrapText) { luckyStyle.tb = 1; } // 텍스트 회전 - 공식 문서: {textRotation: 90} if (alignment.textRotation) { luckyStyle.tr = alignment.textRotation; } } // 🎨 테두리 스타일 변환 - 공식 문서 border 속성 if (xlsxStyle.border) { const border = xlsxStyle.border; luckyStyle.bd = {}; // 상단 테두리 - 공식 문서: {top: {style: "thin", color: {rgb: "000000"}}} if (border.top) { luckyStyle.bd.t = { style: convertBorderStyleToLuckysheet(border.top.style || "thin"), color: convertXlsxColorToLuckysheet(border.top.color) || "rgb(0,0,0)", }; } // 하단 테두리 if (border.bottom) { luckyStyle.bd.b = { style: convertBorderStyleToLuckysheet(border.bottom.style || "thin"), color: convertXlsxColorToLuckysheet(border.bottom.color) || "rgb(0,0,0)", }; } // 좌측 테두리 if (border.left) { luckyStyle.bd.l = { style: convertBorderStyleToLuckysheet(border.left.style || "thin"), color: convertXlsxColorToLuckysheet(border.left.color) || "rgb(0,0,0)", }; } // 우측 테두리 if (border.right) { luckyStyle.bd.r = { style: convertBorderStyleToLuckysheet(border.right.style || "thin"), color: convertXlsxColorToLuckysheet(border.right.color) || "rgb(0,0,0)", }; } } // 🎨 숫자 포맷 변환 - 공식 문서 numFmt 속성 if (xlsxStyle.numFmt) { // numFmt는 문자열 또는 숫자일 수 있음 luckyStyle.ct = { fa: xlsxStyle.numFmt, t: "n", // 숫자 타입 }; } return luckyStyle; } /** * 파일 타입 검증 */ export function validateFileType(file: File): boolean { const fileName = file.name.toLowerCase(); const extension = fileName.split(".").pop(); const supportedExtensions = SUPPORTED_EXTENSIONS.map((ext) => ext.slice(1)); if (!extension) { return false; } return supportedExtensions.includes(extension); } /** * 파일 크기 검증 */ export function validateFileSize(file: File): boolean { return file.size <= MAX_FILE_SIZE; } /** * 파일 이름에서 확장자 제거 */ export function getFileNameWithoutExtension(fileName: string): string { return fileName.replace(/\.[^/.]+$/, ""); } /** * 에러 메시지 생성 */ export function getFileErrorMessage(file: File): string { if (!validateFileType(file)) { const fileName = file.name.toLowerCase(); const extension = fileName.split(".").pop(); if (!extension || extension === fileName) { return `파일 확장자가 없습니다. ${SUPPORTED_EXTENSIONS.join(", ")} 파일만 업로드 가능합니다.`; } return `지원되지 않는 파일 형식입니다. "${extension}" 대신 ${SUPPORTED_EXTENSIONS.join(", ")} 파일을 업로드해주세요.`; } if (!validateFileSize(file)) { const maxSizeMB = Math.round(MAX_FILE_SIZE / (1024 * 1024)); const currentSizeMB = (file.size / (1024 * 1024)).toFixed(2); return `파일 크기가 너무 큽니다. 현재 크기: ${currentSizeMB}MB, 최대 허용: ${maxSizeMB}MB`; } if (file.name.length > 255) { return "파일명이 너무 깁니다. 255자 이하의 파일명을 사용해주세요."; } const invalidChars = /[<>:"/\\|?*]/; if (invalidChars.test(file.name)) { return '파일명에 사용할 수 없는 특수문자가 포함되어 있습니다. (< > : " / \\ | ? *)'; } return ""; } /** * 한글 시트명을 안전하게 처리하는 함수 */ function sanitizeSheetName(sheetName: string): string { if (!sheetName || typeof sheetName !== "string") { return "Sheet1"; } const maxLength = 31; let sanitized = sheetName.trim(); if (sanitized.length > maxLength) { sanitized = sanitized.substring(0, maxLength - 3) + "..."; } sanitized = sanitized.replace(/[\\\/\*\?\[\]]/g, "_"); return sanitized || "Sheet"; } /** * 워크북 구조 검증 함수 */ function validateWorkbook(workbook: any): { isValid: boolean; error?: string } { if (!workbook) { return { isValid: false, error: "워크북이 null 또는 undefined입니다" }; } if (!workbook.SheetNames) { return { isValid: false, error: "워크북에 SheetNames 속성이 없습니다" }; } if (!Array.isArray(workbook.SheetNames)) { return { isValid: false, error: "SheetNames가 배열이 아닙니다" }; } if (workbook.SheetNames.length === 0) { return { isValid: false, error: "워크북에 시트가 없습니다" }; } return { isValid: true }; } /** * SheetJS 데이터를 LuckyExcel 형식으로 변환 */ function convertSheetJSToLuckyExcel(workbook: any): SheetData[] { console.log("🔄 SheetJS → LuckyExcel 형식 변환 시작..."); const luckySheets: SheetData[] = []; // 워크북 구조 검증 const validation = validateWorkbook(workbook); if (!validation.isValid) { console.error("❌ 워크북 검증 실패:", validation.error); throw new Error(`워크북 구조 오류: ${validation.error}`); } console.log(`📋 발견된 시트: ${workbook.SheetNames.join(", ")}`); workbook.SheetNames.forEach((sheetName: string, index: number) => { console.log( `📋 시트 ${index + 1}/${workbook.SheetNames.length} "${sheetName}" 변환 중...`, ); const safeSheetName = sanitizeSheetName(sheetName); const worksheet = workbook.Sheets[sheetName]; if (!worksheet) { console.warn( `⚠️ 시트 "${sheetName}"를 찾을 수 없습니다. 빈 시트로 생성합니다.`, ); luckySheets.push({ id: `sheet_${index}`, name: safeSheetName, data: [[""]], config: { container: `luckysheet_${index}`, title: safeSheetName, lang: "ko", data: [ { name: safeSheetName, index: index.toString(), celldata: [], status: 1, order: index, row: 100, column: 26, }, ], options: { showtoolbar: true, showinfobar: true, showsheetbar: true, showstatisticBar: true, allowCopy: true, allowEdit: true, enableAddRow: true, enableAddCol: true, }, }, }); return; } try { // 시트 범위 확인 const range = worksheet["!ref"]; if (!range) { console.warn( `⚠️ 시트 "${sheetName}"에 데이터 범위가 없습니다. 빈 시트로 처리합니다.`, ); luckySheets.push({ id: `sheet_${index}`, name: safeSheetName, data: [[""]], config: { container: `luckysheet_${index}`, title: safeSheetName, lang: "ko", data: [ { name: safeSheetName, index: index.toString(), celldata: [], status: 1, order: index, row: 100, column: 26, }, ], options: { showtoolbar: true, showinfobar: true, showsheetbar: true, showstatisticBar: true, allowCopy: true, allowEdit: true, enableAddRow: true, enableAddCol: true, }, }, }); return; } // 범위 파싱 const rangeObj = XLSX.utils.decode_range(range); const maxRow = rangeObj.e.r + 1; const maxCol = rangeObj.e.c + 1; console.log(`📐 시트 "${sheetName}" 크기: ${maxRow}행 x ${maxCol}열`); // 2D 배열로 데이터 변환 const data: any[][] = []; const cellData: any[] = []; // 데이터 배열 초기화 for (let row = 0; row < maxRow; row++) { data[row] = new Array(maxCol).fill(""); } // 셀 데이터 변환 for (let row = rangeObj.s.r; row <= rangeObj.e.r; row++) { for (let col = rangeObj.s.c; col <= rangeObj.e.c; col++) { const cellAddress = XLSX.utils.encode_cell({ r: row, c: col }); const cell = worksheet[cellAddress]; if ( cell && cell.v !== undefined && cell.v !== null && cell.v !== "" ) { let cellValue = cell.v; if (typeof cellValue === "string") { cellValue = cellValue.trim(); if (cellValue.length > 1000) { cellValue = cellValue.substring(0, 997) + "..."; } } // 2D 배열에 데이터 저장 data[row][col] = cellValue; // LuckyExcel celldata 형식으로 변환 const luckyCell: any = { r: row, c: col, v: { v: cellValue, m: cell.w || String(cellValue), // 포맷팅된 텍스트 우선 사용 ct: { fa: "General", t: "g" }, }, }; // 🎨 xlsx-js-style 스타일 정보 처리 if (cell.s) { console.log( `🎨 셀 ${cellAddress}에 스타일 정보 발견:`, JSON.stringify(cell.s, null, 2), ); const convertedStyle = convertXlsxStyleToLuckysheet(cell.s); console.log( `🎨 변환된 Luckysheet 스타일:`, JSON.stringify(convertedStyle, null, 2), ); luckyCell.v.s = convertedStyle; } // 셀 타입에 따른 추가 처리 if (cell.t === "s") { luckyCell.v.ct.t = "s"; } else if (cell.t === "n") { luckyCell.v.ct.t = "n"; // 숫자 포맷 처리 if (cell.z) { luckyCell.v.ct.fa = cell.z; } } else if (cell.t === "d") { luckyCell.v.ct.t = "d"; // 날짜 포맷 처리 if (cell.z) { luckyCell.v.ct.fa = cell.z; } } else if (cell.t === "b") { luckyCell.v.ct.t = "b"; } // 수식 처리 if (cell.f) { luckyCell.v.f = cell.f; } cellData.push(luckyCell); } } } // 🔗 병합 셀 정보 처리 const mergeData: any[] = []; if (worksheet["!merges"]) { worksheet["!merges"].forEach((merge: any) => { mergeData.push({ r: merge.s.r, // 시작 행 c: merge.s.c, // 시작 열 rs: merge.e.r - merge.s.r + 1, // 행 병합 수 cs: merge.e.c - merge.s.c + 1, // 열 병합 수 }); }); console.log( `🔗 시트 "${sheetName}"에서 ${mergeData.length}개 병합 셀 발견`, ); } // 📏 열 너비 정보 처리 const colhidden: { [key: number]: number } = {}; if (worksheet["!cols"]) { worksheet["!cols"].forEach((col: any, colIndex: number) => { if (col && col.hidden) { colhidden[colIndex] = 0; // 숨겨진 열 } else if (col && col.wpx) { // 픽셀 단위 너비가 있으면 기록 (Luckysheet에서 활용 가능) console.log(`📏 열 ${colIndex}: ${col.wpx}px`); } }); } // 📐 행 높이 정보 처리 const rowhidden: { [key: number]: number } = {}; if (worksheet["!rows"]) { worksheet["!rows"].forEach((row: any, rowIndex: number) => { if (row && row.hidden) { rowhidden[rowIndex] = 0; // 숨겨진 행 } else if (row && row.hpx) { // 픽셀 단위 높이가 있으면 기록 console.log(`📐 행 ${rowIndex}: ${row.hpx}px`); } }); } // SheetData 객체 생성 const sheetData: SheetData = { id: `sheet_${index}`, name: safeSheetName, data: data, config: { container: `luckysheet_${index}`, title: safeSheetName, lang: "ko", data: [ { name: safeSheetName, index: index.toString(), celldata: cellData, status: 1, order: index, row: maxRow, column: maxCol, // 🎨 xlsx-js-style로부터 추가된 스타일 정보들 ...(mergeData.length > 0 && { merge: mergeData }), // 병합 셀 ...(Object.keys(colhidden).length > 0 && { colhidden }), // 숨겨진 열 ...(Object.keys(rowhidden).length > 0 && { rowhidden }), // 숨겨진 행 }, ], options: { showtoolbar: true, showinfobar: true, showsheetbar: true, showstatisticBar: true, allowCopy: true, allowEdit: true, enableAddRow: true, enableAddCol: true, }, }, }; luckySheets.push(sheetData); console.log(`✅ 시트 "${sheetName}" 변환 완료: ${cellData.length}개 셀`); } catch (sheetError) { console.error(`❌ 시트 "${sheetName}" 변환 중 오류:`, sheetError); // 오류 발생 시 빈 시트로 생성 luckySheets.push({ id: `sheet_${index}`, name: safeSheetName, data: [[""]], config: { container: `luckysheet_${index}`, title: safeSheetName, lang: "ko", data: [ { name: safeSheetName, index: index.toString(), celldata: [], status: 1, order: index, row: 100, column: 26, }, ], options: { showtoolbar: true, showinfobar: true, showsheetbar: true, showstatisticBar: true, allowCopy: true, allowEdit: true, enableAddRow: true, enableAddCol: true, }, }, }); } }); // 최소 1개 시트는 보장 if (luckySheets.length === 0) { console.log("📄 시트가 없어서 기본 시트를 생성합니다."); luckySheets.push({ id: "sheet_0", name: "Sheet1", data: [[""]], config: { container: "luckysheet_0", title: "Sheet1", lang: "ko", data: [ { name: "Sheet1", index: "0", celldata: [], status: 1, order: 0, row: 100, column: 26, }, ], options: { showtoolbar: true, showinfobar: false, showsheetbar: true, showstatisticBar: false, allowCopy: true, allowEdit: true, enableAddRow: true, enableAddCol: true, }, }, }); } console.log( `🎉 SheetJS → LuckyExcel 변환 완료: ${luckySheets.length}개 시트`, ); return luckySheets; } /** * SheetJS로 파일을 읽고 XLSX로 변환한 뒤 LuckyExcel로 처리 */ async function processFileWithSheetJSToXLSX( file: File, ): Promise<{ sheets: SheetData[]; xlsxBuffer: ArrayBuffer }> { console.log("📊 SheetJS → XLSX → LuckyExcel 파이프라인 시작..."); const arrayBuffer = await file.arrayBuffer(); const fileName = file.name.toLowerCase(); const isCSV = fileName.endsWith(".csv"); const isXLS = fileName.endsWith(".xls"); // const isXLSX = fileName.endsWith(".xlsx"); // 1단계: SheetJS로 파일 읽기 let workbook: any; try { if (isCSV) { // CSV 파일 처리 - UTF-8 디코딩 후 읽기 console.log("📄 CSV 파일을 SheetJS로 읽는 중..."); const text = new TextDecoder("utf-8").decode(arrayBuffer); workbook = XLSX.read(text, { type: "string", codepage: 65001, // UTF-8 raw: false, }); } else { // XLS/XLSX 파일 처리 - 스타일 정보 강제 추출 옵션 console.log(`📊 ${isXLS ? "XLS" : "XLSX"} 파일을 SheetJS로 읽는 중...`); workbook = XLSX.read(arrayBuffer, { cellStyles: true, // 스타일 정보 보존 cellNF: true, // 숫자 형식 보존 (스타일의 일부) bookProps: true, // 문서 속성 보존 (스타일 정보 포함 가능) WTF: true, // 더 관대한 파싱 }); } } catch (readError) { console.error("❌ SheetJS 파일 읽기 실패:", readError); throw new Error( `파일을 읽을 수 없습니다: ${readError instanceof Error ? readError.message : readError}`, ); } // 파일 버퍼 크기 검증 if (arrayBuffer.byteLength === 0) { throw new Error("파일이 비어있습니다."); } // 워크북 null 체크 if (!workbook) { throw new Error("워크북을 생성할 수 없습니다."); } // 기본 검증만 수행 if (!workbook.SheetNames || workbook.SheetNames.length === 0) { throw new Error("시트 이름 정보가 없습니다."); } // Sheets 객체가 없으면 빈 객체로 초기화 if (!workbook.Sheets) { workbook.Sheets = {}; } console.log("✅ SheetJS 워크북 읽기 성공:", { sheetNames: workbook.SheetNames, sheetCount: workbook.SheetNames.length, }); // 🎨 스타일 정보 상세 분석 (개발 모드에서만) if (import.meta.env.DEV) { analyzeSheetStyles(workbook); } // 2단계: 워크북을 XLSX ArrayBuffer로 변환 let xlsxArrayBuffer: ArrayBuffer; try { console.log("🔄 XLSX 형식으로 변환 중..."); const xlsxData = XLSX.write(workbook, { type: "array", bookType: "xlsx", cellStyles: true, // 스타일 정보 보존 }); console.log(`✅ XLSX 변환 완료: ${xlsxData.length} bytes`); // xlsxData는 Uint8Array이므로 ArrayBuffer로 변환 if (xlsxData instanceof Uint8Array) { xlsxArrayBuffer = xlsxData.buffer.slice( xlsxData.byteOffset, xlsxData.byteOffset + xlsxData.byteLength, ); } else if (xlsxData instanceof ArrayBuffer) { xlsxArrayBuffer = xlsxData; } else { // 다른 타입의 경우 새 ArrayBuffer 생성 xlsxArrayBuffer = new ArrayBuffer(xlsxData.length); const view = new Uint8Array(xlsxArrayBuffer); for (let i = 0; i < xlsxData.length; i++) { view[i] = xlsxData[i]; } } console.log(`✅ XLSX 변환 완료: ${xlsxArrayBuffer.byteLength} bytes`); } catch (writeError) { console.error("❌ XLSX 변환 실패:", writeError); throw new Error( `XLSX 변환 실패: ${writeError instanceof Error ? writeError.message : writeError}`, ); } // 3단계: ArrayBuffer가 완전히 준비된 후 LuckyExcel로 처리 console.log("🍀 LuckyExcel로 변환된 XLSX 처리 중..."); // ArrayBuffer 최종 검증 if (!xlsxArrayBuffer) { throw new Error("ArrayBuffer가 생성되지 않았습니다"); } if (xlsxArrayBuffer.byteLength === 0) { throw new Error("ArrayBuffer 크기가 0입니다"); } // 원본 파일명에서 확장자를 .xlsx로 변경 // const xlsxFileName = file.name.replace(/\.(csv|xls|xlsx)$/i, ".xlsx"); console.log("🍀 LuckyExcel 처리 시작..."); // Promise를 사용한 LuckyExcel 처리 return new Promise<{ sheets: SheetData[]; xlsxBuffer: ArrayBuffer }>( (resolve, reject) => { try { // LuckyExcel API는 (arrayBuffer, successCallback, errorCallback) 형태로 호출 // 공식 문서: LuckyExcel.transformExcelToLucky(file, successCallback, errorCallback) (window.LuckyExcel as any).transformExcelToLucky( xlsxArrayBuffer, // 성공 콜백 함수 (두 번째 매개변수) (exportJson: any, _luckysheetfile: any) => { try { console.log( "🍀 LuckyExcel 변환 성공:", exportJson?.sheets?.length || 0, "개 시트", ); // 데이터 유효성 검사 if ( !exportJson || !exportJson.sheets || !Array.isArray(exportJson.sheets) || exportJson.sheets.length === 0 ) { console.warn( "⚠️ LuckyExcel 결과가 유효하지 않습니다. SheetJS 방식으로 대체 처리합니다.", ); // LuckyExcel 실패 시 SheetJS 데이터를 직접 변환 const fallbackSheets = convertSheetJSToLuckyExcel(workbook); resolve({ sheets: fallbackSheets, xlsxBuffer: xlsxArrayBuffer, }); return; } // LuckyExcel 변환이 성공한 경우 - SheetData 형식으로 변환 const sheets: SheetData[] = exportJson.sheets.map( (luckySheet: any, index: number) => { const sheetName = luckySheet.name || `Sheet${index + 1}`; const maxRow = luckySheet.row || 0; const maxCol = luckySheet.column || 0; // 2D 배열 초기화 const data: any[][] = []; for (let r = 0; r < maxRow; r++) { data[r] = new Array(maxCol).fill(""); } // celldata에서 데이터 추출 if ( luckySheet.celldata && Array.isArray(luckySheet.celldata) ) { luckySheet.celldata.forEach((cell: any) => { if ( cell && typeof cell.r === "number" && typeof cell.c === "number" ) { const row = cell.r; const col = cell.c; if (row < maxRow && col < maxCol && cell.v) { const cellValue = cell.v.v || cell.v.m || ""; data[row][col] = String(cellValue).trim(); } } }); } // 빈 데이터 처리 if (data.length === 0) { data.push([""]); } return { id: `sheet_${index}`, name: sheetName, data: data, xlsxBuffer: xlsxArrayBuffer, // 변환된 XLSX ArrayBuffer 포함 config: { container: `luckysheet_${index}`, title: sheetName, lang: "ko", data: [ { name: sheetName, index: index.toString(), celldata: luckySheet.celldata || [], status: 1, order: index, row: maxRow, column: maxCol, }, ], options: { showtoolbar: true, showinfobar: false, showsheetbar: true, showstatisticBar: true, allowCopy: true, allowEdit: true, enableAddRow: true, enableAddCol: true, }, }, }; }, ); console.log("✅ LuckyExcel 처리 성공:", sheets.length, "개 시트"); resolve({ sheets, xlsxBuffer: xlsxArrayBuffer }); } catch (processError) { console.error("❌ LuckyExcel 후처리 중 오류:", processError); // LuckyExcel 후처리 실패 시 SheetJS 방식으로 대체 try { console.log("🔄 SheetJS 방식으로 대체 처리..."); const fallbackSheets = convertSheetJSToLuckyExcel(workbook); resolve({ sheets: fallbackSheets, xlsxBuffer: xlsxArrayBuffer, }); } catch (fallbackError) { console.error("❌ SheetJS 대체 처리도 실패:", fallbackError); reject(fallbackError); } } }, // 오류 콜백 함수 (세 번째 매개변수) (error: any) => { console.error("❌ LuckyExcel 변환 오류:", error); // LuckyExcel 오류 시 SheetJS 방식으로 대체 try { console.log("🔄 SheetJS 방식으로 대체 처리..."); const fallbackSheets = convertSheetJSToLuckyExcel(workbook); resolve({ sheets: fallbackSheets, xlsxBuffer: xlsxArrayBuffer }); } catch (fallbackError) { console.error("❌ SheetJS 대체 처리도 실패:", fallbackError); reject(fallbackError); } }, ); } catch (luckyError) { console.error("❌ LuckyExcel 호출 중 오류:", luckyError); // LuckyExcel 호출 실패 시 SheetJS 방식으로 대체 try { console.log("🔄 SheetJS 방식으로 대체 처리..."); const fallbackSheets = convertSheetJSToLuckyExcel(workbook); resolve({ sheets: fallbackSheets, xlsxBuffer: xlsxArrayBuffer }); } catch (fallbackError) { console.error("❌ SheetJS 대체 처리도 실패:", fallbackError); reject(fallbackError); } } }, ); } /** * XLSX 파일을 바로 LuckyExcel로 처리 (공식 예제 순서 준수) * - 공식 문서 예제를 그대로 따름: LuckyExcel.transformExcelToLucky → luckysheet.create */ async function processXLSXWithLuckyExcel( file: File, ): Promise<{ sheets: SheetData[]; xlsxBuffer: ArrayBuffer }> { console.log("🍀 XLSX 파일을 LuckyExcel로 직접 처리 시작..."); console.log(`📊 XLSX 파일: ${file.name}, 크기: ${file.size} bytes`); // Promise를 사용한 LuckyExcel 처리 (공식 예제 순서) return new Promise<{ sheets: SheetData[]; xlsxBuffer: ArrayBuffer }>( (resolve, reject) => { // Make sure to get the xlsx file first, and then use the global method window.LuckyExcel to convert (window.LuckyExcel as any).transformExcelToLucky( file, // After obtaining the converted table data, use luckysheet to initialize or update the existing luckysheet workbook function (exportJson: any, _luckysheetfile: any) { console.log( "🍀 LuckyExcel 변환 성공:", exportJson?.sheets?.length || 0, "개 시트", ); // ArrayBuffer는 성공 시에만 생성 file .arrayBuffer() .then((arrayBuffer) => { // exportJson.sheets를 SheetData 형식으로 단순 변환 const sheets: SheetData[] = exportJson.sheets.map( (sheet: any, index: number) => ({ id: `sheet_${index}`, name: sheet.name || `Sheet${index + 1}`, data: [[""]], // 실제 데이터는 luckysheet에서 처리 xlsxBuffer: arrayBuffer, config: { container: `luckysheet_${index}`, title: exportJson.info?.name || file.name, lang: "ko", // Note: Luckysheet needs to introduce a dependency package and initialize the table container before it can be used data: exportJson.sheets, // exportJson.sheets를 그대로 전달 // 공식 예제에 따른 설정 ...(exportJson.info?.name && { title: exportJson.info.name, }), ...(exportJson.info?.creator && { userInfo: exportJson.info.creator, }), options: { showtoolbar: true, showinfobar: false, showsheetbar: true, showstatisticBar: true, allowCopy: true, allowEdit: true, enableAddRow: true, enableAddCol: true, }, }, }), ); console.log( "✅ XLSX 파일 LuckyExcel 처리 완료:", sheets.length, "개 시트", ); resolve({ sheets, xlsxBuffer: arrayBuffer }); }) .catch((bufferError) => { reject(new Error(`ArrayBuffer 생성 실패: ${bufferError}`)); }); }, // Import failed. Is your file a valid xlsx? function (err: any) { console.error("❌ LuckyExcel 변환 실패:", err); reject(new Error(`XLSX 파일 변환 실패: ${err}`)); }, ); }, ); } /** * 엑셀 파일을 SheetData 배열로 변환 (파일 형식별 최적화 버전) * - XLSX: LuckyExcel 직접 처리 (스타일 정보 완전 보존) * - CSV/XLS: SheetJS → XLSX → LuckyExcel 파이프라인 */ export async function processExcelFile(file: File): Promise { try { const errorMessage = getFileErrorMessage(file); if (errorMessage) { return { success: false, error: errorMessage, fileName: file.name, fileSize: file.size, file: file, }; } // 파일 형식 감지 const fileName = file.name.toLowerCase(); const isCSV = fileName.endsWith(".csv"); const isXLS = fileName.endsWith(".xls"); const isXLSX = fileName.endsWith(".xlsx"); if (!isCSV && !isXLS && !isXLSX) { return { success: false, error: "지원되지 않는 파일 형식입니다. .csv, .xls, .xlsx 파일을 사용해주세요.", fileName: file.name, fileSize: file.size, file: file, }; } console.log( `📁 파일 처리 시작: ${file.name} (${isCSV ? "CSV" : isXLS ? "XLS" : "XLSX"})`, ); let sheets: SheetData[]; let xlsxBuffer: ArrayBuffer; if (isXLSX) { // 🍀 XLSX 파일: LuckyExcel 직접 처리 (스타일 정보 완전 보존) console.log("🍀 XLSX 파일 - LuckyExcel 직접 처리 방식 사용"); const result = await processXLSXWithLuckyExcel(file); sheets = result.sheets; xlsxBuffer = result.xlsxBuffer; } else { // 📊 CSV/XLS 파일: SheetJS → XLSX → LuckyExcel 파이프라인 console.log(`📊 ${isCSV ? "CSV" : "XLS"} 파일 - SheetJS 파이프라인 사용`); const result = await processFileWithSheetJSToXLSX(file); sheets = result.sheets; xlsxBuffer = result.xlsxBuffer; } if (!sheets || sheets.length === 0) { return { success: false, error: "파일에서 유효한 시트를 찾을 수 없습니다.", fileName: file.name, fileSize: file.size, file: file, }; } console.log(`🎉 파일 처리 완료: ${sheets.length}개 시트`); return { success: true, data: sheets, fileName: file.name, fileSize: file.size, file: file, xlsxBuffer, }; } catch (error) { console.error("❌ 파일 처리 중 오류 발생:", error); let errorMessage = "파일을 읽는 중 오류가 발생했습니다."; if (error instanceof Error) { if ( error.message.includes("파일에 워크시트가 없습니다") || error.message.includes("워크북 구조 오류") || error.message.includes("파일 처리 실패") || error.message.includes("파일 읽기 실패") || error.message.includes("XLSX 변환 실패") || error.message.includes("파일이 비어있습니다") || error.message.includes("워크북을 생성할 수 없습니다") || error.message.includes("유효한 시트가 없습니다") || error.message.includes("시트 이름 정보가 없습니다") || error.message.includes("파일을 읽을 수 없습니다") || error.message.includes("XLSX 파일 처리 실패") || error.message.includes("XLSX 파일 변환 실패") ) { errorMessage = error.message; } else if (error.message.includes("transformExcelToLucky")) { errorMessage = "Excel 파일 변환에 실패했습니다. 파일이 손상되었거나 지원되지 않는 형식일 수 있습니다."; } else { errorMessage = `파일 처리 중 오류: ${error.message}`; } } return { success: false, error: errorMessage, fileName: file.name, fileSize: file.size, file: file, }; } } /** * 여러 파일 중 유효한 파일만 필터링 */ export function filterValidFiles(files: FileList): File[] { return Array.from(files).filter((file) => { const errorMessage = getFileErrorMessage(file); return errorMessage === ""; }); } /** * 파일 목록의 에러 정보 수집 */ export function getFileErrors( files: FileList, ): { file: File; error: string }[] { const errors: { file: File; error: string }[] = []; Array.from(files).forEach((file) => { const errorMessage = getFileErrorMessage(file); if (errorMessage !== "") { errors.push({ file, error: errorMessage }); } }); return errors; }