From 6a008db1025e9765f16a8005c0318eecc704c74f Mon Sep 17 00:00:00 2001 From: jmj Date: Thu, 20 Aug 2026 16:54:01 +0900 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=EB=8B=A4=ED=81=AC=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=EC=97=90=EC=84=9C=20=ED=94=BC=EB=93=9C=EB=B0=B1=20PDF?= =?UTF-8?q?=20=EA=B0=80=20=EA=B2=80=EC=9D=80=20=EB=B0=B0=EA=B2=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=80=EC=9E=A5=EB=90=98=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit html2canvas 는 '지금 화면에 보이는 대로' 캡처한다. 다크모드 사용자가 리포트를 PDF 로 내려받으면 검은 배경에 밝은 글씨인 문서가 나온다 — 인쇄하면 잉크만 먹고 남에게 보내기도 어렵다. 피드백 리포트는 면접의 최종 산출물이라 PDF 가 곧 결과물이다. 캡처하는 동안만 SEED 라이트 팔레트를 강제하고(`applyColorMode('light-only')`) finally 로 원래 모드를 복원한다. CSS 변수 재계산이 화면에 반영되도록 한 프레임 기다린 뒤 캡처한다 — 바로 찍으면 이전 색이 그대로 들어간다. 라이트 사용자는 분기 자체를 타지 않아 불필요한 리페인트가 없다. --- .../features/feedback/lib/downloadPdf.test.ts | 70 +++++++++++++++++++ .../src/features/feedback/lib/downloadPdf.ts | 25 ++++++- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/feedback/lib/downloadPdf.test.ts diff --git a/frontend/src/features/feedback/lib/downloadPdf.test.ts b/frontend/src/features/feedback/lib/downloadPdf.test.ts new file mode 100644 index 00000000..fcdea35b --- /dev/null +++ b/frontend/src/features/feedback/lib/downloadPdf.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// 캡처 시점에 이 어떤 팔레트였는지 기록해 둔다. +let schemeDuringCapture: string | undefined +const save = vi.fn() + +vi.mock('html2canvas-pro', () => ({ + default: vi.fn(async () => { + schemeDuringCapture = document.documentElement.dataset.seedUserColorScheme + return { width: 800, height: 600, toDataURL: () => 'data:image/png;base64,x' } + }), +})) + +vi.mock('jspdf', () => ({ + // `new jsPDF(...)` 로 호출되므로 화살표 함수는 쓸 수 없다(생성자 불가). + jsPDF: vi.fn(function () { + return { + internal: { pageSize: { getWidth: () => 400, getHeight: () => 600 } }, + addImage: vi.fn(), + addPage: vi.fn(), + save, + } + }), +})) + +const { downloadElementAsPdf } = await import('./downloadPdf') + +describe('downloadElementAsPdf', () => { + beforeEach(() => { + schemeDuringCapture = undefined + save.mockClear() + localStorage.clear() + document.documentElement.removeAttribute('data-seed-color-mode') + document.documentElement.removeAttribute('data-seed-user-color-scheme') + }) + + // 다크 화면을 그대로 캡처하면 검은 배경에 밝은 글씨인 PDF 가 나온다 — 인쇄·공유용으로 못 쓴다. + it('다크모드여도 라이트 팔레트로 캡처한다', async () => { + localStorage.setItem('stackup.color-mode', 'dark-only') + document.documentElement.dataset.seedColorMode = 'dark-only' + document.documentElement.dataset.seedUserColorScheme = 'dark' + + await downloadElementAsPdf(document.createElement('div'), 'r.pdf') + + expect(schemeDuringCapture).toBe('light') + expect(save).toHaveBeenCalledWith('r.pdf') + }) + + it('캡처가 끝나면 원래 컬러 모드로 되돌린다', async () => { + localStorage.setItem('stackup.color-mode', 'dark-only') + document.documentElement.dataset.seedColorMode = 'dark-only' + document.documentElement.dataset.seedUserColorScheme = 'dark' + + await downloadElementAsPdf(document.createElement('div'), 'r.pdf') + + expect(document.documentElement.dataset.seedColorMode).toBe('dark-only') + expect(document.documentElement.dataset.seedUserColorScheme).toBe('dark') + }) + + // 라이트 사용자는 팔레트를 건드릴 이유가 없다(불필요한 리페인트·깜빡임 방지). + it('라이트 모드에서는 팔레트를 건드리지 않는다', async () => { + document.documentElement.dataset.seedColorMode = 'system' + document.documentElement.dataset.seedUserColorScheme = 'light' + + await downloadElementAsPdf(document.createElement('div'), 'r.pdf') + + expect(schemeDuringCapture).toBe('light') + expect(document.documentElement.dataset.seedColorMode).toBe('system') + }) +}) diff --git a/frontend/src/features/feedback/lib/downloadPdf.ts b/frontend/src/features/feedback/lib/downloadPdf.ts index 90831b53..a43e31ff 100644 --- a/frontend/src/features/feedback/lib/downloadPdf.ts +++ b/frontend/src/features/feedback/lib/downloadPdf.ts @@ -1,6 +1,26 @@ // DOM 요소를 캡처해 A4 PDF 로 저장(클라이언트). 한글은 DOM 렌더를 래스터화하므로 폰트 임베딩 불필요. // jspdf/html2canvas-pro 는 무거우므로 다운로드 시점에 동적 import (메인 번들 비대화 방지). // Tailwind v4 의 oklch 색을 지원하려면 html2canvas-pro 사용. +import { applyColorMode, isDarkApplied, readStoredColorMode } from '@/shared/lib/color-mode' + +/** + * 콜백이 도는 동안만 라이트 팔레트를 적용한다. 원래 모드는 무슨 일이 있어도 되돌린다. + * + *

SEED 는 `` 속성으로 팔레트를 고르므로 속성만 바꾸면 되지만, CSS 변수가 다시 + * 계산되어 화면에 반영될 시간을 한 프레임 준다 — 바로 캡처하면 이전 색이 찍힌다. + */ +async function withLightPalette(run: () => Promise): Promise { + if (!isDarkApplied()) return run() + const previous = readStoredColorMode() + applyColorMode('light-only') + try { + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))) + return await run() + } finally { + applyColorMode(previous) + } +} + export async function downloadElementAsPdf( el: HTMLElement, filename: string, @@ -10,7 +30,10 @@ export async function downloadElementAsPdf( import('html2canvas-pro'), ]) - const canvas = await html2canvas(el, { scale: 2 }) + // html2canvas 는 '지금 화면에 보이는 대로' 캡처한다. 다크모드 사용자가 그대로 뽑으면 + // 검은 배경에 밝은 글씨인 PDF 가 나온다 — 출력·공유용으로는 쓸 수 없다. + // 캡처하는 동안만 라이트 팔레트를 강제하고 원래 설정으로 되돌린다. + const canvas = await withLightPalette(() => html2canvas(el, { scale: 2 })) const img = canvas.toDataURL('image/png') const pdf = new jsPDF({ unit: 'px', format: 'a4' })