<?php
/**
 * This file is part of the transcoding library. It contains the
 * definition of the {@link TranscodingActionResizeIMG} class.
 * 
 * @author Sylvain Lequeux
 * @author Francois Daoust <fd@w3.org>
 * @package TransPythia
 * @version $Revision: 1.44 $
 * @license http://www.w3.org/Consortium/Legal/2002/copyright-software-20021231.html W3C Software Notice and License
 * @copyright Copyright (c) 2009, W3C (MIT, ERCIM, Keio)
 */


/**
 * Include the {@link TranscodingAction} base class definition.
 */
require_once(dirname(__FILE__) . '/transcodingaction.php');



/**
 * Transcoding action that resizes images that appear in the given HTML content
 * to match the requesting device's list of supported image formats and screen
 * size.
 * 
 * A few options may be set through a call to
 * {@link TranscodingAction::setOption()} as needed:
 * - mobile_device: contains the DDR property reference that the transcoding
 *   uses to tell whether the requesting device is mobile or not. The action
 *   uses the is_wireless_device property of the WURFL namespace when the option
 *   is not set.
 * - image_format_support: contains the DDR property reference that the action
 *   uses to know the list of image formats supported by the requesting device.
 *   The action uses the imageFormatSupport property of the DDR Core Vocabulary
 *   when the option is not set.
 * - resolution_width: contains the DDR property reference that the action
 *   uses to know the width of the screen of the requesting device.
 *   The action uses the displayWidth property of the DDR Core Vocabulary
 *   when the option is not set.
 * - resolution_height: contains the DDR property reference that the action
 *   uses to know the height of the screen of the requesting device.
 *   The action uses the displayHeight property of the DDR Core Vocabulary
 *   when the option is not set.
 * - purge: purge the file cache when true. False by default.
 * - ratio: keep the ratio of the original image when true. True by default.
 * - img_cache: File path to the folder that is to contain adapted images.
 * - img_cache_uri: URI path to the cache folder.
 * - uri_mappings => The list of URI mappings to use to convert an absolute HTTP
 *   URI to a local file. Mappings must be separated by a space. Each mapping
 *   consists of a root URI and a root folder separated by a '|'.
 *   Ex: http://example.com/img/|/var/www/img/
 * - max_image_size: maximum image size in bytes. Images that are still bigger
 *   than this size after conversion are removed from the content.
 * 
 * This transcoding action does two jobs in practice:
 * - it resizes and saves the images that appear in the given HTML content.
 *   Images are saved in the cache subfolder (the code must have write
 *   access to that folder!)  
 * - it updates the img tag definitions in the HTML content with the new
 *   src, width and height attribute values. 
 *   
 * Image formats that are not supported by the requesting device and that
 * cannot be converted to another format are removed. 
 * 
 * @author Sylvain Lequeux
 * @author Francois Daoust <fd@w3.org>
 * @package TransPythia
 * @version $Revision: 1.44 $
 * @license http://www.w3.org/Consortium/Legal/2002/copyright-software-20021231.html W3C Software Notice and License
 * @copyright Copyright (c) 2009, W3C (MIT, ERCIM, Keio)
 */
class TranscodingActionResizeIMG extends TranscodingAction{
	/**
	 * @var int Default maximum image size in bytes. Bigger images get removed.
	 */
	static private $DEFAULT_MAX_IMAGE_SIZE = 10240;
	
	/**
	 * @var int screen width of the requesting device in pixels 
	 */
	private $screenWidth;
	/**
	 * @var int screen height of the requesting device in pixels 
	 */
	private $screenHeight;
	/**
	 * @var array(string) the list of image formats supported by the requesting device 
	 */
	private $supportedFormats;
	/**
	 * @var array(string=>string) List of URI mappings to convert HTTP URIs
	 *   to local files.
	 */
	private $uriMappings = array();
	
