Gerando PDFs utilizando React e Puppeteer

guilhermedecastroleite

Guilherme Leite

Posted on December 7, 2020

Gerando PDFs utilizando React e Puppeteer

A criação de arquivos PDF de páginas web customizadas é uma exigência comum, mas nem sempre as soluções padrão dos navegadores são suficientes para se conseguir criar um PDF com paginação e tamanhos corretos.

Nesse artigo passaremos por todas as etapas necessárias para se criar um PDF de uma página estática utilizando React e Puppeteer.

Todo o código que utilizaremos nesse projeto está disponível no Github.

Índice

1. O que é o Puppeteer?

O Puppeteer é uma biblioteca do Node que provê uma API de controle do Chrome de maneira headless, ou seja, apenas em memória e sem a necessidade de visualmente existir um browser na tela. Esse tipo de abordagem permite utilizar o navegador em um script como vamos fazer mais à frente. Muitas vezes também é utilizado em testes end-to-end e em scrapers.

Uma das vantagens de se utilizar o Puppeteer para geração dos PDFs é que, como resultado, teremos um PDF real, vetorizado e com alta qualidade de impressão. Algo que não conseguimos com outros métodos que utilizam screenshots para geração dos documentos.

Para mais informações acesse a documentação oficial: https://pptr.dev/

2. Criando o Projeto

Como primeiro passo criaremos nosso projeto React que servirá de base para a criação dos PDFs.

Obs: Caso você já tenha um projeto criado fique à vontade para pular essa etapa, mas lembre-se, as medidas de sua página React usada na impressão devem ser as mesmas do script do Puppeteer que será configurado mais à frente.

Como exemplo, criaremos uma página contendo gráficos e conteúdos de texto. Podemos iniciar nosso projeto de maneira rápida criando um setup inicial com o create-react-app.

npx create-react-app pdf-puppeteer
cd pdf-puppeteer
yarn start
Enter fullscreen mode Exit fullscreen mode

Remova os arquivos logo.svg, App.css e App.test.js. Troque o código em src/App.js pelo código abaixo. É extremamente importante que a tela que será acessada para criação do PDF tenha medidas iguais as que forem configuradas no script do Puppeteer.

Repare que no código utilizamos as medidas em milímetros de uma página A4. Fique à vontade para alterar o tamanho da maneira que achar necessário, sempre garantindo que as medidas estejam iguais às usadas no Puppeteer e sigam a proporção do tamanho de página escolhido.

import logo from './logo.svg';
import Chart from './components/Chart';

const chartData = [
  {
    name: 'Item 1',
    value: 51.1,
  },
  {
    name: 'Item 2',
    value: 28.9,
  },
  {
    name: 'Item 3',
    value: 20,
  },
  {
    name: 'Item 4',
    value: 70.1,
  },
  {
    name: 'Item 5',
    value: 34.7,
  },
]

