Optimiser les PNG dans le navigateur avec Oxipng
L’optimisation des PNG côté navigateur peut réduire la taille des fichiers à téléverser sans envoyer
l’image à un serveur au préalable. Ce guide utilise @jsquash/oxipng, qui exécute Oxipng
via WebAssembly. Oxipng est un optimiseur distinct d’OptiPNG ; l’URL historique de cette page conserve
l’ancien nom.
Comprendre Oxipng
Oxipng réécrit la compression et la représentation des PNG sans modifier les pixels décodés. Nous désactivons l’optimisation des pixels transparents, car modifier des valeurs RGB invisibles contreviendrait à l’égalité stricte des pixels. Les métadonnées et la représentation binaire du fichier peuvent changer ; des pixels sans perte n’impliquent pas des fichiers identiques octet par octet.
Nous utilisons le codec monothread du paquet, dont la version est fixée, dans un Worker dédié. L’interface reste réactive et l’annulation met fin à ce Worker. Cela évite d’exiger une isolation entre origines ou un pool de workers imbriqué.
Mise en œuvre
Utilisez un nouveau répertoire de projet vide. Cette configuration a été testée sous Linux avec
Node.js 24.15.0 et npm 11.12.1 fourni avec cette version,
ainsi qu’avec Node.js 26.8.1 et npm 12.0.2. Ces versions ont été testées ; les versions ultérieures
maintenues de Node.js 24.x conviennent également. Vérifiez node --version et
npm --version avant d’enregistrer les fichiers.
Enregistrez les quatre fichiers ci-dessous dans ce répertoire. Dans 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" }
}
Dans 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>
Dans main.js, chaque tâche possède un Worker et une URL de téléchargement.
La sélection d’un fichier démarre la tâche ; aucune donnée d’image n’est téléversée :
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()
})
Utiliser les Web Workers pour améliorer les performances
Dans worker.js, vérifiez les limites d’allocation avant d’appeler l’optimiseur PNG
proprement dit. Les vérifications de l’en-tête et des blocs constituent des garde-fous et ne remplacent
pas la validation du codec. Les PNG animés sont rejetés afin que le budget de pixels corresponde à
une seule image.
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 })
}
})
L’appel de bas niveau à optimise
passe le niveau 2, désactive l’entrelacement et laisse les valeurs RGB transparentes inchangées.
Le suffixe ?url correspond à un
import de ressource avec Vite. Vite génère le
fichier WASM avec l’application. Le chemin du codec est fixé pour cette version du paquet ;
vérifiez-le lors d’une mise à niveau. Consultez la
documentation du paquet jSquash
pour l’API de haut niveau et les options multithreads.
Compatibilité des navigateurs
Depuis le répertoire du projet, installez les paquets aux versions fixées et démarrez l’application :
npm install && npm run dev
Conservez le fichier package-lock.json généré. Si l’installation échoue, corrigez
l’erreur de dépendance ou de réseau signalée et relancez cette commande dans le même répertoire ;
les fichiers source enregistrés restent en place. Ouvrez l’URL locale affichée par Vite dans un
navigateur disposant des Workers de type module, de WebAssembly, des API File/Blob et des URL d’objet.
La procédure complète a été testée dans Chromium 145 sous Linux ; les autres moteurs de navigateur
et les appareils mobiles nécessitent leurs propres vérifications.
Sélectionnez un fichier avec PNG file. L’état passe à
Optimized PNG is ready. Cliquez sur
Download PNG pour enregistrer optimized.png. Si le fichier produit n’est pas
plus petit, vous verrez
The original PNG is already as small or smaller.
Le téléchargement contient alors les octets d’origine.
Arrêtez le serveur de développement avec Ctrl+C. Compilez l’application et testez la même procédure de sélection de fichier et de téléchargement avec les ressources de production :
npm run build && npm run preview
Ouvrez l’URL de prévisualisation affichée par Vite et arrêtez ce serveur avec Ctrl+C une fois terminé. Vite preview sert la version compilée locale pour les vérifications ; utilisez un hébergement statique pour le déploiement.
L’ouverture de l’application récupère son JS ; la première tâche récupère également la ressource WASM. Les images restent locales, mais la récupération de ces ressources applicatives constitue tout de même une activité réseau.
Considérations relatives aux performances
L’exemple accepte au maximum 8 MiB, 4 millions de pixels et 4096 pixels par côté. Il exécute une seule tâche à la fois et met fin au traitement après 30 secondes ou en cas d’annulation. Ces limites sont celles de l’application et ne garantissent pas un plafond de mémoire utilisée par le navigateur. Commencez avec des limites prudentes sur les appareils mobiles.
Le niveau de compression 2 maintient une charge modérée dans cet exemple. Les niveaux supérieurs peuvent prendre plus de temps sans réduction de taille significative. Si le fichier généré est plus volumineux, le téléchargement conserve l’original.
Annuler et réessayer
Cliquez sur Cancel pendant le traitement. Cela met fin au Worker et fait passer l’état à Canceled. Vous pouvez sélectionner à nouveau le même fichier ou en choisir un autre. Le démarrage d’une tâche supprime le téléchargement précédent ; une tâche annulée ou ayant échoué ne fournit aucun résultat à télécharger.
Un fichier vide ou de plus de 8 MiB est refusé immédiatement. Avant de charger le codec, le Worker rejette les en-têtes invalides, les PNG animés et les images dépassant les limites de dimensions ou de pixels. Le codec vérifie les données PNG restantes. Après 30 secondes, une tâche bloquée est interrompue et l’état affiche Optimization timed out. Vous pouvez sélectionner à nouveau un fichier pour réessayer.
Si un PNG dont la validité est établie échoue, vérifiez que la ressource .wasm
répond correctement dans le panneau réseau du navigateur. Une ressource manquante ne laisse aucun
téléchargement, tout comme une erreur du codec. Recompilez à partir des paquets aux versions fixées
et testez à nouveau la prévisualisation. Les images restent locales dans cet exemple ; un serveur
recevant un éventuel téléversement doit tout de même valider le fichier.
Pour le traitement côté serveur de davantage de formats, découvrez le service de traitement d’images de Transloadit.
