diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-29 11:48:59 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-29 11:48:59 +0000 |
| commit | 10f90dc68dec42e9a64e081cc0dce6a484447290 (patch) | |
| tree | 5bc8bb30e03b09a602e7d414d943d0e7f24b1a0f /lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx | |
| parent | 792fb0c21136eededecf52b5b4aa1a252bdc4bfb (diff) | |
(대표님, 박서영, 최겸) document-list-only, gtc, vendorDocu, docu-list-rule
Diffstat (limited to 'lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx')
| -rw-r--r-- | lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx | 681 |
1 files changed, 408 insertions, 273 deletions
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 |
