Pular para o conteúdo
Node.js

Node.js: gerando o PDF de um link com Puppeteer

Paloma Macetko
Ilustração colorida de um unicórnio operando uma prensa mágica que transforma a janela de um site em um documento PDF lacrado, ao lado de uma coruja segurando uma folha A4 e um pergaminho longo medido por uma fita métrica luminosa

Olá meus Unicórnios! 🦄✨

"Gera um PDF dessa página pra mim?" 😅 Parece o pedido mais simples do mundo — a página já existe, o navegador já sabe imprimir, é só juntar as duas coisas. Foi mais ou menos isso que eu pensei.

E funciona mesmo: seis linhas de Puppeteer e sai um PDF. O problema é que sai um PDF errado — e do jeito mais cruel possível, sem erro nenhum no terminal. O arquivo abre, tem cara de PDF, tem o texto certo. Só que o fundo sumiu, ou metade da página não veio, ou ele tem duas folhas achatadas em vez de uma.

Neste artigo eu monto a geração de PDF de um link do começo ao fim, e conto as armadilhas uma a uma — inclusive um bug que eu mesma escrevi e só descobri porque fui contar as páginas do arquivo. 😳

Exemplos_Puppeteer_LinkParaPDF no GitHubO código completo deste artigo, com uma página no navegador para você testar sem escrever nada.github.com

🎯 O que a gente quer

Duas saídas bem diferentes, e a diferença entre elas é metade do assunto:

  • A4 paginado — várias folhas, com margem, do jeito que sai da impressora. É o que você quer para um contrato, um relatório, uma nota.
  • Página inteira — uma folha única, alta, do tamanho da rolagem. É o que você quer para arquivar a página como ela é na tela, sem cortar um gráfico no meio.

Quase todo tutorial mostra só o primeiro. O segundo é onde mora o bug bonito. 🐛

🚀 A versão de seis linhas (que funciona e engana)

Vamos começar honestamente pelo caminho fácil, porque ele quase resolve:

const puppeteer = require('puppeteer');

const navegador = await puppeteer.launch();
const pagina = await navegador.newPage();
await pagina.goto('https://exemplo.com.br');
await pagina.pdf({ path: 'saida.pdf', format: 'A4' });
await navegador.close();

Isso gera um PDF. De verdade. E se a sua página for um texto simples em fundo branco, pode ser que esteja tudo certo e você não precise ler o resto do artigo. 🙂

Mas se ela for um site de verdade — com fundo colorido, com CSS responsivo, com imagem que carrega depois — cada uma das quatro linhas do meio esconde uma armadilha. Vamos uma por uma.

🎨 Armadilha 1: o fundo some inteiro

Esta é a mais rápida de explicar e a que mais assusta: o Chrome, ao imprimir, descarta toda cor de fundo. É o mesmo comportamento do Ctrl+P do seu navegador, que economiza tinta da impressora.

O resultado é um PDF em que o fundo escuro virou branco. Se o site tiver tema escuro, o texto branco fica branco no branco — o PDF sai literalmente em branco, com o texto lá dentro, invisível. 😱

await pagina.pdf({
    path: 'saida.pdf',
    format: 'A4',
    printBackground: true   // sem isto, adeus cor de fundo
});

📱 Armadilha 2: o site acha que é um celular

Esta demorou mais para eu entender. O PDF saía com o layout mobile: menu virado em sanduíche, colunas empilhadas, tudo espremido — dentro de uma folha A4 de 21 centímetros de largura. 🤨

O motivo: quando você não diz nada, o Puppeteer abre a página numa janela de 800×600. O CSS responsivo do site olha essa largura, conclui "isso é uma tela pequena" e entrega o layout de celular. O PDF depois estica aquilo para o tamanho da folha.

A correção é definir a janela antes de navegar:

await pagina.setViewport({ width: 1200, height: 900 });

Repare que a viewport é o que o CSS enxerga; o tamanho da folha é outra conversa, e vem depois. São dois números diferentes que muita gente confunde — e a confusão fica pior na armadilha 5.

🖨️ Armadilha 3: o Chrome imprime a versão de impressão

Essa é traiçoeira porque o PDF sai bonito, só que incompleto.

Ao gerar PDF, o Chrome aplica a mídia print do CSS. E muito site tem regras @media print que escondem bloco inteiro — menu, barra lateral, rodapé, banner. Faz todo sentido para papel; não faz sentido nenhum quando o que você quer é arquivar a página como ela é.

