사주 풀이 고도화, NAS 배포 자동화

This commit is contained in:
2026-02-16 19:02:04 +09:00
parent d513c063cf
commit 7042373448
44 changed files with 6280 additions and 978 deletions

View File

@@ -1,10 +1,78 @@
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로 변환하여 다운로드
* @param elementId - PDF로 변환할 HTML 요소의 ID
* @param filename - 다운로드될 PDF 파일명
*/
export async function downloadPDF(elementId: string, filename: string) {
try {
@@ -13,38 +81,33 @@ export async function downloadPDF(elementId: string, filename: string) {
throw new Error(`Element with id "${elementId}" not found`);
}
// 로딩 표시
const originalContent = element.innerHTML;
// HTML을 캔버스로 변환
const canvas = await html2canvas(element, {
scale: 2, // 해상도 향상
scale: 2,
useCORS: true,
logging: false,
backgroundColor: '#ffffff'
backgroundColor: '#ffffff',
onclone: (_doc: Document, clonedEl: HTMLElement) => {
sanitizeUnsupportedColors(clonedEl.ownerDocument);
}
});
// 캔버스를 이미지로 변환
const imgData = canvas.toDataURL('image/png');
// PDF 생성
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4'
});
const imgWidth = 210; // A4 width in mm
const pageHeight = 297; // A4 height in mm
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();
@@ -52,9 +115,7 @@ export async function downloadPDF(elementId: string, filename: string) {
heightLeft -= pageHeight;
}
// PDF 다운로드
pdf.save(filename);
return true;
} catch (error) {
console.error('PDF 생성 중 오류 발생:', error);
@@ -63,10 +124,6 @@ export async function downloadPDF(elementId: string, filename: string) {
}
}
/**
* 현재 페이지를 PDF로 다운로드
* @param filename - 다운로드될 PDF 파일명
*/
export async function downloadCurrentPageAsPDF(filename: string) {
return downloadPDF('pdf-content', filename);
}