Redimensión de imágenes proporcionalmente en PHP

Una necesidad que surge en todas las webs cuyos contenidos son introducidos por un usuario (o varios) a través de un gestor de contenidos, es adaptar las imágenes que estos suben al diseño de la misma para que no se deforme. Además, las proporciones de una imagen en una sección pueden ser diferentes a cómo se muestra en otra; un caso típico es usar una proporción para un listado y otra para la ficha de cada elemento, por ejemplo las noticias, los productos, etc.

Para resolver este problema sirve el conjunto de clases PHP que presentó en este artículo. Este software, a partir de una imagen original, creará una copia por cada tamaño que necesitemos. El proyecto completo puede verse en Github y el objetivo del presente artículo es explicar cómo funciona. En primer lugar, instanciamos la clase:

$objResize = Signia_ImageResize_Factory::getInstanceOf($srcFile, $destFile, $newSize);

Donde:

  • $srcFile es el path hacia la imagen que ha subido el usuario.
  • $desFile es el path de la imagen de destino.
  • $newSize es el tamaño deseado.

A partir de un array con los tamaños deseados podemos crear un bucle por cada uno de ellos ($newSize). Los índices widthMaxheigthMax hacen referencia al tamaño máximo permitido, mientras que width y height pueden entenderse como widthMin y heightMin. Veamos un ejemplo:

$imageType = [
  'slide' => ['width' => 1000, 'widthMax' => 1000, 'height' => 400, 'heightMax' => 400, 'background' => '000000'],
  'r2_34' => ['width' => 468, 'widthMax' => 2340, 'height' => 200, 'heightMax' => 1000],
  'r2_7'  => ['width' => 1080, 'widthMax' => 2700, 'height' => 400, 'heightMax' => 1000],
  'r1_6'  => ['width' => 459, 'widthMax' => 1000, 'height' => 287, 'heightMax' => 625],
  'r1'    => ['width' => 266, 'widthMax' => 266, 'height' => 177, 'heightMax' => 177]
];

En Factory.php podemos ver la clase Signia_ImageResize_Factory:

class Signia_ImageResize_Factory
{
	static public function getInstanceOf($srcImageName, $destImageName, $newSize)
	{
		$aux       = explode(".", $destImageName);
		$extension = end($aux);
		if (preg_match("/jpg|JPG|jpeg|JPEG/", $extension)) {
			$extension = "jpeg";
		}
		$imageResizer = "Signia_ImageResize_" . ucfirst($extension);

		return new $imageResizer($srcImageName, $destImageName, $newSize);
	}
}

Su cometido es invocar la clase que corresponda según la extensión del fichero: Signia_ImageResize_Jpeg, Signia_ImageResize_Gif, etc. Todas ellas tienen el mismo objetivo: crear un recurso imagen a partir del fichero para, más adelante, poder manipular el tamaño de la imagen. Observemos el código de una de ellas, Signia_ImageResize_Jpeg:

class Signia_ImageResize_Jpeg extends Signia_ImageResize_Abstract
{
	function getResizedImage()
	{
		if (!file_exists($this->srcImageName)) {
                    return $this->error_message;
                }
      
                $this->srcImage = @imagecreatefromjpeg($this->srcImageName);
                if ($this->initCheck() && $this->sizeControl()) {
                      $this->destImage = imagecreatetruecolor($this->destWidth, $this->destHeight);
                      if ($this->background) {
                            $this->resizeImageWithBackground();
                      }else{
                           $this->resizeImage();
                      }
                      imagejpeg($this->destImage, $this->destImageName, 50);
         
                      return true;
                }		
	}
}

El tercer parámetro de imagejpeg() es un número entre 0 y 100 que especifica la calidad de la imagen. Obviamente, una mayor calidad implica también un fichero más grande. Google da mucha importancia a la velocidad de carga del sitio a la hora de situarlo entre los resultados de búsqueda, y con 50 se consigue un fichero de peso reducido sin sacrificar demasiada calidad. Esto es especialmente importante si tenemos en cuenta que muchos usuarios descuidan este tema, pudiendo incluso llegar a subir imágenes de más de un 1 MB (siendo esta práctica compatible con la quejas acerca de la velocidad de carga de su sitio).

Si el lector observa las clases Signia_ImageResize_Png y Signia_ImageResize_Gif, comprobará que hay unos extras para que, a pesar de la manipulación de la imagen, no se pierda la transparencia, efecto que sólo puede existir en estos dos formatos de imagen.