O sintoma é o PDF vir com pedaços faltando e nenhum erro em lugar nenhum. Uma linha resolve:

await pagina.emulateMediaType('screen');

⏳ Armadilha 4: o PDF chega antes da página

O goto() sem opções volta assim que o HTML chegou — e o HTML chegar não quer dizer que a página ficou pronta. Fonte, imagem, gráfico montado por JavaScript: tudo isso vem depois. O PDF sai com espaço em branco onde o conteúdo ainda ia aparecer.

await pagina.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });

O networkidle0 espera a rede ficar quieta (zero requisição por meio segundo). É o mais paciente dos modos e o certo para PDF. E prepare-se para esperar: numa página pesada isso leva alguns segundos, e é tempo bem gasto.

O timeout explícito importa por um motivo que só aparece na próxima seção: em algum momento, alguma página vai estourar esse prazo. E o que acontece nesse momento é mais grave do que parece.

💀 O erro que derruba o servidor três horas depois

Essa não é bem uma armadilha do PDF — é uma armadilha de servidor, e é a que faz mais estrago. Olhe o código do começo do artigo de novo:

await pagina.goto(url, { waitUntil: 'networkidle0' });
await pagina.pdf({ path: 'saida.pdf' });
await navegador.close();

Se o goto estourar o timeout, ele lança exceção. E aí o close() da última linha simplesmente nunca roda. 💀

O pedido devolve erro 500, o cliente vê a mensagem, a vida segue — só que ficou um Chrome inteiro vivo na memória da máquina. Um por erro. Numa API que recebe links de fora, onde link quebrado e site lento são o dia a dia, isso come a RAM do servidor em poucas horas até tudo parar.

O conserto é chato de lembrar e fácil de escrever — try/finally:

const navegador = await puppeteer.launch();

try {
    const pagina = await navegador.newPage();
    await pagina.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
    await pagina.pdf(parametros);
} finally {
    // No finally, nao depois do pdf(): senao o Chrome sobrevive ao erro.
    await navegador.close();
}

Repare que não tem catch. Não é esquecimento: eu quero que o erro suba para quem chamou, para virar um 400 com mensagem. O finally está ali só para garantir que o navegador morre nos dois caminhos, o feliz e o infeliz.

📏 A armadilha bonita: a página inteira numa folha só

Agora a parte que me custou a tarde. 😅

Para gerar uma folha única do tamanho da rolagem, a receita que se acha em todo lugar é: mede a altura da página, e passa essa altura como tamanho da folha. Assim:

const altura = await pagina.evaluate(() => document.body.scrollHeight);

await pagina.pdf({
    path: 'saida.pdf',
    width: '1200px',
    height: altura + 'px',
    printBackground: true
});

Eu escrevi exatamente isso. Rodei. Saiu um PDF de 22 KB, sem erro nenhum, e eu quase publiquei o artigo. 🙈

Só que em vez de confiar no "abriu, então tá certo", eu fui contar as páginas dentro do arquivo:

const fs = require('fs');
const bruto = fs.readFileSync('saida.pdf').toString('latin1');

const paginas = (bruto.match(/\/Type\s*\/Page[^s]/g) || []).length;
const medida = bruto.match(/\/MediaBox\s*\[([^\]]+)\]/);

console.log('paginas:', paginas, '| MediaBox:', medida[1]);
paginas: 2 | MediaBox: 0 0 900 96

Duas páginas. De 96 pontos de altura — três centímetros. 🤯 Eu tinha pedido uma folha única e alta, e recebi duas tirinhas achatadas.

🔍 Por que o body mente sobre a altura

Fui medir tudo o que dava para medir na mesma página:

const medidas = await pagina.evaluate(() => ({
    bodyScroll: document.body.scrollHeight,
    docScroll: document.documentElement.scrollHeight,
    bodyOffset: document.body.offsetHeight,
    bodyRect: document.body.getBoundingClientRect().height
}));
{
  "bodyScroll": 127,
  "docScroll": 900,
  "bodyOffset": 127,
  "bodyRect": 127.078125
}

Aí está. O <body> mede 127px; o <html> mede 900px. Sete vezes mais.

O motivo, quando cai a ficha, é óbvio: o <body> cresce só até o conteúdo dele. Numa página curta, o conteúdo tem 127px de texto e o body para ali — quem estica até preencher a janela é o <html>. Como eu media o body, pedi uma folha de 127px de altura para uma página de 900px. O Chrome fez o que eu mandei, cortou o que não coubesse na primeira folha e jogou o resto na segunda.

