html.Element를 html,pdf,markdown 파일로 저장하기

바다·2022년 8월 15일
4

javascript

목록 보기
1/4
post-thumbnail

웹 브라우정 상에서 특정 부분을 html,pdf, markdown파일로 저장하고 싶다면 그 기능을 어떻게 구현할 수 있을까 🤔?
그 물음에 대해 찾은 방법과 그 과정에서 공부한 것들을 정리해봤다.

1. 파일로 저장하고 싶은 element


위의 사진은 파일 저장기능을 구현해야 했던 notion 프로젝트이다. 여기서 frame이 파일로 저장되어야할 대상이다.

2. 파일로 저장하는 방법

1) 파일로 저장하기 전 준비

: element를 스타일링을 유지한 채 새로운 html로 만들기

A. 저장될 element만을 담당하는 css 파일

element를 파일로 저장할 때 대부분은 화면상에서 보이는 모습 대로 저장하고 싶을 것다. 즉, 기존에 작성했던 스타일 관련한 코드가 적용된 element이 파일에 저장되기를 바라는 것이다.

화면에 보이는 대로 element를 파일에 저장하기 위해서 어떻게 해야할까?
내가 찾은 방법은 style 요소이다.
scss를 변환한 css 파일을 이용해 html에 스타일링을 하고 있기 때문에 스타일에 대한 코드는 모두 style에 담겨 있기 때문에 저장된 element에 대한 스타일 코드를 읽어오면 되었고 그러기 위해서는 element의 스타일링만을 담당하는 css파일을 만들었다.

  • css 구조
css 파일설명
main.cssframe 외의 element에 대한 스타일링을 담당
frame.cssframe에 대한 스타일링만을 담당

B. element를 string type 객체로 변경하기

a. 변경 이유

뒤에서 자세히 소개하겠지만 파일로 저장하는 방법은 다음과 같다.

  • html element를 파일로 변환, 저장하는 방법

    파일 형식방법
    htmla 태그의 href와 download
    markdowna 태그의 href와 download
    pdf▪️ 새로운 window 창과 window.print()
    ▪️ 라이브러리(html2canvas+jsPdf)

라이브러리를 이용하는 방법을 제외하고는 파일로 저장되는 element를 string type으로 바꾸는 과정이 필요한다.

왜냐하면 a 태그의 download기능을 이용하려면 저장할 파일의 url이 필요한데 Blob 객체를 이용하면 string type의 객체의 url를 만들 수 있으며, 새로운 window창을 열어서 새로운 창에 window.document.write()메서스를 이용해 파일로 저장되는 element를 새로운 창에 넣을 것이기 때문에 element를 string type의 객체로 바꾸어야한다. 또한 이는 element의 스타일링에 대한 새로운 파일을 추가할 필요 없이 기존의 코드를 재사용할 수 있는 이점이 있다.

b. 변경하는 방법

a) 현재 브라우저 상에 있는 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 

왜 파일로 저장되는 element만이 아닌 아예 새로운 html을 만들었나🤔?

그 이유는 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();

b) 현재 브라우저 상에 없는 element의 경우

notion은 해당 페이지의 아래에 있는 서브 페이지들(브라우저에서는 페이지명과 아이콘만 보임)을 파일로 저장할 수 있는 기능을 제공하고 있다.
이처럼 현재 브라우저 상에 없는 element를 파일로 저장해야 하는 경우라면 어떻게 해야할까?
파일로 저장해야 할 element을 return 하는 Component와 ReactDOMWServer를 이용하면 된다.

  • Frame component
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)를 따로 만들었다.

2) 파일로 변환,저장

A. html, markdown 파일로 변환,저장

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으로 변경하는 작업을 추가적으로 해야한다.

B. pdf 파일로 변환,저장

a. window.print()를 이용한 방법

  • code
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>
  	</>
)};
  • 인쇄 미리보기 창

d) 장단점

ⓐ장점: 바닐라 js, 스타일링과 페이지 여백 설정

라이브러리를 사용하지 않아도, 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를 각각의 파일로 변환 할 경우 인쇄 미리보기 창이 여러개 열린다는 단점은 여전히 존재한다.

b. html2canvas, jsPdf 이용한 방법

a) html2canvas 와 jsPdf

jsPdf 를 사용하면 브라우저를 pdf 파일로 다운로드할 수 있다. jsPdf와 선택한 html 요소를 canvas에 담을 수 있게 해주는 라이브러리인 html2canvas을 같이 사용하면 jsPdf에서 html의 스타일이 제대로 담기지 않는 오류를 보완할 수 있어 jsPdf와 html2canvas를 같이 사용하기도 한다.

