fix(WEB): DocumentViewer PDF 이미지 누락 해결

- PDF 렌더링 시 이미지 누락 이슈 수정
- 해결 과정 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-11 20:53:06 +09:00
parent 113d82c254
commit 4decb99856
2 changed files with 172 additions and 1 deletions

View 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)
```

View File

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