fix(capture): Windows Chrome 에서 이미지 캡처 실패 — 외부 폰트 임베드 회피
Deploy momo-erp / deploy (push) Failing after 42s

- html-to-image 의 toPng 가 Pretendard CDN 임베드 단계에서 fail 하면 캡처 전체가
  깨짐 (Windows Chrome 에서 자주 발생). skipFonts + cacheBust + jpeg fallback 추가
- 거래명세표(orders) / 발주서(procurements) 양쪽이 같은 코드를 복붙으로 갖고 있던
  걸 lib/capture-share.ts 로 통합
- 실패 시 err.message 를 swal 에 노출 (이전엔 "잠시 후 다시 시도하세요" 만 떠서
  사용자가 원인 추적 불가)
- navigator.share 의 AbortError(사용자 취소) 는 silent 처리 + 그 외엔 다운로드 폴백

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-08 14:10:52 +09:00
parent ffada52fd4
commit 23599b9c18
3 changed files with 122 additions and 59 deletions
+10 -37
View File
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
import { Check, Download, X, RefreshCcw, Truck, AlertCircle, Package } from "lucide-react";
import Swal from "sweetalert2";
import { captureAndShare } from "@/lib/capture-share";
interface Order {
OBJID: string; ORDER_NO: string; ORDER_DATE: string;
@@ -391,42 +392,14 @@ function StatementPreview({
const editable = order.STATUS === "REQUESTED";
// 거래명세표를 이미지로 캡처 → 공유 또는 다운로드
const captureAndShare = async () => {
try {
const mod = await import("html-to-image");
if (!statementRef.current) return;
// 거래처에 보낼 이미지에서 "현재고"는 내부 정보라 숨긴다.
const hideEls = statementRef.current.querySelectorAll<HTMLElement>(".js-no-export");
const prev: { el: HTMLElement; display: string }[] = [];
hideEls.forEach((el) => { prev.push({ el, display: el.style.display }); el.style.display = "none"; });
let dataUrl: string;
try {
dataUrl = await mod.toPng(statementRef.current, {
backgroundColor: "#ffffff",
pixelRatio: 2,
});
} finally {
// 복원
prev.forEach((p) => { p.el.style.display = p.display; });
}
const blob = await (await fetch(dataUrl)).blob();
const file = new File([blob], `${order.ORDER_NO}_거래명세표.png`, { type: "image/png" });
// Web Share API (모바일/지원 브라우저) → 카카오톡/메신저 등으로 공유
const navAny = navigator as Navigator & { canShare?: (data: { files: File[] }) => boolean };
if (navAny.canShare && navAny.canShare({ files: [file] })) {
await navigator.share({ files: [file], title: `${order.ORDER_NO} 거래명세표`, text: order.COMPANY_NAME });
return;
}
// 폴백: 이미지 파일 다운로드
const a = document.createElement("a");
a.href = dataUrl;
a.download = `${order.ORDER_NO}_거래명세표.png`;
a.click();
Swal.fire({ icon: "success", title: "이미지 저장 완료", text: "다운로드한 이미지를 카톡 등에 첨부해 보내세요.", timer: 1500, showConfirmButton: false });
} catch (err) {
console.error("[capture]", err);
Swal.fire({ icon: "error", title: "이미지 캡처 실패", text: "잠시 후 다시 시도하세요." });
}
const handleCapture = async () => {
if (!statementRef.current) return;
await captureAndShare({
node: statementRef.current,
filename: `${order.ORDER_NO}_거래명세표`,
shareTitle: `${order.ORDER_NO} 거래명세표`,
shareText: order.COMPANY_NAME,
});
};
// 비고 저장
@@ -484,7 +457,7 @@ function StatementPreview({
<div className="flex justify-end gap-2 print:hidden flex-wrap">
<button
type="button"
onClick={captureAndShare}
onClick={handleCapture}
className="inline-flex items-center gap-1 h-8 px-3 rounded-lg bg-amber-100 text-amber-800 text-xs font-bold hover:bg-amber-200"
title="이미지로 캡처해 카톡 등에 공유"
>
+10 -22
View File
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { Plus, Send, Search, RefreshCcw, X, Download, Image as ImageIcon } from "lucide-react";
import Swal from "sweetalert2";
import { captureAndShare } from "@/lib/capture-share";
interface ProcRow {
OBJID: string; PROC_NO: string; PROC_DATE: string;
@@ -289,27 +290,14 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
const editable = detail.proc.STATUS === "OPEN";
const formRef = useRef<HTMLDivElement>(null);
const captureAndShare = async () => {
try {
const mod = await import("html-to-image");
if (!formRef.current) return;
const dataUrl = await mod.toPng(formRef.current, { backgroundColor: "#ffffff", pixelRatio: 2 });
const blob = await (await fetch(dataUrl)).blob();
const file = new File([blob], `${detail.proc.PROC_NO}_발주서.png`, { type: "image/png" });
const navAny = navigator as Navigator & { canShare?: (data: { files: File[] }) => boolean };
if (navAny.canShare && navAny.canShare({ files: [file] })) {
await navigator.share({ files: [file], title: `${detail.proc.PROC_NO} 발주서`, text: detail.proc.VENDOR_NAME ?? "" });
return;
}
const a = document.createElement("a");
a.href = dataUrl;
a.download = `${detail.proc.PROC_NO}_발주서.png`;
a.click();
Swal.fire({ icon: "success", title: "이미지 저장 완료", text: "다운로드한 이미지를 카톡 등에 첨부해 보내세요.", timer: 1500, showConfirmButton: false });
} catch (err) {
console.error("[capture]", err);
Swal.fire({ icon: "error", title: "이미지 캡처 실패", text: "잠시 후 다시 시도하세요." });
}
const handleCapture = async () => {
if (!formRef.current) return;
await captureAndShare({
node: formRef.current,
filename: `${detail.proc.PROC_NO}_발주서`,
shareTitle: `${detail.proc.PROC_NO} 발주서`,
shareText: detail.proc.VENDOR_NAME ?? "",
});
};
return (
@@ -318,7 +306,7 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
<div className="flex justify-end gap-2 mb-2 print:hidden flex-wrap">
<button
type="button"
onClick={captureAndShare}
onClick={handleCapture}
className="inline-flex items-center gap-1 h-8 px-3 rounded-lg bg-amber-100 text-amber-800 text-xs font-bold hover:bg-amber-200"
title="이미지로 캡처해 카톡 등에 공유"
>
+102
View File
@@ -0,0 +1,102 @@
// 거래명세표 / 발주서 등 DOM 영역을 PNG 로 캡처해 공유 또는 다운로드.
// Windows Chrome 에서 html-to-image 의 toPng 가 외부 폰트(Pretendard CDN) 임베드 단계에서
// 실패하는 케이스를 회피하기 위해 skipFonts + cacheBust + jpeg fallback 을 사용한다.
import Swal from "sweetalert2";
interface CaptureShareOptions {
/** 캡처 대상 DOM (영역 외 버튼은 미리 .js-no-export 클래스로 hide 처리됨) */
node: HTMLElement;
/** 저장 파일명 베이스 (확장자 제외) */
filename: string;
/** 공유 타이틀 (Web Share API) */
shareTitle: string;
/** 공유 텍스트 */
shareText?: string;
}
export async function captureAndShare({ node, filename, shareTitle, shareText }: CaptureShareOptions): Promise<void> {
// 거래처에 보낼 이미지에서 내부 정보(.js-no-export)는 잠시 숨긴다.
const hideEls = node.querySelectorAll<HTMLElement>(".js-no-export");
const prev: { el: HTMLElement; display: string }[] = [];
hideEls.forEach((el) => { prev.push({ el, display: el.style.display }); el.style.display = "none"; });
let dataUrl: string;
try {
const mod = await import("html-to-image");
const baseOpts = {
backgroundColor: "#ffffff",
pixelRatio: 2,
cacheBust: true,
// Windows Chrome 에서 외부 CDN 폰트(Pretendard) 임베드 단계가
// CORS/네트워크 문제로 실패하면 toPng 전체가 깨짐 → 폰트 임베드 스킵
skipFonts: true,
} as const;
try {
dataUrl = await mod.toPng(node, baseOpts);
} catch (pngErr) {
console.warn("[capture] toPng 실패, toJpeg 로 재시도:", pngErr instanceof Error ? pngErr.message : pngErr);
dataUrl = await mod.toJpeg(node, { ...baseOpts, quality: 0.95 });
}
} catch (err) {
// 복원 후 에러 표시
prev.forEach((p) => { p.el.style.display = p.display; });
const msg = err instanceof Error ? err.message : String(err);
console.error("[capture] 캡처 자체 실패:", err);
await Swal.fire({
icon: "error",
title: "이미지 캡처 실패",
html: `<div class="text-left text-xs">${msg}</div><br><span class="text-xs text-slate-500">브라우저 콘솔(F12)에 자세한 로그가 있습니다.</span>`,
});
return;
} finally {
// 캡처 끝나면 (성공/실패 무관) 숨겼던 요소 복원
prev.forEach((p) => { p.el.style.display = p.display; });
}
// 공유 또는 다운로드 단계
try {
const ext = dataUrl.startsWith("data:image/jpeg") ? "jpg" : "png";
const mime = ext === "jpg" ? "image/jpeg" : "image/png";
const blob = await (await fetch(dataUrl)).blob();
const file = new File([blob], `${filename}.${ext}`, { type: mime });
// Web Share API (모바일 + 일부 데스크톱) - files 공유 지원 시
const navAny = navigator as Navigator & { canShare?: (data: { files: File[] }) => boolean };
if (navAny.canShare && navAny.canShare({ files: [file] })) {
try {
await navigator.share({ files: [file], title: shareTitle, text: shareText ?? "" });
return;
} catch (shareErr) {
// 사용자가 취소했거나 share API 실패 → 다운로드로 폴백
const name = (shareErr as Error)?.name;
if (name === "AbortError") return; // 사용자 취소면 그냥 종료
console.warn("[capture] navigator.share 실패, 다운로드로 폴백:", shareErr);
}
}
// 폴백: 다운로드
const a = document.createElement("a");
a.href = dataUrl;
a.download = `${filename}.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
Swal.fire({
icon: "success",
title: "이미지 저장 완료",
text: "다운로드한 이미지를 카톡 등에 첨부해 보내세요.",
timer: 1500,
showConfirmButton: false,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[capture] 공유/다운로드 단계 실패:", err);
Swal.fire({
icon: "error",
title: "이미지 공유 실패",
html: `<div class="text-left text-xs">${msg}</div>`,
});
}
}