summaryrefslogtreecommitdiff
path: root/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-29 11:48:59 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-29 11:48:59 +0000
commit10f90dc68dec42e9a64e081cc0dce6a484447290 (patch)
tree5bc8bb30e03b09a602e7d414d943d0e7f24b1a0f /lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
parent792fb0c21136eededecf52b5b4aa1a252bdc4bfb (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.tsx681
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