Corrige imágenes subidas giradas en PHP con jpegtran
Para corregir un JPEG subido que aparece de lado, transforma la imagen almacenada según su orientación EXIF y luego restablece esa orientación. El comando PHP que aparece a continuación guarda un JPEG independiente con la orientación correcta, sin otra compresión con pérdidas. Rechaza entradas dañadas y rotaciones que dejarían una franja sin corregir en un borde de la imagen.
Comprende los datos de orientación EXIF
Una etiqueta de orientación EXIF indica al visor cómo mostrar la imagen almacenada. Cambiar solo
esa etiqueta no gira los datos de la imagen; girar los datos sin restablecer la etiqueta puede
hacer que el visor vuelva a girarla. Lee la orientación IFD0 de la imagen
principal, no la de una miniatura incrustada.
Los ocho valores son importantes, incluidos los casos de imágenes reflejadas. Estas son las operaciones que se deben aplicar a la imagen almacenada, con los ángulos de rotación medidos en sentido horario:
| Valor EXIF | Corrección | Argumentos de jpegtran |
|---|---|---|
| 1 | Mantener la orientación | Sin transformación |
| 2 | Reflejar de izquierda a derecha | -flip horizontal |
| 3 | Girar hasta invertir | -rotate 180 |
| 4 | Reflejar de arriba abajo | -flip vertical |
| 5 | Reflejar sobre la diagonal de la esquina superior izquierda a la inferior derecha | -transpose |
| 6 | Girar 90° | -rotate 90 |
| 7 | Reflejar sobre la diagonal de la esquina superior derecha a la inferior izquierda | -transverse |
| 8 | Girar 270° | -rotate 270 |
La correspondencia sigue las definiciones de orientación de ExifTool. Cuando la imagen principal no tiene una etiqueta de orientación, este ejemplo no modifica su geometría. No puede inferir cómo debe orientarse una foto sin etiqueta. Un valor fuera del rango 1–8 es un error.
Usa jpegtran para rotar sin pérdidas
jpegtran reorganiza los coeficientes DCT cuantizados y evita el ciclo de
decodificación y recompresión JPEG de una rotación con GD o ImageMagick. Aquí, «sin pérdidas»
describe esa transformación de coeficientes; los bytes y los metadatos del archivo de salida
cambiarán.
Las transformaciones perfectas dependen de los límites de los bloques JPEG.
-perfect rechaza una transformación no compatible;
-trim descartaría píxeles de los bordes. Usa también
-strict para tratar como errores las advertencias del decodificador,
incluidos los datos de imagen truncados. Estas opciones se documentan en la
guía de uso de libjpeg-turbo.
Este ejemplo crea una copia para visualización. -copy icc conserva el perfil
de color, pero descarta los demás metadatos de origen, incluidos GPS, detalles de la cámara,
campos de derechos de autor, XMP, comentarios y miniaturas incrustadas. ExifTool escribe después
un nuevo valor de 1 en EXIF Orientation. Conserva el original si necesitas sus metadatos.
Usar -copy all en su lugar requeriría una política independiente para los
campos de orientación y las vistas previas desactualizados.
Prepara la línea de comandos de Linux
Usa PHP CLI con su extensión EXIF, jpegtran de libjpeg-turbo y ExifTool,
instalados y disponibles en PATH. Este tutorial se probó en Linux con
PHP 8.5.10, libjpeg-turbo 3.2.0 y ExifTool 13.55. Usa un directorio local privado en un sistema de
archivos que admita enlaces duros para guardar la salida.
Comprueba tu instalación actual antes de guardar el 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
La opción -d extension=exif habilita una extensión EXIF compartida instalada para esta
invocación. Si EXIF ya está habilitada, omite esa opción en todos los comandos; cargarla dos veces
genera una advertencia. Si PHP no puede cargarla, instala la extensión correspondiente a tu
compilación de PHP antes de continuar. Consulta las
instrucciones de instalación de EXIF en PHP y las
opciones de configuración de la CLI.
Las comprobaciones de versión deben identificar libjpeg-turbo y ExifTool, sin advertencias de
inicio de PHP.
Guarda el comando PHP completo
Guarda lo siguiente como orient-jpeg.php. Acepta una ruta de entrada y una nueva
ruta de salida. Usa exif_read_data() con secciones
separadas y rechaza las advertencias de lectura de metadatos. Incluso los JPEG con orientación 1
y los que no tienen etiqueta pasan por jpegtran; la ausencia de una etiqueta
de orientación nunca justifica copiar bytes sin comprobarlos.
proc_open() con un array de argumentos ejecuta
las herramientas sin un shell. Las rutas de entrada se resuelven como rutas absolutas, de modo
que un nombre de archivo que empieza con un guion no se interpreta como una opción de la
herramienta. Elige las rutas en el código de tu aplicación al adaptar este comando a un worker;
el ejemplo no autoriza rutas proporcionadas por un cliente 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);
}
Solo se pasa el archivo temporal a la opción
-overwrite_original de ExifTool.
El archivo de origen permanece intacto.
link() de PHP crea el destino después de
que el procesamiento finaliza correctamente; se rechaza cualquier archivo o enlace simbólico
existente. Un bloque finally elimina el nombre temporal tanto en caso de
éxito como ante errores normales.
Ejecútalo con un JPEG subido
Coloca un archivo JPEG real llamado photo.jpg junto al script y luego ejecuta:
php -d extension=exif orient-jpeg.php photo.jpg photo-fixed.jpg
Una ejecución correcta imprime Saved: seguido de la ruta absoluta de
salida y termina con el código de estado 0. Los fallos escriben un diagnóstico en la salida de
error estándar y terminan con el código de estado 1. Si vuelves a ejecutar el mismo comando,
este se niega a sobrescribir photo-fixed.jpg. Pon entre comillas las rutas que
contengan espacios.
Inspecciona los metadatos resultantes:
exiftool -n -IFD0:Orientation -ImageWidth -ImageHeight ./photo-fixed.jpg
El valor esperado de Orientation es 1. Las orientaciones 5–8 intercambian el ancho y el alto. Abre la salida y comprueba un elemento asimétrico, como un texto, para confirmar tanto su rotación como que no esté reflejado. Volver a procesar esa salida con un nombre de archivo distinto debería dejar intacta la geometría de la imagen.
Gestiona archivos rechazados y tareas por lotes
El diagnóstico «transformation is not perfect» significa que la operación solicitada no puede
transformar todos los bloques de los bordes. Un ejemplo es la rotación de 90° de un JPEG con un
bloque inferior parcial. Conserva el original y decide si tu aplicación permite recortar o crear
una versión derivada decodificada y codificada de nuevo. Eliminar -perfect
cambia esa decisión de forma silenciosa y puede dejar una franja del borde mal orientada.
Los archivos vacíos, los archivos que no son JPEG, los datos de imagen truncados y los valores de orientación no válidos provocan un fallo en lugar de generar un mensaje de éxito. Se aceptan JPEG progresivos como entrada; este comando no solicita una salida progresiva. Tampoco promete la conversión a un perfil de compatibilidad JPEG específico.
Para una cola o un lote, invoca el comando una vez por cada entrada con un destino distinto y registra cada código de salida. Reintenta los archivos rechazados solo después de decidir cómo gestionar su fallo. Mantén estas tareas fuera de la solicitud HTTP: aplica límites de tamaño de subida y de recursos, conserva los diagnósticos de las herramientas en los registros del worker y devuelve un mensaje de error sencillo al cliente. El comando local se ocupa de normalizar la orientación, no de la autenticación de las subidas ni de los tiempos de espera del worker.
