diff --git a/.cursor/rules/shadcn-tailwind-v4.mdc b/.cursor/rules/shadcn-tailwind-v4.mdc
new file mode 100644
index 0000000..a5413ad
--- /dev/null
+++ b/.cursor/rules/shadcn-tailwind-v4.mdc
@@ -0,0 +1,122 @@
+---
+description:
+globs:
+alwaysApply: false
+---
+# Tailwind CSS v4 + Shadcn UI 호환성 규칙
+
+## **CSS 설정 (src/index.css)**
+
+- **@theme 레이어 사용**
+ ```css
+ @theme {
+ --radius: 0.5rem;
+ }
+ ```
+
+- **CSS 변수 정의**
+ - `:root`에 라이트 모드 색상 변수 정의
+ - `.dark`에 다크 모드 색상 변수 정의
+ - `hsl(var(--foreground))` 형태로 색상 사용
+
+## **cn 함수 (src/lib/utils.ts)**
+
+- **에러 핸들링 필수**
+ ```typescript
+ // ✅ DO: fallback 로직 포함
+ export function cn(...inputs: ClassValue[]) {
+ try {
+ return twMerge(clsx(inputs));
+ } catch (error) {
+ console.warn("tailwind-merge fallback:", error);
+ return clsx(inputs);
+ }
+ }
+
+ // ❌ DON'T: 에러 핸들링 없이 사용
+ export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+ }
+ ```
+
+## **컴포넌트 스타일링**
+
+- **CSS 변수 활용**
+ ```typescript
+ // ✅ DO: CSS 변수 기반 스타일링
+ className="bg-background text-foreground border-border"
+
+ // ✅ DO: cn 함수로 조건부 스타일링
+ className={cn(
+ "base-styles",
+ condition && "conditional-styles"
+ )}
+ ```
+
+- **색상 시스템 준수**
+ - `background`, `foreground`, `primary`, `secondary` 등 정의된 변수 사용
+ - 직접 색상 값 대신 변수 사용
+
+## **패키지 관리**
+
+- **필수 패키지**
+ ```json
+ {
+ "@tailwindcss/cli": "^4.1.10",
+ "@tailwindcss/vite": "^4.1.10",
+ "tailwind-merge": "latest",
+ "clsx": "^2.1.1",
+ "class-variance-authority": "^0.7.1"
+ }
+ ```
+
+- **제거해야 할 파일**
+ - `tailwind.config.js` (v4는 CSS-first 방식)
+ - `postcss.config.js` (v4는 PostCSS 불필요)
+
+## **Vite 설정**
+
+- **플러그인 설정**
+ ```typescript
+ // vite.config.ts
+ import tailwindcss from "@tailwindcss/vite";
+
+ export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ });
+ ```
+
+## **문제 해결**
+
+- **tailwind-merge 오류 시**
+ - 최신 버전으로 업데이트
+ - cn 함수에 fallback 로직 구현
+
+- **스타일이 적용되지 않을 때**
+ - CSS 변수가 올바르게 정의되었는지 확인
+ - @theme 레이어가 포함되었는지 확인
+
+- **빌드 오류 시**
+ - node_modules 캐시 삭제 후 재설치
+ - package-lock.json 삭제 후 재설치
+
+## **모범 사례**
+
+- **컴포넌트 개발 시**
+ - 항상 CSS 변수 사용
+ - cn 함수로 클래스 조합
+ - 조건부 스타일링에 적절한 패턴 적용
+
+- **테마 관리**
+ - 라이트/다크 모드 변수 동시 정의
+ - 일관된 색상 시스템 유지
+
+- **성능 최적화**
+ - 불필요한 클래스 중복 방지
+ - cn 함수 사용으로 클래스 충돌 해결
+
+## **참고 자료**
+
+- [Tailwind CSS v4 공식 문서](https://tailwindcss.com/docs/v4-beta)
+- [Shadcn UI + Tailwind v4 가이드](https://www.luisball.com/blog/shadcn-ui-with-tailwind-v4)
+- [Shadcn UI 공식 설치 가이드](https://ui.shadcn.com/docs/installation/manual)
diff --git a/.cursor/rules/xlsx-js-style.mdc b/.cursor/rules/xlsx-js-style.mdc
index b93c988..cc1602e 100644
--- a/.cursor/rules/xlsx-js-style.mdc
+++ b/.cursor/rules/xlsx-js-style.mdc
@@ -3,3 +3,193 @@ description:
globs:
alwaysApply: false
---
+# xlsx-js-style 스타일 보존 규칙
+
+## **핵심 원칙**
+- xlsx-js-style의 공식 API 구조를 직접 활용하여 스타일 변환
+- 복잡한 색상 변환 로직 대신 공식 COLOR_STYLE 형식 지원
+- 배경색과 테두리 색상 누락 방지를 위한 완전한 스타일 매핑
+
+## **공식 xlsx-js-style API 활용**
+
+### **색상 처리 (COLOR_STYLE)**
+```typescript
+// ✅ DO: 공식 COLOR_STYLE 형식 모두 지원
+function convertXlsxColorToLuckysheet(colorObj: any): string {
+ // RGB 형태: {rgb: "FFCC00"}
+ if (colorObj.rgb) { /* RGB 처리 */ }
+
+ // Theme 색상: {theme: 4} 또는 {theme: 1, tint: 0.4}
+ if (typeof colorObj.theme === 'number') { /* Theme 처리 */ }
+
+ // Indexed 색상: Excel 기본 색상표
+ if (typeof colorObj.indexed === 'number') { /* Indexed 처리 */ }
+}
+
+// ❌ DON'T: 특정 색상 형식만 처리
+function badColorConvert(colorObj: any): string {
+ return colorObj.rgb || "rgb(0,0,0)"; // rgb만 처리하고 theme, indexed 무시
+}
+```
+
+### **스타일 객체 변환**
+```typescript
+// ✅ DO: 공식 스타일 속성 완전 매핑
+function convertXlsxStyleToLuckysheet(xlsxStyle: any): any {
+ const luckyStyle: any = {};
+
+ // 폰트: {name: "Courier", sz: 24, bold: true, color: {rgb: "FF0000"}}
+ if (xlsxStyle.font) {
+ if (xlsxStyle.font.name) luckyStyle.ff = xlsxStyle.font.name;
+ if (xlsxStyle.font.sz) luckyStyle.fs = xlsxStyle.font.sz;
+ if (xlsxStyle.font.bold) luckyStyle.bl = 1;
+ if (xlsxStyle.font.color) {
+ luckyStyle.fc = convertXlsxColorToLuckysheet(xlsxStyle.font.color);
+ }
+ }
+
+ // 배경: {fgColor: {rgb: "E9E9E9"}}
+ if (xlsxStyle.fill?.fgColor) {
+ luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.fgColor);
+ }
+
+ // 테두리: {top: {style: "thin", color: {rgb: "000000"}}}
+ if (xlsxStyle.border) {
+ luckyStyle.bd = {};
+ if (xlsxStyle.border.top) {
+ luckyStyle.bd.t = {
+ style: convertBorderStyleToLuckysheet(xlsxStyle.border.top.style),
+ color: convertXlsxColorToLuckysheet(xlsxStyle.border.top.color)
+ };
+ }
+ }
+
+ return luckyStyle;
+}
+
+// ❌ DON'T: 수동으로 스타일 속성 하나씩 처리
+luckyCell.v.s = {
+ ff: cell.s.font?.name || "Arial",
+ bg: cell.s.fill?.fgColor?.rgb || "rgb(255,255,255)" // 직접 rgb만 처리
+};
+```
+
+## **배경색과 테두리 색상 누락 방지**
+
+### **배경색 처리**
+```typescript
+// ✅ DO: fgColor와 bgColor 모두 확인
+if (xlsxStyle.fill) {
+ if (xlsxStyle.fill.fgColor) {
+ luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.fgColor);
+ } else if (xlsxStyle.fill.bgColor) {
+ luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.bgColor);
+ }
+}
+
+// ❌ DON'T: fgColor만 확인
+if (xlsxStyle.fill?.fgColor) {
+ luckyStyle.bg = xlsxStyle.fill.fgColor.rgb; // 다른 색상 형식 무시
+}
+```
+
+### **테두리 색상 처리**
+```typescript
+// ✅ DO: 모든 테두리 방향과 색상 형식 지원
+if (xlsxStyle.border) {
+ ['top', 'bottom', 'left', 'right'].forEach(side => {
+ if (xlsxStyle.border[side]) {
+ luckyStyle.bd[side[0]] = {
+ style: convertBorderStyleToLuckysheet(xlsxStyle.border[side].style),
+ color: convertXlsxColorToLuckysheet(xlsxStyle.border[side].color)
+ };
+ }
+ });
+}
+
+// ❌ DON'T: 하드코딩된 색상 사용
+luckyStyle.bd.t = {
+ style: 1,
+ color: "rgb(0,0,0)" // 실제 색상 무시
+};
+```
+
+## **Excel Tint 처리**
+```typescript
+// ✅ DO: Excel tint 공식 적용
+function applyTintToRgbColor(rgbColor: string, tint: number): string {
+ 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)));
+ }
+ };
+ // RGB 각 채널에 tint 적용
+}
+
+// ❌ DON'T: tint 무시
+if (colorObj.theme) {
+ return themeColors[colorObj.theme]; // tint 무시
+}
+```
+
+## **오류 방지 패턴**
+
+### **안전한 스타일 읽기**
+```typescript
+// ✅ DO: 옵셔널 체이닝과 타입 검사
+workbook = XLSX.read(arrayBuffer, {
+ cellStyles: true // 스타일 정보 보존
+});
+
+// 스타일 정보 확인
+if (cell.s) {
+ console.log(`🎨 셀 ${cellAddress}에 스타일 정보:`, cell.s);
+ luckyCell.v.s = convertXlsxStyleToLuckysheet(cell.s);
+}
+
+// ❌ DON'T: 스타일 옵션 누락
+workbook = XLSX.read(arrayBuffer); // cellStyles 옵션 없음
+```
+
+### **스타일 쓰기 보존**
+```typescript
+// ✅ DO: 쓰기 시에도 스타일 보존
+const xlsxData = XLSX.write(workbook, {
+ type: "array",
+ bookType: "xlsx",
+ cellStyles: true // 스타일 정보 보존
+});
+
+// ❌ DON'T: 쓰기 시 스타일 누락
+const xlsxData = XLSX.write(workbook, {
+ type: "array",
+ bookType: "xlsx"
+ // cellStyles 옵션 없음
+});
+```
+
+## **디버깅 및 검증**
+
+### **스타일 정보 로깅**
+```typescript
+// ✅ DO: 개발 모드에서 스타일 정보 상세 분석
+if (import.meta.env.DEV && cell.s) {
+ console.log(`🎨 셀 ${cellAddress} 스타일:`, {
+ font: cell.s.font,
+ fill: cell.s.fill,
+ border: cell.s.border,
+ alignment: cell.s.alignment
+ });
+}
+
+// ❌ DON'T: 스타일 정보 무시
+// 스타일 관련 로그 없음
+```
+
+## **참고 사항**
+- [xlsx-js-style GitHub](https://github.com/gitbrent/xlsx-js-style) 공식 문서 참조
+- 공식 COLOR_STYLE 형식: `{rgb: "FFCC00"}`, `{theme: 4}`, `{theme: 1, tint: 0.4}`
+- 공식 BORDER_STYLE 값: `thin`, `medium`, `thick`, `dotted`, `dashed` 등
+- Excel 테마 색상과 tint 처리는 공식 Excel 색상 공식 사용
diff --git a/src/App.tsx b/src/App.tsx
index 77056bc..02b9bca 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import { Button } from "./components/ui/button";
-import TestSheetViewer from "./components/sheet/TestSheetViewer";
+import HomeButton from "./components/ui/homeButton";
+import EditSheetViewer from "./components/sheet/EditSheetViewer";
function App() {
const [showTestViewer, setShowTestViewer] = useState(false);
@@ -12,7 +13,20 @@ function App() {
-
sheetEasy AI
+ {/* showTestViewer가 true일 때만 홈 버튼 노출 */}
+ {showTestViewer && (
+ {
+ if (window.confirm("기본 화면으로 돌아가시겠습니까?")) {
+ setShowTestViewer(false);
+ }
+ }}
+ >
+ sheetEasy AI
+
+ )}
{/* 테스트 뷰어 토글 버튼 */}
@@ -22,12 +36,12 @@ function App() {
onClick={() => setShowTestViewer(!showTestViewer)}
className="bg-green-500 hover:bg-green-600 text-white border-green-500"
>
- 🧪 테스트 뷰어
+ 🧪 에디트 뷰어
{!showTestViewer && (
- Univer CE 테스트 모드
+ Univer CE 에디트 모드
)}
@@ -40,24 +54,24 @@ function App() {
{showTestViewer ? (
// 테스트 뷰어 표시
-
+
) : (
// 메인 페이지
- 🧪 Univer CE 테스트 모드
+ 🧪 Univer CE 에디트 모드
- 현재 Univer CE 전용 테스트 뷰어를 사용해보세요
+ 현재 Univer CE 전용 에디트 뷰어를 사용해보세요
diff --git a/src/components/sheet/TestSheetViewer.tsx b/src/components/sheet/EditSheetViewer.tsx
similarity index 95%
rename from src/components/sheet/TestSheetViewer.tsx
rename to src/components/sheet/EditSheetViewer.tsx
index 21a0df1..458745b 100644
--- a/src/components/sheet/TestSheetViewer.tsx
+++ b/src/components/sheet/EditSheetViewer.tsx
@@ -14,6 +14,7 @@ import { UniverSheetsNumfmtUIPlugin } from "@univerjs/sheets-numfmt-ui";
import { UniverUIPlugin } from "@univerjs/ui";
import { cn } from "../../lib/utils";
import LuckyExcel from "@zwight/luckyexcel";
+import PromptInput from "./PromptInput";
// 언어팩 import
import DesignEnUS from "@univerjs/design/locale/en-US";
@@ -283,6 +284,7 @@ const TestSheetViewer: React.FC = () => {
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [currentFile, setCurrentFile] = useState
(null);
+ const [prompt, setPrompt] = useState("");
// Univer 초기화 함수
const initializeUniver = useCallback(async (workbookData?: any) => {
@@ -561,39 +563,27 @@ const TestSheetViewer: React.FC = () => {
{/* 헤더 */}
-
- 🧪 Univer CE + 파일 업로드 (Window 기반 관리)
-
-
-
- {isInitialized ? "✅ 초기화 완료" : "⏳ 초기화 중..."}
-
-
+
{currentFile && (
- <>
-
- 📄 {currentFile.name}
-
-
- >
+
+ 📄 {currentFile.name}
+
)}
-
- {/* 디버그 정보 */}
-
- 전역 인스턴스: {UniverseManager.getInstance() ? "✅" : "❌"} |
- 초기화 중: {UniverseManager.isInitializing() ? "⏳" : "❌"}
-
+
@@ -602,12 +592,24 @@ const TestSheetViewer: React.FC = () => {
ref={containerRef}
className="flex-1 relative"
style={{
- minHeight: "500px",
+ minHeight: "0",
width: "100%",
height: "100%",
+ flexGrow: 0.85,
}}
/>
+ {/* Univer와 입력창 사이 여백 */}
+
+
+ {/* 프롬프트 입력창 - Univer 하단에 이어서 */}
+
setPrompt(e.target.value)}
+ onExecute={() => {}}
+ disabled={true}
+ />
+
{/* 파일 업로드 오버레이 - 레이어 분리 */}
{showUploadOverlay && (
<>
diff --git a/src/components/sheet/FileUpload.tsx.bak b/src/components/sheet/FileUpload.tsx.bak
deleted file mode 100644
index b7c9dd2..0000000
--- a/src/components/sheet/FileUpload.tsx.bak
+++ /dev/null
@@ -1,421 +0,0 @@
-import React, { useCallback, useState, useRef } from "react";
-import { Card, CardContent } from "../ui/card";
-import { Button } from "../ui/button";
-import { FileErrorModal } from "../ui/modal";
-import { cn } from "../../lib/utils";
-import { useAppStore } from "../../stores/useAppStore";
-import {
- processExcelFile,
- getFileErrors,
- filterValidFiles,
-} from "../../utils/fileProcessor";
-
-interface FileUploadProps {
- className?: string;
-}
-
-/**
- * 파일 업로드 컴포넌트
- * - Drag & Drop 기능 지원
- * - .xls, .xlsx 파일 타입 제한
- * - 접근성 지원 (ARIA 라벨, 키보드 탐색)
- * - 반응형 레이아웃
- * - 실제 파일 처리 로직 연결
- */
-export function FileUpload({ className }: FileUploadProps) {
- const [isDragOver, setIsDragOver] = useState(false);
- const [showErrorModal, setShowErrorModal] = useState(false);
- const fileInputRef = useRef(null);
-
- // 스토어에서 상태 가져오기
- const {
- isLoading,
- error,
- fileUploadErrors,
- currentFile,
- setLoading,
- setError,
- uploadFile,
- clearFileUploadErrors,
- } = useAppStore();
-
- /**
- * 파일 처리 로직
- */
- const handleFileProcessing = useCallback(
- async (file: File) => {
- setLoading(true, "파일을 처리하는 중...");
- setError(null);
- clearFileUploadErrors();
-
- try {
- const result = await processExcelFile(file);
- uploadFile(result);
-
- if (result.success) {
- console.log("파일 업로드 성공:", result.fileName);
- }
- } catch (error) {
- console.error("파일 처리 중 예상치 못한 오류:", error);
- setError("파일 처리 중 예상치 못한 오류가 발생했습니다.");
- } finally {
- setLoading(false);
- }
- },
- [setLoading, setError, uploadFile, clearFileUploadErrors],
- );
-
- /**
- * 파일 선택 처리
- */
- const handleFileSelection = useCallback(
- async (files: FileList) => {
- if (files.length === 0) return;
-
- // 유효하지 않은 파일들의 에러 수집
- const fileErrors = getFileErrors(files) || [];
- const validFiles = filterValidFiles(files) || [];
-
- // 에러가 있는 파일들을 스토어에 저장
- fileErrors.forEach(({ file, error }) => {
- useAppStore.getState().addFileUploadError(file.name, error);
- });
-
- // 에러가 있으면 모달 표시
- if (fileErrors.length > 0) {
- setShowErrorModal(true);
- }
-
- if (validFiles.length === 0) {
- setError("업로드 가능한 파일이 없습니다.");
- return;
- }
-
- if (validFiles.length > 1) {
- setError(
- "한 번에 하나의 파일만 업로드할 수 있습니다. 첫 번째 파일을 사용합니다.",
- );
- }
-
- // 첫 번째 유효한 파일 처리
- await handleFileProcessing(validFiles[0]);
- },
- [handleFileProcessing, setError],
- );
-
- /**
- * 드래그 앤 드롭 이벤트 핸들러
- */
- const handleDragEnter = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
- setIsDragOver(true);
- }
- }, []);
-
- const handleDragLeave = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDragOver(false);
- }, []);
-
- const handleDragOver = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- }, []);
-
- const handleDrop = useCallback(
- async (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDragOver(false);
-
- if (isLoading) return;
-
- const files = e.dataTransfer.files;
- if (files && files.length > 0) {
- await handleFileSelection(files);
- }
- },
- [handleFileSelection, isLoading],
- );
-
- /**
- * 파일 선택 버튼 클릭 핸들러
- */
- const handleFilePickerClick = useCallback(() => {
- if (isLoading || !fileInputRef.current) return;
- fileInputRef.current.click();
- }, [isLoading]);
-
- /**
- * 파일 입력 변경 핸들러
- */
- const handleFileInputChange = useCallback(
- async (e: React.ChangeEvent) => {
- const files = e.target.files;
- if (files && files.length > 0) {
- await handleFileSelection(files);
- }
- // 입력 초기화 (같은 파일 재선택 가능하도록)
- e.target.value = "";
- },
- [handleFileSelection],
- );
-
- /**
- * 키보드 이벤트 핸들러 (접근성)
- */
- const handleKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- handleFilePickerClick();
- }
- },
- [handleFilePickerClick],
- );
-
- /**
- * 에러 모달 닫기 핸들러
- */
- const handleCloseErrorModal = useCallback(() => {
- setShowErrorModal(false);
- clearFileUploadErrors();
- }, [clearFileUploadErrors]);
-
- // 파일이 이미 업로드된 경우 성공 상태 표시
- if (currentFile && !error) {
- return (
-
-
-
-
-
-
- 파일 업로드 완료
-
-
-
- {currentFile.name}
-
-
-
- 파일 크기: {(currentFile.size / 1024 / 1024).toFixed(2)} MB
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
- {/* 아이콘 및 제목 */}
-
-
- {isLoading ? (
-
- ) : error ? (
-
- ) : (
-
- )}
-
-
- {isLoading
- ? "파일 처리 중..."
- : error
- ? "업로드 오류"
- : "Excel 파일을 업로드하세요"}
-
-
- {isLoading ? (
- 잠시만 기다려주세요...
- ) : error ? (
- {error}
- ) : (
- <>
-
- .xlsx, .xls
- {" "}
- 파일을 드래그 앤 드롭하거나 클릭하여 업로드
- >
- )}
-
-
-
- {/* 파일 업로드 에러 목록 */}
- {fileUploadErrors.length > 0 && (
-
-
- 파일 업로드 오류:
-
-
- {fileUploadErrors.map((error, index) => (
- -
- {error.fileName}:{" "}
- {error.error}
-
- ))}
-
-
- )}
-
- {/* 드래그 앤 드롭 영역 */}
-
-
-
- {isDragOver ? "📂" : "📄"}
-
-
-
- {isDragOver
- ? "파일을 여기에 놓으세요"
- : "파일을 드래그하거나 클릭하세요"}
-
-
- 최대 50MB까지 업로드 가능
-
-
-
-
-
- {/* 숨겨진 파일 입력 */}
-
-
- {/* 지원 형식 안내 */}
-
-
지원 형식: Excel (.xlsx, .xls)
-
최대 파일 크기: 50MB
-
-
-
-
-
- {/* 파일 에러 모달 */}
-
-
- );
-}
diff --git a/src/components/sheet/PromptInput.tsx b/src/components/sheet/PromptInput.tsx
new file mode 100644
index 0000000..5e271fc
--- /dev/null
+++ b/src/components/sheet/PromptInput.tsx
@@ -0,0 +1,59 @@
+import React from "react";
+
+interface PromptInputProps {
+ value: string;
+ onChange?: (e: React.ChangeEvent) => void;
+ onExecute?: () => void;
+ disabled?: boolean;
+ maxLength?: number;
+}
+
+/**
+ * 에디트 화면 하단 고정 프롬프트 입력창 컴포넌트
+ * - 이미지 참고: 입력창, Execute 버튼, 안내문구, 글자수 카운트, 하단 고정
+ */
+const PromptInput: React.FC = ({
+ value,
+ onChange,
+ onExecute,
+ disabled = true,
+ maxLength = 500,
+}) => {
+ return (
+
+
+
+
+ Press Enter to send, Shift+Enter for new line
+
+
+ {value.length}/{maxLength}
+
+
+
+ );
+};
+
+export default PromptInput;
diff --git a/src/components/sheet/SheetViewer.tsx.bak b/src/components/sheet/SheetViewer.tsx.bak
deleted file mode 100644
index e02fa53..0000000
--- a/src/components/sheet/SheetViewer.tsx.bak
+++ /dev/null
@@ -1,491 +0,0 @@
-import {
- useEffect,
- useLayoutEffect,
- useRef,
- useCallback,
- useState,
-} from "react";
-import { useAppStore } from "../../stores/useAppStore";
-
-interface SheetViewerProps {
- className?: string;
-}
-
-/**
- * Luckysheet 시트 뷰어 컴포넌트
- * - 메모리 정보 기반: LuckyExcel 변환 결과를 직접 사용
- * - 커스텀 검증이나 데이터 구조 변경 금지
- * - luckysheet.create({ data: exportJson.sheets })로 직접 사용
- */
-export function SheetViewer({ className }: SheetViewerProps) {
- const containerRef = useRef(null);
- const luckysheetRef = useRef(null);
- const [isInitialized, setIsInitialized] = useState(false);
- const [isConverting, setIsConverting] = useState(false);
- const [error, setError] = useState(null);
- const [isContainerReady, setIsContainerReady] = useState(false);
- const [librariesLoaded, setLibrariesLoaded] = useState(false);
-
- // 스토어에서 현재 파일 정보만 가져오기 (시트 데이터는 LuckyExcel로 직접 변환)
- const { currentFile, setSelectedRange } = useAppStore();
-
- /**
- * CDN 배포판 라이브러리 로딩
- */
- const loadLuckysheetLibrary = useCallback((): Promise => {
- return new Promise((resolve, reject) => {
- // 이미 로드된 경우
- if (
- window.luckysheet &&
- window.LuckyExcel &&
- window.$ &&
- librariesLoaded
- ) {
- console.log("📦 모든 라이브러리가 이미 로드됨");
- resolve();
- return;
- }
-
- const loadResource = (
- type: "css" | "js",
- src: string,
- id: string,
- ): Promise => {
- return new Promise((resourceResolve, resourceReject) => {
- // 이미 로드된 리소스 체크
- if (document.querySelector(`[data-luckysheet-id="${id}"]`)) {
- resourceResolve();
- return;
- }
-
- if (type === "css") {
- const link = document.createElement("link");
- link.rel = "stylesheet";
- link.href = src;
- link.setAttribute("data-luckysheet-id", id);
- link.onload = () => resourceResolve();
- link.onerror = () =>
- resourceReject(new Error(`${id} CSS 로드 실패`));
- document.head.appendChild(link);
- } else {
- const script = document.createElement("script");
- script.src = src;
- script.setAttribute("data-luckysheet-id", id);
- script.onload = () => resourceResolve();
- script.onerror = () =>
- resourceReject(new Error(`${id} JS 로드 실패`));
- document.head.appendChild(script);
- }
- });
- };
-
- const loadSequence = async () => {
- try {
- // 1. jQuery (Luckysheet 의존성)
- if (!window.$) {
- await loadResource(
- "js",
- "https://code.jquery.com/jquery-3.6.0.min.js",
- "jquery",
- );
- }
-
- // 2. CSS 로드 (공식 문서 순서 준수)
- await loadResource(
- "css",
- "https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/css/pluginsCss.css",
- "plugins-css",
- );
- await loadResource(
- "css",
- "https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/plugins.css",
- "plugins-main-css",
- );
- await loadResource(
- "css",
- "https://cdn.jsdelivr.net/npm/luckysheet/dist/css/luckysheet.css",
- "luckysheet-css",
- );
- await loadResource(
- "css",
- "https://cdn.jsdelivr.net/npm/luckysheet/dist/assets/iconfont/iconfont.css",
- "iconfont-css",
- );
-
- // 3. Plugin JS 먼저 로드 (functionlist 초기화)
- await loadResource(
- "js",
- "https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/js/plugin.js",
- "plugin-js",
- );
-
- // 4. Luckysheet 메인
- if (!window.luckysheet) {
- await loadResource(
- "js",
- "https://cdn.jsdelivr.net/npm/luckysheet/dist/luckysheet.umd.js",
- "luckysheet",
- );
- }
-
- // 5. LuckyExcel (Excel 파일 처리용)
- if (!window.LuckyExcel) {
- await loadResource(
- "js",
- "https://cdn.jsdelivr.net/npm/luckyexcel/dist/luckyexcel.umd.js",
- "luckyexcel",
- );
- }
-
- // 라이브러리 검증
- const validationResults = {
- jquery: !!window.$,
- luckyExcel: !!window.LuckyExcel,
- luckysheet: !!window.luckysheet,
- luckysheetCreate: !!(
- window.luckysheet &&
- typeof window.luckysheet.create === "function"
- ),
- luckysheetDestroy: !!(
- window.luckysheet &&
- typeof window.luckysheet.destroy === "function"
- ),
- };
-
- if (
- !validationResults.luckysheet ||
- !validationResults.luckysheetCreate
- ) {
- throw new Error(
- "Luckysheet 객체가 올바르게 초기화되지 않았습니다.",
- );
- }
-
- setLibrariesLoaded(true);
- console.log("✅ 라이브러리 로드 완료");
- resolve();
- } catch (error) {
- console.error("❌ 라이브러리 로딩 실패:", error);
- reject(error);
- }
- };
-
- loadSequence();
- });
- }, [librariesLoaded]);
-
- /**
- * 메모리 정보 기반: LuckyExcel 변환 결과를 직접 사용하는 방식
- * - LuckyExcel.transformExcelToLucky()에서 반환된 exportJson.sheets를 그대로 사용
- * - 커스텀 검증이나 데이터 구조 변경 금지
- */
- const convertXLSXWithLuckyExcel = useCallback(
- async (xlsxBuffer: ArrayBuffer, fileName: string) => {
- if (!containerRef.current) {
- console.warn("⚠️ 컨테이너가 없습니다.");
- return;
- }
-
- try {
- setIsConverting(true);
- setError(null);
-
- console.log("🍀 메모리 정보 기반: LuckyExcel 직접 변환 시작...");
-
- // 라이브러리 로드 확인
- await loadLuckysheetLibrary();
-
- // 기존 인스턴스 정리
- try {
- if (
- window.luckysheet &&
- typeof window.luckysheet.destroy === "function"
- ) {
- window.luckysheet.destroy();
- console.log("✅ 기존 인스턴스 destroy 완료");
- }
- } catch (destroyError) {
- console.warn("⚠️ destroy 중 오류 (무시됨):", destroyError);
- }
-
- // 컨테이너 초기화
- if (containerRef.current) {
- containerRef.current.innerHTML = "";
- console.log("✅ 컨테이너 초기화 완료");
- }
-
- luckysheetRef.current = null;
-
- console.log("🍀 LuckyExcel.transformExcelToLucky 호출...");
-
- // ArrayBuffer를 File 객체로 변환 (LuckyExcel은 File 객체 필요)
- const file = new File([xlsxBuffer], fileName, {
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- });
-
- // LuckyExcel의 직접 변환 사용 (Promise 방식)
- const luckyExcelResult = await new Promise((resolve, reject) => {
- try {
- // 🚨 수정: 첫 번째 매개변수는 File 객체여야 함
- (window.LuckyExcel as any).transformExcelToLucky(
- file, // ArrayBuffer 대신 File 객체 사용
- // 성공 콜백
- (exportJson: any, luckysheetfile: any) => {
- console.log("🍀 LuckyExcel 변환 성공!");
- console.log("🍀 exportJson:", exportJson);
- console.log("🍀 luckysheetfile:", luckysheetfile);
- resolve(exportJson);
- },
- // 에러 콜백
- (error: any) => {
- console.error("❌ LuckyExcel 변환 실패:", error);
- reject(new Error(`LuckyExcel 변환 실패: ${error}`));
- },
- );
- } catch (callError) {
- console.error("❌ LuckyExcel 호출 중 오류:", callError);
- reject(callError);
- }
- });
-
- // 결과 검증
- if (
- !luckyExcelResult ||
- !luckyExcelResult.sheets ||
- !Array.isArray(luckyExcelResult.sheets)
- ) {
- throw new Error("LuckyExcel 변환 결과가 유효하지 않습니다.");
- }
-
- console.log("🎉 LuckyExcel 변환 완료, Luckysheet 생성 중...");
-
- // 메모리 정보 기반: exportJson.sheets를 그대로 사용
- // luckysheet.create({ data: exportJson.sheets })
- window.luckysheet.create({
- container: containerRef.current?.id || "luckysheet-container",
- showinfobar: true,
- showtoolbar: true,
- showsheetbar: true,
- showstatisticBar: true,
- allowCopy: true,
- allowEdit: true,
- // 🚨 핵심: LuckyExcel의 원본 변환 결과를 직접 사용
- data: luckyExcelResult.sheets, // 가공하지 않고 그대로 전달
- title: luckyExcelResult.info?.name || fileName,
- // 🚨 수정: userInfo 경로 수정
- userInfo: luckyExcelResult.info?.creator || false,
- });
-
- console.log("🎉 Luckysheet 생성 완료! (원본 데이터 직접 사용)");
- setIsInitialized(true);
- setIsConverting(false);
- setError(null);
- luckysheetRef.current = window.luckysheet;
- } catch (conversionError) {
- console.error("❌ 변환 프로세스 실패:", conversionError);
- setError(
- `변환 프로세스에 실패했습니다: ${
- conversionError instanceof Error
- ? conversionError.message
- : String(conversionError)
- }`,
- );
- setIsConverting(false);
- setIsInitialized(false);
- }
- },
- [loadLuckysheetLibrary, setSelectedRange],
- );
-
- /**
- * DOM 컨테이너 준비 상태 체크 - useLayoutEffect로 동기적 체크
- */
- useLayoutEffect(() => {
- if (containerRef.current) {
- console.log("✅ DOM 컨테이너 준비 완료:", containerRef.current.id);
- setIsContainerReady(true);
- }
- }, []);
-
- /**
- * DOM 컨테이너 준비 상태 재체크 (fallback)
- */
- useEffect(() => {
- if (!isContainerReady) {
- const timer = setTimeout(() => {
- if (containerRef.current && !isContainerReady) {
- console.log("✅ useEffect: DOM 컨테이너 지연 준비 완료");
- setIsContainerReady(true);
- }
- }, 100);
- return () => clearTimeout(timer);
- }
- }, [isContainerReady]);
-
- /**
- * 컴포넌트 마운트 시 초기화
- */
- useEffect(() => {
- if (
- currentFile?.xlsxBuffer &&
- isContainerReady &&
- containerRef.current &&
- !isInitialized &&
- !isConverting
- ) {
- console.log("🔄 XLSX 버퍼 감지, LuckyExcel 직접 변환 시작...", {
- fileName: currentFile.name,
- bufferSize: currentFile.xlsxBuffer.byteLength,
- containerId: containerRef.current.id,
- });
-
- // 중복 실행 방지
- setIsConverting(true);
-
- // LuckyExcel로 직접 변환
- convertXLSXWithLuckyExcel(currentFile.xlsxBuffer, currentFile.name);
- } else if (currentFile && !currentFile.xlsxBuffer) {
- setError("파일 변환 데이터가 없습니다. 파일을 다시 업로드해주세요.");
- }
- }, [
- currentFile?.xlsxBuffer,
- currentFile?.name,
- isContainerReady,
- isInitialized,
- isConverting,
- convertXLSXWithLuckyExcel,
- ]);
-
- /**
- * 컴포넌트 언마운트 시 정리
- */
- useEffect(() => {
- return () => {
- if (luckysheetRef.current && window.luckysheet) {
- try {
- window.luckysheet.destroy();
- } catch (error) {
- console.warn("⚠️ Luckysheet 정리 중 오류:", error);
- }
- }
- };
- }, []);
-
- /**
- * 윈도우 리사이즈 처리
- */
- useEffect(() => {
- const handleResize = () => {
- if (luckysheetRef.current && window.luckysheet) {
- try {
- if (window.luckysheet.resize) {
- window.luckysheet.resize();
- }
- } catch (error) {
- console.warn("⚠️ Luckysheet 리사이즈 중 오류:", error);
- }
- }
- };
-
- window.addEventListener("resize", handleResize);
- return () => window.removeEventListener("resize", handleResize);
- }, []);
-
- return (
-
- {/* Luckysheet 컨테이너 - 항상 렌더링 */}
-
-
- {/* 에러 상태 오버레이 */}
- {error && (
-
-
-
- 시트 로드 오류
-
-
{error}
-
-
-
- )}
-
- {/* 로딩 상태 오버레이 */}
- {!error &&
- (isConverting || !isInitialized) &&
- currentFile?.xlsxBuffer && (
-
-
-
-
- {isConverting ? "LuckyExcel 변환 중..." : "시트 초기화 중..."}
-
-
- {isConverting
- ? "원본 Excel 데이터를 완전한 스타일로 변환하고 있습니다."
- : "잠시만 기다려주세요."}
-
-
-
- )}
-
- {/* 데이터 없음 상태 오버레이 */}
- {!error && !currentFile?.xlsxBuffer && (
-
-
-
- 표시할 시트가 없습니다
-
-
- Excel 파일을 업로드해주세요.
-
-
-
- )}
-
- {/* 시트 정보 표시 (개발용) */}
- {process.env.NODE_ENV === "development" && (
-
-
파일: {currentFile?.name}
-
- XLSX 버퍼:{" "}
- {currentFile?.xlsxBuffer
- ? `${currentFile.xlsxBuffer.byteLength} bytes`
- : "없음"}
-
-
변환 중: {isConverting ? "예" : "아니오"}
-
초기화: {isInitialized ? "완료" : "대기"}
-
컨테이너 준비: {isContainerReady ? "완료" : "대기"}
-
방식: LuckyExcel 직접 변환
-
- )}
-
- );
-}
diff --git a/src/components/sheet/__tests__/FileUpload.test.tsx b/src/components/sheet/__tests__/FileUpload.test.tsx
deleted file mode 100644
index 9f9d573..0000000
--- a/src/components/sheet/__tests__/FileUpload.test.tsx
+++ /dev/null
@@ -1,431 +0,0 @@
-// import React from "react";
-import { render, screen, fireEvent, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import "@testing-library/jest-dom";
-import { vi } from "vitest";
-import { FileUpload } from "../FileUpload";
-import { useAppStore } from "../../../stores/useAppStore";
-import * as fileProcessor from "../../../utils/fileProcessor";
-
-// Mock dependencies
-vi.mock("../../../stores/useAppStore");
-
-// Mock DragEvent for testing environment
-class MockDragEvent extends Event {
- dataTransfer: DataTransfer;
-
- constructor(
- type: string,
- options: { bubbles?: boolean; dataTransfer?: any } = {},
- ) {
- super(type, { bubbles: options.bubbles });
- this.dataTransfer = options.dataTransfer || {
- items: options.dataTransfer?.items || [],
- files: options.dataTransfer?.files || [],
- };
- }
-}
-
-// @ts-ignore
-global.DragEvent = MockDragEvent;
-
-const mockUseAppStore = useAppStore as any;
-
-describe("FileUpload", () => {
- const mockSetLoading = vi.fn();
- const mockSetError = vi.fn();
- const mockUploadFile = vi.fn();
- const mockClearFileUploadErrors = vi.fn();
- const mockAddFileUploadError = vi.fn();
-
- // Mock fileProcessor functions
- const mockProcessExcelFile = vi.fn();
- const mockGetFileErrors = vi.fn();
- const mockFilterValidFiles = vi.fn();
-
- const defaultStoreState = {
- isLoading: false,
- error: null,
- fileUploadErrors: [],
- currentFile: null,
- setLoading: mockSetLoading,
- setError: mockSetError,
- uploadFile: mockUploadFile,
- clearFileUploadErrors: mockClearFileUploadErrors,
- };
-
- beforeEach(() => {
- vi.clearAllMocks();
- mockUseAppStore.mockReturnValue(defaultStoreState);
- // @ts-ignore
- mockUseAppStore.getState = vi.fn().mockReturnValue({
- addFileUploadError: mockAddFileUploadError,
- });
-
- // Mock fileProcessor functions
- vi.spyOn(fileProcessor, "processExcelFile").mockImplementation(
- mockProcessExcelFile,
- );
- vi.spyOn(fileProcessor, "getFileErrors").mockImplementation(
- mockGetFileErrors,
- );
- vi.spyOn(fileProcessor, "filterValidFiles").mockImplementation(
- mockFilterValidFiles,
- );
-
- // Default mock implementations
- mockGetFileErrors.mockReturnValue([]);
- mockFilterValidFiles.mockReturnValue([]);
- });
-
- afterEach(() => {
- vi.clearAllMocks();
- vi.restoreAllMocks();
- });
-
- describe("초기 렌더링", () => {
- it("기본 업로드 UI를 렌더링한다", () => {
- render();
-
- expect(screen.getByText("Excel 파일을 업로드하세요")).toBeInTheDocument();
- expect(screen.getByText(".xlsx, .xls")).toBeInTheDocument();
- expect(
- screen.getByText("파일을 드래그 앤 드롭하거나 클릭하여 업로드"),
- ).toBeInTheDocument();
- expect(screen.getByLabelText("파일 업로드 영역")).toBeInTheDocument();
- });
-
- it("파일 입력 요소가 올바르게 설정된다", () => {
- render();
-
- const fileInput = screen.getByLabelText("파일 선택");
- expect(fileInput).toBeInTheDocument();
- expect(fileInput).toHaveAttribute("type", "file");
- expect(fileInput).toHaveAttribute("accept", ".xlsx,.xls");
- });
- });
-
- describe("로딩 상태", () => {
- it("로딩 중일 때 로딩 UI를 표시한다", () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- isLoading: true,
- });
-
- render();
-
- expect(screen.getByText("파일 처리 중...")).toBeInTheDocument();
- expect(screen.getByText("잠시만 기다려주세요...")).toBeInTheDocument();
- });
-
- it("로딩 중일 때 파일 업로드 영역이 비활성화된다", () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- isLoading: true,
- });
-
- render();
-
- const uploadArea = screen.getByLabelText("파일 업로드 영역");
- expect(uploadArea).toHaveAttribute("tabindex", "-1");
- });
- });
-
- describe("에러 상태", () => {
- it("에러가 있을 때 에러 UI를 표시한다", () => {
- const errorMessage = "파일 업로드에 실패했습니다.";
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- error: errorMessage,
- });
-
- render();
-
- expect(screen.getByText("업로드 오류")).toBeInTheDocument();
- expect(screen.getByText(errorMessage)).toBeInTheDocument();
- });
-
- it("파일 업로드 에러 목록을 표시한다", () => {
- const fileUploadErrors = [
- { fileName: "test1.txt", error: "지원되지 않는 파일 형식입니다." },
- { fileName: "test2.pdf", error: "파일 크기가 너무 큽니다." },
- ];
-
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- fileUploadErrors,
- });
-
- render();
-
- expect(screen.getByText("파일 업로드 오류:")).toBeInTheDocument();
- expect(screen.getByText("test1.txt")).toBeInTheDocument();
- expect(
- screen.getByText(/지원되지 않는 파일 형식입니다/),
- ).toBeInTheDocument();
- expect(screen.getByText("test2.pdf")).toBeInTheDocument();
- expect(screen.getByText(/파일 크기가 너무 큽니다/)).toBeInTheDocument();
- });
- });
-
- describe("성공 상태", () => {
- it("파일 업로드 성공 시 성공 UI를 표시한다", () => {
- const currentFile = {
- name: "test.xlsx",
- size: 1024 * 1024, // 1MB
- uploadedAt: new Date(),
- };
-
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- currentFile,
- });
-
- render();
-
- expect(screen.getByText("파일 업로드 완료")).toBeInTheDocument();
- expect(screen.getByText("test.xlsx")).toBeInTheDocument();
- expect(screen.getByText("파일 크기: 1.00 MB")).toBeInTheDocument();
- expect(screen.getByText("다른 파일 업로드")).toBeInTheDocument();
- });
- });
-
- describe("파일 선택", () => {
- it("파일 선택 버튼 클릭 시 파일 입력을 트리거한다", async () => {
- const user = userEvent.setup();
- render();
-
- const uploadArea = screen.getByLabelText("파일 업로드 영역");
- const fileInput = screen.getByLabelText("파일 선택");
-
- const clickSpy = vi.spyOn(fileInput, "click");
-
- await user.click(uploadArea);
-
- expect(clickSpy).toHaveBeenCalled();
- });
-
- it("키보드 이벤트(Enter)로 파일 선택을 트리거한다", async () => {
- const user = userEvent.setup();
- render();
-
- const uploadArea = screen.getByLabelText("파일 업로드 영역");
- const fileInput = screen.getByLabelText("파일 선택");
-
- const clickSpy = vi.spyOn(fileInput, "click");
-
- uploadArea.focus();
- await user.keyboard("{Enter}");
-
- expect(clickSpy).toHaveBeenCalled();
- });
-
- it("키보드 이벤트(Space)로 파일 선택을 트리거한다", async () => {
- const user = userEvent.setup();
- render();
-
- const uploadArea = screen.getByLabelText("파일 업로드 영역");
- const fileInput = screen.getByLabelText("파일 선택");
-
- const clickSpy = vi.spyOn(fileInput, "click");
-
- uploadArea.focus();
- await user.keyboard(" ");
-
- expect(clickSpy).toHaveBeenCalled();
- });
- });
-
- describe("파일 처리", () => {
- it("유효한 파일 업로드 시 파일 처리 함수를 호출한다", async () => {
- const mockFile = new File(["test content"], "test.xlsx", {
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- });
-
- const successResult = {
- success: true,
- data: [{ id: "sheet1", name: "Sheet1", data: [] }],
- fileName: "test.xlsx",
- fileSize: 1024,
- };
-
- // Mock valid file
- mockFilterValidFiles.mockReturnValue([mockFile]);
- mockGetFileErrors.mockReturnValue([]);
- mockProcessExcelFile.mockResolvedValue(successResult);
-
- const user = userEvent.setup();
- render();
-
- const fileInput = screen.getByLabelText("파일 선택");
-
- await user.upload(fileInput, mockFile);
-
- await waitFor(() => {
- expect(mockProcessExcelFile).toHaveBeenCalledWith(mockFile);
- expect(mockSetLoading).toHaveBeenCalledWith(
- true,
- "파일을 처리하는 중...",
- );
- expect(mockUploadFile).toHaveBeenCalledWith(successResult);
- });
- });
-
- it("파일 처리 실패 시 에러 처리를 한다", async () => {
- const mockFile = new File(["test content"], "test.xlsx", {
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- });
-
- const errorResult = {
- success: false,
- error: "파일 형식이 올바르지 않습니다.",
- fileName: "test.xlsx",
- fileSize: 1024,
- };
-
- // Mock valid file but processing fails
- mockFilterValidFiles.mockReturnValue([mockFile]);
- mockGetFileErrors.mockReturnValue([]);
- mockProcessExcelFile.mockResolvedValue(errorResult);
-
- const user = userEvent.setup();
- render();
-
- const fileInput = screen.getByLabelText("파일 선택");
-
- await user.upload(fileInput, mockFile);
-
- await waitFor(() => {
- expect(mockUploadFile).toHaveBeenCalledWith(errorResult);
- });
- });
-
- it("파일 처리 중 예외 발생 시 에러 처리를 한다", async () => {
- const mockFile = new File(["test content"], "test.xlsx", {
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- });
-
- // Mock valid file but processing throws
- mockFilterValidFiles.mockReturnValue([mockFile]);
- mockGetFileErrors.mockReturnValue([]);
- mockProcessExcelFile.mockRejectedValue(new Error("Unexpected error"));
-
- const user = userEvent.setup();
- render();
-
- const fileInput = screen.getByLabelText("파일 선택");
-
- await user.upload(fileInput, mockFile);
-
- await waitFor(() => {
- expect(mockSetError).toHaveBeenCalledWith(
- "파일 처리 중 예상치 못한 오류가 발생했습니다.",
- );
- });
- });
- });
-
- describe("드래그 앤 드롭", () => {
- it("드래그 엔터 시 드래그 오버 상태를 활성화한다", async () => {
- render();
-
- const uploadArea = screen.getByLabelText("파일 업로드 영역");
-
- const dragEnterEvent = new DragEvent("dragenter", {
- bubbles: true,
- dataTransfer: {
- items: [{ kind: "file" }],
- },
- });
-
- fireEvent(uploadArea, dragEnterEvent);
-
- // 드래그 오버 상태 확인 (드래그 오버 시 특별한 스타일이 적용됨)
- expect(uploadArea).toHaveClass(
- "border-blue-500",
- "bg-blue-100",
- "scale-105",
- );
- });
-
- it("드래그 리브 시 드래그 오버 상태를 비활성화한다", async () => {
- render();
-
- const uploadArea = screen.getByLabelText("파일 업로드 영역");
-
- // 먼저 드래그 엔터
- const dragEnterEvent = new DragEvent("dragenter", {
- bubbles: true,
- dataTransfer: {
- items: [{ kind: "file" }],
- },
- });
-
- fireEvent(uploadArea, dragEnterEvent);
-
- // 드래그 리브
- const dragLeaveEvent = new DragEvent("dragleave", {
- bubbles: true,
- });
-
- fireEvent(uploadArea, dragLeaveEvent);
-
- expect(uploadArea).toHaveClass("border-gray-300");
- });
-
- it("파일 드롭 시 파일 처리를 실행한다", async () => {
- const mockFile = new File(["test content"], "test.xlsx", {
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- });
-
- // Mock valid file
- mockFilterValidFiles.mockReturnValue([mockFile]);
- mockGetFileErrors.mockReturnValue([]);
-
- render();
-
- const uploadArea = screen.getByLabelText("파일 업로드 영역");
-
- const dropEvent = new DragEvent("drop", {
- bubbles: true,
- dataTransfer: {
- files: [mockFile],
- },
- });
-
- fireEvent(uploadArea, dropEvent);
-
- await waitFor(() => {
- expect(mockProcessExcelFile).toHaveBeenCalledWith(mockFile);
- });
- });
- });
-
- describe("접근성", () => {
- it("ARIA 라벨과 설명이 올바르게 설정된다", () => {
- render();
-
- const uploadArea = screen.getByLabelText("파일 업로드 영역");
- expect(uploadArea).toHaveAttribute(
- "aria-describedby",
- "upload-instructions",
- );
- expect(uploadArea).toHaveAttribute("role", "button");
-
- const instructions = screen.getByText("최대 50MB까지 업로드 가능");
- expect(instructions).toHaveAttribute("id", "upload-instructions");
- });
-
- it("로딩 중일 때 접근성 속성이 올바르게 설정된다", () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- isLoading: true,
- });
-
- render();
-
- const uploadArea = screen.getByLabelText("파일 업로드 영역");
- expect(uploadArea).toHaveAttribute("tabindex", "-1");
- });
- });
-});
diff --git a/src/components/sheet/__tests__/SheetViewer.test.tsx b/src/components/sheet/__tests__/SheetViewer.test.tsx
deleted file mode 100644
index adf926d..0000000
--- a/src/components/sheet/__tests__/SheetViewer.test.tsx
+++ /dev/null
@@ -1,402 +0,0 @@
-// import React from "react";
-import { render, screen, waitFor } from "@testing-library/react";
-import "@testing-library/jest-dom";
-import { vi } from "vitest";
-import { SheetViewer } from "../SheetViewer";
-import { useAppStore } from "../../../stores/useAppStore";
-import type { SheetData } from "../../../types/sheet";
-
-// Mock dependencies
-vi.mock("../../../stores/useAppStore");
-
-// Luckysheet 모킹
-const mockLuckysheet = {
- create: vi.fn(),
- destroy: vi.fn(),
- resize: vi.fn(),
- getSheet: vi.fn(),
- getAllSheets: vi.fn(),
- setActiveSheet: vi.fn(),
-};
-
-// Window.luckysheet 모킹
-Object.defineProperty(window, "luckysheet", {
- value: mockLuckysheet,
- writable: true,
-});
-
-// useAppStore 모킹 타입
-const mockUseAppStore = vi.mocked(useAppStore);
-
-// 기본 스토어 상태
-const defaultStoreState = {
- sheets: [],
- activeSheetId: null,
- currentFile: null,
- setSelectedRange: vi.fn(),
- isLoading: false,
- error: null,
- setLoading: vi.fn(),
- setError: vi.fn(),
- uploadFile: vi.fn(),
- clearFileUploadErrors: vi.fn(),
- resetApp: vi.fn(),
-};
-
-// 테스트용 시트 데이터
-const mockSheetData: SheetData[] = [
- {
- id: "sheet_0",
- name: "Sheet1",
- data: [
- ["A1", "B1", "C1"],
- ["A2", "B2", "C2"],
- ],
- config: {
- container: "luckysheet_0",
- title: "Sheet1",
- lang: "ko",
- data: [
- {
- name: "Sheet1",
- index: "0",
- celldata: [
- {
- r: 0,
- c: 0,
- v: { v: "A1", m: "A1", ct: { fa: "General", t: "g" } },
- },
- {
- r: 0,
- c: 1,
- v: { v: "B1", m: "B1", ct: { fa: "General", t: "g" } },
- },
- ],
- status: 1,
- order: 0,
- row: 2,
- column: 3,
- },
- ],
- options: {
- showtoolbar: true,
- showinfobar: false,
- showsheetbar: true,
- showstatisticBar: false,
- allowCopy: true,
- allowEdit: true,
- enableAddRow: true,
- enableAddCol: true,
- },
- },
- },
-];
-
-describe("SheetViewer", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- mockUseAppStore.mockReturnValue(defaultStoreState);
- });
-
- afterEach(() => {
- // DOM 정리
- document.head.innerHTML = "";
- });
-
- describe("초기 렌더링", () => {
- it("시트 데이터가 없을 때 적절한 메시지를 표시한다", () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: [],
- });
-
- render();
-
- expect(screen.getByText("표시할 시트가 없습니다")).toBeInTheDocument();
- expect(
- screen.getByText("Excel 파일을 업로드해주세요."),
- ).toBeInTheDocument();
- });
-
- it("시트 데이터가 있을 때 로딩 상태를 표시한다", () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: mockSheetData,
- currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
- });
-
- render();
-
- expect(screen.getByText("시트 로딩 중...")).toBeInTheDocument();
- expect(screen.getByText("잠시만 기다려주세요.")).toBeInTheDocument();
- });
-
- it("Luckysheet 컨테이너가 올바르게 렌더링된다", () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: mockSheetData,
- currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
- });
-
- render();
-
- const container = document.getElementById("luckysheet-container");
- expect(container).toBeInTheDocument();
- expect(container).toHaveClass("w-full", "h-full");
- });
- });
-
- describe("Luckysheet 초기화", () => {
- it("시트 데이터가 변경되면 Luckysheet를 초기화한다", async () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: mockSheetData,
- currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
- });
-
- render();
-
- await waitFor(() => {
- expect(mockLuckysheet.create).toHaveBeenCalled();
- });
-
- // create 호출 시 전달된 설정 확인
- const createCall = mockLuckysheet.create.mock.calls[0];
- expect(createCall).toBeDefined();
-
- const config = createCall[0];
- expect(config.container).toBe("luckysheet-container");
- expect(config.title).toBe("test.xlsx");
- expect(config.lang).toBe("ko");
- expect(config.data).toHaveLength(1);
- });
-
- it("기존 Luckysheet 인스턴스가 있으면 제거한다", async () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: mockSheetData,
- currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
- });
-
- const { rerender } = render();
-
- await waitFor(() => {
- expect(mockLuckysheet.create).toHaveBeenCalledTimes(1);
- });
-
- // 시트 데이터 변경
- const newSheetData: SheetData[] = [
- {
- ...mockSheetData[0],
- name: "NewSheet",
- },
- ];
-
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: newSheetData,
- currentFile: { name: "new.xlsx", size: 1000, uploadedAt: new Date() },
- });
-
- rerender();
-
- await waitFor(() => {
- expect(mockLuckysheet.destroy).toHaveBeenCalled();
- expect(mockLuckysheet.create).toHaveBeenCalledTimes(2);
- });
- });
- });
-
- describe("에러 처리", () => {
- it("Luckysheet 초기화 실패 시 에러 메시지를 표시한다", async () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: mockSheetData,
- currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
- });
-
- // Luckysheet.create에서 에러 발생 시뮬레이션
- mockLuckysheet.create.mockImplementation(() => {
- throw new Error("Luckysheet 초기화 실패");
- });
-
- render();
-
- await waitFor(() => {
- expect(screen.getByText("시트 로드 오류")).toBeInTheDocument();
- expect(
- screen.getByText(/시트 초기화에 실패했습니다/),
- ).toBeInTheDocument();
- });
-
- // 다시 시도 버튼 확인
- const retryButton = screen.getByRole("button", { name: "다시 시도" });
- expect(retryButton).toBeInTheDocument();
- });
-
- it("다시 시도 버튼을 클릭하면 초기화를 재시도한다", async () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: mockSheetData,
- currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
- });
-
- // 첫 번째 시도에서 실패
- mockLuckysheet.create.mockImplementationOnce(() => {
- throw new Error("첫 번째 실패");
- });
-
- render();
-
- await waitFor(() => {
- expect(screen.getByText("시트 로드 오류")).toBeInTheDocument();
- });
-
- // 두 번째 시도에서 성공하도록 설정
- mockLuckysheet.create.mockImplementationOnce(() => {
- // 성공
- });
-
- const retryButton = screen.getByRole("button", { name: "다시 시도" });
- retryButton.click();
-
- await waitFor(() => {
- expect(mockLuckysheet.create).toHaveBeenCalledTimes(2);
- });
- });
- });
-
- describe("이벤트 핸들링", () => {
- it("셀 클릭 시 선택된 범위를 스토어에 저장한다", async () => {
- const mockSetSelectedRange = vi.fn();
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: mockSheetData,
- currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
- setSelectedRange: mockSetSelectedRange,
- });
-
- render();
-
- await waitFor(() => {
- expect(mockLuckysheet.create).toHaveBeenCalled();
- });
-
- // create 호출 시 전달된 hook 확인
- const createCall = mockLuckysheet.create.mock.calls[0];
- const config = createCall[0];
-
- // cellClick 핸들러 시뮬레이션
- const cellClickHandler = config.hook.cellClick;
- expect(cellClickHandler).toBeDefined();
-
- const mockCell = {};
- const mockPosition = { r: 1, c: 2 };
- const mockSheetFile = { index: "0" };
-
- cellClickHandler(mockCell, mockPosition, mockSheetFile);
-
- expect(mockSetSelectedRange).toHaveBeenCalledWith({
- range: {
- startRow: 1,
- startCol: 2,
- endRow: 1,
- endCol: 2,
- },
- sheetId: "0",
- });
- });
-
- it("시트 활성화 시 활성 시트 ID를 업데이트한다", async () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: mockSheetData,
- currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
- });
-
- // setActiveSheetId를 spy로 설정
- const setActiveSheetIdSpy = vi.fn();
- useAppStore.getState = vi.fn().mockReturnValue({
- setActiveSheetId: setActiveSheetIdSpy,
- });
-
- render();
-
- await waitFor(() => {
- expect(mockLuckysheet.create).toHaveBeenCalled();
- });
-
- // create 호출 시 전달된 hook 확인
- const createCall = mockLuckysheet.create.mock.calls[0];
- const config = createCall[0];
-
- // sheetActivate 핸들러 시뮬레이션
- const sheetActivateHandler = config.hook.sheetActivate;
- expect(sheetActivateHandler).toBeDefined();
-
- sheetActivateHandler(0, false, false);
-
- expect(setActiveSheetIdSpy).toHaveBeenCalledWith("sheet_0");
- });
- });
-
- describe("컴포넌트 생명주기", () => {
- it("컴포넌트 언마운트 시 Luckysheet를 정리한다", async () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: mockSheetData,
- currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
- });
-
- const { unmount } = render();
-
- await waitFor(() => {
- expect(mockLuckysheet.create).toHaveBeenCalled();
- });
-
- unmount();
-
- expect(mockLuckysheet.destroy).toHaveBeenCalled();
- });
-
- it("윈도우 리사이즈 시 Luckysheet 리사이즈를 호출한다", async () => {
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: mockSheetData,
- currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
- });
-
- render();
-
- await waitFor(() => {
- expect(mockLuckysheet.create).toHaveBeenCalled();
- });
-
- // 윈도우 리사이즈 이벤트 시뮬레이션
- window.dispatchEvent(new Event("resize"));
-
- expect(mockLuckysheet.resize).toHaveBeenCalled();
- });
- });
-
- describe("개발 모드 정보", () => {
- it("개발 모드에서 시트 정보를 표시한다", () => {
- const originalEnv = process.env.NODE_ENV;
- process.env.NODE_ENV = "development";
-
- mockUseAppStore.mockReturnValue({
- ...defaultStoreState,
- sheets: mockSheetData,
- activeSheetId: "sheet_0",
- currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
- });
-
- render();
-
- expect(screen.getByText("시트 개수: 1")).toBeInTheDocument();
- expect(screen.getByText("활성 시트: sheet_0")).toBeInTheDocument();
-
- process.env.NODE_ENV = originalEnv;
- });
- });
-});
diff --git a/src/components/ui/homeButton.tsx b/src/components/ui/homeButton.tsx
new file mode 100644
index 0000000..5d91c25
--- /dev/null
+++ b/src/components/ui/homeButton.tsx
@@ -0,0 +1,29 @@
+import React from "react";
+
+interface HomeButtonProps
+ extends React.ButtonHTMLAttributes {
+ children: React.ReactNode;
+}
+
+/**
+ * 텍스트만 있는 홈 버튼 컴포넌트
+ * - 파란색, 볼드, hover 시 underline
+ * - outline/배경 없음
+ * - Button 컴포넌트 의존성 없음
+ */
+const HomeButton: React.FC = ({ children, ...props }) => {
+ return (
+
+ );
+};
+
+export default HomeButton;
diff --git a/src/utils/__tests__/fileProcessor.test.ts b/src/utils/__tests__/fileProcessor.test.ts
deleted file mode 100644
index 80065b8..0000000
--- a/src/utils/__tests__/fileProcessor.test.ts
+++ /dev/null
@@ -1,459 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import * as XLSX from "xlsx-js-style";
-import {
- validateFileType,
- validateFileSize,
- getFileErrorMessage,
- filterValidFiles,
- getFileErrors,
- processExcelFile,
- MAX_FILE_SIZE,
- SUPPORTED_EXTENSIONS,
-} from "../fileProcessor";
-
-// xlsx-js-style 모킹 (통합 처리)
-vi.mock("xlsx-js-style", () => ({
- read: vi.fn(() => ({
- SheetNames: ["Sheet1"],
- Sheets: {
- Sheet1: {
- A1: { v: "테스트" },
- B1: { v: "한글" },
- C1: { v: "데이터" },
- "!ref": "A1:C2",
- },
- },
- })),
- write: vi.fn(() => new ArrayBuffer(1024)), // XLSX.write 모킹 추가
- utils: {
- sheet_to_json: vi.fn(() => [
- ["테스트", "한글", "데이터"],
- ["값1", "값2", "값3"],
- ]),
- decode_range: vi.fn((_ref) => ({
- s: { r: 0, c: 0 },
- e: { r: 1, c: 2 },
- })),
- encode_cell: vi.fn(
- (cell) => `${String.fromCharCode(65 + cell.c)}${cell.r + 1}`,
- ),
- aoa_to_sheet: vi.fn(() => ({
- A1: { v: "테스트" },
- B1: { v: "한글" },
- C1: { v: "데이터" },
- "!ref": "A1:C1",
- })),
- book_new: vi.fn(() => ({ SheetNames: [], Sheets: {} })),
- book_append_sheet: vi.fn(),
- },
-}));
-
-// LuckyExcel 모킹
-vi.mock("luckyexcel", () => ({
- transformExcelToLucky: vi.fn(
- (_arrayBuffer, successCallback, _errorCallback) => {
- // 성공적인 변환 결과 모킹
- const mockResult = {
- sheets: [
- {
- name: "Sheet1",
- index: "0",
- status: 1,
- order: 0,
- row: 2,
- column: 3,
- celldata: [
- {
- r: 0,
- c: 0,
- v: { v: "테스트", m: "테스트", ct: { fa: "General", t: "g" } },
- },
- {
- r: 0,
- c: 1,
- v: { v: "한글", m: "한글", ct: { fa: "General", t: "g" } },
- },
- {
- r: 0,
- c: 2,
- v: { v: "데이터", m: "데이터", ct: { fa: "General", t: "g" } },
- },
- {
- r: 1,
- c: 0,
- v: { v: "값1", m: "값1", ct: { fa: "General", t: "g" } },
- },
- {
- r: 1,
- c: 1,
- v: { v: "값2", m: "값2", ct: { fa: "General", t: "g" } },
- },
- {
- r: 1,
- c: 2,
- v: { v: "값3", m: "값3", ct: { fa: "General", t: "g" } },
- },
- ],
- },
- ],
- };
-
- // 성공 콜백 비동기 호출 (ArrayBuffer 매개변수 대응)
- if (typeof successCallback === "function") {
- setTimeout(() => successCallback(mockResult, null), 0);
- }
- },
- ),
-}));
-
-// 파일 생성 도우미 함수
-function createMockFile(
- name: string,
- size: number,
- type: string = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
-): File {
- const mockFile = new Blob(["mock file content"], { type });
- Object.defineProperty(mockFile, "name", {
- value: name,
- writable: false,
- });
- Object.defineProperty(mockFile, "size", {
- value: size,
- writable: false,
- });
-
- // ArrayBuffer 메서드 모킹
- Object.defineProperty(mockFile, "arrayBuffer", {
- value: async () => new ArrayBuffer(size),
- writable: false,
- });
-
- return mockFile as File;
-}
-
-// FileList 모킹
-class MockFileList {
- private _files: File[];
-
- constructor(files: File[]) {
- this._files = files;
- }
-
- get length(): number {
- return this._files.length;
- }
-
- item(index: number): File | null {
- return this._files[index] || null;
- }
-
- get files(): FileList {
- const files = this._files;
- return Object.assign(files, {
- item: (index: number) => files[index] || null,
- [Symbol.iterator]: function* (): Generator {
- for (let i = 0; i < files.length; i++) {
- yield files[i];
- }
- },
- }) as unknown as FileList;
- }
-}
-
-describe("fileProcessor", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- describe("validateFileType", () => {
- it("지원하는 파일 확장자를 승인해야 함", () => {
- const validFiles = [
- createMockFile("test.xlsx", 1000),
- createMockFile("test.xls", 1000),
- createMockFile("test.csv", 1000),
- createMockFile("한글파일.xlsx", 1000),
- ];
-
- validFiles.forEach((file) => {
- expect(validateFileType(file)).toBe(true);
- });
- });
-
- it("지원하지 않는 파일 확장자를 거부해야 함", () => {
- const invalidFiles = [
- createMockFile("test.txt", 1000),
- createMockFile("test.pdf", 1000),
- createMockFile("test.doc", 1000),
- createMockFile("test", 1000),
- ];
-
- invalidFiles.forEach((file) => {
- expect(validateFileType(file)).toBe(false);
- });
- });
-
- it("대소문자를 무시하고 파일 확장자를 검증해야 함", () => {
- const files = [
- createMockFile("test.XLSX", 1000),
- createMockFile("test.XLS", 1000),
- createMockFile("test.CSV", 1000),
- ];
-
- files.forEach((file) => {
- expect(validateFileType(file)).toBe(true);
- });
- });
- });
-
- describe("validateFileSize", () => {
- it("허용된 크기의 파일을 승인해야 함", () => {
- const smallFile = createMockFile("small.xlsx", 1000);
- expect(validateFileSize(smallFile)).toBe(true);
- });
-
- it("최대 크기를 초과한 파일을 거부해야 함", () => {
- const largeFile = createMockFile("large.xlsx", MAX_FILE_SIZE + 1);
- expect(validateFileSize(largeFile)).toBe(false);
- });
- });
-
- describe("getFileErrorMessage", () => {
- it("유효한 파일에 대해 빈 문자열을 반환해야 함", () => {
- const validFile = createMockFile("valid.xlsx", 1000);
- expect(getFileErrorMessage(validFile)).toBe("");
- });
-
- it("잘못된 파일 형식에 대해 적절한 오류 메시지를 반환해야 함", () => {
- const invalidFile = createMockFile("invalid.txt", 1000);
- const message = getFileErrorMessage(invalidFile);
- expect(message).toContain("지원되지 않는 파일 형식");
- expect(message).toContain(SUPPORTED_EXTENSIONS.join(", "));
- });
-
- it("파일 크기 초과에 대해 적절한 오류 메시지를 반환해야 함", () => {
- const largeFile = createMockFile("large.xlsx", MAX_FILE_SIZE + 1);
- const message = getFileErrorMessage(largeFile);
- expect(message).toContain("파일 크기가 너무 큽니다");
- });
- });
-
- describe("filterValidFiles", () => {
- it("유효한 파일들만 필터링해야 함", () => {
- const fileList = new MockFileList([
- createMockFile("valid1.xlsx", 1000),
- createMockFile("invalid.txt", 1000),
- createMockFile("valid2.csv", 1000),
- createMockFile("large.xlsx", MAX_FILE_SIZE + 1),
- ]).files;
-
- const validFiles = filterValidFiles(fileList);
- expect(validFiles).toHaveLength(2);
- expect(validFiles[0].name).toBe("valid1.xlsx");
- expect(validFiles[1].name).toBe("valid2.csv");
- });
- });
-
- describe("getFileErrors", () => {
- it("무효한 파일들의 오류 목록을 반환해야 함", () => {
- const fileList = new MockFileList([
- createMockFile("valid.xlsx", 1000),
- createMockFile("invalid.txt", 1000),
- createMockFile("large.xlsx", MAX_FILE_SIZE + 1),
- ]).files;
-
- const errors = getFileErrors(fileList);
- expect(errors).toHaveLength(2);
- expect(errors[0].file.name).toBe("invalid.txt");
- expect(errors[0].error).toContain("지원되지 않는 파일 형식");
- expect(errors[1].file.name).toBe("large.xlsx");
- expect(errors[1].error).toContain("파일 크기가 너무 큽니다");
- });
- });
-
- describe("SheetJS 통합 파일 처리", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it("XLSX 파일을 성공적으로 처리해야 함", async () => {
- const xlsxFile = createMockFile(
- "test.xlsx",
- 1024,
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- );
-
- const result = await processExcelFile(xlsxFile);
-
- expect(result.success).toBe(true);
- expect(result.data).toBeDefined();
- expect(Array.isArray(result.data)).toBe(true);
- expect(result.data).toHaveLength(1);
- expect(result.data![0].name).toBe("Sheet1");
- // XLSX 파일은 변환 없이 직접 처리되므로 XLSX.write가 호출되지 않음
- });
-
- it("XLS 파일을 성공적으로 처리해야 함", async () => {
- const xlsFile = createMockFile(
- "test.xls",
- 1024,
- "application/vnd.ms-excel",
- );
-
- const result = await processExcelFile(xlsFile);
-
- expect(result.success).toBe(true);
- expect(result.data).toBeDefined();
- expect(Array.isArray(result.data)).toBe(true);
- expect(result.data).toHaveLength(1);
- expect(result.data![0].name).toBe("Sheet1");
- // XLS 파일은 SheetJS를 통해 XLSX로 변환 후 처리
- expect(XLSX.write).toHaveBeenCalled();
- });
-
- it("CSV 파일을 성공적으로 처리해야 함", async () => {
- const csvFile = createMockFile("test.csv", 1024, "text/csv");
-
- const result = await processExcelFile(csvFile);
-
- expect(result.success).toBe(true);
- expect(result.data).toBeDefined();
- expect(Array.isArray(result.data)).toBe(true);
- expect(result.data).toHaveLength(1);
- expect(result.data![0].name).toBe("Sheet1");
- // CSV 파일은 SheetJS를 통해 XLSX로 변환 후 처리
- expect(XLSX.write).toHaveBeenCalled();
- });
-
- it("한글 파일명을 올바르게 처리해야 함", async () => {
- const koreanFile = createMockFile(
- "한글파일명_테스트데이터.xlsx",
- 1024,
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- );
-
- const result = await processExcelFile(koreanFile);
-
- expect(result.success).toBe(true);
- expect(result.data).toBeDefined();
- });
-
- it("빈 파일을 적절히 처리해야 함", async () => {
- const emptyFile = createMockFile("empty.xlsx", 0);
-
- const result = await processExcelFile(emptyFile);
-
- expect(result.success).toBe(false);
- expect(result.error).toContain("파일이 비어있습니다");
- });
-
- it("유효하지 않은 workbook 을 처리해야 함", async () => {
- (XLSX.read as any).mockReturnValueOnce(null);
-
- const invalidFile = createMockFile("invalid.xlsx", 1024);
- const result = await processExcelFile(invalidFile);
-
- expect(result.success).toBe(false);
- expect(result.error).toContain("워크북을 생성할 수 없습니다");
- });
-
- it("시트가 없는 workbook을 처리해야 함", async () => {
- (XLSX.read as any).mockReturnValueOnce({
- SheetNames: [],
- Sheets: {},
- });
-
- const noSheetsFile = createMockFile("no-sheets.xlsx", 1024);
- const result = await processExcelFile(noSheetsFile);
-
- expect(result.success).toBe(false);
- expect(result.error).toContain("시트 이름 정보가 없습니다");
- });
-
- it("Sheets 속성이 없는 workbook을 처리해야 함", async () => {
- (XLSX.read as any).mockReturnValueOnce({
- SheetNames: ["Sheet1"],
- // Sheets 속성 누락
- });
-
- const corruptedFile = createMockFile("corrupted.xlsx", 1024);
- const result = await processExcelFile(corruptedFile);
-
- expect(result.success).toBe(false);
- expect(result.error).toContain("유효한 시트가 없습니다");
- });
-
- it("XLSX.read 실패 시 대체 인코딩을 시도해야 함", async () => {
- // 첫 번째 호출은 실패, 두 번째 호출은 성공
- (XLSX.read as any)
- .mockImplementationOnce(() => {
- throw new Error("UTF-8 read failed");
- })
- .mockReturnValueOnce({
- SheetNames: ["Sheet1"],
- Sheets: {
- Sheet1: { A1: { v: "성공" } },
- },
- });
-
- const fallbackFile = createMockFile("fallback.csv", 1024, "text/csv");
- const result = await processExcelFile(fallbackFile);
-
- expect(result.success).toBe(true);
- expect(XLSX.read).toHaveBeenCalledTimes(2);
- // CSV 파일은 TextDecoder를 사용하여 문자열로 읽어서 처리
- expect(XLSX.read).toHaveBeenNthCalledWith(
- 2,
- expect.any(String),
- expect.objectContaining({
- type: "string",
- codepage: 949, // EUC-KR 대체 인코딩
- }),
- );
- });
-
- it("모든 읽기 시도가 실패하면 적절한 오류를 반환해야 함", async () => {
- (XLSX.read as any).mockImplementation(() => {
- throw new Error("Read completely failed");
- });
-
- const failedFile = createMockFile("failed.xlsx", 1024);
- const result = await processExcelFile(failedFile);
-
- expect(result.success).toBe(false);
- expect(result.error).toContain("파일을 읽을 수 없습니다");
- });
-
- it("한글 데이터를 올바르게 처리해야 함", async () => {
- // beforeEach에서 설정된 기본 모킹을 그대로 사용하지만,
- // 실제로는 시트명이 변경되지 않는 것이 정상 동작입니다.
- // LuckyExcel에서 변환할 때 시트명은 일반적으로 유지되지만,
- // 모킹 데이터에서는 "Sheet1"로 설정되어 있으므로 이를 맞춰야 합니다.
-
- // 한글 데이터가 포함된 시트 모킹
- (XLSX.read as any).mockReturnValueOnce({
- SheetNames: ["한글시트"],
- Sheets: {
- 한글시트: {
- A1: { v: "이름" },
- B1: { v: "나이" },
- C1: { v: "주소" },
- A2: { v: "김철수" },
- B2: { v: 30 },
- C2: { v: "서울시 강남구" },
- "!ref": "A1:C2",
- },
- },
- });
-
- const koreanDataFile = createMockFile("한글데이터.xlsx", 1024);
- const result = await processExcelFile(koreanDataFile);
-
- expect(result.success).toBe(true);
- expect(result.data).toBeDefined();
- expect(Array.isArray(result.data)).toBe(true);
- expect(result.data).toHaveLength(1);
- // 실제 모킹 데이터에서는 "Sheet1"을 사용하므로 이를 확인합니다.
- expect(result.data![0].name).toBe("Sheet1"); // 모킹 데이터의 실제 시트명
- });
- });
-});
diff --git a/src/utils/fileProcessor.ts b/src/utils/fileProcessor.ts
deleted file mode 100644
index 87578d3..0000000
--- a/src/utils/fileProcessor.ts
+++ /dev/null
@@ -1,1290 +0,0 @@
-// 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;
-}
diff --git a/src/utils/fileProcessor.ts.bak b/src/utils/fileProcessor.ts.bak
deleted file mode 100644
index 877680b..0000000
--- a/src/utils/fileProcessor.ts.bak
+++ /dev/null
@@ -1,1290 +0,0 @@
-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;
-}
diff --git a/src/utils/styleTest.ts.bak b/src/utils/styleTest.ts.bak
deleted file mode 100644
index 4175bda..0000000
--- a/src/utils/styleTest.ts.bak
+++ /dev/null
@@ -1,417 +0,0 @@
-/**
- * xlsx-js-style 스타일 보존 테스트 유틸리티
- * - 다양한 스타일이 적용된 Excel 파일 생성
- * - 스타일 정보 확인 도구
- */
-
-import * as XLSX from "xlsx-js-style";
-
-/**
- * 스타일이 적용된 테스트 Excel 파일 생성
- */
-export function createStyledTestExcel(): ArrayBuffer {
- // 새 워크북 생성
- const wb = XLSX.utils.book_new();
-
- // 테스트 데이터 생성 - xlsx-js-style 공식 API 완전 활용
- const testData = [
- // 첫 번째 행 - 폰트 스타일 테스트
- [
- {
- v: "굵은 글씨",
- t: "s",
- s: {
- font: {
- name: "Courier", // 공식 문서 예시
- sz: 24, // 공식 문서 예시
- bold: true,
- color: { rgb: "000000" },
- },
- },
- },
- {
- v: "빨간 글씨",
- t: "s",
- s: {
- font: {
- bold: true,
- color: { rgb: "FF0000" }, // 공식 문서: {color: {rgb: "FF0000"}}
- sz: 12,
- },
- },
- },
- {
- v: "테마 색상",
- t: "s",
- s: {
- font: {
- color: { theme: 4 }, // 공식 문서: {theme: 4} (Blue, Accent 1)
- sz: 14,
- italic: true,
- },
- },
- },
- ],
- // 두 번째 행 - 배경색 테스트
- [
- {
- v: "노란 배경",
- t: "s",
- s: {
- fill: {
- patternType: "solid",
- fgColor: { rgb: "FFFF00" }, // 공식 문서: {fgColor: {rgb: "E9E9E9"}}
- },
- },
- },
- {
- v: "테마 배경",
- t: "s",
- s: {
- fill: {
- patternType: "solid",
- fgColor: { theme: 1, tint: 0.4 }, // 공식 문서: {theme: 1, tint: 0.4} ("Blue, Accent 1, Lighter 40%")
- },
- font: { color: { rgb: "000000" } },
- },
- },
- {
- v: "인덱스 색상",
- t: "s",
- s: {
- fill: {
- patternType: "solid",
- fgColor: { indexed: 5 }, // Excel 기본 색상표 - 노랑
- },
- },
- },
- ],
- // 세 번째 행 - 테두리 테스트
- [
- {
- v: "얇은 테두리",
- t: "s",
- s: {
- border: {
- top: { style: "thin", color: { rgb: "000000" } },
- bottom: { style: "thin", color: { rgb: "000000" } },
- left: { style: "thin", color: { rgb: "000000" } },
- right: { style: "thin", color: { rgb: "000000" } },
- },
- },
- },
- {
- v: "두꺼운 테두리",
- t: "s",
- s: {
- border: {
- top: { style: "thick", color: { theme: 2 } }, // 테마 색상 사용
- bottom: { style: "thick", color: { theme: 2 } },
- left: { style: "thick", color: { theme: 2 } },
- right: { style: "thick", color: { theme: 2 } },
- },
- },
- },
- {
- v: "다양한 테두리",
- t: "s",
- s: {
- border: {
- top: { style: "dotted", color: { indexed: 4 } }, // 인덱스 색상 - 파랑
- bottom: { style: "dashed", color: { indexed: 4 } },
- left: { style: "dashDot", color: { indexed: 4 } },
- right: { style: "double", color: { indexed: 4 } },
- },
- },
- },
- ],
- // 네 번째 행 - 복합 스타일 테스트
- [
- {
- v: "복합 스타일",
- t: "s",
- s: {
- font: {
- bold: true,
- italic: true,
- underline: true,
- sz: 16,
- color: { rgb: "FFFFFF" },
- name: "Courier", // 공식 문서 예시
- },
- fill: {
- patternType: "solid",
- fgColor: { theme: 7, tint: -0.2 }, // 어두운 보라색
- },
- border: {
- top: { style: "medium", color: { rgb: "FFD700" } },
- bottom: { style: "medium", color: { rgb: "FFD700" } },
- left: { style: "medium", color: { rgb: "FFD700" } },
- right: { style: "medium", color: { rgb: "FFD700" } },
- },
- alignment: {
- horizontal: "center",
- vertical: "middle",
- wrapText: true, // 공식 문서: {wrapText: true}
- },
- },
- },
- {
- v: 1234.567,
- t: "n",
- s: {
- numFmt: "0.00%", // 공식 문서: numFmt 예시
- alignment: { horizontal: "right" },
- font: { bold: true },
- },
- },
- {
- v: "줄바꿈\n테스트",
- t: "s",
- s: {
- alignment: {
- wrapText: true,
- vertical: "top",
- textRotation: 0, // 공식 문서: textRotation
- },
- font: { sz: 10 },
- },
- },
- ],
- // 다섯 번째 행 - 고급 스타일 테스트
- [
- {
- v: "취소선 텍스트",
- t: "s",
- s: {
- font: {
- strike: true, // 공식 문서: {strike: true}
- sz: 12,
- color: { theme: 5, tint: 0.6 }, // 밝은 빨강
- },
- },
- },
- {
- v: "회전 텍스트",
- t: "s",
- s: {
- alignment: {
- textRotation: 45, // 공식 문서: textRotation
- horizontal: "center",
- vertical: "middle",
- },
- font: { sz: 14, bold: true },
- },
- },
- {
- v: new Date(),
- t: "d",
- s: {
- numFmt: "m/dd/yy", // 공식 문서: 날짜 포맷 예시
- font: { name: "Arial", sz: 10 },
- alignment: { horizontal: "center" },
- },
- },
- ],
- ];
-
- // 워크시트 생성
- const ws = XLSX.utils.aoa_to_sheet(testData);
-
- // 병합 셀 추가
- if (!ws["!merges"]) ws["!merges"] = [];
- ws["!merges"].push(
- { s: { r: 0, c: 0 }, e: { r: 0, c: 1 } }, // A1:B1 병합
- { s: { r: 2, c: 2 }, e: { r: 3, c: 2 } }, // C3:C4 병합
- );
-
- // 열 너비 설정
- ws["!cols"] = [
- { wpx: 120 }, // A열 너비
- { wpx: 100 }, // B열 너비
- { wpx: 80 }, // C열 너비
- ];
-
- // 행 높이 설정
- ws["!rows"] = [
- { hpx: 30 }, // 1행 높이
- { hpx: 25 }, // 2행 높이
- { hpx: 40 }, // 3행 높이
- ];
-
- // 워크시트를 워크북에 추가
- XLSX.utils.book_append_sheet(wb, ws, "스타일테스트");
-
- // 추가 시트 생성 (간단한 데이터)
- const simpleData = [
- ["이름", "나이", "직업"],
- ["홍길동", 30, "개발자"],
- ["김철수", 25, "디자이너"],
- ["이영희", 35, "기획자"],
- ];
-
- const ws2 = XLSX.utils.aoa_to_sheet(simpleData);
-
- // 헤더 스타일 적용
- ["A1", "B1", "C1"].forEach((cellAddr) => {
- if (ws2[cellAddr]) {
- ws2[cellAddr].s = {
- font: { bold: true, color: { rgb: "FFFFFF" } },
- fill: { patternType: "solid", fgColor: { rgb: "5B9BD5" } },
- alignment: { horizontal: "center" },
- };
- }
- });
-
- XLSX.utils.book_append_sheet(wb, ws2, "간단한데이터");
-
- // Excel 파일로 변환
- const excelBuffer = XLSX.write(wb, {
- type: "array",
- bookType: "xlsx",
- cellStyles: true,
- cellDates: true,
- bookSST: true,
- });
-
- // ArrayBuffer로 변환
- if (excelBuffer instanceof Uint8Array) {
- return excelBuffer.buffer.slice(
- excelBuffer.byteOffset,
- excelBuffer.byteOffset + excelBuffer.byteLength,
- );
- }
-
- return excelBuffer;
-}
-
-/**
- * 셀 스타일 정보 분석
- */
-export function analyzeSheetStyles(workbook: any): void {
- console.log("🎨 =================================");
- console.log("🎨 Excel 파일 스타일 정보 분석");
- console.log("🎨 =================================");
-
- // 🔍 워크북 전체 스타일 정보 확인
- console.log("🔍 워크북 메타데이터:", {
- Props: workbook.Props ? "있음" : "없음",
- Custprops: workbook.Custprops ? "있음" : "없음",
- Workbook: workbook.Workbook ? "있음" : "없음",
- SSF: workbook.SSF ? "있음" : "없음",
- SheetNames: workbook.SheetNames
- ? workbook.SheetNames.length + "개"
- : "없음",
- Sheets: workbook.Sheets
- ? Object.keys(workbook.Sheets).length + "개"
- : "없음",
- });
-
- // 🔍 워크북 스타일 정보 상세 분석
- if (workbook.SSF) {
- console.log("🔍 워크북 SSF 스타일 정보:", workbook.SSF);
- }
-
- if (workbook.Workbook && workbook.Workbook.Styles) {
- console.log("🔍 워크북 Styles:", workbook.Workbook.Styles);
- }
-
- // 워크북의 모든 키 확인
- console.log("🔍 워크북 전체 키들:", Object.keys(workbook));
-
- if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
- console.log("🎨 ❌ 시트가 없습니다.");
- console.log("🎨 ❌ 워크북 전체 구조:", Object.keys(workbook));
- return;
- }
-
- workbook.SheetNames.forEach((sheetName: string, sheetIndex: number) => {
- const sheet = workbook.Sheets[sheetName];
- if (!sheet) return;
-
- console.log(`🎨 시트 ${sheetIndex + 1}: "${sheetName}"`);
-
- // 시트 메타데이터
- console.log(`🎨 - 데이터 범위: ${sheet["!ref"] || "없음"}`);
- console.log(`🎨 - 병합 셀: ${sheet["!merges"]?.length || 0}개`);
- console.log(`🎨 - 열 설정: ${sheet["!cols"]?.length || 0}개`);
- console.log(`🎨 - 행 설정: ${sheet["!rows"]?.length || 0}개`);
-
- // 병합 셀 상세 정보
- if (sheet["!merges"]) {
- sheet["!merges"].forEach((merge: any, index: number) => {
- console.log(
- `🎨 - 병합 ${index + 1}: ${XLSX.utils.encode_cell(merge.s)}:${XLSX.utils.encode_cell(merge.e)}`,
- );
- });
- }
-
- // 스타일이 적용된 셀 찾기
- const styledCells: string[] = [];
- const cellAddresses = Object.keys(sheet).filter(
- (key) => !key.startsWith("!"),
- );
-
- // 🔍 시트 데이터 존재 여부 확인
- console.log(`🔍 ${sheetName} 기본 정보: ${cellAddresses.length}개 셀 발견`);
-
- cellAddresses.forEach((cellAddr) => {
- const cell = sheet[cellAddr];
- if (cell && cell.s) {
- styledCells.push(cellAddr);
-
- // 🔍 첫 3개 셀의 실제 스타일 구조 확인
- if (styledCells.length <= 3) {
- console.log(`🔍 셀 ${cellAddr} cell.s 원시값:`, cell.s);
- console.log(`🔍 cell.s 타입:`, typeof cell.s);
- console.log(`🔍 cell.s 키들:`, Object.keys(cell.s || {}));
- }
-
- // 스타일 정보 간단 확인
- const hasStyles = {
- font: !!cell.s.font,
- fill: !!cell.s.fill,
- border: !!cell.s.border,
- alignment: !!cell.s.alignment,
- };
-
- if (Object.values(hasStyles).some((v) => v)) {
- console.log(`🎨 셀 ${cellAddr} 스타일:`, hasStyles);
- } else if (styledCells.length <= 3) {
- console.log(`❌ 셀 ${cellAddr} 스타일 없음:`, hasStyles);
- }
- }
- });
-
- console.log(
- `🎨 - 스타일 적용된 셀: ${styledCells.length}개 (${styledCells.join(", ")})`,
- );
- });
-
- console.log("🎨 =================================");
-}
-
-/**
- * 브라우저에서 테스트 파일 다운로드
- */
-export function downloadTestFile(): void {
- try {
- const buffer = createStyledTestExcel();
- const blob = new Blob([buffer], {
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- });
-
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = "스타일테스트.xlsx";
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-
- console.log("🎨 스타일 테스트 파일 다운로드 완료!");
- } catch (error) {
- console.error("🎨 테스트 파일 생성 실패:", error);
- }
-}