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 호출