From 10f90dc68dec42e9a64e081cc0dce6a484447290 Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
Date: Tue, 29 Jul 2025 11:48:59 +0000
Subject: (대표님, 박서영, 최겸) document-list-only, gtc, vendorDocu,
 docu-list-rule
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../gtc-clauses/table/clause-preview-viewer.tsx    | 681 ++++++++++++---------
 1 file changed, 408 insertions(+), 273 deletions(-)

(limited to 'lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx')

diff --git a/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
index 30e369b4..f979f0ea 100644
--- a/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
+++ b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
@@ -18,6 +18,8 @@ interface ClausePreviewViewerProps {
   document: any
   instance: WebViewerInstance | null
   setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>
+  onSuccess?: () => void
+  onError?: () => void
 }
 
 export function ClausePreviewViewer({
@@ -25,140 +27,263 @@ export function ClausePreviewViewer({
   document,
   instance,
   setInstance,
+  onSuccess,
+  onError,
 }: ClausePreviewViewerProps) {
   const [fileLoading, setFileLoading] = useState<boolean>(true)
+  const [loadingStage, setLoadingStage] = useState<string>("뷰어 준비 중...")
   const viewer = useRef<HTMLDivElement>(null)
   const initialized = useRef(false)
   const isCancelled = useRef(false)
 
-  // WebViewer 초기화
+  // WebViewer 초기화 (단계별)
   useEffect(() => {
     if (!initialized.current && viewer.current) {
       initialized.current = true
       isCancelled.current = false
 
-      requestAnimationFrame(() => {
-        if (viewer.current) {
-          import("@pdftron/webviewer").then(({ default: WebViewer }) => {
-            if (isCancelled.current) {
-              console.log("📛 WebViewer 초기화 취소됨")
-              return
-            }
-
-            const viewerElement = viewer.current
-            if (!viewerElement) return
-
-            WebViewer(
-              {
-                path: "/pdftronWeb",
-                licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
-                fullAPI: true,
-                enableOfficeEditing: true,
-                l: "ko",
-                // 미리보기 모드로 설정
-                enableReadOnlyMode: false,
-              },
-              viewerElement
-            ).then(async (instance: WebViewerInstance) => {
-              setInstance(instance)
-              
-              try {
-                const { disableElements, enableElements, setToolbarGroup } = instance.UI
-
-                // 미리보기에 필요한 도구만 활성화
-                enableElements([
-                  "toolbarGroup-View",
-                  "zoomInButton",
-                  "zoomOutButton",
-                  "fitButton",
-                  "rotateCounterClockwiseButton",
-                  "rotateClockwiseButton",
-                ])
-
-                // 편집 도구는 비활성화
-                disableElements([
-                  "toolbarGroup-Edit",
-                  "toolbarGroup-Insert",
-                  "toolbarGroup-Annotate",
-                  "toolbarGroup-Shapes",
-                  "toolbarGroup-Forms",
-                ])
-                
-                setToolbarGroup("toolbarGroup-View")
-                
-                // 조항 데이터로 문서 생성
-                await generateDocumentFromClauses(instance, clauses, document)
-                
-              } catch (uiError) {
-                console.warn("⚠️ UI 설정 중 오류:", uiError)
-              } finally {
-                setFileLoading(false)
-              }
-            }).catch((error) => {
-              console.error("❌ WebViewer 초기화 실패:", error)
-              setFileLoading(false)
-              toast.error("뷰어 초기화에 실패했습니다.")
-            })
-          })
-        }
-      })
+      initializeViewerStepByStep()
     }
 
     return () => {
       if (instance) {
-        instance.UI.dispose()
+        try {
+          instance.UI.dispose()
+        } catch (error) {
+          console.warn("뷰어 정리 중 오류:", error)
+        }
       }
-      isCancelled.current = true
+      isCancelled.current = true;
+      setTimeout(() => cleanupHtmlStyle(), 500);
     }
   }, [])
 
-  // 조항 데이터로 워드 문서 생성
+  const initializeViewerStepByStep = async () => {
+    try {
+      setLoadingStage("라이브러리 로딩 중...")
+      
+      // 1단계: 라이브러리 동적 import (지연 추가)
+      await new Promise(resolve => setTimeout(resolve, 300))
+      const { default: WebViewer } = await import("@pdftron/webviewer")
+      
+      if (isCancelled.current || !viewer.current) {
+        console.log("📛 WebViewer 초기화 취소됨")
+        return
+      }
+
+      setLoadingStage("뷰어 초기화 중...")
+      
+      // 2단계: WebViewer 인스턴스 생성
+      const webviewerInstance = await WebViewer(
+        {
+          path: "/pdftronWeb",
+          licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+          fullAPI: true,
+          enableOfficeEditing: true,
+          l: "ko",
+          enableReadOnlyMode: false,
+        },
+        viewer.current
+      )
+
+      if (isCancelled.current) {
+        console.log("📛 WebViewer 초기화 취소됨")
+        return
+      }
+
+      setInstance(webviewerInstance)
+      setLoadingStage("UI 설정 중...")
+
+      // 3단계: UI 설정 (약간의 지연 후)
+      await new Promise(resolve => setTimeout(resolve, 500))
+      await configureViewerUI(webviewerInstance)
+
+      setLoadingStage("문서 생성 중...")
+
+      // 4단계: 문서 생성 (충분한 지연 후)
+      await new Promise(resolve => setTimeout(resolve, 800))
+      await generateDocumentFromClauses(webviewerInstance, clauses, document)
+
+    } catch (error) {
+      console.error("❌ WebViewer 단계별 초기화 실패:", error)
+      setFileLoading(false)
+      onError?.() // 초기화 실패 콜백 호출
+      toast.error(`뷰어 초기화 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+    }
+  }
+
+  const configureViewerUI = async (webviewerInstance: WebViewerInstance) => {
+    try {
+      const { disableElements, enableElements, setToolbarGroup } = webviewerInstance.UI
+
+      // 미리보기에 필요한 도구만 활성화
+      enableElements([
+        "toolbarGroup-View",
+        "zoomInButton",
+        "zoomOutButton",
+        "fitButton",
+        "rotateCounterClockwiseButton",
+        "rotateClockwiseButton",
+      ])
+
+      // 편집 도구는 비활성화
+      disableElements([
+        "toolbarGroup-Edit",
+        "toolbarGroup-Insert",
+        "toolbarGroup-Annotate",
+        "toolbarGroup-Shapes",
+        "toolbarGroup-Forms",
+      ])
+
+      setToolbarGroup("toolbarGroup-View")
+
+      console.log("✅ UI 설정 완료")
+    } catch (uiError) {
+      console.warn("⚠️ UI 설정 중 오류:", uiError)
+      // UI 설정 실패해도 계속 진행
+    }
+  }
+
+  // 문서 생성 함수 (재시도 로직 포함)
   const generateDocumentFromClauses = async (
-    instance: WebViewerInstance, 
-    clauses: GtcClauseTreeView[], 
-    document: any
+    webviewerInstance: WebViewerInstance,
+    clauses: GtcClauseTreeView[],
+    document: any,
+    retryCount = 0
   ) => {
+    const MAX_RETRIES = 3
+    
     try {
       console.log("📄 조항 기반 DOCX 문서 생성 시작:", clauses.length)
-      
+
       // 활성화된 조항만 필터링하고 정렬
       const activeClauses = clauses
         .filter(clause => clause.isActive !== false)
         .sort((a, b) => {
-          // sortOrder 또는 itemNumber로 정렬
           if (a.sortOrder && b.sortOrder) {
             return parseFloat(a.sortOrder) - parseFloat(b.sortOrder)
           }
           return a.itemNumber.localeCompare(b.itemNumber, undefined, { numeric: true })
         })
 
-      // ✅ DOCX 문서 생성
-      const docxBlob = await generateDocxDocument(activeClauses, document)
-      
-      // ✅ DOCX 파일로 변환
-      const docxFile = new File([docxBlob], `${document?.title || 'GTC계약서'}_미리보기.docx`, { 
-        type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' 
+      if (activeClauses.length === 0) {
+        throw new Error("활성화된 조항이 없습니다.")
+      }
+
+      setLoadingStage(`문서 생성 중... (${activeClauses.length}개 조항 처리)`)
+
+      // DOCX 문서 생성 (재시도 로직 포함)
+      const docxBlob = await generateDocxDocumentWithRetry(activeClauses, document)
+
+      // 파일 생성
+      const docxFile = new File([docxBlob], `${document?.title || 'GTC계약서'}_미리보기.docx`, {
+        type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
       })
+
+      setLoadingStage("문서 로딩 중...")
+
+      // WebViewer가 완전히 준비된 상태인지 확인
+      await waitForViewerReady(webviewerInstance)
+
+      // DOCX 문서 로드 (재시도 포함)
+      await loadDocumentWithRetry(webviewerInstance, docxFile)
+
+      console.log("✅ DOCX 기반 문서 생성 완료")
+      toast.success("Word 문서 미리보기가 생성되었습니다.")
+      setFileLoading(false)
+      onSuccess?.() // 성공 콜백 호출
+
+    } catch (err) {
+      console.error(`❌ DOCX 문서 생성 중 오류 (시도 ${retryCount + 1}/${MAX_RETRIES + 1}):`, err)
       
-      // ✅ PDFTron에서 DOCX 문서로 로드
-      await instance.UI.loadDocument(docxFile, {
-        filename: `${document?.title || 'GTC계약서'}_미리보기.docx`,
-        enableOfficeEditing: true, // DOCX 편집 모드 활성화
+      if (retryCount < MAX_RETRIES) {
+        console.log(`🔄 ${(retryCount + 1) * 1000}ms 후 재시도...`)
+        setLoadingStage(`재시도 중... (${retryCount + 1}/${MAX_RETRIES})`)
+        
+        await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000))
+        
+        if (!isCancelled.current) {
+          return generateDocumentFromClauses(webviewerInstance, clauses, document, retryCount + 1)
+        }
+      } else {
+        setFileLoading(false)
+        onError?.() // 실패 콜백 호출
+        toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`)
+      }
+    }
+  }
+
+  // WebViewer 준비 상태 확인
+  const waitForViewerReady = async (webviewerInstance: WebViewerInstance, timeout = 5000) => {
+    const startTime = Date.now()
+    
+    while (Date.now() - startTime < timeout) {
+      try {
+        // UI가 준비되었는지 확인
+        if (webviewerInstance.UI && webviewerInstance.Core) {
+          console.log("✅ WebViewer 준비 완료")
+          return
+        }
+      } catch (error) {
+        // 아직 준비되지 않음
+      }
+      
+      await new Promise(resolve => setTimeout(resolve, 100))
+    }
+    
+    throw new Error("WebViewer 준비 시간 초과")
+  }
+
+  // 문서 로드 재시도 함수
+  const loadDocumentWithRetry = async (
+    webviewerInstance: WebViewerInstance, 
+    file: File, 
+    retryCount = 0
+  ) => {
+    const MAX_LOAD_RETRIES = 2
+    
+    try {
+      await webviewerInstance.UI.loadDocument(file, {
+        filename: file.name,
+        enableOfficeEditing: true,
       })
+      console.log("✅ 문서 로드 성공")
+    } catch (error) {
+      console.error(`문서 로드 실패 (시도 ${retryCount + 1}):`, error)
       
-      console.log("✅ DOCX 기반 문서 생성 완료")
-      toast.success("Word 문서 미리보기가 생성되었습니다.")
+      if (retryCount < MAX_LOAD_RETRIES) {
+        await new Promise(resolve => setTimeout(resolve, 1000))
+        return loadDocumentWithRetry(webviewerInstance, file, retryCount + 1)
+      } else {
+        throw new Error(`문서 로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+      }
+    }
+  }
+
+  // DOCX 생성 재시도 함수
+  const generateDocxDocumentWithRetry = async (
+    clauses: GtcClauseTreeView[], 
+    document: any, 
+    retryCount = 0
+  ): Promise<Blob> => {
+    try {
+      return await generateDocxDocument(clauses, document)
+    } catch (error) {
+      console.error(`DOCX 생성 실패 (시도 ${retryCount + 1}):`, error)
       
-    } catch (err) {
-      console.error("❌ DOCX 문서 생성 중 오류:", err)
-      toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`)
+      if (retryCount < 2) {
+        await new Promise(resolve => setTimeout(resolve, 500))
+        return generateDocxDocumentWithRetry(clauses, document, retryCount + 1)
+      } else {
+        throw error
+      }
     }
   }
 
   return (
     <div className="relative w-full h-full overflow-hidden">
-      <div 
-        ref={viewer} 
+      <div
+        ref={viewer}
         className="w-full h-full"
         style={{
           position: 'relative',
@@ -169,10 +294,13 @@ export function ClausePreviewViewer({
         {fileLoading && (
           <div className="absolute inset-0 flex flex-col items-center justify-center bg-white bg-opacity-90 z-10">
             <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
-            <p className="text-sm text-muted-foreground">문서 생성 중...</p>
+            <p className="text-sm text-muted-foreground">{loadingStage}</p>
             <p className="text-xs text-muted-foreground mt-1">
               {clauses.filter(c => c.isActive !== false).length}개 조항 처리 중
             </p>
+            <div className="mt-3 text-xs text-gray-400">
+              초기화에 시간이 걸릴 수 있습니다...
+            </div>
           </div>
         )}
       </div>
@@ -180,82 +308,81 @@ export function ClausePreviewViewer({
   )
 }
 
+// ===== 유틸리티 함수들 =====
 
-
-// ===== data URL 판별 및 디코딩 유틸 =====
+// data URL 판별 및 디코딩 유틸
 function isDataUrl(url: string) {
-    return /^data:/.test(url);
-  }
-  
-  function dataUrlToUint8Array(dataUrl: string): { bytes: Uint8Array; mime: string } {
-    // 형식: data:<mime>;base64,<payload>
-    const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/);
-    if (!match) {
-      // base64가 아닌 data URL도 가능하지만, 여기서는 base64만 지원
-      throw new Error("지원하지 않는 data URL 형식입니다.");
-    }
-    const mime = match[1];
-    const base64 = match[2];
-    const binary = atob(base64);
-    const len = binary.length;
-    const bytes = new Uint8Array(len);
-    for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
-    return { bytes, mime };
+  return /^data:/.test(url);
+}
+
+function dataUrlToUint8Array(dataUrl: string): { bytes: Uint8Array; mime: string } {
+  // 형식: data:<mime>;base64,<payload>
+  const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/);
+  if (!match) {
+    // base64가 아닌 data URL도 가능하지만, 여기서는 base64만 지원
+    throw new Error("지원하지 않는 data URL 형식입니다.");
   }
-  
-  // ===== helper: 이미지 불러오기 + 크기 계산 (data:, http:, / 경로 모두 지원) =====
-  async function fetchImageData(url: string, maxWidthPx = 500) {
-    let blob: Blob;
-    let bytes: Uint8Array;
-  
-    if (isDataUrl(url)) {
-      // data URL → Uint8Array, Blob
-      const { bytes: arr, mime } = dataUrlToUint8Array(url);
-      bytes = arr;
-      blob = new Blob([bytes], { type: mime });
-    } else {
-      // http(s) 또는 상대 경로
-      const res = await fetch(url, { cache: "no-store" });
-      if (!res.ok) throw new Error(`이미지 다운로드 실패 (${res.status})`);
-      blob = await res.blob();
-      const arrayBuffer = await blob.arrayBuffer();
-      bytes = new Uint8Array(arrayBuffer);
-    }
-  
-    // 원본 크기 파악 (공통)
-    const dims = await new Promise<{ width: number; height: number }>((resolve) => {
-      const img = new Image();
-      const objectUrl = URL.createObjectURL(blob);
-      img.onload = () => {
-        const width = img.naturalWidth || 800;
-        const height = img.naturalHeight || 600;
-        URL.revokeObjectURL(objectUrl);
-        resolve({ width, height });
-      };
-      img.onerror = () => {
-        URL.revokeObjectURL(objectUrl);
-        resolve({ width: 800, height: 600 }); // 실패 시 기본값
-      };
-      img.src = objectUrl;
-    });
-  
-    // 비율 유지 축소
-    const scale = Math.min(1, maxWidthPx / (dims.width || maxWidthPx));
-    const width = Math.round((dims.width || maxWidthPx) * scale);
-    const height = Math.round((dims.height || Math.round(maxWidthPx * 0.6)) * scale);
-  
-    return { data: bytes, width, height };
+  const mime = match[1];
+  const base64 = match[2];
+  const binary = atob(base64);
+  const len = binary.length;
+  const bytes = new Uint8Array(len);
+  for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
+  return { bytes, mime };
+}
+
+// 이미지 불러오기 + 크기 계산 (data:, http:, / 경로 모두 지원)
+async function fetchImageData(url: string, maxWidthPx = 500) {
+  let blob: Blob;
+  let bytes: Uint8Array;
+
+  if (isDataUrl(url)) {
+    // data URL → Uint8Array, Blob
+    const { bytes: arr, mime } = dataUrlToUint8Array(url);
+    bytes = arr;
+    blob = new Blob([bytes], { type: mime });
+  } else {
+    // http(s) 또는 상대 경로
+    const res = await fetch(url, { cache: "no-store" });
+    if (!res.ok) throw new Error(`이미지 다운로드 실패 (${res.status})`);
+    blob = await res.blob();
+    const arrayBuffer = await blob.arrayBuffer();
+    bytes = new Uint8Array(arrayBuffer);
   }
 
+  // 원본 크기 파악 (공통)
+  const dims = await new Promise<{ width: number; height: number }>((resolve) => {
+    const img = new Image();
+    const objectUrl = URL.createObjectURL(blob);
+    img.onload = () => {
+      const width = img.naturalWidth || 800;
+      const height = img.naturalHeight || 600;
+      URL.revokeObjectURL(objectUrl);
+      resolve({ width, height });
+    };
+    img.onerror = () => {
+      URL.revokeObjectURL(objectUrl);
+      resolve({ width: 800, height: 600 }); // 실패 시 기본값
+    };
+    img.src = objectUrl;
+  });
+
+  // 비율 유지 축소
+  const scale = Math.min(1, maxWidthPx / (dims.width || maxWidthPx));
+  const width = Math.round((dims.width || maxWidthPx) * scale);
+  const height = Math.round((dims.height || Math.round(maxWidthPx * 0.6)) * scale);
+
+  return { data: bytes, width, height };
+}
+
 // DOCX 문서 생성 (docx 라이브러리 사용)
 async function generateDocxDocument(
-    clauses: GtcClauseTreeView[], 
-    document: any
-  ): Promise<Blob> {
-    const { Document, Packer, Paragraph, TextRun, AlignmentType, ImageRun } = await import("docx");
-  
-    
-function textToParagraphs(text: string, indentLeft: number) {
+  clauses: GtcClauseTreeView[],
+  document: any
+): Promise<Blob> {
+  const { Document, Packer, Paragraph, TextRun, AlignmentType, ImageRun } = await import("docx");
+
+  function textToParagraphs(text: string, indentLeft: number) {
     const lines = text.split("\n");
     return [
       new Paragraph({
@@ -269,11 +396,10 @@ function textToParagraphs(text: string, indentLeft: number) {
       }),
     ];
   }
-  
-  const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087]
 
+  const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087]
 
-async function pushContentWithInlineImages(
+  async function pushContentWithInlineImages(
     content: string,
     indentLeft: number,
     children: any[],
@@ -284,135 +410,135 @@ async function pushContentWithInlineImages(
       const start = match.index ?? 0;
       const end = start + match[0].length;
       const imageId = match[1];
-  
+
       // 앞부분 텍스트
       if (start > lastIndex) {
         const txt = content.slice(lastIndex, start);
         children.push(...textToParagraphs(txt, indentLeft));
       }
-  
+
       // 이미지 삽입
       const imgMeta = imageMap.get(imageId);
       if (imgMeta?.url) {
-        const { data, width, height } = await fetchImageData(imgMeta.url, 520);
-        children.push(
-          new Paragraph({
-            children: [
-              new ImageRun({
-                data,
-                transformation: { width, height },
-              }),
-            ],
-            indent: { left: indentLeft },
-          })
-        );
-        // 사용된 이미지 표시(뒤에서 중복 추가 방지)
-        imageMap.delete(imageId);
-      }
-      // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거)
-  
-      lastIndex = end;
-    }
-  
-    // 남은 꼬리 텍스트
-    if (lastIndex < content.length) {
-      const tail = content.slice(lastIndex);
-      children.push(...textToParagraphs(tail, indentLeft));
-    }
-  }
-  
-  
-    const documentTitle = document?.title || "GTC 계약서";
-    const currentDate = new Date().toLocaleDateString("ko-KR");
-  
-    // depth 추정/정렬
-    const structuredClauses = organizeClausesByHierarchy(clauses);
-  
-    const children: any[] = [
-      new Paragraph({
-        alignment: AlignmentType.CENTER,
-        children: [new TextRun({ text: documentTitle, bold: true, size: 32 })],
-      }),
-      new Paragraph({
-        alignment: AlignmentType.CENTER,
-        children: [new TextRun({ text: `생성일: ${currentDate}`, size: 20, color: "666666" })],
-      }),
-      new Paragraph({ text: "" }),
-      new Paragraph({ text: "" }),
-    ];
-  
-    for (const clause of structuredClauses) {
-      const depth = Math.min(clause.estimatedDepth || 0, 3);
-      const indentLeft = depth * 400;       // 번호/제목
-      const indentContent = indentLeft + 200; // 본문/이미지
-  
-      // 번호 + 제목
-      children.push(
-        new Paragraph({
-          children: [
-            new TextRun({ text: `${clause.itemNumber}${clause.subtitle ? "." : ""}`, bold: true, color: "2563eb" }),
-            ...(clause.subtitle
-              ? [new TextRun({ text: " " }), new TextRun({ text: clause.subtitle, bold: true })]
-              : []),
-          ],
-          indent: { left: indentLeft },
-        })
-      );
-  
-      const imageMap = new Map(
-        Array.isArray((clause as any).images)
-          ? (clause as any).images.map((im: any) => [String(im.id), im])
-          : []
-      );
-  
-      // 내용
-      const hasContent = clause.content && clause.content.trim();
-      if (hasContent) {
-        await pushContentWithInlineImages(clause.content!, indentContent, children, imageMap);
-      }
-  
-    //   else {
-    //     children.push(
-    //       new Paragraph({
-    //         // children: [new TextRun({ text: "(상세 내용 없음)", italics: true, color: "6b7280", size: 20 })],
-    //         indent: { left: indentContent },
-    //       })
-    //     );
-    //   }
-  
-        // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가)
-
-      for (const [, imgMeta] of imageMap) {
         try {
           const { data, width, height } = await fetchImageData(imgMeta.url, 520);
           children.push(
             new Paragraph({
-              children: [new ImageRun({ data, transformation: { width, height } })],
-              indent: { left: indentContent },
+              children: [
+                new ImageRun({
+                  data,
+                  transformation: { width, height },
+                }),
+              ],
+              indent: { left: indentLeft },
             })
           );
-        } catch (e) {
+          // 사용된 이미지 표시(뒤에서 중복 추가 방지)
+          imageMap.delete(imageId);
+        } catch (imgError) {
+          console.warn("이미지 로드 실패:", imgMeta, imgError);
+          // 이미지 로드 실패시 텍스트로 대체
           children.push(
             new Paragraph({
-              children: [new TextRun({ text: `이미지 로드 실패: ${imgMeta.fileName || imgMeta.url}`, color: "b91c1c", size: 20 })],
-              indent: { left: indentContent },
+              children: [new TextRun({ text: `[이미지 로드 실패: ${imgMeta.fileName || imageId}]`, color: "999999" })],
+              indent: { left: indentLeft },
             })
           );
-          console.warn("이미지 로드 실패(잔여):", imgMeta, e);
         }
       }
-  
-      // 조항 간 간격
-      children.push(new Paragraph({ text: "" }));
+      // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거)
+
+      lastIndex = end;
+    }
+
+    // 남은 꼬리 텍스트
+    if (lastIndex < content.length) {
+      const tail = content.slice(lastIndex);
+      children.push(...textToParagraphs(tail, indentLeft));
+    }
+  }
+
+  const documentTitle = document?.title || "GTC 계약서";
+  const currentDate = new Date().toLocaleDateString("ko-KR");
+
+  // depth 추정/정렬
+  const structuredClauses = organizeClausesByHierarchy(clauses);
+
+  const children: any[] = [
+    new Paragraph({
+      alignment: AlignmentType.CENTER,
+      children: [new TextRun({ text: documentTitle, bold: true, size: 32 })],
+    }),
+    new Paragraph({
+      alignment: AlignmentType.CENTER,
+      children: [new TextRun({ text: `생성일: ${currentDate}`, size: 20, color: "666666" })],
+    }),
+    new Paragraph({ text: "" }),
+    new Paragraph({ text: "" }),
+  ];
+
+  for (const clause of structuredClauses) {
+    const depth = Math.min(clause.estimatedDepth || 0, 3);
+    const indentLeft = depth * 400;       // 번호/제목
+    const indentContent = indentLeft + 200; // 본문/이미지
+
+    // 번호 + 제목
+    children.push(
+      new Paragraph({
+        children: [
+          new TextRun({ text: `${clause.itemNumber}${clause.subtitle ? "." : ""}`, bold: true, color: "2563eb" }),
+          ...(clause.subtitle
+            ? [new TextRun({ text: " " }), new TextRun({ text: clause.subtitle, bold: true })]
+            : []),
+        ],
+        indent: { left: indentLeft },
+      })
+    );
+
+    const imageMap = new Map(
+      Array.isArray((clause as any).images)
+        ? (clause as any).images.map((im: any) => [String(im.id), im])
+        : []
+    );
+
+    // 내용
+    const hasContent = clause.content && clause.content.trim();
+    if (hasContent) {
+      await pushContentWithInlineImages(clause.content!, indentContent, children, imageMap);
     }
-  
-    const doc = new Document({
-      sections: [{ properties: {}, children }],
-    });
-  
-    return await Packer.toBlob(doc);
+
+    // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가)
+    for (const [, imgMeta] of imageMap) {
+      try {
+        const { data, width, height } = await fetchImageData(imgMeta.url, 520);
+        children.push(
+          new Paragraph({
+            children: [new ImageRun({ data, transformation: { width, height } })],
+            indent: { left: indentContent },
+          })
+        );
+      } catch (e) {
+        children.push(
+          new Paragraph({
+            children: [new TextRun({ text: `이미지 로드 실패: ${imgMeta.fileName || imgMeta.url}`, color: "b91c1c", size: 20 })],
+            indent: { left: indentContent },
+          })
+        );
+        console.warn("이미지 로드 실패(잔여):", imgMeta, e);
+      }
+    }
+
+    // 조항 간 간격
+    children.push(new Paragraph({ text: "" }));
   }
 
+  const doc = new Document({
+    sections: [{ properties: {}, children }],
+  });
+
+  return await Packer.toBlob(doc);
+}
+
 // 조항들을 계층구조로 정리
 function organizeClausesByHierarchy(clauses: GtcClauseTreeView[]) {
   // depth가 없는 경우 itemNumber로 depth 추정
@@ -421,9 +547,9 @@ function organizeClausesByHierarchy(clauses: GtcClauseTreeView[]) {
     estimatedDepth: clause.depth ?? estimateDepthFromItemNumber(clause.itemNumber)
   })).sort((a, b) => {
     // itemNumber 기준 자연 정렬
-    return a.itemNumber.localeCompare(b.itemNumber, undefined, { 
-      numeric: true, 
-      sensitivity: 'base' 
+    return a.itemNumber.localeCompare(b.itemNumber, undefined, {
+      numeric: true,
+      sensitivity: 'base'
     })
   })
 }
@@ -433,3 +559,12 @@ function estimateDepthFromItemNumber(itemNumber: string): number {
   const parts = itemNumber.split('.')
   return Math.max(0, parts.length - 1)
 }
+
+// WebViewer 정리 함수
+const cleanupHtmlStyle = () => {
+  // iframe 스타일 정리 (WebViewer가 추가한 스타일)
+  const elements = document.querySelectorAll('.Document_container');
+  elements.forEach((elem) => {
+    elem.remove();
+  });
+};
\ No newline at end of file
-- 
cgit v1.2.3