E repare no detalhe cruel: não houve erro. Nem exceção, nem aviso, nem PDF corrompido. Um arquivo válido, que abre, com o conteúdo errado. É o pior tipo de bug — aquele que só aparece se você for conferir o que não costuma conferir.

A correção é pegar o maior dos dois:

const altura = await pagina.evaluate(function () {
    return Math.max(
        document.body.scrollHeight,
        document.documentElement.scrollHeight
    );
});

E o PDF agora:

paginas: 1 | MediaBox: 0 0 900 675.12

Uma folha. 🎉

📐 E o px que não é px

Repare no número que saiu ali em cima: eu pedi width: '1200px' e o PDF ficou com 900 de largura. Sumiram 25%.

Não é bug: o page.pdf() converte pixel de tela em ponto de impressão, e a conta é 1px = ¾ pt (porque a web trabalha com 96 pixels por polegada e o PDF com 72 pontos). Então 1200px viram 900pt, que são exatamente as mesmas 12,5 polegadas na régua. Está certo.

Conto isso porque a minha primeira reação, ao ver o 900, foi "achei outro bug, vou multiplicar por 0,75 para compensar" — e isso teria encolhido a folha de verdade, aí sim criando o problema que eu achava estar consertando. Antes de corrigir um número estranho, vale conferir se ele não está certo em outra unidade. 📏

🔒 Uma linha de segurança que quase todo exemplo esquece

Se a URL vem de fora — de um formulário, de uma API, de um cliente — tem uma checagem que precisa existir antes de qualquer outra coisa:

// Aceita so http e https. Sem esta checagem, um "file:///C:/Windows/..."
// faz o Chrome abrir arquivo do proprio servidor e devolver no PDF.
function validarUrl(texto) {
    let url;
    try {
        url = new URL(String(texto || '').trim());
    } catch (erro) {
        throw new Error('URL invalida');
    }
    if (url.protocol !== 'http:' && url.protocol !== 'https:') {
        throw new Error('Somente http e https sao aceitos');
    }
    return url.href;
}

// Nome de arquivo unico. Um PDF por requisicao, senao duas chamadas
// simultaneas escrevem no mesmo caminho e uma sobrescreve a outra.

Sem isso, alguém manda file:///C:/Windows/win.ini e o seu servidor abre o próprio disco no Chrome e devolve o conteúdo dentro de um PDF, educadamente. 😬 O Puppeteer não tem nada contra isso — para ele, é só mais uma URL.

Testando o caminho da falha, que é o que interessa:

$ node gerar-pdf.js "file:///C:/Windows/win.ini" a4
Falhou: Somente http e https sao aceitos

🧩 O arquivo inteiro

Juntando tudo, este é o gerar-pdf.js completo — o mesmo arquivo que está no repositório e que gerou todas as saídas deste artigo. Ele não depende do Express: dá para copiar sozinho e usar pela linha de comando.

// Gera o PDF de um link usando o Puppeteer.
//
// Duas estrategias, e a diferenca entre elas e o assunto todo:
//   'a4'     -> paginado, folha A4, para imprimir
//   'inteiro' -> uma pagina unica do tamanho da rolagem, para arquivar
//
// Este arquivo nao depende do Express: da para exigir ele sozinho
// (require) ou chamar pela linha de comando.

const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const puppeteer = require('puppeteer');

// Aceita so http e https. Sem esta checagem, um "file:///C:/Windows/..."
// faz o Chrome abrir arquivo do proprio servidor e devolver no PDF.
function validarUrl(texto) {
    let url;
    try {
        url = new URL(String(texto || '').trim());
    } catch (erro) {
        throw new Error('URL invalida');
    }
    if (url.protocol !== 'http:' && url.protocol !== 'https:') {
        throw new Error('Somente http e https sao aceitos');
    }
    return url.href;
}

// Nome de arquivo unico. Um PDF por requisicao, senao duas chamadas
// simultaneas escrevem no mesmo caminho e uma sobrescreve a outra.
function nomeTemporario() {
    return path.join(os.tmpdir(), crypto.randomUUID() + '.pdf');
}

