<?php
/**
 * PHP JPEG ICC profile manipulator class
 *
 * @author Richard Toth aka risko (risko@risko.org)
 * @version 0.2
 * License: BSD License
 *
 * http://sourceforge.net/projects/jpeg-icc/
 * http://jpeg-icc.sourceforge.net/
 * https://github.com/slavicv/jpeg-icc
 
 */
class JPEG_ICC
{
	/**
     * ICC header size in APP2 segment
	 *
	 * 'ICC_PROFILE' 0x00 chunk_no chunk_cnt
     */
    const ICC_HEADER_LEN = 14;

    /**
     * maximum data len of a JPEG marker
     */
    const MAX_BYTES_IN_MARKER = 65533;

	/**
	 * ICC header marker
	 */
	const ICC_MARKER = "ICC_PROFILE\x00";

	/**
	 * Rendering intent field (Bytes 64 to 67 in ICC profile data)
	 */
	const ICC_RI_PERCEPTUAL = 0x00000000;
	const ICC_RI_RELATIVE_COLORIMETRIC = 0x00000001;
	const ICC_RI_SATURATION = 0x00000002;
	const ICC_RI_ABSOLUTE_COLORIMETRIC = 0x00000003;

	/**
	 * ICC profile data
	 * @var		string
	 */
    private $icc_profile = '';

	/**
	 * ICC profile data size
	 * @var		int
	 */
    private $icc_size = 0;
	/**
	 * ICC profile data chunks count
	 * @var		int
	 */
    private $icc_chunks = 0;


	/**
	 * Class contructor
	 */
    public function  __construct()
    {
    }

	/**
	 * Load ICC profile from JPEG file.
	 *
	 * Returns true if profile successfully loaded, false otherwise.
	 *
	 * @param		string		file name
	 * @return		bool
	 */
    public function LoadFromJPEG($fname)
    {
		$f = file_get_contents($fname);
		$len = strlen($f);
		$pos = 0;
		$counter = 0;
		$profile_chunks = array(); // tu su ulozene jednotlive casti profilu

		while ($pos < $len && $counter < 1000)
		{
			$pos = strpos($f, "\xff", $pos);
			if ($pos === false) break; // dalsie 0xFF sa uz nenaslo - koniec vyhladavania

			$type = $this->getJPEGSegmentType($f, $pos);
			switch ($type)
			{
				case 0xe2: // APP2
					//echo "APP2 ";
					$size = $this->getJPEGSegmentSize($f, $pos);
					//echo "Size: $size\n";
					
					if ($this->getJPEGSegmentContainsICC($f, $pos, $size))
					{
						//echo "+ ICC Profile: YES\n";
						list($chunk_no, $chunk_cnt) = $this->getJPEGSegmentICCChunkInfo($f, $pos);
						//echo "+ ICC Profile chunk number: $chunk_no\n";
						//echo "+ ICC Profile chunks count: $chunk_cnt\n";

						if ($chunk_no <= $chunk_cnt)
						{
							$profile_chunks[$chunk_no] = $this->getJPEGSegmentICCChunk($f, $pos);

							if ($chunk_no == $chunk_cnt) // posledny kusok
							{
								ksort($profile_chunks);
								$this->SetProfile(implode('', $profile_chunks));
								return true;
							}
						}
					}
					$pos += $size + 2; // size of segment data + 2B size of segment marker
					break;

				case 0xe0: // APP0
				case 0xe1: // APP1
				case 0xe3: // APP3
				case 0xe4: // APP4
				case 0xe5: // APP5
				case 0xe6: // APP6
				case 0xe7: // APP7
				case 0xe8: // APP8
				case 0xe9: // APP9
				case 0xea: // APP10
				case 0xeb: // APP11
				case 0xec: // APP12
				case 0xed: // APP13
				case 0xee: // APP14
				case 0xef: // APP15
				case 0xc0: // SOF0
				case 0xc2: // SOF2
				case 0xc4: // DHT
				case 0xdb: // DQT
				case 0xda: // SOS
				case 0xfe: // COM
					$size = $this->getJPEGSegmentSize($f, $pos);
					$pos += $size + 2; // size of segment data + 2B size of segment marker
					break;

				case 0xd8: // SOI
				case 0xdd: // DRI
				case 0xd9: // EOI
				case 0xd0: // RST0
				case 0xd1: // RST1
				case 0xd2: // RST2
				case 0xd3: // RST3
				case 0xd4: // RST4
				case 0xd5: // RST5
				case 0xd6: // RST6
				case 0xd7: // RST7
				default:
					$pos += 2;
					break;
			}
			$counter++;
		}

		return false;
    }

