From 4decb99856bf537c02b5a908edcbe12f1f10d8b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 11 Feb 2026 20:53:06 +0900 Subject: [PATCH] =?UTF-8?q?fix(WEB):=20DocumentViewer=20PDF=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=88=84=EB=9D=BD=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PDF 렌더링 시 이미지 누락 이슈 수정 - 해결 과정 문서 추가 Co-Authored-By: Claude Opus 4.6 --- .../[FIX-2026-02-09] PDF-이미지-누락-해결.md | 127 ++++++++++++++++++ .../document-system/viewer/DocumentViewer.tsx | 46 ++++++- 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 claudedocs/dev/[FIX-2026-02-09] PDF-이미지-누락-해결.md diff --git a/claudedocs/dev/[FIX-2026-02-09] PDF-이미지-누락-해결.md b/claudedocs/dev/[FIX-2026-02-09] PDF-이미지-누락-해결.md new file mode 100644 index 00000000..8d09c1c5 --- /dev/null +++ b/claudedocs/dev/[FIX-2026-02-09] PDF-이미지-누락-해결.md @@ -0,0 +1,127 @@ +# [FIX] PDF 변환 시 이미지 누락 문제 해결 + +**날짜**: 2026-02-09 +**수정 파일**: `src/components/document-system/viewer/DocumentViewer.tsx` +**영향 범위**: DocumentViewer를 사용하는 모든 문서 (공통 수정) + +--- + +## 문제 + +중간검사성적서 등 문서에서 **도해 이미지**가 화면에는 정상 표시되지만, **PDF 다운로드 시 이미지가 누락**되는 현상. + +## 원인 분석 + +### PDF 생성 흐름 + +``` +1. DocumentViewer.handlePdf() +2. .print-area DOM 요소를 cloneWithInlineStyles()로 복제 +3. clone.outerHTML → HTML 문자열 추출 +4. /api/pdf/generate POST 전송 +5. Puppeteer가 page.setContent(html) → PDF 렌더링 +``` + +### 핵심 원인: `setContent()`의 base URL 부재 + +Puppeteer의 `page.setContent(html)`은 **로컬 HTML 문자열을 렌더링**하므로 base URL이 없다. + +| 이미지 src 유형 | 브라우저 (화면) | Puppeteer (PDF) | +|----------------|----------------|-----------------| +| 상대 경로 `/uploads/img.jpg` | `localhost:3000` 기준으로 해석 | base URL 없음 → 로드 실패 | +| 절대 URL `https://api.example.com/img.jpg` | 정상 로드 | 서버 네트워크 환경에 따라 실패 가능 | +| Blob URL `blob:http://...` | 브라우저 메모리 참조 | Puppeteer 컨텍스트에 없음 → 로드 실패 | +| Data URL `data:image/png;base64,...` | 정상 | 정상 (HTML에 내장) | + +**결론**: `` 가 상대/절대/blob URL이면 Puppeteer PDF에서 이미지가 빠진다. + +## 해결 방법 + +### 접근: 이미지를 base64 data URL로 인라인 변환 + +PDF API로 HTML을 보내기 **전에**, 모든 `` 태그의 `src`를 **base64 data URL로 변환**하여 HTML 자체에 이미지를 내장시킨다. + +### 구현: `convertImagesToBase64()` + +```typescript +// DocumentViewer.tsx에 추가 +const convertImagesToBase64 = async (original: HTMLElement, clone: HTMLElement): Promise => { + const originalImages = Array.from(original.querySelectorAll('img')); + const clonedImages = Array.from(clone.querySelectorAll('img')); + + await Promise.all( + originalImages.map(async (img, index) => { + const clonedImg = clonedImages[index]; + if (!clonedImg) return; + + const src = img.src; + if (!src || src.startsWith('data:')) return; // 이미 data URL이면 스킵 + + try { + // 1차: fetch → blob → FileReader → dataURL + const response = await fetch(src); + const blob = await response.blob(); + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + clonedImg.setAttribute('src', dataUrl); + } catch { + // 2차 fallback: canvas 방식 + try { + if (img.complete && img.naturalWidth > 0) { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(img, 0, 0); + clonedImg.setAttribute('src', canvas.toDataURL('image/png')); + } + } + } catch { + // 변환 실패 시 원본 src 유지 + } + } + }) + ); +}; +``` + +### 호출 위치: `handlePdf()` 내부 + +```typescript +// 변경 전 +const clonedElement = cloneWithInlineStyles(printAreaEl); +const html = clonedElement.outerHTML; + +// 변경 후 +const clonedElement = cloneWithInlineStyles(printAreaEl); +await convertImagesToBase64(printAreaEl, clonedElement); // 이미지 인라인 변환 +const html = clonedElement.outerHTML; +``` + +## 영향 범위 (공통 적용) + +`DocumentViewer`가 모든 문서의 PDF 생성을 담당하므로, **1곳 수정으로 전체 해결**: + +| 화면 | 이미지 필드 | 적용 | +|------|------------|------| +| 스크린 중간검사 | `schematicImage` (도해) | 자동 적용 | +| 슬랫 중간검사 | `schematicImage` (도해) | 자동 적용 | +| 조인트바 중간검사 | `schematicImage` (도해) | 자동 적용 | +| 절곡 중간검사 | `schematicImage` (도해) | 자동 적용 | +| 절곡 재공품 중간검사 | `schematicImage` (도해) | 자동 적용 | +| 제품검사 성적서 (품질) | `productImages` | 자동 적용 | +| 기타 DocumentViewer 문서 | 모든 `` | 자동 적용 | + +## 변환 전략 우선순위 + +``` +1. data URL → 스킵 (이미 인라인) +2. fetch → blob → FileReader → dataURL (동일 출처, CORS 허용) +3. canvas drawImage → toDataURL (로컬 이미지 fallback) +4. 실패 시 원본 src 유지 (graceful degradation) +``` diff --git a/src/components/document-system/viewer/DocumentViewer.tsx b/src/components/document-system/viewer/DocumentViewer.tsx index db1407fc..e33e740f 100644 --- a/src/components/document-system/viewer/DocumentViewer.tsx +++ b/src/components/document-system/viewer/DocumentViewer.tsx @@ -161,6 +161,49 @@ export function DocumentViewer({ return clone; }; + // 이미지를 base64 data URL로 인라인 변환 (PDF 이미지 누락 해결) + const convertImagesToBase64 = async (original: HTMLElement, clone: HTMLElement): Promise => { + const originalImages = Array.from(original.querySelectorAll('img')); + const clonedImages = Array.from(clone.querySelectorAll('img')); + + await Promise.all( + originalImages.map(async (img, index) => { + const clonedImg = clonedImages[index]; + if (!clonedImg) return; + + const src = img.src; + if (!src || src.startsWith('data:')) return; + + try { + const response = await fetch(src); + const blob = await response.blob(); + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + clonedImg.setAttribute('src', dataUrl); + } catch { + try { + if (img.complete && img.naturalWidth > 0) { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(img, 0, 0); + clonedImg.setAttribute('src', canvas.toDataURL('image/png')); + } + } + } catch { + // 변환 실패 시 원본 src 유지 + } + } + }) + ); + }; + // 계산된 스타일을 인라인으로 적용 const applyStyles = (element: HTMLElement, computedStyle: CSSStyleDeclaration) => { const importantStyles = [ @@ -204,8 +247,9 @@ export function DocumentViewer({ return; } - // 인라인 스타일 적용된 HTML 복제 + // 인라인 스타일 적용된 HTML 복제 + 이미지 base64 인라인 변환 const clonedElement = cloneWithInlineStyles(printAreaEl); + await convertImagesToBase64(printAreaEl, clonedElement); const html = clonedElement.outerHTML; // 서버 API 호출