Files
saju-web/lib/pdf-utils.ts

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);
}