	/**
	 * Save previously loaded ICC profile into JPEG file.
	 *
	 * @param		string		JPEG file name
	 */
    public function SaveToJPEG($fname)
    {
		if ($this->icc_profile == '') throw new Exception("No profile loaded.\n");

		if (!file_exists($fname)) throw new Exception("File $fname doesn't exist.\n");
		if (!is_readable($fname)) throw new Exception("File $fname isn't readable.\n");
		$dir = realpath($fname);
		if (!is_writable($dir)) throw new Exception("Directory $fname isn't writeable.\n");

		$f = file_get_contents($fname);
		if ($this->insertProfile($f))
		{
			$fsize = strlen($f);
			$ret = file_put_contents($fname, $f);
			if ($ret === false || $ret < $fsize) throw new Exception ("Write failed.\n");
		}
    }

	/**
	 * Load profile from ICC file.
	 *
	 * @param		string		file name
	 */
    public function LoadFromICC($fname)
    {
		if (!file_exists($fname)) throw new Exception("File $fname doesn't exist.\n");
		if (!is_readable($fname)) throw new Exception("File $fname isn't readable.\n");

		$this->SetProfile(file_get_contents($fname));
    }

	/**
	 * Save profile to ICC file.
	 *
	 * @param		string		file name
	 * @param		bool		[force overwrite]
	 */
    public function SaveToICC($fname, $force_overwrite = false)
    {
		if ($this->icc_profile == '') throw new Exception("No profile loaded.\n");
		$dir = realpath($fname);
		if (!is_writable($dir)) throw new Exception("Directory $fname isn't writeable.\n");
		if (!$force_overwrite && file_exists($fname)) throw new Exception("File $fname exists.\n");

		$ret = file_put_contents($fname, $this->icc_profile);
		if ($ret === false || $ret < $this->icc_size) throw new Exception ("Write failed.\n");
	}

	/**
	 * Remove profile from JPEG file and save it as a new file.
	 * Overwriting destination file can be forced
	 *
	 * @param		string		source file
	 * @param		string		destination file
	 * @param		bool		[force overwrite]
	 * @return		bool
	 */
    public function RemoveFromJPEG($input, $output, $force_overwrite = false)
    {
		if (!file_exists($input)) throw new Exception("File $input doesn't exist.\n");
		if (!is_readable($input)) throw new Exception("File $input isn't readable.\n");
		$dir = realpath($output);
		if (!is_writable($dir)) throw new Exception("Directory $output isn't writeable.\n");
		if (!$force_overwrite && file_exists($output)) throw new Exception("File $output exists.\n");

		$f = file_get_contents($input);
		$this->removeProfile($f);
		$fsize = strlen($f);
		$ret = file_put_contents($output, $f);
		if ($ret === false || $ret < $fsize) throw new Exception ("Write failed.\n");

		return true; // any other error throws exception
    }

	/**
	 * Set profile directly
	 *
	 * @param		string		profile data
	 */
    public function SetProfile($data)
    {
		$this->icc_profile = $data;
		$this->icc_size = strlen($data);
		$this->countChunks();
    }

	/**
	 * Get profile directly
	 *
	 * @return		string
	 */
    public function GetProfile()
    {
		return $this->icc_profile;
    }

	/**
	 * Count in how many chunks we need to divide the profile to store it in JPEG APP2 segments
	 */
    private function countChunks()
    {
		$this->icc_chunks = ceil($this->icc_size / ((float) (self::MAX_BYTES_IN_MARKER - self::ICC_HEADER_LEN)));
    }

	/**
	 * Set Rendering Intent of the profile.
	 *
	 * Possilbe values are ICC_RI_PERCEPTUAL, ICC_RI_RELATIVE_COLORIMETRIC, ICC_RI_SATURATION or ICC_RI_ABSOLUTE_COLORIMETRIC.
	 * 
	 * @param		int		rendering intent
	 */
	private function setRenderingIntent($newRI)
	{
		if ($this->icc_size >= 68)
		{
			substr_replace($this->icc_profile, pack('N', $newRI), 64, 4);
		}
	}

	/**
	 * Get value of Rendering Intent field in ICC profile
	 *
	 * @return		int
	 */
	private function getRenderingIntent()
	{
		if ($this->icc_size >= 68)
		{
			$arr = unpack('Nint', substr($this->icc_profile, 64, 4));
			return $arr['int'];
		}

		return null;
	}

