Node.js: gerando o PDF de um link com Puppeteer
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. 😳
🎯 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 PDF | Causa |
|---|---|
| Fundo branco, texto sumido | Falta printBackground: true |
| Layout de celular na folha A4 | Falta setViewport (o padrão é 800×600) |
| Blocos faltando, sem erro | Mídia print; use emulateMediaType('screen') |
| Espaço em branco no meio | Falta waitUntil: 'networkidle0' |
| Página inteira sai cortada em duas | body.scrollHeight sozinho; use o Math.max |
| Servidor fica sem memória com o tempo | close() fora do finally |
| Folha 25% menor que o esperado | Nenhuma — 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.
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
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.