function App() {
  return (
    <div
      style={{
        width: '209.55mm',
        height: '298.45mm',
        padding:'12mm',
        backgroundColor: '#FFF',
      }}
    >
      {/** Header */}
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
        <div>
          <h1>Data Report</h1>
          <h2>{new Date().getYear() + 1900}</h2>
        </div>
        <img src={logo} className="App-logo" alt="logo" style={{ width: '50mm', height: '50mm'}}/>
      </div>

      {/** Introduction text */}
      <h3>Introduction</h3>
      <h5 style={{ fontWeight: 'normal' }}>
        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Dolor sit amet consectetur adipiscing elit duis tristique sollicitudin. Commodo viverra maecenas accumsan lacus vel facilisis volutpat est velit. Ut eu sem integer vitae. Bibendum neque egestas congue quisque egestas diam in. Quis lectus nulla at volutpat diam. Cursus euismod quis viverra nibh. Amet consectetur adipiscing elit duis tristique sollicitudin nibh sit amet. Nibh sed pulvinar proin gravida hendrerit lectus a. Purus in massa tempor nec feugiat nisl pretium. Velit dignissim sodales ut eu sem integer vitae justo eget. Augue ut lectus arcu bibendum at varius. Interdum varius sit amet mattis vulputate enim. In hendrerit gravida rutrum quisque non tellus orci. Lectus nulla at volutpat diam ut venenatis. Massa tempor nec feugiat nisl pretium fusce id velit ut. Aliquet sagittis id consectetur purus ut faucibus. Eget mi proin sed libero enim.
      </h5>

      {/** Chart with title */}
      <h3>Chart 01</h3>
      <Chart
        data={chartData}
        barProps={{
          isAnimationActive: false,
        }}
      />

      {/** Info text */}
      <h5 style={{ fontWeight: 'normal' }}>
        Pulvinar pellentesque habitant morbi tristique senectus et netus. Nunc eget lorem dolor sed viverra ipsum nunc aliquet bibendum. Enim ut tellus elementum sagittis vitae et leo duis ut. Adipiscing vitae proin sagittis nisl. Orci phasellus egestas tellus rutrum tellus pellentesque eu tincidunt tortor. Id nibh tortor id aliquet lectus proin nibh nisl condimentum. Platea dictumst vestibulum rhoncus est pellentesque. Dictum sit amet justo donec enim diam vulputate. Libero volutpat sed cras ornare arcu dui. Magna fermentum iaculis eu non diam.
      </h5>

      {/** Chart with title */}
      <h3>Chart 02</h3>
      <Chart
        data={chartData}
        barProps={{
          isAnimationActive: false,
        }}
      />
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Será necessário também criar o arquivo src/components/Chart.jsx contendo o componente de gráfico que utilizaremos no exemplo. Precisamos também instalar a biblioteca Recharts, uma excelente opção para criação de gráficos em SVG para React.

yarn add recharts
Enter fullscreen mode Exit fullscreen mode
import React from 'react';

import PropTypes from 'prop-types';

import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  ReferenceLine,
  ResponsiveContainer,
} from 'recharts';

const CustomBarLabel = ({ isPercentage, labelProps, ...props }) => {
  const {
    x, y, value, width, height,
  } = props;

  const xPosition = width >= 0 ? x + width + 4 : x + width - ((value === -100 || value % 1 !== 0) ? 27 : 20);
  const yPosition = y + height / 2 + 6;

  return (
    <text
      dy={-4}
      x={xPosition}
      y={yPosition}
      textAnchor='right'
      fontSize={12}
      fontWeight='600'
      fill='#4D5365'
      fontFamily='Helvetica'
      {...labelProps}
    >
      {isPercentage ? `${value.toFixed(1).replace(/\.0$/, '')}%` : value.toFixed(1).replace(/\.0$/, '')}
    </text>
  );
};

CustomBarLabel.propTypes = {
  x: PropTypes.number.isRequired,
  y: PropTypes.number.isRequired,
  value: PropTypes.number.isRequired,
  width: PropTypes.number.isRequired,
  height: PropTypes.number.isRequired,
  isPercentage: PropTypes.bool,
  labelProps: PropTypes.object,
};

CustomBarLabel.defaultProps = {
  isPercentage: false,
  labelProps: {},
};

const Chart = ({
  data,
  range,
  width,
  height,
  barSize,
  barProps,
  xAxisProps,
  yAxisProps,
  barChartProps,
  labelProps,
  isPercentage,
  legend,
  children,
}) => {
  const { min, max, step } = range;

  const ticks = (max - min) / step + 2;

  const addLines = (start, end, arrayStep = 1) => {
    const len = Math.floor((end - start) / arrayStep) + 1;
    return Array(len).fill().map((_, idx) => start + (idx * arrayStep));
  };

  return (
    <ResponsiveContainer width={width} height={height}>
      <BarChart
        data={data}
        margin={{
          top: 0, right: 0, left: 10, bottom: 0,
        }}
        layout='vertical'
        barSize={barSize}
        {...barChartProps}
      >
        <XAxis
          type='number'
          tickCount={ticks}
          orientation='top'
          domain={[min, max]}
          axisLine={false}
          tickLine={false}
          tick={{
            fill: '#6F798B',
            fontSize: 14,
            fontFamily: 'Helvetica',
          }}
          {...xAxisProps}
        />
        <YAxis
          dx={-16}
          type='category'
          dataKey='name'
          axisLine={false}
          tickLine={false}
          tick={{
            fill: '#4D5365',
            fontSize: 16,
            lineHeight: 22,
            fontFamily: 'Helvetica',
          }}
          interval={0}
          {...yAxisProps}
        />
        {addLines(min, max, step).map((item) => (
          <ReferenceLine key={item} x={item} style={{ fill: '#CDD2DB' }} />
        ))}
        <Bar
          dataKey='value'
          fill='#A35ADA'
          label={(props) => <CustomBarLabel isPercentage={isPercentage} labelProps={labelProps} {...props} />}
          {...barProps}
        />
        {children}
      </BarChart>
    </ResponsiveContainer>
  );
};

Chart.propTypes = {
  data: PropTypes.array,
  range: PropTypes.shape({
    min: PropTypes.number,
    max: PropTypes.number,
    step: PropTypes.number,
  }),
  width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  barSize: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  barProps: PropTypes.object,
  xAxisProps: PropTypes.object,
  yAxisProps: PropTypes.object,
  barChartProps: PropTypes.object,
  labelProps: PropTypes.object,
  isPercentage: PropTypes.bool,
  legend: PropTypes.bool,
  children: PropTypes.any,
};

Chart.defaultProps = {
  data: [{ name: null, value: null }],
  range: {
    min: 0,
    max: 100,
    step: 20,
  },
  width: '100%',
  height: 254,
  barSize: 22,
  barProps: {},
  xAxisProps: {},
  yAxisProps: {},
  barChartProps: {},
  labelProps: {},
  isPercentage: false,
  legend: false,
  children: null,
};

export default Chart;
Enter fullscreen mode Exit fullscreen mode

Com nossa página inicial e componentes criados podemos visualizar no browser como ficará nosso arquivo em http://localhost/3000

3. Criando o script do Puppeteer

Para utilizar o Puppeteer precisaremos instalar três pacotes:

  • Puppeteer: pacote com o Chrome headless que será utilizado para geração dos PDFs
  • Babel-Core: Usado para converter versões recentes do Javascript para ambientes e navegadores mais antigos.
  • Babel-Node: CLI que funciona da mesma forma que o Node.js, com a vantagem de compilar códigos ES6 usando o Babel.

Rode o comando no terminal dentro da pasta do projeto para instalar os pacotes necessários:

yarn add -D @babel/core @babel/node puppeteer
Enter fullscreen mode Exit fullscreen mode

Com os pacotes adicionados podemos criar nosso script no arquivo src/generate.js com o código abaixo.

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('http://localhost:3000');
  await page.pdf({
    path: 'src/assets/puppeteer-test.pdf',
    printBackground: true,
    width: '209.55mm',
    height: '298.45mm',

  });
  await browser.close();
})();
Enter fullscreen mode Exit fullscreen mode

