Optimiza PNG en el navegador con OxiPNG
La optimización de PNG en el navegador puede reducir el tamaño de la subida sin enviar antes la
imagen a un servidor. Esta guía usa el paquete real @jsquash/oxipng, que ejecuta OxiPNG mediante WebAssembly.
OxiPNG es un optimizador distinto de OptiPNG; la URL histórica de la página conserva el nombre
anterior.
Entender OxiPNG
OxiPNG reescribe la compresión y la representación del PNG sin cambiar los píxeles decodificados. Desactivamos la optimización de píxeles transparentes, ya que cambiar valores RGB invisibles violaría la igualdad estricta de píxeles. Los metadatos del archivo y la representación binaria pueden cambiar; que los píxeles no tengan pérdida no significa que los archivos sean idénticos byte a byte.
Usamos el códec de un solo hilo fijado por el paquete dentro de un Worker dedicado. La interfaz sigue respondiendo y la cancelación termina ese Worker. Así se evita tener que exigir aislamiento de origen cruzado o un grupo anidado de workers.
Implementación
Crea un proyecto con estos archivos. En 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" }
}
Ejecuta npm install con Node.js 24 o una versión posterior y conserva el lockfile generado. En 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>
En main.js, cada trabajo es dueño de un Worker y de una URL de descarga. Seleccionar un archivo
inicia el trabajo; no se sube ningún dato de imagen:
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 mejorar el rendimiento
En worker.js, comprueba los límites de asignación antes de llamar al optimizador de PNG real. Las
comprobaciones de encabezado y de chunks son medidas de protección, no un sustituto de la validación
del códec. Los PNG animados se rechazan para que el presupuesto de píxeles describa una sola imagen.
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 })
}
})
La importación ?url es una importación de recursos de Vite, no una URL de paquete del navegador.
Vite emite el archivo WASM junto con la aplicación. La ruta del códec está fijada a esta versión del
paquete; verifícala al actualizar. Consulta el repositorio de jSquash para conocer la API
de alto nivel compatible y las opciones de multihilo.
Compatibilidad con navegadores
Ejecuta npm run dev y abre la URL local de Vite. Antes de publicar, ejecuta npm run build, luego
npm run preview y prueba también la compilación de producción. La aplicación requiere Workers de módulo,
WebAssembly, las API de File/Blob y las URL de objeto. Los navegadores no compatibles reciben el
estado de error en lugar de una supuesta garantía universal de versiones.
La primera optimización debe descargar los recursos JS y WASM de la aplicación. Las imágenes permanecen en local, pero descargar esos recursos de la aplicación sigue siendo actividad de red.
Consideraciones de rendimiento
El ejemplo acepta como máximo 8 MiB, 4 millones de píxeles y 4096 píxeles por lado. Ejecuta un trabajo a la vez y termina el procesamiento después de 30 segundos o tras una cancelación. Estos son límites de la aplicación, no garantías sobre el uso máximo de memoria del navegador. Empieza de forma conservadora en dispositivos móviles.
El nivel de compresión 2 mantiene el ejemplo en un punto moderado. Los niveles mayores pueden tardar más sin lograr ahorros significativos. Si el archivo generado es más grande, la descarga sigue siendo el original.
Casos de uso
Este enfoque puede reducir el tamaño de subida de los PNG para capturas de pantalla, diagramas y recursos transparentes. Valida los píxeles decodificados con archivos de prueba representativos, incluida la transparencia, y comprueba los archivos no válidos, los flujos de cancelación y reintento, y las respuestas de WASM faltantes. No lo uses como prueba de que una subida es inofensiva: el servidor receptor debe validar los archivos de todos modos.
Conclusión
Un optimizador de PNG real en el navegador necesita un códec funcional, trabajo acotado y una gestión explícita del ciclo de vida. Esta implementación mantiene el procesamiento de PNG en un Worker desechable y libera las URL de descarga. Para el procesamiento en el servidor con más formatos, explora el servicio de procesamiento de imágenes de Transloadit.