	/**
	 * Process and adapt images that appear in the given HTML content to
	 * match the requesting device's capabilities.
	 * 
	 * @param Evidence $evidence The evidence that identifies the requesting device.
	 * @param $content string The HTML content to transcode.
	 * @return string The transcoded content.
	 * @exception SystemException The evidence is not valid.
	 */
	public function apply($content, $evidence){
		$this->initPropertyValues($evidence);
		
		$this->initproperty('mobile_device',
			TranscodingAction::$WURFL_MOBILE_DEVICE,
			TranscodingAction::$WURFL_VOCABULARY,
			TranscodingAction::$WURFL_DEFAULT_ASPECT);
			
		$this->initProperty('image_format_support',
			'imageFormatSupport',
			TranscodingAction::$CORE_VOCABULARY,
			'webBrowser');
		$this->initProperty('resolution_width',
			'displayWidth',
			TranscodingAction::$CORE_VOCABULARY,
			'device');
		$this->initProperty('resolution_height',
			'displayHeight',
			TranscodingAction::$CORE_VOCABULARY,
			'device');
		
		$this->initOption('max_image_size', 'int', self::$DEFAULT_MAX_IMAGE_SIZE);
		$this->initOption('keep_ratio', 'bool', true);	
		
		// Unless otherwise mentioned, we'll consider the transcoding action
		// is run in the context of an HTTP request. Note it is not mandatory.
		if ($_SERVER) {
			$baseUri = $_SERVER['REQUEST_URI'];
		}
		if (!$baseUri) {
			$baseUri = "";
		}
		$this->initOption('base_uri', 'string', $baseUri);
		$this->initOption('uri_mappings', 'string', $baseUri . '|' . $baseUri);
		
		// Make sure paths contain a final '/'
		$this->initOption('img_cache', 'string', 'cache/');
		$path = $this->getOption('img_cache');
		$lastChar = substr($path, strlen($path) - 1);
		if($lastChar != '/' && $lastChar != '\\'){
			$path .= '/';
			$this->setOption('img_cache', $path);
		}
		
		$this->initOption('img_cache_uri', 'string', '');
		$path = $this->getOption('img_cache_uri');
		$lastChar = substr($path, strlen($path) - 1);
		if (($lastChar != '/')
		&& ($lastChar != '\\')
		&& ($lastChar != '=')) {
			// The cache URI may be a URI that ends up with a query string,
			// hence the check on '='
			$path .= '/';
			$this->setOption('img_cache_uri', $path);
		}
		
		// Purge the cache if requested
		$this->initOption('purge', 'bool', false);
		if($this->getOption('purge')){
			$this->purgeCache();
		}
		
		// The transcoding action only applies to devices identified as mobile
		// (note format conversion could benefit all sorts of devices though)  
		$property = $this->getOption('mobile_device');
		$is_mobile_device = $this->getPropertyValuePr($property);
		if(!isset($is_mobile_device) || !$is_mobile_device->getBoolean()){
		  	return $content;
		}
		
		$this->uriMappings = array();
		$mappings = explode(' ', $this->getOption('uri_mappings'));
		foreach ($mappings as $mapping) {
			$maparray = explode('|', $mapping);
			$this->uriMappings[$maparray[0]] = $maparray[1];
		}
		
		// Analyse and adapt images as needed
		return $this->adaptImages($content);
	}

	/**
	 * Checks whether the image format is in the list of image formats supported
	 * by the requesting device.
	 * 
	 * @param string $format the format of the image as returned by the getimagesize function.
	 * @return bool true when the format is supported, false otherwise.
	 */
	private function isFormatSupported($format) {
		$formatStr = strtolower($this->imageFormat2Str($format));
		foreach ($this->supportedFormats as $supportedFormat) {	
			if ($formatStr == strtolower($supportedFormat)) {
				return true;
			}
		}
		return false;
	}
	
	/**
	 * Determines the optimal image format to use to serve an image.
	 * 
	 * @param mixed $format the current format of the image
	 * @return string The optimal image format to use to serve the image
	 */
	private function computeAdaptedImageFormat($format){
		if ($this->isFormatSupported($format)) {
			return $format;
		}
		else {
			return $this->supportedFormats[0];
		}
	}

	/**
	 * Converts an image format to its string representation.
	 * 
	 * @param mixed $format The format in the enumerate format.
	 * @return string The string representation of the image format.
	 */
	private function imageFormat2Str($format){
		switch($format){
			case IMAGETYPE_GIF: {
				return 'gif';
			}
			case IMAGETYPE_JPEG: {
				return 'jpg';
			}
			case IMAGETYPE_PNG: {
				return 'png';
			}
			default: {
				return NULL;
			}
		}
	}
	
	
/**
	 * Converts an image format described as a string to its equivalent using
	 * one of the available PHP contants.
	 * 
	 * @param mixed $format The format in the enumerate format.
	 * @return string The string representation of the image format.
	 */
	private function imageStr2Format($format){
		switch($format){
			case 'gif': {
				return IMAGETYPE_GIF;
			}
			case 'jpg': {
				return IMAGETYPE_JPEG;
			}
			case 'jpeg': {
				return IMAGETYPE_JPEG;
			}
			case 'png': {
				return IMAGETYPE_PNG;
			}
			default: {
				return NULL;
			}
		}
	}
	
