Corriger la rotation des images envoyées en PHP avec jpegtran
Pour corriger un JPEG envoyé de travers, transformez l’image stockée selon son orientation EXIF, puis réinitialisez cette orientation. La commande PHP ci-dessous enregistre un JPEG distinct, correctement orienté, sans nouvelle passe de compression avec perte. Elle refuse les entrées endommagées et les rotations qui laisseraient une bande non corrigée le long d’un bord de l’image.
Comprendre les données d’orientation EXIF
Une balise d’orientation EXIF indique à un visualiseur comment afficher l’image stockée. Modifier
uniquement cette balise ne fait pas pivoter les données de l’image ; faire pivoter les données sans
réinitialiser la balise peut amener un visualiseur à les faire pivoter une seconde fois. Lisez
l’orientation IFD0 de l’image principale, plutôt que celle d’une miniature intégrée.
Les huit valeurs comptent, y compris les cas en miroir. Voici les opérations à appliquer à l’image stockée, les angles de rotation étant mesurés dans le sens horaire :
| Valeur EXIF | Correction | Arguments jpegtran |
|---|---|---|
| 1 | Laisser à l’endroit | Aucune transformation |
| 2 | Inverser de gauche à droite | -flip horizontal |
| 3 | Mettre à l’envers | -rotate 180 |
| 4 | Inverser de haut en bas | -flip vertical |
| 5 | Réfléchir selon la diagonale allant du coin supérieur gauche au coin inférieur droit | -transpose |
| 6 | Faire pivoter de 90° | -rotate 90 |
| 7 | Réfléchir selon la diagonale allant du coin supérieur droit au coin inférieur gauche | -transverse |
| 8 | Faire pivoter de 270° | -rotate 270 |
La correspondance suit les définitions d’orientation d’ExifTool. Lorsque l’image principale n’a pas de balise d’orientation, cet exemple laisse sa géométrie inchangée. Il ne peut pas deviner dans quel sens une photo sans balise devrait être orientée. Une valeur en dehors de la plage 1–8 est une erreur.
Utiliser jpegtran pour une rotation sans perte
jpegtran réorganise les coefficients DCT quantifiés, ce qui évite le cycle de décodage et de
recompression JPEG d’une rotation avec GD ou ImageMagick. Ici, « sans perte » décrit cette
transformation des coefficients ; les octets et les métadonnées du fichier de sortie changeront.
Les transformations parfaites dépendent des limites des blocs JPEG. -perfect refuse une transformation
non prise en charge ; -trim supprimerait des pixels de bord. Utilisez aussi -strict pour traiter
comme des échecs les avertissements du décodeur, y compris ceux liés à des données d’image tronquées.
Ces options sont documentées dans le
guide d’utilisation de libjpeg-turbo.
Cet exemple crée une copie destinée à l’affichage. -copy icc conserve le profil colorimétrique mais
supprime les autres métadonnées source, notamment le GPS, les détails de l’appareil photo, les champs
de copyright, XMP, les commentaires et les miniatures intégrées. ExifTool écrit ensuite une nouvelle
valeur EXIF Orientation égale à 1. Conservez l’original si vous avez besoin de ses métadonnées.
Utiliser -copy all à la place nécessiterait une politique distincte pour les champs d’orientation et
les aperçus obsolètes.
Préparer la ligne de commande Linux
Utilisez PHP CLI avec son extension EXIF, ainsi que jpegtran de libjpeg-turbo et ExifTool, installés et
disponibles dans PATH. Ce tutoriel a été testé sous Linux avec PHP 8.5.10, libjpeg-turbo 3.2.0 et
ExifTool 13.55. Pour la sortie, utilisez un répertoire local privé sur un système de fichiers qui prend
en charge les liens physiques.
Vérifiez votre installation existante avant d’enregistrer le script :
php -d extension=exif -r 'if (!extension_loaded("exif") || !function_exists("proc_open")) { fwrite(STDERR, "PHP needs EXIF and proc_open.\n"); exit(1); } echo "PHP ", PHP_VERSION, ": EXIF and proc_open available\n";' &&
jpegtran -version &&
exiftool -ver
L’option -d extension=exif active une extension EXIF partagée déjà installée pour cette invocation. Si EXIF
est déjà activée, omettez cette option partout ; la charger deux fois produit un avertissement. Si PHP
ne parvient pas à la charger, installez l’extension correspondant à votre build PHP avant de
continuer. Consultez les
instructions d’installation d’EXIF dans la documentation PHP et les
options de configuration de la CLI.
Les vérifications de version doivent identifier libjpeg-turbo et ExifTool, sans aucun avertissement au
démarrage de PHP.
Enregistrer la commande PHP complète
Enregistrez ce script sous orient-jpeg.php. Il accepte un chemin d’entrée et un nouveau chemin de sortie.
Il utilise exif_read_data() avec des sections séparées
et rejette les avertissements de lecture des métadonnées. Même les JPEG d’orientation 1 et ceux sans
balise passent par jpegtran ; l’absence de balise d’orientation n’est jamais une raison de copier
des octets non vérifiés.
proc_open() avec un tableau d’arguments lance
les outils sans shell. Les chemins d’entrée sont résolus en chemins absolus, de sorte qu’un nom de
fichier commençant par un tiret n’est pas interprété comme une option d’outil. Choisissez les chemins
dans le code de votre application lorsque vous adaptez cette commande à un worker ; l’exemple
n’autorise pas les chemins fournis par un client HTTP.
<?php
declare(strict_types=1);
function runTool(array $arguments): void
{
$process = proc_open($arguments, [
0 => ['file', '/dev/null', 'r'],
1 => ['file', '/dev/null', 'w'],
2 => STDERR,
], $pipes);
if ($process === false || proc_close($process) !== 0) {
throw new RuntimeException($arguments[0] . ' failed; see the diagnostic above.');
}
}
function orientJpeg(string $input, string $output): string
{
$source = realpath($input);
if ($source === false || !is_file($source) || !is_readable($source)) {
throw new RuntimeException('Input must be an existing, readable local file.');
}
$directory = realpath(dirname($output));
if ($directory === false || !is_dir($directory) || !is_writable($directory)) {
throw new RuntimeException('Output directory must exist and be writable.');
}
$destination = $directory . DIRECTORY_SEPARATOR . basename($output);
if (file_exists($destination) || is_link($destination)) {
throw new RuntimeException('Output already exists; choose a new filename.');
}
$metadata = exif_read_data($source, null, true);
if ($metadata === false) {
throw new RuntimeException('Cannot read JPEG headers.');
}
$orientation = $metadata['IFD0']['Orientation'] ?? 1;
$transforms = [
1 => [],
2 => ['-flip', 'horizontal'],
3 => ['-rotate', '180'],
4 => ['-flip', 'vertical'],
5 => ['-transpose'],
6 => ['-rotate', '90'],
7 => ['-transverse'],
8 => ['-rotate', '270'],
];
if (!is_int($orientation) || !isset($transforms[$orientation])) {
throw new RuntimeException('EXIF Orientation must be an integer from 1 to 8.');
}
$temporary = tempnam($directory, '.orient-');
if ($temporary === false) {
throw new RuntimeException('Cannot create a temporary output.');
}
try {
runTool([
'jpegtran', '-strict', '-perfect', '-copy', 'icc',
...$transforms[$orientation], '-outfile', $temporary, $source,
]);
runTool(['exiftool', '-overwrite_original', '-IFD0:Orientation#=1', $temporary]);
$written = exif_read_data($temporary, null, true);
if (($written['IFD0']['Orientation'] ?? null) !== 1) {
throw new RuntimeException('Could not verify the output orientation tag.');
}
// A hard link publishes the finished file without replacing an existing name.
if (!link($temporary, $destination)) {
throw new RuntimeException('Could not create the output; choose a new filename.');
}
} finally {
unlink($temporary);
}
return $destination;
}
// Reject PHP warnings, including unreadable or malformed metadata, instead of guessing.
set_error_handler(static function (int $severity, string $message): never {
throw new ErrorException($message, 0, $severity);
});
try {
if ($argc !== 3) {
throw new RuntimeException('Usage: php orient-jpeg.php INPUT.jpg OUTPUT.jpg');
}
if (!extension_loaded('exif') || !function_exists('proc_open')) {
throw new RuntimeException('PHP needs EXIF and proc_open enabled.');
}
$output = orientJpeg($argv[1], $argv[2]);
fwrite(STDOUT, "Saved: $output\n");
} catch (Throwable $error) {
fwrite(STDERR, 'Failed: ' . $error->getMessage() . "\n");
exit(1);
}
Seul le fichier temporaire est transmis à l’option
-overwrite_original
d’ExifTool. La source reste intacte. La fonction PHP link()
crée la destination une fois le traitement réussi ; un fichier ou un lien symbolique existant est
refusé. Un bloc finally supprime le nom temporaire en cas de succès comme en cas d’erreur ordinaire.
Exécuter la commande sur un JPEG envoyé
Placez un vrai fichier JPEG nommé photo.jpg à côté du script, puis exécutez :
php -d extension=exif orient-jpeg.php photo.jpg photo-fixed.jpg
Une exécution réussie affiche Saved: suivi du chemin de sortie absolu et se termine avec le code
de sortie 0. En cas d’échec, un diagnostic est écrit sur la sortie d’erreur standard et la commande
se termine avec le code 1. Si vous relancez la même commande, elle refuse d’écraser photo-fixed.jpg.
Mettez entre guillemets les chemins contenant des espaces.
Inspectez les métadonnées obtenues :
exiftool -n -IFD0:Orientation -ImageWidth -ImageHeight ./photo-fixed.jpg
La valeur Orientation doit être 1. Les orientations 5–8 échangent la largeur et la hauteur. Ouvrez le fichier de sortie et vérifiez un élément asymétrique, comme du texte, pour confirmer à la fois sa rotation et l’absence d’effet miroir. Retraiter ce fichier de sortie vers un autre nom de fichier devrait laisser la géométrie de l’image inchangée.
Gérer les fichiers refusés et les traitements par lots
Un diagnostic « transformation is not perfect » signifie que l’opération demandée ne peut pas
transformer chaque bloc de bord. Une rotation de 90° d’un JPEG ayant un bloc inférieur partiel en est
un exemple. Conservez l’original et décidez si votre application autorise un recadrage ou un dérivé
décodé puis réencodé. Supprimer -perfect modifie silencieusement cette décision et peut laisser une
bande de bord mal orientée.
Les fichiers vides, les fichiers non JPEG, les données d’image tronquées et les valeurs d’orientation invalides provoquent un échec au lieu de produire un message de succès. Les JPEG progressifs sont acceptés en entrée ; cette commande ne demande pas de sortie progressive. Elle ne garantit pas non plus la conversion vers un profil de compatibilité JPEG particulier.
Pour une file d’attente ou un lot, invoquez la commande une fois par entrée avec une destination distincte et enregistrez chaque code de sortie. Ne relancez les fichiers refusés qu’après avoir choisi comment gérer leur échec. Gardez ces tâches en dehors de la requête HTTP : appliquez des limites de taille d’envoi et de ressources, conservez les diagnostics des outils dans les journaux du worker et renvoyez un simple message d’échec au client. La commande locale couvre la normalisation de l’orientation, mais pas l’authentification des envois ni les délais d’expiration des workers.