class Signia_ImageResize_Gif extends Signia_ImageResize_Abstract
{
   function getResizedImage()
   {
      if (!file_exists($this->srcImageName)) {
         return $this->error_message;
      }
      $this->srcImage = imagecreatefromgif($this->srcImageName);
      if ($this->initCheck() && $this->sizeControl()) {
         $transparency = imagecolortransparent($this->srcImage);
         if ($transparency != -1) {
            $this->destImage    = imagecreatetruecolor($this->aResult[0], $this->aResult[1]);
            $colorTransparent   = imagecolorsforindex($this->srcImage, $transparency);
            $idColorTransparent = imagecolorallocatealpha($this->destImage, $colorTransparent['red'], $colorTransparent['green'], $colorTransparent['blue'], $colorTransparent['alpha']);
            imagefill($this->destImage, 0, 0, $idColorTransparent);
            imagecolortransparent($this->destImage, $idColorTransparent);
         } else {
            $this->destImage = imagecreatetruecolor($this->aResult[0], $this->aResult[1]);
         }
         if ($this->background) {
            $this->resizeImageWithBackground();
         }else{
            $this->resizeImage(true);
         }
         imagegif($this->destImage, $this->destImageName);
         return true;
      }
   }
}

Es desde estas clases donde se llama a la parte más compleja del código, la que realmente redimensiona la imagen: Abstract.php. Básicamente, la imagen que sube el usuario puede diferir en cuanto a las proporciones deseadas en si el ratio de la imagen original es menor o mayor. De no diferenciar el proceso para actuar de forma disitinta en cada caso, algunas imágenes se deformarán, tal y como pasa en los ejemplos que he visto en Stack Overflow sobre el tema. Para entender cómo funciona, bastará con explicar uno sólo de los casos: cuando el destino es mayor. Primero, veamos el contenido de la clase:

abstract class Signia_ImageResize_Abstract
{

   protected $srcImage;
   protected $srcImageName;
   protected $srcWidth;
   protected $srcHeight;
   protected $srcRatio;
   protected $destImage;
   protected $destImageName;
   protected $destWidth;
   protected $destHeight;
   protected $destRatio;
   protected $widthMin;
   protected $heightMin;
   protected $widthMax;
   protected $heightMax;
   protected $background;
   protected $bincolor;
   protected $error;
   public $error_message;
   protected $aMessages = array(
       'src_not_found'      => "El fichero de origen no se encuentra",
       'not_enought_params' => "No se han definido los tamaños de salida",
       'size_values'        => "La imagen es demasiado pequeña para generar las copias",
       'ratio'              => "No se ha podido calcular el ratio destino de la copia"
   );
   protected $aResult   = [];

   public function __construct($srcImageName, $destImageName, $newSize)
   {
      $this->srcImageName   = $srcImageName;
      $this->destImageName  = $destImageName;
      $this->widthMin       = (!isset($newSize['width'])) ? 1 : $newSize['width'];
      $this->heightMin      = (!isset($newSize['height'])) ? 1 : $newSize['height'];
      $this->widthMax       = (!isset($newSize['widthMax']) || $newSize['widthMax'] > 8192) ? 8192 : $newSize['widthMax'];
      $this->heightMax      = (!isset($newSize['heightMax']) || $newSize['heightMax'] > 6144) ? 6144 : $newSize['heightMax'];
      $this->background     = (!isset($newSize['background'])) ? null : $newSize['background'];
      $this->sizeControlled = (!isset($newSize['sizeControlled'])) ? true : $newSize['sizeControlled'];
      if ($this->background) {
         $this->setBincolor();
      }
   }

   public function __destruct()
   {
      if (isset($this->destImage) && is_resource($this->destImage)) {
         imagedestroy($this->destImage);
      }
      if (isset($this->srcImage) && is_resource($this->srcImage)) {
         imagedestroy($this->srcImage);
      }
   }