	/**
	 * Computes the dimension of the image to display taking into account the
	 * dimensions of the original image, its width/height ratio and the
	 * dimensions of the screen of the requesting device.
	 * 
	 * @param int $imageWidth The width of the original image
	 * @param int $imageHeight The height of the original image
	 * @return array(int,int) The width and height of the image to display  
	 */
	private function computeAdaptedImageDimensions($imageWidth, $imageHeight) {
		$keepRatio = $this->getOption('keep_ratio');
		
		$adaptedWidth = $imageWidth;
		$adaptedHeight = $imageHeight;
		
		if ($this->screenWidth < $imageWidth) {
			$adaptedWidth = $this->screenWidth;
			if ($keepRatio) {
				$adaptedHeight = intVal($imageHeight * $adaptedWidth / $imageWidth);
			}
		}
		if ($this->screenHeight < $imageHeight) {
			$adaptedHeight = $this->screenHeight;
			if ($keepRatio) {
				$adaptedWidth = intVal($imageWidth * $adaptedHeight / $imageHeight);
			}
		}
		
		return array($adaptedWidth, $adaptedHeight);
	} 

	/**
	 * Computes the filename to use to save the image in the file cache.
	 * 
	 * @param string $fileName The absolute path to the initial image
	 * @param int $width The adapted image width.
	 * @param int $height The adapted image height.
	 * @param int $format The adapted image format (using one of the IMAGETYPE_[FOO] constants).
	 * @return string The filename to use to save the image in the file cache
	 */
	private function getCacheName($fileName, $width, $height, $format){
		$cacheFileName = md5_file($fileName)
			. '_' . $width
			. '_' . $height
			. '.' . $this->imageFormat2Str($format);
		return $cacheFileName;
	}
	
	/**
	 * Adapts the image to the requested dimensions and format, and saves the image in the cache.
	 * 
	 * @param string $fileName The absolute path to the image to adapt.
	 * @param int $fileInfo Information about current image, as returned by getimagesize.
	 *   The info will be computed if it's not provided
	 * @param int $width The requested image width.
	 * @param int $height The requested image height.
	 * @param int $format The requested image format (using one of the IMAGETYPE_[FOO] constants).
	 * @return string The full path to the adapted image in the cache. The image is not re-created
	 *    if it already exists!
	 */
	private function createAdaptedImage($fileName, $fileInfo, $width, $height, $format) {	
		if (!$fileInfo) {
			$fileInfo = getimagesize($fileName);
		}

		// Compute the name of the adapted image
		$filename = $this->getCacheName($fileName, $width, $height, $format);
		$fullfilename = $this->getOption('img_cache') . $filename;
		
		// Return if the image has already been converted
		if (file_exists($fullfilename)) {
			return $fullfilename;
		}
		
		// Make sure we can create an image in the cache
		if (!is_writable($this->getOption('img_cache'))) {
			throw new SystemException(
				'The action requires write access rights to the cache directory: "'
					. $this->getOption('img_cache') . '".',
				SystemException::$CANNOT_PROCEED);
		}

		// Create an empty image of the requested dimensions
		$im = imagecreatetruecolor($width, $height);
		
		// Load the original image.
		$source = NULL;
		switch ($fileInfo[2]) {
			case IMAGETYPE_GIF :{
				$source = imagecreatefromgif($fileName);
				break;
			}
			case IMAGETYPE_JPEG :{
				$source = imagecreatefromjpeg($fileName);
				break;
			}
			case IMAGETYPE_PNG :{
				$source = imagecreatefrompng($fileName);
				break;
			}
			default : {
				throw new SystemException(
					'Unsupported image format: ' . $image_size[2],
					SystemException::$CANNOT_PROCEED);
			}
		}

		// Resize the image as requested
		imagealphablending($im, false);
		imagesavealpha($im, true);
		imagecopyresampled($im, $source,
			0, 0,
			0, 0,
			$width, $height,
			$fileInfo[0], $fileInfo[1]);
		imagealphablending($im, true);
		
		// Save the adapted image using the requested format
		switch ($format) {
			case IMAGETYPE_GIF: {
				imagegif($im, $fullfilename);
				break;
			}
			case IMAGETYPE_JPEG: {
				imagejpeg($im, $fullfilename);
				break;
			}
			case IMAGETYPE_PNG: {
				imagepng($im, $fullfilename);
				break;
			}
			default : {
				throw new SystemException(
					'Format '.$format.' (IMAGETYPE enum) is not supported!',
					SystemException::$CANNOT_PROCEED);
			}
		}
		return $fullfilename;
	}