async function gerarPdf(urlBruta, opcoes) {
    const url = validarUrl(urlBruta);
    const config = opcoes || {};
    const modo = config.modo === 'inteiro' ? 'inteiro' : 'a4';
    const saida = config.saida || nomeTemporario();
    const espera = Number(config.espera) || 60000;

    const inicio = process.hrtime.bigint();

    // headless: 'new' e o padrao das versoes atuais; deixado explicito
    // porque a mensagem de depreciacao do modo antigo assusta quem copia.
    const navegador = await puppeteer.launch({
        headless: 'new',
        args: ['--no-sandbox', '--disable-setuid-sandbox']
    });

    try {
        const pagina = await navegador.newPage();

        // A viewport define a largura que o CSS enxerga ANTES do PDF.
        // Sem ela o Chrome usa 800x600 e um site responsivo entrega o
        // layout de celular dentro de uma folha A4 de 21 cm.
        await pagina.setViewport({ width: 1200, height: 900 });

        await pagina.goto(url, { waitUntil: 'networkidle0', timeout: espera });

        // ARMADILHA: o Chrome imprime com a media 'print'. Muito site
        // esconde bloco inteiro no @media print, e o PDF sai capenga sem
        // erro nenhum. Forcando 'screen', o PDF vira o que se ve na tela.
        await pagina.emulateMediaType('screen');

        const parametros = {
            path: saida,
            // Sem printBackground o Chrome descarta TODA cor de fundo:
            // fundo escuro vira branco e texto branco some no branco.
            printBackground: true
        };

        if (modo === 'a4') {
            parametros.format = 'A4';
            parametros.margin = { top: '10mm', right: '10mm', bottom: '10mm', left: '10mm' };
        } else {
            // Uma folha so, do tamanho da rolagem. A altura tem que ser
            // medida DEPOIS do networkidle0, senao o lazy-load ainda nao
            // cresceu a pagina e o PDF corta o final.
            //
            // NAO usar document.body.scrollHeight sozinho: o <body> cresce
            // so ate o conteudo dele. Numa pagina cujo fundo e altura vem
            // do <html>, ele devolve um numero MENOR que a pagina, e o PDF
            // sai cortado. O maior dos dois e o que se ve rolando.
            const altura = await pagina.evaluate(function () {
                return Math.max(
                    document.body.scrollHeight,
                    document.documentElement.scrollHeight
                );
            });
            parametros.width = '1200px';
            parametros.height = altura + 'px';
            // Com altura sob medida, margem sobrando gera uma 2a folha
            // em branco. Por isso vai zerada aqui e so aqui.
            parametros.margin = { top: '0', right: '0', bottom: '0', left: '0' };
        }

        await pagina.pdf(parametros);
    } finally {
        // No finally, nao depois do pdf(): se o goto estourar o timeout,
        // um Chrome fica vivo em segundo plano a cada erro. Em servidor,
        // isso come a memoria da maquina em poucas horas.
        await navegador.close();
    }

    const tamanho = fs.statSync(saida).size;
    const duracao = Number(process.hrtime.bigint() - inicio) / 1e9;

    return {
        arquivo: saida,
        tamanho: tamanho,
        duracao: Number(duracao.toFixed(2)),
        modo: modo
    };
}

module.exports = { gerarPdf, validarUrl };

// Uso pela linha de comando: node gerar-pdf.js <url> [a4|inteiro] [saida.pdf]
if (require.main === module) {
    const [, , url, modo, saida] = process.argv;

    if (!url) {
        console.log('Uso: node gerar-pdf.js <url> [a4|inteiro] [saida.pdf]');
        process.exit(1);
    }

    gerarPdf(url, { modo: modo, saida: saida })
        .then(function (r) {
            console.log('Arquivo : ' + r.arquivo);
            console.log('Modo    : ' + r.modo);
            console.log('Tamanho : ' + (r.tamanho / 1024).toFixed(1) + ' KB');
            console.log('Duracao : ' + r.duracao + 's');
        })
        .catch(function (erro) {
            console.error('Falhou: ' + erro.message);
            process.exit(1);
        });
}

🌐 Expondo como API

Com o gerador pronto, o servidor fica curto. Duas rotas, porque na prática se pede o PDF de duas maneiras bem diferentes:

  • POST /pdf — devolve o binário, para o navegador baixar direto.
  • POST /pdf/b64 — devolve JSON com o PDF em Base64, para quem consome de outro sistema e prefere não lidar com stream.
// Servidor de exemplo: recebe um link e devolve o PDF.
//
// Duas rotas de proposito diferente:
//   POST /pdf     -> devolve o PDF binario, para baixar direto
//   POST /pdf/b64 -> devolve JSON com o PDF em Base64, para quem consome
//                    a API de outro sistema e nao quer lidar com stream

const fs = require('fs');
const express = require('express');
const path = require('path');
const { gerarPdf } = require('./gerar-pdf');

