웹 브라우정 상에서 특정 부분을 html,pdf, markdown파일로 저장하고 싶다면 그 기능을 어떻게 구현할 수 있을까 🤔?
그 물음에 대해 찾은 방법과 그 과정에서 공부한 것들을 정리해봤다.
위의 사진은 파일 저장기능을 구현해야 했던 notion 프로젝트이다. 여기서 frame이 파일로 저장되어야할 대상이다.
element를 파일로 저장할 때 대부분은 화면상에서 보이는 모습 대로 저장하고 싶을 것다. 즉, 기존에 작성했던 스타일 관련한 코드가 적용된 element이 파일에 저장되기를 바라는 것이다.
화면에 보이는 대로 element를 파일에 저장하기 위해서 어떻게 해야할까?
내가 찾은 방법은 style 요소이다.
scss를 변환한 css 파일을 이용해 html에 스타일링을 하고 있기 때문에 스타일에 대한 코드는 모두 style에 담겨 있기 때문에 저장된 element에 대한 스타일 코드를 읽어오면 되었고 그러기 위해서는 element의 스타일링만을 담당하는 css파일을 만들었다.
css 파일 | 설명 |
---|---|
main.css | frame 외의 element에 대한 스타일링을 담당 |
frame.css | frame에 대한 스타일링만을 담당 |
뒤에서 자세히 소개하겠지만 파일로 저장하는 방법은 다음과 같다.
html element를 파일로 변환, 저장하는 방법
파일 형식 | 방법 |
---|---|
html | a 태그의 href와 download |
markdown | a 태그의 href와 download |
▪️ 새로운 window 창과 window.print() ▪️ 라이브러리(html2canvas+jsPdf) |
라이브러리를 이용하는 방법을 제외하고는 파일로 저장되는 element를 string type으로 바꾸는 과정이 필요한다.
왜냐하면 a 태그의 download기능을 이용하려면 저장할 파일의 url이 필요한데 Blob 객체를 이용하면 string type의 객체의 url를 만들 수 있으며, 새로운 window창을 열어서 새로운 창에 window.document.write()메서스를 이용해 파일로 저장되는 element를 새로운 창에 넣을 것이기 때문에 element를 string type의 객체로 바꾸어야한다. 또한 이는 element의 스타일링에 대한 새로운 파일을 추가할 필요 없이 기존의 코드를 재사용할 수 있는 이점이 있다.
const frame =document.getElementsByClassName("frame")[0] as HTMLElement;
// 파일로 저장될 element
const styleTag= [...document.querySelectorAll("style")];
const styleCode= styleTag[1].outerHTML; // frame 에 대한 스타일 코드가 들어 있는 style 태그
const convertHtml =(title:string, frameHtml:string)=>{
const html =`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
${styleCode}
<style>
body{
display:flex;
flex-direction:column;
align-items:center;
}
//유저가 파일에서 이미지를 넣을 지 선택에 따라 이미지를 보여주거나 감추는 스타일 코드
${content === noFileImage &&
'img, .pageCover, .pageIcon, .media, .pageImgIcon{display:none}'
}
</style>
</head>
<body>
${frameHtml}
</body>
</html>`;
return html;
};
const currentPageFrameHtml = convertHtml(page.header.title, frame.outerHTML);
// string 타입 객체로 변환된 frame
그 이유는 2-1)-A 에서 언급한 element에 대한 style 요소를 활용해 화면상에 보이는 그대로 element를 파일로 변환,저장하기 위해서이다.
위에서는 frame만 추출하고 싶어서 style tag 중 frame 관련한 style tag 를 사용했는데 만약 여러개의 style tag를 사용하고 싶다면 Array.prototype.join() 을 이용하면 된다.
const styleTags =[...document.quretySeletorAll("style")];
const styleCode = styleTags.map((style)=> style.outerHtml).join();
notion은 해당 페이지의 아래에 있는 서브 페이지들(브라우저에서는 페이지명과 아이콘만 보임)을 파일로 저장할 수 있는 기능을 제공하고 있다.
이처럼 현재 브라우저 상에 없는 element를 파일로 저장해야 하는 경우라면 어떻게 해야할까?
파일로 저장해야 할 element을 return 하는 Component와 ReactDOMWServer를 이용하면 된다.
import ReactDOMServer from 'react-dom/server';
import React from 'react';
const Frame =()=>{
...
return (
<div className="frame">
....
</div>
)
};
....
const Export =()=>{
...
const frameComponent:JSX.Element =
<Frame
page={subPage}
....//props
/>;
const subPageFrame:string = ReactDOMServer.renderToString(frameComponent);
const subPageHtml = convertHtml(subPage, subPageFrame); // return subPage as string
import Frame from "./Frame"; // .frame 은 return하는 Frame component
import ReactDOMServer from 'react-dom/server';
//.......
const Export =()=>{
....
type GetSubPageFrameReturn ={
jsx:JSX.Element,
title:string,
};
// page 안의 subPage에 대한 frame들을 반환하는 함수
function getSubPageFrame(subPagesId:string[]):GetSubPageFrameReturn[]{
const subPages = subPagesId.map((id:string)=> findPage(pagesId,pages,id));
const subPageFrames= subPages.map((subPage:Page)=>{
const frameComponent =<Frame
page={subPage}
//...props
/>;
return { jsx:frameComponent, title:subPage.header.title};
});
return subPageFrames;
};
type ConvertSubPageFrameIntoHtmlReturn ={html:string,title:string};
// subPage 의 frame들로 새로운 html을 반들어 반환하는 함수
function convertSubPageFrameIntoHtml(subPagesId:string[]):ConvertSubPageFrameHtmlReturn[]{
const subPageFrames = getSubPageFrame(subPagesId)
.map(({jsx,title}:GetSubPageFrameReturn)=>({
frameHtml:ReactDOMServer.renderToString(jsx),
title:title
}));
const subPageHtmls = subPageFrames
.map((
{frameHtml, title}:{frameHtml:string, title:string}
) => ({
html:convertHtml(title, frameHtml),
title: title})
);
return subPageHtmls;
};
....
};
notion 프로젝트에서는 라이브러리를 이용해 pdf 파일을 저장할 경우에 html 이 아닌 frame component만 필요해서 component를 반환하는 함수(getSubPageFrame )와 이를 html으로 변경하는 함수(convertSubPageFrameIntoHtml)를 따로 만들었다.
const Export =()=>{
const html ="HTML";
const pdf="PDF";
const markdown="Markdown";
const everything="Everything";
const noFileImage = 'No files or images';
type Format = typeof html| typeof pdf | typeof markdown;
type Content = typeof everything| typeof noFileImage;
const [format, setFormat]=useState<Format>(html); // 파일의 확장자
const [content, setContent]=useState<Content>(everything); // 파일로 저장될 내용에 이미지를 넣을지 말지에 대한
//...
// html, markdown 파일로 저장
function exportDocument(targetPageTitle:string,targetHtml:string, type:string, format:Format){
//type은 MIME
const blob = new Blob([targetHtml], {type:type});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const extension =format.toLowerCase();
a.href =url;
a.download =`${targetPageTitle}.${extension}`;
exportHtml?.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
};
const exportHtml=()=>{
exportDocument(page.header.title,currentPageFrameHtml, "text/html",format);
if(includeSubPage && page.subPagesId!==null){
//subPages도 파일 저장대상일 경우
convertSubPageFrameIntoHtml(page.subPagesId)
.forEach(({html,title}:ConvertSubPageFrameIntoHtmlReturn)=>
exportDocument(title,html, "text/html", format));
};
};
const exportMarkdown=()=>{
const markdownText =NodeHtmlMarkdown.translate(currentPageFrameHtml);
exportDocument(page.header.title,markdownText,"text/markdown", format);
if(includeSubPage && page.subPagesId!==null){
//subPages도 파일 저장대상일 경우
convertSubPageFrameIntoHtml(page.subPagesId)
. forEach(({html,title}:ConvertSubPageFrameIntoHtmlReturn)=>{
const subPageMarkdownText =NodeHtmlMarkdown.translate(html);
exportDocument(title,subPageMarkdownText, "text/markdown", format)});
};
};
//...
return(
<>
//...
<button onClick={exportHtml}>
Export html
</button>
<button onClick={exportMarkdown}>
Export markdown
</button>
</>
)};
exportDocument는 html, markdown 파일로 저장할 수 있는 기능을 담당하는 함수이다. 파일의 확장자(형식)에 따라서 Blob의 타입을 다르게 설정하면 된다. (html 은 "text/html"이고 markdown은 "text/markdown")
markdown 형식으로 저장할 경우에는 NodeHtmlMarkdown.translate()를 사용하여 string type을 markdown으로 변경하는 작업을 추가적으로 해야한다.
const Export =()=>{
...
function printPdf(htmlDocument:string){
const printWindow = window.open('', '', 'height=400,width=800');
printWindow?.document.write(htmlDocument);
printWindow?.document.close();
printWindow?.print();
if(printWindow!==null){
// 인쇄 미리보기 창이 닫힌 후에 printwindow창 닫기
printWindow.onload =function(){
printWindow.close();
}
}
};
if(includeSubPage && page.subPagesId!==null){
convertSubPageFrameIntoHtml(page.subPagesId)
.forEach(({html,title}:GetSubPageFrameHtmlReturn)=>
printPdf(html));
};
};
...
return(
<>
<button onClick={printPdf}>
Print pdf
</button>
</>
)};
라이브러리를 사용하지 않아도, js만으로도 pdf로 변환이 가능하다는 장점이 있다. 또한 라이브러리를 이용하 pdf 변환시에 문제가 되는 스타일링 오류가 발생하지 않으며 유저가 원하는 대로 pdf 페이지의 여백을 설정할 수 있다는 점에서 좋다.
window.print를 이용할 경우, 인쇄 미리보기 창과 인쇄 대상이 되는 새로운 창이 뜨기 때문에 사용자가 번거로울 수 있다는 단점이 있다. 이는 저장하고자 하는 pdf 파일이 여러 개 일 경우 큰 단점으로 작용할 수 있다.
인쇄 대상이 되는 창(=printWindow)을 window.close를 이용해 닫을 수 있지만, 단, 이미지가 있는 경우에는 프린트 미리보기 창 내에서 이미지가 업로드 되기 전에 인쇄 대상이 되는 창(printWindow)을 닫을 경우 인쇄 미리보기 창에서 이미지가 업로드 되지 않는 에러가 발생하기때문에 주의해야한다.
이에 대한 해결책으로 onafterprint를 추천하는 글들을 봤고 이를 사용해봤지만, 창이 닫히지 않았다.
그래서 내가 찾은 방법은 window.onload 이다. 미리보기 창에서 인쇄나 취소버튼을 누르거나 미리 보기 창이 꺼지면 printWindow 창이 열리는데, printWindow창이 열렸을때 즉, 로드 되었을 때 해당 창을 닫도록 했다. 이러면 이미지가 로드 되지 않는 오류를 해결하고, 미리 보기 창이 닫히면 pdf 파일을 저장하기 위해 만든 창(printWindow)도 닫히게 된다.
....
printWindow?.print();
if(printWindow!==null){
// 인쇄 미리보기 창이 닫힌 후에 printwindow창 닫기
printWindow.onload =function(){
printWindow.close();
}
}
....
그러나 여러 element를 각각의 파일로 변환 할 경우 인쇄 미리보기 창이 여러개 열린다는 단점은 여전히 존재한다.
jsPdf 를 사용하면 브라우저를 pdf 파일로 다운로드할 수 있다. jsPdf와 선택한 html 요소를 canvas에 담을 수 있게 해주는 라이브러리인 html2canvas을 같이 사용하면 jsPdf에서 html의 스타일이 제대로 담기지 않는 오류를 보완할 수 있어 jsPdf와 html2canvas를 같이 사용하기도 한다.
const Export=()=>{
....
// 브라우저 상의 html element 를 pdf로 변환
function convertPdf(frame:HTMLElement, title:string){
const frameCopy = frame.cloneNode(true);
const root =document.getElementById("root");
const printFrame =document.createElement("div");
printFrame.id ="printFrame";
const frameHeight =frame.scrollHeight;
printFrame.setAttribute("style","position:absolute; left:-99999999px");
printFrame.append(frameCopy);
root?.append(printFrame);
html2canvas(printFrame, {
width:window.innerWidth,
height:frameHeight,
useCORS:true, //외부 이미지 사용시에 해줘야함
}).then(function(canvas){
const imgData = canvas.toDataURL('image/png');
const imgWidth= 210;
const pageHeight = imgWidth * 1.414;
const imgHeight = (canvas.height * imgWidth) / canvas.width ;
let heightLeft =imgHeight;
const doc =new jsPDF("p","mm", "a4");
let position =0;
doc.addImage(imgData, "PNG", 0 , position, imgWidth , imgHeight, "","FAST");
heightLeft -=pageHeight;
// 이미지가 파일의 페이지의 길이보가 긴 경우, 이미지를 끊어서 다른 페이지에 저장해야함
while (heightLeft >= 0) {
position = heightLeft - imgHeight ;
doc.addPage();
doc.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight , "","FAST");
heightLeft -= pageHeight;
};
doc.save(`${title}.pdf`);
printFrame.remove();
//subPage 의 frame을 파일로 저장하는 경우
frame.classList.contains("subFrame") && frame.remove();
})
};
//subPage의 frame element를 pdf로 변환
function convertSubPageIntoPdf(subPageId:string[]){
const makeFrameElement=(jsx:JSX.Element, title:string)=>{
const subFrame = document.createElement("div");
subFrame.className="editor subFrame";
const subFrmaeHtml = ReactDOMServer.renderToString(jsx);
subFrame.innerHTML =subFrmaeHtml;
root?.appendChild(subFrame);
convertPdf(subFrame,title );
};
getSubPageFrame(subPageId)
.forEach(({jsx,title}:GetSubPageFrameReturn)=>
makeFrameElement(jsx, title))
};
type GetSubPageFrameReturn ={
jsx:JSX.Element,
title:string,
};
function getSubPageFrame(subPagesId:string[]):GetSubPageFrameReturn[]{
const subPages = subPagesId.map((id:string)=> findPage(pagesId,pages,id));
const subPageFrames= subPages.map((subPage:Page)=>{
const frameComponent =<Frame
page={subPage}
//...props
/>;
return { jsx:frameComponent, title:subPage.header.title};
});
return subPageFrames;
};
const exportPdf=()=>{
convertPdf(frame,page.header.title);
if(includeSubPage && page.subPagesId!==null){
convertSubPageIntoPdf(page.subPagesId);
};
};
...
return(
<>
<button onClick={exportPdf}>
Export Pdf
</button>
</>
)};
1. frame 요소를 복사하는 이유?
복사본이 아닌 실제 frame을 printFrame에 자식 요소로 넣으면 복사해서 넣어지는 것이 아니라 웹브라우저 상에서의 frame이 printFrame의 자식요소로 이동하게 되어서 파일로 저장하는 창을 끄면 웹브라우저 상에서 보이지 않게 된다.
2. canvas의 크기 설정
저장하고 자 하는 element가 잘리지 않고 그대로 canvas에 그려지기 위해서는 canvas의 크기를 element 에 맞게 설정해야한다. 그러나 canvas에 그려지는 printFrame의 width, height를 이용해 크기로 canvas의 크기를 설정하면 element가 잘려서 그려지기 때문에 다음과 같이 설정했다.
{
width: window.innerWidth,
height: frame.srcollHeight
}
canvas의 height의 경우 printFrame이 아니라 파일로 저장된 frame의 scrollHeight(화면 밖에 존재하는 frame 영역도 고려해야 하기때문에 clientHeight나 offsetHeight를 사용하지 않았다.)을 사용해 크기를 설정했다.
canavas의 width에서 height와 달리 frame의 width를 이용하지 않은 이유는 아래의 사진 처럼 frame의 너비가 화면의 너비보다 큰 경우 page 의 글자들이 말줄임표로 잘리는 경우가 발생한다. 따라서 primeFrame은 root의 너비를 100%로 상속 받기 때문에 브라우저의 너비를 이용해 canavas의 width를 설정했다.
printFrame을 화면 밖에 위치시켰기때문에 실제로는 보여지지 않지만, printFrame와 파일로 저장할 frame이 어떠한 레이아웃으로 있는지 보여주기 위해 화면에 구현해 봤다.
3. canvas의 옵션으로 useCORS를 true로 설정한 이유?
html2canvas 는 내부 경로의 이미지를 사용하기 때문에 외부에서 이미지를 가져오는 경우에는 useCORS를 true로 설정해주어야한다.
notion 프로젝트에는 따로 서버를 두지 않고 base64의 형식으로 이미지 데이터를 불러오고 있는데 이 경우에도 useCORS 값을 true 해주어야 이미지가 나타난다.
useCORS : Whether to attempt to load images from a server using CORS
window.print와 달리 버튼을 누르면 바로 pdf 파일이 다운로드 된다는 장점이 있다. 이는 사용자의 편의성 측면에서 좋은 장점이다.
스타일링 상 오류가 있는 jsPdf의 단점을 보완하고자 html2canvas를 같이 사용했지만 html2canvas에서도 아래의 사진과 같은 스타일링 오류가 발생했다.
특히 , page의 title이 잘리는 오류는 기존의 frame의 style관련 code를 변경해도 윗 부분이 잘리는 오류는 수정할 수 없었다.
페이지의 여백에서도 단점이 존재한다.
jsPdf는 페이지의 여백을 줄 수 있는 옵션을 제공하지만 두번째 페이지에서 여백을 적용하는게 어려우며 여백은 코드로 이미 확정되어지기 때문에 사용자가 본인이 원하는 여백을 설정할 수 없다는 단점이 존재한다.
방법 | 장점 | 단점 |
---|---|---|
window.print | ▪️ 별로의 라이브러리를 이용하지 않고 pdf 파일로 저장할 수 있음 ▪️ pdf로 저장된 element의 스타일이 화면에 보이는그대로 적용 ▪️ 사용자가 원하는 pdf 파일 page의 여백을 설정할 수 있음 | ▪️ 파일 저장 시 바로 저장되지 않음. ▪️인쇄 미리보기 창이 열려 사용자의 편의성면에서 좋지 않음. |
html2canvas+ jsPdf | 파일 저장 시 별도의 창이 열리지 않고 바로 저장할 수 있음 | ▪️ 스타일링의 오류 ▪️ pdf 파일의 여백 설정이 어려움 |
notion 프로젝트에는 pdf로 변환하는 방법 중 window.print 를 이용하는 방법을 적용했다. 라이브러리를 이용한 방법의 스타일 오류는 pdf를 저장하고 이를 다시 볼 사용자의 입장에서 보면 아주 큰 단점이라고 느꼈기 때문이다. 미리보기 창이 많이 열리는 단점이 존재하지만 결국 사용자의 목적인 현재 페이지를 얼마나 pdf로 잘 구현했냐는 측면에서 봤을 때 window.print가 더 적합하다고 생각한다.