	/**
	 * Size of JPEG segment
	 *
	 * @param		string		file data
	 * @param		int			start of segment
	 * @return		int
	 */
	private function getJPEGSegmentSize(&$f, $pos)
	{
		$arr = unpack('nint', substr($f, $pos + 2, 2)); // segment size has offset 2 and length 2B
		return $arr['int'];
	}

	/**
	 * Type of JPEG segment
	 *
	 * @param		string		file data
	 * @param		int			start of segment
	 * @return		int
	 */
	private function getJPEGSegmentType(&$f, $pos)
	{
		$arr = unpack('Cchar', substr($f, $pos + 1, 1)); // segment type has offset 1 and length 1B
		return $arr['char'];
	}

	/**
	 * Check if segment contains ICC profile marker
	 *
	 * @param		string		file data
	 * @param		int			position of segment data
	 * @param		int			size of segment data (without 2 bytes of size field)
	 * @return		bool
	 */
	private function getJPEGSegmentContainsICC(&$f, $pos, $size)
	{
		if ($size < self::ICC_HEADER_LEN) return false; // ICC_PROFILE 0x00 Marker_no Marker_cnt

		return (bool) (substr($f, $pos + 4, self::ICC_HEADER_LEN - 2) == self::ICC_MARKER); // 4B offset in segment data = 2B segment marker + 2B segment size data
	}

	/**
	 * Get ICC segment chunk info
	 *
	 * @param		string		file data
	 * @param		int			position of segment data
	 * @return		array		{chunk_no, chunk_cnt}
	 */
	private function getJPEGSegmentICCChunkInfo(&$f, $pos)
	{
		$a = unpack('Cchunk_no/Cchunk_count', substr($f, $pos + 16, 2)); // 16B offset to data = 2B segment marker + 2B segment size + 'ICC_PROFILE' + 0x00, 1. byte chunk number, 2. byte chunks count
		return array_values($a);
	}

	/**
	 * Returns chunk of ICC profile data from segment.
	 *
	 * @param		string		&data
	 * @param		int			current position
	 * @return		string
	 */
	private function getJPEGSegmentICCChunk(&$f, $pos)
	{
		$data_offset = $pos + 4 + self::ICC_HEADER_LEN; // 4B JPEG APP offset + 14B ICC header offset
		$size = $this->getJPEGSegmentSize($f, $pos);
		$data_size = $size - self::ICC_HEADER_LEN - 2; // 14B ICC header - 2B of size data
		return substr($f, $data_offset, $data_size);
	}

	/**
	 * Get data of given chunk
	 *
	 * @param		int			chunk number
	 * @return		string
	 */
	private function getChunk($chunk_no)
	{
		if ($chunk_no > $this->icc_chunks) return '';
		
		$max_chunk_size = self::MAX_BYTES_IN_MARKER - self::ICC_HEADER_LEN;
		$from = ($chunk_no - 1) * $max_chunk_size;
		$bytes = ($chunk_no < $this->icc_chunks) ? $max_chunk_size : $this->icc_size % $max_chunk_size;

		return substr($this->icc_profile, $from, $bytes);
	}

	/**
	 * Prepare all data needed to be inserted into JPEG file to add ICC profile.
	 *
	 * @return		string
	 */
	private function prepareJPEGProfileData()
	{
		$data = '';

		for ($i = 1; $i <= $this->icc_chunks; $i++)
		{
			$chunk = $this->getChunk($i);
			$chunk_size = strlen($chunk);
			$data .= "\xff\xe2" . pack('n', $chunk_size + 2 + self::ICC_HEADER_LEN); // APP2 segment marker + size field
			$data .= self::ICC_MARKER . pack('CC', $i, $this->icc_chunks); // profile marker inside segment
			$data .= $chunk;
		}

		return $data;
	}

