자바스크립트 HTML을 PDF로 변환하기 (NodeJS, Puppeteer)

LEEJAEJUN·2024년 2월 7일
0

개요

이제 이력서를 쓸 시기가 왔는데 기존 프로젝트는 모두 리액트 기반에 적절한 라이브러리를 사용해가며 만들어 왔습니다. 그러다보니 이력서가 너무 단조로워 지는 느낌이 들었고, 별다른 라이브러리 없이 자바스크립트로 내 이력서를 만들어주는 프로젝트를 하게 되었습니다.

깃헙: https://github.com/Gaoridang/generate-pdf

어려웠던 점

요소 생성

createElement 라는 함수는 하나의 요소밖에 만들 수 없습니다. 예를 들어 필요한 트리가 아래와 같다면 div, h1, ul, li 네 개의 요소를 만든 뒤 각각 appendChild() 로 묶어줘야 합니다.

<div>
 <h1><h1/>
 <ul>
   <li>
   </li>
 </ul>
</div>

위 과정을 하나의 함수로 만들어서 해결할 수 있다면 좋겠습니다. 리액트에서는 createElement 라는 클래스를 사용해서 요소를 만드는데 (지금은 jsx라는 함수로 바뀐듯 합니다만) 해당 클래스를 참고하여 함수를 만들었습니다.

제 프로젝트에 필요한 요소가 input 을 제외하면 다 공통으로 쓰이는 props밖에 없었기 때문에 나름 수월했지만 수많은 if 를 보고있자니 좀 답답하긴 합니다. 어쨌거나 이 함수를 사용하면 요소를 생성하면서 class, id, type, data, children 등을 추가할 수 있습니다.

특히 이벤트는 이벤트 타입에 따라 다양한 이벤트를 붙일 수 있도록 만들었습니다. onclick, oninput 등 한 요소에 여러 이벤트를 붙일 수 있습니다.

export default function createElement(
  tagName: keyof HTMLElementTagNameMap,
  props?: Partial<ElementProps>
) {
  const element = document.createElement(tagName);

  if (props) {
    if (props.id) element.id = props.id;
    if (props.className) element.className = props.className;
    if (props.text) element.textContent = props.text;
    if (props.attributes) {
      Object.entries(props.attributes).forEach(([key, value]) => element.setAttribute(key, value));
    }

    if (tagName === "input") {
      const inputElement = element as HTMLInputElement;
      if (props.value) inputElement.value = props.value;
      if (props.type) inputElement.type = props.type;
      // oninput은 value가 바뀔 즉시 작동
      if (props.onChange) inputElement.oninput = props.onChange;
      if (props.required) inputElement.required = props.required;
    }

    if (props.children) {
      const children = Array.isArray(props.children) ? props.children : [props.children];
      children.forEach((child) => element.appendChild(child));
    }
    if (props.events) {
      Object.entries(props.events).forEach(([event, handler]) => {
        element.addEventListener(event, handler);
      });
    }
  }

  return element;
}

HTML 컨텐츠를 PDF 형식에 맞게 구조화

여러 input, textarea 에 쓴 값들은 모두 value로 취급될 뿐 그 자체로 요소가 아닙니다. 그렇기 때문에 값을 받아서 <p>{value}</p> 와 같은 형식으로 바꾸어줄 필요가 있습니다.

이때 제목, 이메일, 경력 등 각기 다른 값에 대해 각기 다른 output을 만드는 부분이 쉽지 않았습니다.

const sections = {};
const formElements = form.querySelectorAll("input, textarea");

formElements.forEach((element) => {
  const inputElement = element as HTMLInputElement | HTMLTextAreaElement;
  let value = (element as HTMLInputElement).value.trim();

  if (inputElement instanceof HTMLTextAreaElement) {
    value = value.replace(/\n/g, "<br>");
  }

  if (!value) return;

  const section = inputElement.dataset.section || inputElement.name;
  sections[section] = sections[section] || [];
  sections[section].push(value);
});

let formDataHtml = `<div class='form-data'>`;

Object.keys(sections).forEach((sectionName) => {
  if (sectionName === "title" || sectionName === "position") {
    formDataHtml += `<div class='${sectionName}'><ul>`;
  } else {
    formDataHtml += `
      <div class='${sectionName}'>
        <h2>${convertToKorean(sectionName)}</h2>
      <ul>
    `;
  }
  sections[sectionName].forEach((value, index) => {
    formDataHtml += `<li>${formattedValue}</li>`;
  });
  formDataHtml += `</ul></div>`;
});

formDataHtml += "</div>";
  • 먼저 각 input의 값을 담을 sections 객체를 만듭니다.
  • 모든 input의 value를 가지고 옵니다.
  • textarea의 경우 엔터가 띄어쓰기로 간주되는 현상이 있어서 정규표현식을 이용하여 \n<br> 로 바꾸어 줍니다.
  • 미리 담아놓은 data-section 의 값에 따라 sections 객체에 key: value 형태로 저장합니다.
  • 특정 값에 대한 html 형태를 새로 정의하고 구조화 합니다.
  • 하나로 합쳐 줍니다.

HTML을 PDF로 변환

기존 라이브러리 없이 구현하고자 했으나, 방법을 찾지 못해 결국 Puppeteer 라는 도구를 사용하게 되었습니다. 아래 코드는 NodeJS로 만든 generatePDF api입니다.

const generatePDF = async (req: Request, res: Response) => {
  try {
    const validation = pdfDataSchema.safeParse(req.body);
    if (validation.success) {
      const { htmlContent } = validation.data;

      const browser = await puppeteer.launch();
      const page = await browser.newPage();
      try {
        await page.setContent(htmlContent);
        await page.addStyleTag({ path: path.join(__dirname, '..', 'style.css') });
      } catch (styleError) {
        console.error('Error loading styles:', styleError);
      }
      const pdf = await page.pdf({ format: 'A4' });
      await browser.close();

      res.contentType('application/pdf');
      res.status(200).send(pdf);
    } else {
      res.status(400).send({ message: validation.error.message });
    }
  } catch (error) {
    res.status(500).send({ message: 'Server error' });
  }
};
  • safeParse zod를 사용해 타입 검사를 진행합니다.
  • puppeteer.launch() 통과하면 puppeteer를 실행시키고 페이지를 생성합니다.
  • page.setContent() 해당 페이지에 request body로 받은 값을 세팅합니다.
  • page.addStyleTag() 해당 페이지에 스타일을 주입합니다.
  • page.pdf() 세팅이 끝났으므로 pdf로 변환해줍니다.
  • 페이지를 닫습니다.

느낀점

리액트가 얼마나 편한 도구인지 깨달았습니다. JSX는 신이야.. 여튼 앞으로도 라이브러리를 쓸 때 이게 왜 편하고, 얼마나 편한 도구인지는 알아야겠다는 생각이 듭니다. 유명하면 무작정 사용하는 경우가 있었는데 이걸 경계함으로써 한 단계 발전할 수 있을 것 같습니다.

profile
always be fresh

0개의 댓글