   /**
    * Si ponemos minWidth y width al mismo valor, e igualamos también minHeight y Height,
    * y la imagen que se sube no es del mismo ratio, saldrán franjas por los lados o arriba y abajo.
    * El background indica el color de estas franjas.
    */
   protected function resizeImageWithBackground()
   {
      if ($this->srcRatio > $this->destRatio) {
         $aux1 = floor($this->srcWidth / $this->destWidth * ($this->destHeight - $this->destWidth / $this->srcWidth * $this->srcHeight));
         $aux2 = floor(($this->destHeight - $this->destWidth / $this->srcWidth * $this->srcHeight) / 2);
         imagecopyresampled($this->destImage, $this->srcImage, 0, 0, 0, -$aux1 / 2, $this->destWidth, $this->destHeight, $this->srcWidth, $this->srcHeight + $aux1);
         $aux3 = imagecolorallocate($this->destImage, $this->bincolor[0], $this->bincolor[1], $this->bincolor[2]);
         imagefilledrectangle($this->destImage, 0, 0, $this->destWidth, $aux2 + 1, $aux3);
         imagefilledrectangle($this->destImage, 0, $this->destHeight - $aux2 - 1, $this->destWidth, $this->destHeight, $aux3);
      } elseif ($this->srcRatio < $this->destRatio) {
         $aux1 = floor($this->srcHeight / $this->destHeight * ($this->destWidth - $this->destHeight / $this->srcHeight * $this->srcWidth));
         $aux2 = floor(($this->destWidth - $this->destHeight / $this->srcHeight * $this->srcWidth) / 2);
         imagecopyresampled($this->destImage, $this->srcImage, 0, 0, -$aux1 / 2, 0, $this->destWidth, $this->destHeight, $this->srcWidth + $aux1, $this->srcHeight);
         $aux3 = imagecolorallocate($this->destImage, $this->bincolor[0], $this->bincolor[1], $this->bincolor[2]);
         imagefilledrectangle($this->destImage, 0, 0, $aux2 + 1, $this->destHeight, $aux3);
         imagefilledrectangle($this->destImage, $this->destWidth - $aux2 - 1, 0, $this->destWidth, $this->destHeight, $aux3);
      }
   }

   /**
    * @param boolean $transparent Para preservar transparencia (imágenes gif y png)
    */
   protected function resizeImage($transparent = false)
   {
      if ($this->srcRatio > $this->destRatio) { // En el original hay más width por cada píxel de height.
         $canvas = imagecreatetruecolor($this->destHeight * $this->srcRatio, $this->destHeight);
         if ($transparent) {
            imagealphablending($canvas, false);
            imagesavealpha($canvas, true);
         }
         $destWidth = $this->destHeight * $this->srcRatio;
         // $canvas contendrá la imagen original con el height del destino y el width recortado proporcionalmente según srcRatio:
         imagecopyresampled($canvas, $this->srcImage, 0, 0, 0, 0, $destWidth, $this->destHeight, $this->srcWidth, $this->srcHeight);
         $auxWidth  = $this->destHeight * $this->srcRatio; // $canvas no es recortada desde x = 0, es decir, no se recorta sólo por la derecha sino también por la izquierda
         imagecopyresampled($this->destImage, $canvas, 0, 0, ($auxWidth - $this->destWidth) / 2, 0, $this->destWidth, $this->destHeight, $this->destWidth, $this->destHeight);
      } elseif ($this->srcRatio < $this->destRatio) { // Menos width por cada height en el original.
         $inverseSrcRatio = $this->srcHeight / $this->srcWidth;
         $canvas          = imagecreatetruecolor($this->destWidth, $this->destWidth * $inverseSrcRatio);
         if ($transparent) {
            imagealphablending($canvas, false);
            imagesavealpha($canvas, true);
         }
         imagecopyresampled($canvas, $this->srcImage, 0, 0, 0, 0, $this->destWidth, $this->destWidth * $inverseSrcRatio, $this->srcWidth, $this->srcHeight);
         $auxHeight = $this->destWidth * $inverseSrcRatio;
         imagecopyresampled($this->destImage, $canvas, 0, 0, 0, ($auxHeight - $this->destHeight) / 2, $this->destWidth, $this->destHeight, $this->destWidth, $this->destHeight);
      } else {
         imagecopyresampled($this->destImage, $this->srcImage, 0, 0, 0, 0, $this->destWidth, $this->destHeight, $this->srcWidth, $this->srcHeight);
      }
   }

   protected function initCheck()
   {
      if (!file_exists($this->srcImageName)) {
         $this->error         = true;
         $this->error_message = $this->aMessages['src_not_found'];
         return false;
      }
      if (!$this->widthMax && !$this->heightMax && !$this->widthMin && !$this->heightMin) {
         $this->error         = true;
         $this->error_message = $this->aMessages['not_enought_params'];
         return false;
      }
      if (!is_resource($this->srcImage)) {
         return false;
      }
      $this->srcWidth  = imagesx($this->srcImage);
      $this->srcHeight = imagesy($this->srcImage);
      $this->error     = false;
      return true;
   }