const PORTA = process.env.PORTA || 3009;

const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, 'publico')));

// Apaga o temporario sempre, inclusive quando o envio falha no meio.
function apagar(arquivo) {
    fs.promises.unlink(arquivo).catch(function () { });
}

app.post('/pdf', async function (req, res) {
    try {
        const r = await gerarPdf(req.body.url, { modo: req.body.modo });

        res.setHeader('Content-Type', 'application/pdf');
        res.setHeader('Content-Disposition', 'attachment; filename="pagina.pdf"');
        // Cabecalhos proprios para o navegador nao ter que adivinhar, e
        // para o front conseguir mostrar o tempo sem abrir o arquivo.
        res.setHeader('X-Duracao', String(r.duracao));
        res.setHeader('X-Tamanho', String(r.tamanho));

        const fluxo = fs.createReadStream(r.arquivo);
        fluxo.pipe(res);
        fluxo.on('close', function () { apagar(r.arquivo); });
    } catch (erro) {
        res.status(400).json({ sucesso: false, mensagem: erro.message });
    }
});

app.post('/pdf/b64', async function (req, res) {
    try {
        const r = await gerarPdf(req.body.url, { modo: req.body.modo });
        const base64 = await fs.promises.readFile(r.arquivo, { encoding: 'base64' });
        apagar(r.arquivo);

        res.json({
            sucesso: true,
            tamanho: r.tamanho,
            duracao: r.duracao,
            modo: r.modo,
            pdf: base64
        });
    } catch (erro) {
        res.status(400).json({ sucesso: false, mensagem: erro.message });
    }
});

app.listen(PORTA, function () {
    console.log('Abra http://localhost:' + PORTA + ' no navegador');
});

Duas coisas que eu não faria diferente:

O nome do arquivo temporário é um UUID. Parece exagero até você imaginar dois pedidos chegando no mesmo segundo: com nome fixo, um sobrescreve o PDF do outro e alguém recebe o documento de outra pessoa. 😳

E o temporário é apagado no close do stream, não logo depois do pipe. Se apagar antes, você corre para deletar um arquivo que ainda está sendo enviado.

✅ Rodando de ponta a ponta

Subindo o servidor e batendo nas duas rotas:

$ curl -s -D - -o saida.pdf -X POST http://localhost:3009/pdf \
    -H "Content-Type: application/json" \
    -d '{"url":"https://example.com","modo":"a4"}'

HTTP/1.1 200 OK
Content-Type: application/pdf
X-Duracao: 1.22
X-Tamanho: 22530

E a rota Base64, conferindo que o que voltou é PDF mesmo:

sucesso: true | modo: inteiro | tamanho: 22491 | duracao: 1.19s
base64 comeca com: JVBERi0x -> decodificado: %PDF-

Aquele JVBERi0x é a assinatura %PDF- em Base64 — é o jeito rápido de saber que o Base64 é um PDF de verdade e não uma mensagem de erro empacotada. 🙂

E a conferência de que a limpeza funciona, que é o tipo de coisa que ninguém testa e todo mundo descobre com o disco cheio:

$ ls "$TEMP"/*.pdf | wc -l
0

📋 O resumo das armadilhas

Sintoma no PDFCausa
Fundo branco, texto sumidoFalta printBackground: true
Layout de celular na folha A4Falta setViewport (o padrão é 800×600)
Blocos faltando, sem erroMídia print; use emulateMediaType('screen')
Espaço em branco no meioFalta waitUntil: 'networkidle0'
Página inteira sai cortada em duasbody.scrollHeight sozinho; use o Math.max
Servidor fica sem memória com o tempoclose() fora do finally
Folha 25% menor que o esperadoNenhuma — px vira ponto, e está certo

📦 O código no GitHub

Está tudo num repositório, com o formulário em publico/ para você colar um endereço e ver o PDF sair. O README ainda ensina a trocar uma linha e ver o bug da altura acontecer na sua frente, em 30 segundos.

Exemplos_Puppeteer_LinkParaPDF no GitHubO código completo deste artigo, com uma página no navegador para você testar sem escrever nada.github.com

A lição que eu levo dessa tarde não é nenhuma das linhas de código: é que "o arquivo abriu" não é teste. O PDF de duas páginas achatadas abria perfeitamente, tinha o texto certo, pesava 22 KB e passaria por qualquer revisão de olho. Só entregou o erro quando eu contei as páginas de dentro dele. 🔍

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também