fix(WEB): DocumentViewer PDF 이미지 누락 해결
- PDF 렌더링 시 이미지 누락 이슈 수정 - 해결 과정 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
127
claudedocs/dev/[FIX-2026-02-09] PDF-이미지-누락-해결.md
Normal file
127
claudedocs/dev/[FIX-2026-02-09] PDF-이미지-누락-해결.md
Normal file
@@ -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에 내장) |
|
||||||
|
|
||||||
|
**결론**: `<img src="...">` 가 상대/절대/blob URL이면 Puppeteer PDF에서 이미지가 빠진다.
|
||||||
|
|
||||||
|
## 해결 방법
|
||||||
|
|
||||||
|
### 접근: 이미지를 base64 data URL로 인라인 변환
|
||||||
|
|
||||||
|
PDF API로 HTML을 보내기 **전에**, 모든 `<img>` 태그의 `src`를 **base64 data URL로 변환**하여 HTML 자체에 이미지를 내장시킨다.
|
||||||
|
|
||||||
|
### 구현: `convertImagesToBase64()`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// DocumentViewer.tsx에 추가
|
||||||
|
const convertImagesToBase64 = async (original: HTMLElement, clone: HTMLElement): Promise<void> => {
|
||||||
|
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<string>((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 문서 | 모든 `<img>` | 자동 적용 |
|
||||||
|
|
||||||
|
## 변환 전략 우선순위
|
||||||
|
|
||||||
|
```
|
||||||
|
1. data URL → 스킵 (이미 인라인)
|
||||||
|
2. fetch → blob → FileReader → dataURL (동일 출처, CORS 허용)
|
||||||
|
3. canvas drawImage → toDataURL (로컬 이미지 fallback)
|
||||||
|
4. 실패 시 원본 src 유지 (graceful degradation)
|
||||||
|
```
|
||||||
@@ -161,6 +161,49 @@ export function DocumentViewer({
|
|||||||
return clone;
|
return clone;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 이미지를 base64 data URL로 인라인 변환 (PDF 이미지 누락 해결)
|
||||||
|
const convertImagesToBase64 = async (original: HTMLElement, clone: HTMLElement): Promise<void> => {
|
||||||
|
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<string>((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 applyStyles = (element: HTMLElement, computedStyle: CSSStyleDeclaration) => {
|
||||||
const importantStyles = [
|
const importantStyles = [
|
||||||
@@ -204,8 +247,9 @@ export function DocumentViewer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인라인 스타일 적용된 HTML 복제
|
// 인라인 스타일 적용된 HTML 복제 + 이미지 base64 인라인 변환
|
||||||
const clonedElement = cloneWithInlineStyles(printAreaEl);
|
const clonedElement = cloneWithInlineStyles(printAreaEl);
|
||||||
|
await convertImagesToBase64(printAreaEl, clonedElement);
|
||||||
const html = clonedElement.outerHTML;
|
const html = clonedElement.outerHTML;
|
||||||
|
|
||||||
// 서버 API 호출
|
// 서버 API 호출
|
||||||
|
|||||||
Reference in New Issue
Block a user