   protected function sizeControl()
   {
      if (!isset($this->background) && $this->sizeControlled) {
         if ($this->srcWidth < $this->widthMin || $this->srcHeight < $this->heightMin) {
            $this->error         = true;
            $this->error_message = $this->aMessages['size_values'];
            return false;
         }
      }
      // Resolución de incongruencias del tamaño solicitado en $imageType
      if ($this->widthMin > $this->widthMax) {
         $this->widthMin = $this->widthMax;
      }
      if ($this->heightMin > $this->heightMax) {
         $this->heightMin = $this->heightMax;
      }
      // Fin resolución.
      $widthAux  = $this->srcWidth;
      $heightAux = $this->srcHeight;
      //Primero recorta por el ancho:
      if ($this->srcWidth > $this->widthMax) {
         $widthAux  = $this->widthMax;
         $heightAux *= $this->widthMax / $this->srcWidth;
      }
      /* Después por el alto. Si la altura inicial es inferior a la mínima después 
       * de haber sido recortada heightAux = heightMin y widthAux = ...
       */
      if ($heightAux < $this->heightMin) {
         $heightAux = $this->heightMin;
         // Si no, si el alto es superior al máximo, se recorta, volviendo a adaptar el width.
      } elseif ($heightAux > $this->heightMax) {
         if (($this->heightMax / $heightAux) * $widthAux < $this->widthMin) { // Siempre >= 1
            $widthAux = $this->widthMin;
         } else {
            $widthAux *= $this->heightMax / $heightAux;
         }
         $heightAux = $this->heightMax;
      }
      $this->srcRatio   = $this->srcWidth / $this->srcHeight;
      $this->destRatio  = $widthAux / $heightAux;
      $this->destWidth  = (int) $widthAux;
      $this->destHeight = (int) $heightAux;
      return $this;
   }

   private function setBincolor()
   {
      $hexcolor       = str_split($this->background, 2);
      $bincolor[0]    = hexdec('0x{' . $hexcolor[0] . '}');
      $bincolor[1]    = hexdec('0x{' . $hexcolor[1] . '}');
      $bincolor[2]    = hexdec('0x{' . $hexcolor[2] . '}');
      $this->bincolor = $bincolor;
      return $this;
   }
}

Este es un esquema del proceso:

El rectángulo más grande representa la imagen original, mientras el rectángulo más pequeño que contiene representa el tamaño deseado, cada uno tiene representado su ratio o razón en forma de diagonal. En el método resizeImage() se hace, en primer lugar, una redimensión de la imagen en donde la altura pasa a ser la de la imagen de destino y el ancho se adapta proporcionalmente, la imagen resultante se guarda en la variable de tipo recurso $canvas. A continuación, se procede a recortar, pero no desde la esquina inferior izquierda, sino desde un punto x al que se le suma $this->destWidth, de este modo, se recorta la misma cantidad por el lado izquierdo por el derecho. La razón de esto es que las imágenes tienden a tener el objeto principal en el centro.

El esquema también incluye un ejemplo numérico en el que la imagen original tiene un ratio de 1’3 periódico, es decir, unos 13 píxeles de ancho por cada 10 de alto (no tiene mucho sentido hablar de 1’3 píxeles pues es una unididad indivisible), mientras que el ratio deseado es de 0’665.

Cuando el destino tiene un ratio mayor, podemos ver en el siguiente esquema que la idea es la misma, sólo que en el redimensionamiento inicial lo que se adapta proporcionalmente es el alto a partir de un ancho que se fija:

Esquema reducción imagenEl método initCheck() comprueba, en primer lugar, que exista la imagen original para, a continuación comprobar que el array $imageType tiene todos los parámetros esperados. Finalmente, obtiene el ancho y el alto de la imagen original.

El objetivo del método sizeControl() consiste en calcular el ancho y el alto de la imagen de destino, así como el ratio de esta y la original. Previamente, resuelve incongruencias en los parámetros, como, por ejemplo, que el ancho mínimo tolerado (widthMin) sea menor que el máximo (widthMax).

Veamos a continuación un ejemplo: los tamaños r1, r1_6  y slide que aparecen al principio del presente artículo a partir esta imagen:

Júpiter

Hacer click para obtener la imagen original, aunque WordPress redujo algo su peso.

Tamaño r1_6

Tamaño r1_6. Si hacemos click sobre la imagen podremos apreciar que su tamaño coincide con el especificado y que el peso del fichero se ha reducido mucho, más allá de lo que sería proporcional a la reducción de dimensiones, sin afectar, a simple vista, a la calidad de la imagen.

Tamaño r1

Tamaño r1

El comportamiento es diferente cuando, por un lado existe el parámetro background y, por el otro, height y maxHeight valen lo mismo, así como width y maxWidth, pero la imagen subida no es del mismo ratio. En este caso, aparecerán unas franjas horizontales o verticales en el color de este parámetro, lo cual es útil en circuntancias en que queremos que la imagen que se visualiza en la web sea de un tamaño exacto, como podría ser, por ejemplo, en el caso de slides.

Júpiter slide

Generalmente, es conveniente que el color de las franjas coincida con el color de fondo de la web, aunque en este caso se ha escogido el negro.