b) code

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

c) 함수 설명

ⓐ convertPdf: 브라우저 상의 frame를 pdf로 변환

  1. 파일로 저장된 대상인 frame요소를 복사하기
    ⬇️
  2. 1의 복사본을 새로 만든 id가 printFrame인 div 요소에 자식요소로 넣기
    ⬇️
  3. 2에서 만든 prinfFrame을 canvas에 그리기(html2canvas 사용)
    ⬇️
  4. canvas에서 이미지 가져오기
    ⬇️
  5. 이미지로 pdf 만들기(jsPdf 사용)
    ⬇️
  6. printFrame 요소 삭제

🔹부가설명

1. frame 요소를 복사하는 이유?
복사본이 아닌 실제 frame을 printFrame에 자식 요소로 넣으면 복사해서 넣어지는 것이 아니라 웹브라우저 상에서의 frame이 printFrame의 자식요소로 이동하게 되어서 파일로 저장하는 창을 끄면 웹브라우저 상에서 보이지 않게 된다.

2. canvas의 크기 설정
저장하고 자 하는 element가 잘리지 않고 그대로 canvas에 그려지기 위해서는 canvas의 크기를 element 에 맞게 설정해야한다. 그러나 canvas에 그려지는 printFrame의 width, height를 이용해 크기로 canvas의 크기를 설정하면 element가 잘려서 그려지기 때문에 다음과 같이 설정했다.

  • canvas 크기
  {
  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와 파일로 저장된 frame

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

ⓑ convertSubPageIntoPdf: 현재 브라우저 상에없는 subPage의 frame을 pdf로 변환

  1. subPage의 frame component를 추출(추출된 요소는 JSX.Element 타입)
    ⬇️
  2. JSX.Element를 ReactDOMServer.renderToString() 으로 string type 으로 변환(subFrameHtml)
    ⬇️
  3. subFrame이라는 새로운 div element를 만들고, div 안에 innerHtml으로 subFrameHtml을 넣어줌
    ⬇️
  4. 3으로 만들어진 subFrame element를 convertPdf를 이용해 pdf로 변환

d) 장단점

ⓐ장점 : 파일 저장 시 사용자 편의성

window.print와 달리 버튼을 누르면 바로 pdf 파일이 다운로드 된다는 장점이 있다. 이는 사용자의 편의성 측면에서 좋은 장점이다.

ⓑ단점: 스타일링과 페이지 여백

스타일링 상 오류가 있는 jsPdf의 단점을 보완하고자 html2canvas를 같이 사용했지만 html2canvas에서도 아래의 사진과 같은 스타일링 오류가 발생했다.
특히 , page의 title이 잘리는 오류는 기존의 frame의 style관련 code를 변경해도 윗 부분이 잘리는 오류는 수정할 수 없었다.

페이지의 여백에서도 단점이 존재한다.
jsPdf는 페이지의 여백을 줄 수 있는 옵션을 제공하지만 두번째 페이지에서 여백을 적용하는게 어려우며 여백은 코드로 이미 확정되어지기 때문에 사용자가 본인이 원하는 여백을 설정할 수 없다는 단점이 존재한다.

  • pdf 변환,저장 방법의 장단점 비교
방법장점단점
window.print▪️ 별로의 라이브러리를 이용하지 않고 pdf 파일로 저장할 수 있음
▪️ pdf로 저장된 element의 스타일이 화면에 보이는그대로 적용
▪️ 사용자가 원하는 pdf 파일 page의 여백을 설정할 수 있음
▪️ 파일 저장 시 바로 저장되지 않음.
▪️인쇄 미리보기 창이 열려 사용자의 편의성면에서 좋지 않음.
html2canvas+ jsPdf파일 저장 시 별도의 창이 열리지 않고 바로 저장할 수 있음▪️ 스타일링의 오류
▪️ pdf 파일의 여백 설정이 어려움

notion 프로젝트에는 pdf로 변환하는 방법 중 window.print 를 이용하는 방법을 적용했다. 라이브러리를 이용한 방법의 스타일 오류는 pdf를 저장하고 이를 다시 볼 사용자의 입장에서 보면 아주 큰 단점이라고 느꼈기 때문이다. 미리보기 창이 많이 열리는 단점이 존재하지만 결국 사용자의 목적인 현재 페이지를 얼마나 pdf로 잘 구현했냐는 측면에서 봤을 때 window.print가 더 적합하다고 생각한다.

profile
🐣프론트 개발 공부 중 (우테코 6기)

0개의 댓글