Otimizar PNGs no navegador com Oxipng
A otimização de PNGs no navegador pode reduzir o tamanho do upload sem enviar a imagem a um servidor
antes. Este guia usa @jsquash/oxipng, que executa o Oxipng via WebAssembly.
O Oxipng é um otimizador diferente do OptiPNG; a URL histórica da página mantém o nome anterior.
Entender o Oxipng
O Oxipng reescreve a compressão e a representação do PNG sem alterar os pixels decodificados. Desativamos a otimização de pixels transparentes, pois alterar valores RGB invisíveis violaria a igualdade estrita entre pixels. Os metadados e a representação binária do arquivo podem mudar; pixels sem perdas não significam arquivos idênticos byte a byte.
Usamos o codec de thread única do pacote, com versão fixada, dentro de um Worker dedicado. A interface continua responsiva, e o cancelamento encerra esse Worker. Isso evita exigir isolamento entre origens ou um pool de Workers aninhado.
Implementação
Use um diretório de projeto novo e vazio. A configuração foi testada no Linux com
Node.js 24.15.0 e o npm 11.12.1 incluído nessa versão,
e com Node.js 26.8.1 e npm 12.0.2. Essas são as versões testadas; versões posteriores da linha
Node.js 24.x que ainda recebem manutenção também são adequadas. Confira
node --version e npm --version antes de salvar os arquivos.
Salve os quatro arquivos abaixo nesse diretório. Em package.json:
{
"name": "browser-png-optimizer",
"private": true,
"type": "module",
"scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" },
"dependencies": { "@jsquash/oxipng": "2.3.0" },
"devDependencies": { "vite": "7.3.6" }
}
Em index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PNG optimizer</title>
</head>
<body>
<h1>PNG optimizer</h1>
<label for="file">PNG file</label>
<input id="file" type="file" accept="image/png">
<button id="cancel" type="button" disabled>Cancel</button>
<p id="status" role="status">Ready.</p>
<a id="download" hidden>Download PNG</a>
<script type="module" src="/main.js"></script>
</body>
</html>
Em main.js, cada tarefa tem seu próprio Worker e sua própria URL de download.
Selecionar um arquivo inicia a tarefa; nenhum dado da imagem é enviado por upload:
const input = document.getElementById('file')
const cancel = document.getElementById('cancel')
const status = document.getElementById('status')
const download = document.getElementById('download')
let worker
let timer
let job = 0
let downloadUrl
function release() {
worker?.terminate()
worker = undefined
clearTimeout(timer)
input.value = ''
input.disabled = false
cancel.disabled = true
}
function clearDownload() {
if (downloadUrl) URL.revokeObjectURL(downloadUrl)
downloadUrl = undefined
download.hidden = true
download.removeAttribute('href')
}
cancel.addEventListener('click', () => {
job += 1
release()
status.textContent = 'Canceled.'
})
input.addEventListener('change', async () => {
const file = input.files?.[0]
if (!file) return
const current = ++job
release()
clearDownload()
if (file.size === 0 || file.size > 8 * 1024 * 1024) {
status.textContent = 'Choose a PNG no larger than 8 MiB.'
return
}
input.disabled = true
cancel.disabled = false
status.textContent = 'Optimizing PNG.'
const fail = (message) => {
if (current !== job) return
job += 1
release()
status.textContent = message
}
timer = setTimeout(() => fail('Optimization timed out.'), 30_000)
try {
const bytes = await file.arrayBuffer()
if (current !== job) return
worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })
worker.addEventListener('error', () => fail('PNG optimization failed.'))
worker.addEventListener('messageerror', () => fail('PNG optimization failed.'))
worker.addEventListener('message', ({ data }) => {
if (current !== job) return
if (!data.ok) return fail('Choose a valid, non-animated PNG within the image limits.')
const result = new Blob([data.bytes], { type: 'image/png' })
const output = result.size < file.size ? result : file
downloadUrl = URL.createObjectURL(output)
download.href = downloadUrl
download.download = 'optimized.png'
download.hidden = false
status.textContent = result.size < file.size
? 'Optimized PNG is ready.'
: 'The original PNG is already as small or smaller.'
release()
})
worker.postMessage(bytes, [bytes])
} catch {
fail('PNG optimization failed.')
}
})
window.addEventListener('pagehide', () => {
job += 1
release()
clearDownload()
})
Usar Web Workers para melhorar o desempenho
Em worker.js, verifique os limites de alocação antes de chamar o otimizador de
PNG propriamente dito. As verificações de cabeçalho e blocos são medidas de proteção, não substituem
a validação do codec. PNGs animados são rejeitados para que o limite de pixels corresponda a uma
única imagem.
import init, { optimise } from '@jsquash/oxipng/codec/pkg/squoosh_oxipng.js'
import wasmUrl from '@jsquash/oxipng/codec/pkg/squoosh_oxipng_bg.wasm?url'
function validatePng(buffer) {
const data = new Uint8Array(buffer)
const signature = [137, 80, 78, 71, 13, 10, 26, 10]
if (data.length < 33 || data.length > 8 * 1024 * 1024 ||
!signature.every((byte, i) => data[i] === byte)) throw new Error('Invalid PNG.')
const view = new DataView(buffer)
if (view.getUint32(8) !== 13 || view.getUint32(12) !== 0x49484452) {
throw new Error('Missing PNG header.')
}
const width = view.getUint32(16)
const height = view.getUint32(20)
if (width === 0 || height === 0 || width > 4096 || height > 4096 ||
width * height > 4_000_000) throw new Error('Image exceeds pixel limit.')
let offset = 8
while (offset + 12 <= data.length) {
const length = view.getUint32(offset)
const type = view.getUint32(offset + 4)
if (length > data.length - offset - 12 || type === 0x6163544c) {
throw new Error('Invalid or animated PNG.')
}
offset += length + 12
if (type === 0x49454e44) {
if (length !== 0 || offset !== data.length) throw new Error('Invalid PNG ending.')
return
}
}
throw new Error('Truncated PNG.')
}
self.addEventListener('message', async ({ data }) => {
try {
if (!(data instanceof ArrayBuffer)) throw new Error('Invalid input.')
validatePng(data)
const response = await fetch(wasmUrl)
if (!response.ok) throw new Error('WASM unavailable.')
await init(await response.arrayBuffer())
const bytes = optimise(new Uint8Array(data), 2, false, false)
self.postMessage({ ok: true, bytes: bytes.buffer }, [bytes.buffer])
} catch {
self.postMessage({ ok: false })
}
})
A chamada de baixo nível optimise
passa o nível 2, desativa o entrelaçamento e mantém os valores RGB transparentes inalterados.
O sufixo ?url é uma
importação de recurso do Vite. O Vite gera o arquivo WASM junto
com a aplicação. O caminho do codec está vinculado a essa versão do pacote; verifique-o ao atualizar.
Consulte a documentação do pacote jSquash
para conhecer a API de alto nível e as opções de multithreading.
Compatibilidade com navegadores
No diretório do projeto, instale os pacotes nas versões fixadas e inicie a aplicação:
npm install && npm run dev
Mantenha o package-lock.json gerado. Se a instalação falhar, corrija o erro de dependência
ou de rede informado e repita esse comando no mesmo diretório; os arquivos-fonte salvos permanecem
no lugar. Abra a URL local exibida pelo Vite em um navegador com suporte a Workers de módulo,
WebAssembly, APIs File/Blob e URLs de objeto. O fluxo de trabalho completo foi testado no Chromium
145 no Linux; outros mecanismos de navegador e dispositivos móveis precisam de testes próprios.
Selecione um arquivo com PNG file.
O status muda para Optimized PNG is ready. Clique em
Download PNG para salvar optimized.png.
Se o arquivo candidato não for menor, você verá
The original PNG is already as small or smaller.
Nesse caso, o download contém os bytes originais.
Encerre o servidor de desenvolvimento com Ctrl+C. Gere o build e teste o mesmo fluxo de seleção de arquivo e download com os recursos de produção:
npm run build && npm run preview
Abra a URL de pré-visualização exibida pelo Vite e encerre esse servidor com Ctrl+C ao terminar. O Vite preview serve o build local para verificação; use uma hospedagem estática para a implantação.
Ao abrir a aplicação, seu JS é baixado; a primeira tarefa também baixa o recurso WASM. As imagens permanecem locais, mas baixar esses recursos da aplicação ainda gera atividade de rede.
Considerações de desempenho
O exemplo aceita no máximo 8 MiB, 4 milhões de pixels e 4096 pixels por lado. Ele executa uma tarefa por vez e encerra o processamento após 30 segundos ou em caso de cancelamento. Esses são limites da aplicação, não garantias sobre o pico de uso de memória do navegador. Comece com limites conservadores em dispositivos móveis.
O nível de compressão 2 mantém o exemplo moderado. Níveis mais altos podem levar mais tempo sem reduções significativas de tamanho. Se o arquivo gerado for maior, o download continua sendo o original.
Cancelar e tentar novamente
Clique em Cancel enquanto o processamento estiver pendente. Isso encerra o Worker e muda o status para Canceled. Você pode selecionar o mesmo arquivo novamente ou escolher outro. Iniciar uma tarefa remove o download anterior; uma tarefa cancelada ou que falhou não tem resultado para download.
Um arquivo vazio ou maior que 8 MiB é recusado imediatamente. O Worker rejeita cabeçalhos inválidos, PNGs animados e imagens fora dos limites de dimensões ou de pixels antes de carregar o codec. O codec verifica os demais dados do PNG. Após 30 segundos, uma tarefa travada é encerrada e o status exibe Optimization timed out. Você pode selecionar um arquivo novamente para repetir a tentativa.
Se o processamento de um PNG comprovadamente válido falhar, verifique se o recurso
.wasm retorna uma resposta de sucesso no painel de rede do navegador.
Um recurso ausente não deixa nenhum download disponível, assim como um erro do codec. Gere o build
novamente com os pacotes nas versões fixadas e teste a pré-visualização outra vez. As imagens
permanecem locais neste exemplo; um servidor que venha a receber um upload ainda precisa validar
o arquivo.
Para processamento no servidor com mais formatos, conheça o serviço de processamento de imagens da Transloadit.
