Optimisation efficace des images JPEG en PHP avec jpegoptim
Exécutez jpegoptim sur une copie temporaire, vérifiez son code de sortie et ne publiez le résultat que si le nom de sortie choisi est encore disponible. Cet exemple PHP CLI laisse votre JPEG source intact et refuse de remplacer une sortie existante, y compris lorsque l’entrée est corrompue.
Choisir ce qu’il faut modifier
L’optimisation JPEG sans perte réorganise les données compressées sans nouvel encodage qui réduirait la qualité. Cela ne signifie pas que le fichier obtenu a des octets identiques. Le script ci-dessous propose trois modes distincts :
| Mode | Options jpegoptim | Modification prévue |
|---|---|---|
lossless (par défaut) | --strip-none | Optimiser la compression en conservant les métadonnées. |
strip | --strip-all --force | Supprimer les métadonnées sans réduire la qualité JPEG. |
quality80 | --strip-none --max=80 | Autoriser une baisse de qualité en conservant les métadonnées. |
Le manuel de jpegoptim décrit
--max comme un plafond de qualité, et non comme un pourcentage de réduction de la
taille du fichier. Un JPEG dont la qualité est déjà inférieure à ce seuil peut suivre le chemin sans
perte. Évitez --max et --size lorsque la préservation de la qualité
d’image est une exigence.
Les métadonnées demandent une décision distincte : supprimer les données EXIF peut faire perdre
l’orientation, et supprimer les profils ICC peut modifier le rendu des couleurs. N’utilisez
strip que lorsque ces changements sont acceptables. Ce mode force une réécriture afin que
la suppression des métadonnées ne soit pas ignorée faute de gain de taille. Même
--strip-all peut laisser des marqueurs JFIF ou Adobe générés par l’encodeur ;
--strip-none permet aussi que ces marqueurs soient régénérés. Aucun des deux modes ne promet
une archive des métadonnées identique octet pour octet.
Installer les outils en ligne de commande
Utilisez un répertoire Linux local que vous contrôlez, avec assez d’espace pour une copie temporaire,
sur un système de fichiers qui prend en charge les liens physiques. Vous avez besoin de PHP CLI avec
proc_open() activé et de l’exécutable jpegoptim. Sous Ubuntu 24.04, installez les deux
avec APT :
sudo apt-get update &&
sudo apt-get install -y php-cli jpegoptim &&
php --version &&
jpegoptim --version
Ubuntu 24.04 fournit le paquet jpegoptim 1.4.7, qui prend en charge les options utilisées ici. Ce flux de travail a été testé avec PHP 8.3 et jpegoptim 1.4.7, ainsi qu’avec PHP 8.5.10 et jpegoptim 1.5.6. Il ne nécessite ni Composer, ni GD, ni serveur web.
Créez un nouveau répertoire de travail :
mkdir jpeg-demo && cd jpeg-demo
Si cette commande échoue, arrêtez-vous et choisissez un nom de répertoire inutilisé. Placez dans ce
répertoire un JPEG qui vous appartient, sous le nom input.jpg. Enregistrez le script
suivant à côté, sous le nom optimize-jpeg.php.
Écrire une sortie distincte depuis PHP
Le répertoire de sortie doit déjà exister. Le script vérifie les collisions avant de commencer le
traitement, puis utilise link() de PHP pour donner au
fichier temporaire terminé son nom définitif. Cette opération refuse aussi une sortie créée pendant
l’exécution de jpegoptim. Le nettoyage ne supprime que le nom temporaire.
<?php
declare(strict_types=1);
function main(array $args): void
{
if (count($args) < 3 || count($args) > 4) {
throw new InvalidArgumentException(
'Usage: php optimize-jpeg.php INPUT OUTPUT [lossless|strip|quality80]'
);
}
$options = match ($args[3] ?? 'lossless') {
'lossless' => ['--strip-none'],
'strip' => ['--strip-all', '--force'],
'quality80' => ['--strip-none', '--max=80'],
default => throw new InvalidArgumentException('Unknown optimization mode.'),
};
$binary = getenv('JPEGOPTIM_BINARY') ?: '/usr/bin/jpegoptim';
if (!str_starts_with($binary, '/') || !is_file($binary) || !is_executable($binary)) {
throw new RuntimeException('Set JPEGOPTIM_BINARY to an executable absolute path.');
}
$input = realpath($args[1]);
if ($input === false || !is_file($input) || !is_readable($input)) {
throw new RuntimeException('Input must be a readable local file.');
}
$directory = realpath(dirname($args[2]));
if ($directory === false || !is_dir($directory) || !is_writable($directory)) {
throw new RuntimeException('Output directory must exist and be writable.');
}
$output = $directory . '/' . basename($args[2]);
if (file_exists($output) || is_link($output)) {
throw new RuntimeException('Output already exists; choose a new filename.');
}
$before = filesize($input);
if ($before === false || $before === 0) {
throw new RuntimeException('Input is empty or its size cannot be read.');
}
$temporary = tempnam($directory, '.jpegoptim-');
if ($temporary === false) {
throw new RuntimeException('Cannot create a temporary file.');
}
try {
// tempnam can fall back to the system temp directory; keep publication on one filesystem.
if (dirname($temporary) !== $directory || !copy($input, $temporary)) {
throw new RuntimeException('Cannot create the working copy in the output directory.');
}
$process = proc_open(
[$binary, '--nofix', '--quiet', ...$options, '--', $temporary],
[0 => ['file', '/dev/null', 'r'], 1 => STDERR, 2 => STDERR],
$pipes
);
if (!is_resource($process)) {
throw new RuntimeException('Cannot start jpegoptim.');
}
$status = proc_close($process);
if ($status !== 0) {
throw new RuntimeException("jpegoptim failed (exit $status); no output published.");
}
clearstatcache(true, $temporary);
$after = filesize($temporary);
if ($after === false || $after === 0) {
throw new RuntimeException('jpegoptim did not leave a nonempty output.');
}
if (!link($temporary, $output)) {
throw new RuntimeException('Cannot publish output; check for a collision or filesystem error.');
}
} finally {
unlink($temporary);
}
printf("Created %s: %d -> %d bytes; saved %d bytes\n", $output, $before, $after, $before - $after);
}
try {
main($argv);
} catch (Throwable $error) {
fwrite(STDERR, 'Error: ' . $error->getMessage() . "\n");
exit(1);
}
proc_open() accepte un tableau d’arguments,
si bien que les chemins ne deviennent pas des commandes shell. jpegoptim ne reçoit que le fichier
temporaire, jamais la source ni la destination finale. L’option --nofix rejette les
avertissements de décompression comme les erreurs, au lieu de tenter de réparer un JPEG endommagé.
Une vérification du type MIME ne suffirait pas à elle seule à établir que l’image entière peut être
décodée.
Exécutez le mode sans perte par défaut :
php optimize-jpeg.php input.jpg lossless.jpg
Le script utilise /usr/bin/jpegoptim, l’emplacement fourni par le paquet Ubuntu. Si vous l’avez
installé ailleurs, définissez JPEGOPTIM_BINARY sur son chemin absolu lors de l’appel. N’acceptez
pas ce réglage ni des options arbitraires de l’optimiseur provenant d’une requête HTTP.
Une exécution réussie se termine avec le code de sortie zéro et affiche le chemin de sortie, la
taille en octets de l’entrée et de la sortie, ainsi que les octets économisés. Une économie nulle est
valide : vous obtenez tout de même une sortie distincte. Une économie négative signifie que la
sortie a grossi, ce que permet le mode forcé strip. Relancer la commande avec le même
nom de sortie échoue sans modifier ce fichier. Utiliser le nom de l’entrée comme sortie échoue
également.
Une entrée corrompue, un exécutable manquant ou un processus jpegoptim en échec produit un code de sortie non nul. Aucune sortie finale n’est publiée lors de ces échecs, et la copie temporaire est supprimée pendant la gestion normale des exceptions. Les sorties existantes et la source restent en place.
Mesurer les résultats de la compression
Comparez les autres modes en utilisant de nouveaux noms de sortie :
php optimize-jpeg.php input.jpg stripped.jpg strip &&
php optimize-jpeg.php input.jpg quality80.jpg quality80
Notez les tailles en octets avec les versions installées et le mode utilisé. Comparez des photos représentatives, de petites miniatures et des images déjà optimisées par un autre outil ; un gain sur l’une ne permet pas de prédire un gain sur une autre. Le script ne redimensionne pas les images et ne garantit pas une taille de fichier cible.
Ouvrez les sorties dans une visionneuse d’images. Pour quality80, examinez les détails
fins, les dégradés et le texte en taille réelle avant d’accepter la modification de qualité. Pour
strip, vérifiez l’orientation et les couleurs par rapport à l’original. Si vous
automatisez une vérification sans perte, décodez la source et la sortie avec le même décodeur, puis
comparez les tampons de pixels et les dimensions ; comparer les empreintes des fichiers JPEG teste
plutôt l’identité des octets.
Appeler le script depuis un worker
Pour une tâche par lots ou une application Laravel, conservez l’original et attribuez une nouvelle destination à chaque image. Exécutez cette opération CLI dans un worker et vérifiez son code de sortie avant d’enregistrer une sortie comme disponible. Une tâche en échec devrait conserver la source pour le diagnostic ou une nouvelle tentative. Un wrapper Composer a toujours besoin de l’exécutable natif et d’une politique explicite pour les échecs et les collisions de destination.
Considérations de sécurité
Cet exemple traite des fichiers locaux dans des répertoires contrôlés par le même compte de
confiance. Ce n’est ni un point de terminaison d’envoi de fichiers ni un bac à sable pour des
fichiers malveillants. Il laisse les permissions de sortie privées (le fichier temporaire commence
avec le mode 0600) ;
définissez délibérément toute permission ultérieure destinée à servir les fichiers sur le web.
Un service d’envoi de fichiers a aussi besoin de limites en octets et en pixels, d’un traitement
isolé et d’un délai maximal côté worker qui met fin au processus externe. Sous Linux,
set_time_limit() de PHP ne limite pas le temps passé dans des
opérations externes. Ce petit script CLI n’a pas de délai d’expiration de processus, et un arrêt
forcé peut laisser des fichiers temporaires derrière lui. Gardez son répertoire de travail privé et
gérez le nettoyage des tâches interrompues dans le worker qui possède ces fichiers.
