Otimize PNGs no navegador com OxiPNG
A otimização de PNG no navegador pode reduzir o tamanho do upload sem enviar a imagem antes a um
servidor. Este guia usa o pacote real @jsquash/oxipng, que executa o OxiPNG por meio
de WebAssembly. O OxiPNG é um otimizador diferente do OptiPNG; a URL histórica da página mantém o
nome anterior.
Entendendo 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, já que alterar valores RGB invisíveis violaria a igualdade estrita de pixels. Os metadados do arquivo e a representação binária podem mudar; pixels sem perdas não significam arquivos idênticos byte a byte.
Usamos o codec single-threaded fixado do pacote dentro de um Worker dedicado. A interface continua responsiva, e o cancelamento encerra esse Worker. Isso evita exigir isolamento entre origens (cross-origin isolation) ou um pool de workers aninhado.
Implementação
Crie um projeto com estes arquivos. 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.1" }
}
Execute npm install com Node.js 24 ou mais recente e mantenha o lockfile gerado.
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 é dona de um Worker e de uma URL de download.
Selecionar um arquivo inicia a tarefa; nenhum dado de 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()
})
Usando Web Workers para um desempenho melhor
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 de chunks são salvaguardas, não um substituto
para a validação do codec. PNGs animados são rejeitados para que o orçamento de pixels descreva 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 })
}
})
O import ?url é um import de asset do Vite, não uma URL de pacote do
navegador. O Vite emite o arquivo WASM junto com a aplicação. O caminho do codec está fixado nesta
versão do pacote; verifique-o ao atualizar. Consulte o
repositório do jSquash para conhecer a API de alto nível suportada
e as opções de multithreading.
Compatibilidade com navegadores
Execute npm run dev e abra a URL local do Vite. Antes de publicar, execute
npm run build, depois npm run preview e teste também o build de
produção. A aplicação exige module Workers, WebAssembly, as APIs File/Blob e object URLs. Navegadores
sem suporte recebem o estado de erro, em vez de uma suposta garantia universal de versão.
A primeira otimização precisa baixar os assets JS e WASM da aplicação. As imagens continuam locais, mas buscar esses assets da aplicação ainda é 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 trabalho após 30 segundos ou em caso de cancelamento. Esses são limites da aplicação, não garantias sobre o pico de memória do navegador. Comece de forma conservadora em dispositivos móveis.
O nível de compressão 2 mantém o exemplo moderado. Níveis maiores podem levar mais tempo sem uma economia significativa. Se o arquivo gerado for maior, o download continua sendo o original.
Casos de uso
Essa abordagem pode reduzir o tamanho do upload de PNGs para capturas de tela, diagramas e assets transparentes. Valide os pixels decodificados com fixtures representativas, incluindo transparência, e verifique arquivos inválidos, fluxos de cancelamento e nova tentativa e respostas WASM ausentes. Não use isso como prova de que um upload é inofensivo: o servidor que o recebe ainda precisa validar os arquivos.
Conclusão
Um otimizador de PNG real no navegador precisa de um codec funcional, trabalho limitado e um gerenciamento explícito do ciclo de vida. Esta implementação mantém o processamento de PNG em um Worker descartável e libera as URLs de download. Para processamento no servidor com mais formatos, conheça o serviço de processamento de imagens da Transloadit.