	/**
	 * Removes profile from JPEG data
	 *
	 * @param		string		&data
	 * @return		bool
	 */
	private function removeProfile(&$jpeg_data)
	{
		$len = strlen($jpeg_data);
		$pos = 0;
		$counter = 0; // ehm...
		$chunks_to_go = -1;

		while ($pos < $len && $counter < 100)
		{
			$pos = strpos($jpeg_data, "\xff", $pos);
			if ($pos === false) break; // no more 0xFF - we can end up with search

			// analyze next segment
			$type = $this->getJPEGSegmentType($jpeg_data, $pos);

			switch ($type)
			{
				case 0xe2: // APP2
					$size = $this->getJPEGSegmentSize($jpeg_data, $pos);

					if ($this->getJPEGSegmentContainsICC($jpeg_data, $pos, $size))
					{
						list($chunk_no, $chunk_cnt) = $this->getJPEGSegmentICCChunkInfo($jpeg_data, $pos);
						if ($chunks_to_go == -1) $chunks_to_go = $chunk_cnt; // first time save chunks count

						$jpeg_data = substr_replace($jpeg_data, '', $pos, $size + 2); // remove this APP segment from dataset (segment size + 2B app marker)
						$len -= $size + 2; // shorten the size

						if (--$chunks_to_go == 0) return true; // no more icc profile chunks, store file

						break; // go out without changing the position
					}
					$pos += $size + 2; // size of segment data + 2B size of segment marker
					break;

				case 0xe0: // APP0
				case 0xe1: // APP1
				case 0xe3: // APP3
				case 0xe4: // APP4
				case 0xe5: // APP5
				case 0xe6: // APP6
				case 0xe7: // APP7
				case 0xe8: // APP8
				case 0xe9: // APP9
				case 0xea: // APP10
				case 0xeb: // APP11
				case 0xec: // APP12
				case 0xed: // APP13
				case 0xee: // APP14
				case 0xef: // APP15
				case 0xc0: // SOF0
				case 0xc2: // SOF2
				case 0xc4: // DHT
				case 0xdb: // DQT
				case 0xda: // SOS
				case 0xfe: // COM
					$size = $this->getJPEGSegmentSize($jpeg_data, $pos);
					$pos += $size + 2; // size of segment data + 2B size of segment marker
					break;

				case 0xd8: // SOI
				case 0xdd: // DRI
				case 0xd9: // EOI
				case 0xd0: // RST0
				case 0xd1: // RST1
				case 0xd2: // RST2
				case 0xd3: // RST3
				case 0xd4: // RST4
				case 0xd5: // RST5
				case 0xd6: // RST6
				case 0xd7: // RST7
				default:
					$pos += 2;
					break;
			}
			$counter++;
		}

		return false;
	}

	/**
	 * Inserts profile to JPEG data.
	 *
	 * Inserts profile immediately after SOI section
	 *
	 * @param		string		&data
	 * @return		bool
	 */
	private function insertProfile(&$jpeg_data)
	{
		$len = strlen($jpeg_data);
		$pos = 0;
		$counter = 0; // ehm...
		$chunks_to_go = -1;

		while ($pos < $len && $counter < 100)
		{
			$pos = strpos($jpeg_data, "\xff", $pos);
			if ($pos === false) break; // no more 0xFF - we can end up with search

			// analyze next segment
			$type = $this->getJPEGSegmentType($jpeg_data, $pos);

			switch ($type)
			{
				case 0xd8: // SOI
					$pos += 2;
					
					$p_data = $this->prepareJPEGProfileData();
					if ($p_data != '')
					{
						$before = substr($jpeg_data, 0, $pos);
						$after = substr($jpeg_data, $pos);
						$jpeg_data = $before . $p_data . $after;
						return true;
					}
					return false;
					//break;

				case 0xe0: // APP0
				case 0xe1: // APP1
				case 0xe2: // APP2
				case 0xe3: // APP3
				case 0xe4: // APP4
				case 0xe5: // APP5
				case 0xe6: // APP6
				case 0xe7: // APP7
				case 0xe8: // APP8
				case 0xe9: // APP9
				case 0xea: // APP10
				case 0xeb: // APP11
				case 0xec: // APP12
				case 0xed: // APP13
				case 0xee: // APP14
				case 0xef: // APP15
				case 0xc0: // SOF0
				case 0xc2: // SOF2
				case 0xc4: // DHT
				case 0xdb: // DQT
				case 0xda: // SOS
				case 0xfe: // COM
					$size = $this->getJPEGSegmentSize($jpeg_data, $pos);
					$pos += $size + 2; // size of segment data + 2B size of segment marker
					break;

				case 0xdd: // DRI
				case 0xd9: // EOI
				case 0xd0: // RST0
				case 0xd1: // RST1
				case 0xd2: // RST2
				case 0xd3: // RST3
				case 0xd4: // RST4
				case 0xd5: // RST5
				case 0xd6: // RST6
				case 0xd7: // RST7
				default:
					$pos += 2;
					break;
			}
			$counter++;
		}

		return false;
	}
}
?>