	/**
	 * Adapts an image to fit the properties of the requesting device.
	 * 
	 * @param string $src The path to the original image, as it appears in the
	 *   markup. The path may be an absolute file path, a relative file path,
	 *   or an absolute HTTP URI.
	 * @return string The file name of the adapted image in the cache directory
	 *   if the image was adapted, an empty string if the image should be
	 *   deleted, NULL otherwise.
	 */
	private function adaptImage($src) {
		// Convert the source to a local path if possible
		$fileName = $this->mapUriToFile(
			$src,
			$this->getOption('base_uri'),
			$this->uriMappings);

		$adaptedImage = NULL;
		if ($fileName && file_exists($fileName)) {
			// Retrieve image dimensions
			$imageInfo = getimagesize($fileName);
			
			// Compute adapted image's dimensions
			$adaptedDimensions = $this->computeAdaptedImageDimensions(
				$imageInfo[0], $imageInfo[1]);

			// Compute the adapted image format
			// (current image format is preferred when possible)
			$adaptedFormat = $this->computeAdaptedImageFormat($imageInfo[2]);
			
			if (($adaptedDimensions[0] != $imageInfo[0])
			|| ($adaptedDimensions[1] != $imageInfo[1])
			|| ($adaptedFormat != $imageInfo[2])) {
				// Image's dimensions or format need to be adapted to fit
				// the requesting device, let's give it a try!
				$adaptedFileName = $this->createAdaptedImage(
					$fileName, $imageInfo,
					$adaptedDimensions[0], $adaptedDimensions[1], $adaptedFormat);
				$imageInfo = getimagesize($adaptedFileName);
			}
			else {
				// No need to adapt the image's dimensions and format so far
				$adaptedFileName = $fileName;
			}
			
			// Check image size and reduce the size of the image if it's too big.
			// (300 bytes are added to the size of an image as an approximation
			// to the size of the HTTP headers that are returned when the image
			// is fetched over HTTP)
			$imageSize = filesize($adaptedFileName);
			$imageSize += 300;
			if ($imageSize > $this->getOption('max_image_size')) {
				$adaptedDimensions[0] = intVal($adaptedDimensions[0] / 2);
				$adaptedDimensions[1] = intVal($adaptedDimensions[1] / 2);
				
				$adaptedFileName = $this->createAdaptedImage(
					$adaptedFileName, $imageInfo,
					$adaptedDimensions[0], $adaptedDimensions[1], $adaptedFormat);
			}
			
			if ($adaptedFileName) {
				$mappings = array();
				$mappings[$this->getOption('img_cache_uri')] = $this->getOption('img_cache');
				$adaptedSrc = $this->mapFileToUri(
					$adaptedFileName,
					$this->getOption('base_uri'),
					$mappings);
				$adaptedImage = array($adaptedSrc, $adaptedDimensions[0], $adaptedDimensions[1]);
			}
		}
		
		return $adaptedImage;
	}

