Gerando PDFs utilizando React e Puppeteer
Tags: react, javascript, tutorial, pdf
Published: 2020-12-07 19:12:40

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](https://github.com/guilhermedecastroleite/pdf-puppeteer).

### Índice

* [O que é o Puppeteer](#o-que-e)
* [Criando o projeto](#criando)
* [Criando o script do Puppeteer](#criando-script)
* [Gerando o PDF](#gerando)
* [Pontos importantes e dicas](#dicas)
* [Código](#codigo)

### 1. O que é o **Puppeteer**? <a name="o-que-e"></a>

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 <a name="criando"></a>

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.

```javascript
npx create-react-app pdf-puppeteer
cd pdf-puppeteer
yarn start
```

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.

```jsx
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;
```

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**.

```javascript
yarn add recharts
```

```jsx
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;
```
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 <a name="criando-script"></a>

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:

```javascript
yarn add -D @babel/core @babel/node puppeteer
```

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

```javascript
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();
})();
```
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 <a name="gerando"></a>

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.

```javascript
"scripts": {
  "start": "react-scripts start",
  "build": "react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject",
  "generate": "npx babel-node src/generate.js"
},
```

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:

```javascript
yarn generate
``` 

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](https://dev-to-uploads.s3.amazonaws.com/i/axia8vauv6ckutoardin.png)

### 5. Pontos importantes e dicas: <a name="dicas"></a>

- 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](https://pptr.dev/#?product=Puppeteer&version=v5.5.0&show=api-pagegotourl-options).
```javascript
  await page.goto('http://localhost:3000/report-cba-full/12', { waitUntil: 'networkidle0' });
```
- 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](https://pptr.dev/#?product=Puppeteer&version=v5.5.0&show=api-pagesetdefaultnavigationtimeouttimeout).
```javascript
    await page.setDefaultNavigationTimeout(0);
```
### 6. Código <a name="codigo"></a>

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 guilhermedecastroleite/pdf-puppeteer no-readme %}