Compactei um PDF de 266 MB sem mandar nada pra servidor nenhum
Minha esposa precisava de um PDF menor pro NotebookLM, os sites de compressão eram só propaganda, e o Claude Code me mostrou que dava pra fazer tudo no navegador.
![]()
- O projeto tá aberto: github.com/joaomarcostg/compacta-pdf
Esse projeto começou com a minha esposa tentando subir um PDF no NotebookLM. O NotebookLM recusava, porque o arquivo era grande demais. Eu falei o que todo marido dev fala: “me manda aí que eu compacto pra você”.
Primeiro tentei do jeito normal. Abri um desses sites de “compress PDF” e dei de cara com propaganda pra todo lado, limite de tamanho e assinatura pra liberar o arquivo grande. Sem contar que você está subindo um documento pro servidor de alguém que você não conhece. Não, obrigado.
Então joguei o arquivo no Claude Code e pedi pra ele compactar. Ele compactou. Aí perguntei como ele tinha feito, e a resposta foi basicamente: Ghostscript, com os parâmetros certos pra reamostrar as imagens.
Foi aí que caiu a ficha. Se é isso, dá pra colocar no ar de graça. O processamento roda no computador de quem usa, então não tem servidor pra pagar nem arquivo de ninguém passando por lugar nenhum. Assim nasceu o Compacta PDF: o arquivo é processado no próprio navegador e não sai do computador.
O que ele faz
Você arrasta um ou mais PDFs, escolhe um preset e baixa o resultado. São três presets:
| Preset | Resolução das imagens | Qualidade JPEG |
|---|---|---|
| Menor tamanho | 90 dpi | 65 |
| Equilibrado | 110 dpi | 75 |
| Alta qualidade | 150 dpi | 82 |
Texto e vetor ficam como estão. Só as imagens são reamostradas e recodificadas. É aí que mora o peso de quase todo PDF gordo.
O número que está no README: um export do Canva com 62 páginas e 266 MB sai com uns 5 MB no preset Equilibrado, em cerca de 15 segundos.
Ghostscript dentro da aba
Não escrevi compressor de PDF. O trabalho pesado é do Ghostscript, que faz isso há décadas. A diferença é que ele está compilado pra WebAssembly, usando o build do @okathira/ghostpdl-wasm. O app chama o Ghostscript como se fosse a linha de comando, passando os argumentos num array:
function ghostscriptArgs(preset) {
const { dpi, jpegQ } = PRESETS[preset] || PRESETS.balanced;
return [
'-dNOPAUSE', '-dBATCH', '-dSAFER',
'-sDEVICE=pdfwrite',
'-dDetectDuplicateImages=true',
'-dDownsampleColorImages=true',
`-dColorImageResolution=${dpi}`,
'-dColorImageFilter=/DCTEncode',
`-dJPEGQ=${jpegQ}`,
'-sOutputFile=/output.pdf',
'/input.pdf',
];
}
(A lista real é mais longa: trata imagens em tons de cinza e monocromáticas, faz subset de fontes e desliga o filtro automático pra forçar JPEG.)
O /input.pdf e o /output.pdf não existem no seu disco. Eles vivem no sistema de arquivos em memória que o Emscripten monta pro módulo. O worker escreve os bytes lá, roda o callMain e lê o resultado de volta:
gs.FS.writeFile('/input.pdf', new Uint8Array(buffer));
exitCode = gs.callMain(ghostscriptArgs(preset));
output = gs.FS.readFile('/output.pdf');
Tudo num Web Worker
Ghostscript processando 60 páginas trava qualquer thread. Se rodasse na thread principal, a página congelaria. Por isso ele roda num Web Worker, e a interface só recebe mensagens.
Algumas decisões nesse worker me parecem as mais interessantes do projeto:
Compila uma vez, instancia várias. O .wasm tem uns 15 MB. O worker baixa e compila o módulo uma vez só, assim que nasce. Cada arquivo ganha uma instância nova do Ghostscript, mas reaproveita o módulo já compilado através do gancho instantiateWasm. Instância nova por job evita estado sujo de uma compressão vazar pra outra.
Progresso lendo o log. O Ghostscript não tem API de progresso, mas escreve Processing pages 1 through 62 e depois Page 1, Page 2… no stdout. O worker intercepta cada linha com duas regex e manda { page, totalPages } pra interface. Barra de progresso de graça.
O buffer é transferido, não copiado. Tanto na ida quanto na volta o ArrayBuffer vai na lista de transferíveis do postMessage. Com arquivo de centenas de MB, isso faz diferença. Escrevi um petisco só sobre isso.
Um arquivo por vez. A interface mantém uma fila e só manda o próximo PDF quando o anterior termina. Paralelizar aqui só aumentaria o risco de estourar memória.
O que pode dar errado
A parte que mais tem código, proporcionalmente, é tratar falha. Três casos aparecem:
- PDF com senha. O Ghostscript reclama no log. O worker procura
passwordno texto e devolve uma mensagem em português dizendo pra remover a senha. - Arquivo grande demais. Cada aba tem um teto de memória. Se o WebAssembly estoura, o worker pode morrer inteiro. A interface escuta o
onerror, marca o job atual como falho, mata o worker e sobe outro do zero. A fila continua. - Resultado maior que o original. Acontece com PDF que já estava enxuto. Nesse caso o app descarta o resultado e devolve o arquivo original, avisando. Não faz sentido entregar um “compactado” mais pesado.
if (buffer.byteLength >= job.file.size) {
job.blob = job.file;
job.kept = true;
}
Offline e instalável
Não tem build. É HTML, CSS e JavaScript puro, pronto pra subir em qualquer hospedagem estática. Um service worker guarda tudo em cache na primeira visita, incluindo os 15 MB do motor. Depois disso o app abre na hora e funciona sem internet. Com o manifest, dá pra instalar como app no Chrome, no Edge ou no Safari do macOS.
Um detalhe chato: módulo de Web Worker não carrega de file://. Então abrir o index.html direto do disco não funciona. Precisa de um servidor, nem que seja o python3 -m http.server.
E uma nota de licença: o Ghostscript é AGPL-3.0. Quem hospedar publicamente precisa manter o código-fonte disponível. Um repositório público resolve.
Onde está
O código está no GitHub. São quatro arquivos que importam: index.html, app.js, worker.js e sw.js. Dá pra ler tudo numa sentada.