	/**
	 * Extracts and adapts images defined in the HTML content to fit
	 * the capabilities of the requesting device.
	 * 
	 * @param $content the html code to parse.
	 * @return the modified html code.
	 */
	private function adaptImages($content) {
		// Retrieve screen size (default to 240*320 if not known)
		$property = $this->getOption('resolution_width');
		$propertyValue = $this->getPropertyValuePr($property);
		if(!isset($propertyValue)){
			$this->screenWidth = 120;
		}
		else{
			$this->screenWidth = $propertyValue->getInteger();
		}
		
		$property = $this->getOption('resolution_height');
		$propertyValue = $this->getPropertyValuePr($property);
		if(!isset($propertyValue)){
			$this->screenHeight = 160;
		}
		else{
			$this->screenHeight = $propertyValue->getInteger();
		}

		// Retrieve the list of supported image formats
		$property = $this->getOption('image_format_support');
		$propertyValue = $this->getPropertyValuePr($property);
		$no_property = false;
		if(!isset($propertyValue)){
			$no_property = true;
		}
		$tmpEnum = $propertyValue->getEnumeration();
		$this->supportedFormats = array();
		foreach ($tmpEnum as $formatStr) {
			if (trim($formatStr) != '') {
				$format = $this->imageStr2Format($formatStr);
				if ($format) {
					$this->supportedFormats[] = $format;
				} 
			}
		}
		
		// Suppress </img> tags, we will generate clean <img /> markup ourselves.
		$content = preg_replace("/\<\/img\s?\>/Usi", "", $content);
		
		// Suppress all images when device does not support any image format
		if($no_property || (count($this->supportedFormats) == 0)){

			$content = preg_replace('/<img(\s[^>]*)?alt="([^"]*)"[^>]*>/Usi', '<span>\\2</span>', $content);
			$content = preg_replace("/<img(\s[^>]*)?alt='([^']*)'[^>]*>/Usi", '<span>\\2</span>', $content);
			$content = preg_replace('/<img(\s[^>]*)?alt=([^\s]*)\s[^>]*>/Usi', '<span>\\2</span>', $content);
			$content = preg_replace('/<img[^>]*>/Usi', '', $content);
			return $content;
		}
		
		// Parse the list of images in the page, and compute the adapted content
		$adaptedContent = '';
		$offset = 0;
		preg_match_all("/\<img(\s[^>]*)?src=((?:\'[^\']*\')|(?:\"[^\"]*\"))[^>]*\>/Usi",
			$content, $images, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);

		foreach ($images as $image) {
			$src = trim($image[2][0], '\'\"');
			
			// Adapt the image's format and dimensions as required, and if possible.
			$convertedImage = $this->adaptImage($src);
			
			if ($convertedImage) {
				// Update the source of the adapted image, if necessary.
				// Converted image is an array:
				//  0: the source of the adapted image
				//  1: the width of the adapted image
				//  2: the height of the adapted image
				// Make sure the width and height attributes are correctly set
				// (and remove invalid border attribute if needed)
				$imgMarkup = preg_replace('/\s(src|width|height|border)=((?:\'[^\']*\')|(?:\"[^\"]*\"))/Usi', '', $image[0][0]);
				$imgMarkup = preg_replace('|/?>$|Usi', '', $imgMarkup);
				$imgMarkup = '<img src="'
					. $convertedImage[0]
					. '" width="'
					. $convertedImage[1]
					. '" height="'
					. $convertedImage[2]
					. '"'
					. substr($imgMarkup, strlen('<img'))
					. '/>';
			}
			else {
				// Image conversion could not be done, replace the image by its
				// alt attribute when defined.
				preg_match('/ alt=((?:\'[^\']*\')|(?:\"[^\"]*\"))/Usi', $image[0][0], $alt);
				if ($alt) {
					$imgMarkup = '<span>' . trim($alt[1], '\'\"') . '</span>';
				}
				else {
					$imgMarkup = '';
				}
			}
			
			// Complete adapted content and move offset in $content to right
			// after the image that has just been processed.
			$adaptedContent .= substr($content, $offset, $image[0][1] - $offset)
				. $imgMarkup;
			$offset = $image[0][1] + strlen($image[0][0]);
		}
		
		// Add the rest of the content
		$adaptedContent .= substr($content, $offset);
		
		return $adaptedContent;
	}

	/**
	 * Purges the cache subfolder.
	 *  
	 * @return bool True when the cache could be purged. False otherwise.
	 * @exception SystemException The code does not have enough rights to purge the cache.
	 */
	private function purgeCache(){		
		if(!is_writable($this->getOption('img_cache'))) {
			throw new SystemException(
				'Not enough rights to purge the cache.',
				SystemException::$CANNOT_PROCEED);
		}
		$handle = opendir($this->getOption('img_cache'));
		while (false !== ($file = readdir($handle))) {
	        if($file != '.' && $file!='..' && $file!='.cache'){
				unlink($this->getOption('img_cache') .$file);
	        }
	    }
	    closedir($handle);
	    return true;
	}
}

?>