O script executa as seguintes etapas:

  • Cria uma instância do Puppeteer
  • Abre uma nova “página”
  • Navega até a página escolhida. Nesse caso nossa página de exemplo: http://localhost:3000
  • Define o caminho e o nome do arquivo que será gerado, além das medidas usadas para construção da página. A opção printBackground é importante para que as cores originais da página sejam preservadas
  • Espera a geração ser finalizada

4. Gerando o PDF

Agora que já temos nosso código funcionando e nosso script configurado conseguimos terminar nossas alterações para que o PDF possa ser gerado.

Como primeira etapa precisamos adicionar um novo parâmetro chamado generate nos scripts do arquivo package.json como no código abaixo.

"scripts": {
  "start": "react-scripts start",
  "build": "react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject",
  "generate": "npx babel-node src/generate.js"
},
Enter fullscreen mode Exit fullscreen mode

Essa linha é necessária para que possamos usar o babel-node instalado para transpilar nosso código Javascript e executá-lo no Node.

Para gerar o PDF basta rodar o comando abaixo enquanto o servidor React estiver rodando:

yarn generate
Enter fullscreen mode Exit fullscreen mode

O resultado da execução do script é a criação do arquivo PDF com o nome definido no script dentro da pasta assets. Perceba que o arquivo tem as mesmas características da página original e pode ser ampliado sem perder a qualidade, uma das vantagens de se utilizar esse método.

Parabéns! Agora você tem um PDF que representa perfeitamente sua página 😄

Arquivo final

5. Pontos importantes e dicas:

  • Como já dito diversas vezes é essencial que sua página tenha as mesmas dimensões que as definidas no script do Puppeteer. Isso vai garantir que o conteúdo seja representado de maneira fiel e com paginação correta.
  • Cada parte do código com as medidas definidas serão uma página no PDF. Uma dica para múltiplas páginas é criar um componente base de página com todas as características necessárias e envolver seus componentes.
  • Para mudar a proporção de reatrato para paisagem basta alterar as dimensões de largura e altura entre si.
  • Pode ser necessário customizar o script do Puppeteer com itens adicionais de acordo com sua página web. Em casos de páginas com chamadas api a função page.goto pode necessitar da prop waiUntil como no código abaixo. Para mais informações verifique a documentação oficial.
  await page.goto('http://localhost:3000/report-cba-full/12', { waitUntil: 'networkidle0' });
Enter fullscreen mode Exit fullscreen mode
  • Desabilite animações e transições na geração do PDF para que a página não seja gerada de maneira incompleta.
  • Existe um timeout padrão de 30s para finalização do PDF, caso seja necessário há uma opção de se alterar esse valor com a opção setDefaultNavigationTimeout. Para mais informações verifique a documentação oficial.
    await page.setDefaultNavigationTimeout(0);
Enter fullscreen mode Exit fullscreen mode

6. Código

O código utilizado nesse projeto está disponível no Github no repositório abaixo. Fique à vontade para experimentar variações e configurações. Porque não adicionar uma nova página ao seu PDF?

GitHub logo guilhermedecastroleite / pdf-puppeteer

Sample companion repository of a guide for generating PDFs using Puppeteer

💖 💪 🙅 🚩
guilhermedecastroleite
Guilherme Leite

Posted on December 7, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related