130 lines
4.0 KiB
TypeScript
130 lines
4.0 KiB
TypeScript
import jsPDF from 'jspdf';
|
|
import html2canvas from 'html2canvas';
|
|
|
|
/**
|
|
* html2canvas가 지원하지 않는 CSS 색상 함수(lab, oklch, oklab)를
|
|
* 클론된 문서에서 안전한 값으로 치환한다.
|
|
*
|
|
* 접근 방식:
|
|
* 1) <style> 태그의 텍스트에서 직접 regex 치환 (가장 확실)
|
|
* 2) CSSStyleSheet.cssRules에서 문제 속성 제거 (외부 스타일시트 대응)
|
|
* 3) 개별 요소의 인라인 style 정리
|
|
*/
|
|
function sanitizeUnsupportedColors(clonedDoc: Document) {
|
|
const unsafeRe = /(?:oklch|oklab|lab)\([^)]*\)/gi;
|
|
|
|
// 1단계: 모든 <style> 태그 텍스트에서 lab()/oklch()/oklab() → 안전한 색상으로 치환
|
|
const styleTags = clonedDoc.querySelectorAll('style');
|
|
styleTags.forEach((tag) => {
|
|
const text = tag.textContent;
|
|
if (text && unsafeRe.test(text)) {
|
|
tag.textContent = text.replace(unsafeRe, 'rgba(128,128,128,1)');
|
|
}
|
|
// reset lastIndex because we reuse the regex
|
|
unsafeRe.lastIndex = 0;
|
|
});
|
|
|
|
// 2단계: <link> 등 외부 스타일시트의 cssRules에서 문제 속성 제거
|
|
try {
|
|
for (const sheet of Array.from(clonedDoc.styleSheets)) {
|
|
try {
|
|
// <style> 태그는 이미 1단계에서 처리했으므로 skip
|
|
if (sheet.ownerNode && (sheet.ownerNode as HTMLElement).tagName === 'STYLE') continue;
|
|
|
|
const rules = sheet.cssRules;
|
|
if (!rules) continue;
|
|
for (let i = rules.length - 1; i >= 0; i--) {
|
|
const rule = rules[i];
|
|
if (rule instanceof CSSStyleRule) {
|
|
for (let j = rule.style.length - 1; j >= 0; j--) {
|
|
const prop = rule.style.item(j);
|
|
const val = rule.style.getPropertyValue(prop);
|
|
unsafeRe.lastIndex = 0;
|
|
if (unsafeRe.test(val)) {
|
|
rule.style.removeProperty(prop);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// 크로스 오리진 스타일시트는 접근 불가 - 무시
|
|
}
|
|
}
|
|
} catch {
|
|
// styleSheets 접근 실패 시 무시
|
|
}
|
|
|
|
// 3단계: 인라인 style 속성에 남아있는 lab() 정리
|
|
const allElements = clonedDoc.querySelectorAll('*');
|
|
allElements.forEach((el) => {
|
|
const htmlEl = el as HTMLElement;
|
|
const inlineStyle = htmlEl.getAttribute('style');
|
|
if (inlineStyle) {
|
|
unsafeRe.lastIndex = 0;
|
|
if (unsafeRe.test(inlineStyle)) {
|
|
htmlEl.setAttribute(
|
|
'style',
|
|
inlineStyle.replace(unsafeRe, 'rgba(128,128,128,1)')
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* HTML 요소를 PDF로 변환하여 다운로드
|
|
*/
|
|
export async function downloadPDF(elementId: string, filename: string) {
|
|
try {
|
|
const element = document.getElementById(elementId);
|
|
if (!element) {
|
|
throw new Error(`Element with id "${elementId}" not found`);
|
|
}
|
|
|
|
const canvas = await html2canvas(element, {
|
|
scale: 2,
|
|
useCORS: true,
|
|
logging: false,
|
|
backgroundColor: '#ffffff',
|
|
onclone: (_doc: Document, clonedEl: HTMLElement) => {
|
|
sanitizeUnsupportedColors(clonedEl.ownerDocument);
|
|
}
|
|
});
|
|
|
|
const imgData = canvas.toDataURL('image/png');
|
|
|
|
const pdf = new jsPDF({
|
|
orientation: 'portrait',
|
|
unit: 'mm',
|
|
format: 'a4'
|
|
});
|
|
|
|
const imgWidth = 210;
|
|
const pageHeight = 297;
|
|
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
|
let heightLeft = imgHeight;
|
|
let position = 0;
|
|
|
|
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
|
heightLeft -= pageHeight;
|
|
|
|
while (heightLeft > 0) {
|
|
position = heightLeft - imgHeight;
|
|
pdf.addPage();
|
|
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
|
heightLeft -= pageHeight;
|
|
}
|
|
|
|
pdf.save(filename);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('PDF 생성 중 오류 발생:', error);
|
|
alert('PDF 생성에 실패했습니다. 다시 시도해주세요.');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function downloadCurrentPageAsPDF(filename: string) {
|
|
return downloadPDF('pdf-content', filename);
|
|
}
|