<?php
declare(strict_types = 1);

/**
 * Gestion des métadonnées.
 * Permet d'extraire et de formater les informations EXIF, IPTC et XMP.
 *
 * Spécifications EXIF :
 *  https://www.cipa.jp/std/documents/e/DC-X008-Translation-2019-E.pdf (version 2.32)
 *  https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf (version 2.3)
 *	http://www.exif.org/Exif2-2.PDF (version 2.2)
 * Spécifications IPTC :
 *	http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata%28200907%29_1.pdf
 * Spécifications XMP :
 *	http://www.adobe.com/devnet/xmp.html
 *
 * @license https://www.gnu.org/licenses/gpl-3.0.html
 * @link https://www.igalerie.org/
 */
class Metadata
{
	/**
	 * Fabriquants.
	 *
	 * @var array
	 */
	const BRANDS =
	[
		'acer' => 'Acer',
		'aigo' => 'Aigo',
		'apple' => 'Apple',
		'benq' => 'BenQ',
		'blackberry' => 'BlackBerry',
		'canon' => 'Canon',
		'casio' => 'Casio',
		'concord' => 'Concord',
		'dji' => 'DJI',
		'docomo' => 'DoCoMo',
		'epson' => 'Epson',
		'fuji' => 'Fujifilm',
		'fujifilm' => 'Fujifilm',
		'google' => 'Google',
		'gopro' => 'GoPro',
		'hasselblad' => 'Hasselblad',
		'helio' => 'Helio',
		'hewlett-packard|hp' => 'Hewlett Packard',
		'htc' => 'HTC',
		'huawei' => 'Huawei',
		'jvc' => 'JVC',
		'kddi' => 'KDDI',
		'kodak' => 'Kodak',
		'kyocera' => 'Kyocera',
		'leaf' => 'Leaf',
		'leica' => 'Leica',
		'lenovo' => 'Lenovo',
		'lg' => 'LG',
		'olympus' => 'Olympus',
		'oneplus' => 'OnePlus',
		'meizu' => 'Meizu',
		'minolta' => 'Konica Minolta',
		'motorola' => 'Motorola',
		'nikon' => 'Nikon',
		'nintendo' => 'Nintendo',
		'nokia' => 'Nokia',
		'nvidia' => 'Nvidia',
		'oppo' => 'Oppo',
		'palm' => 'Palm',
		'panasonic' => 'Panasonic',
		'parrot' => 'Parrot',
		'pentax' => 'Pentax',
		'phase one' => 'Phase One',
		'polaroid' => 'Polaroid',
		'ricoh' => 'Ricoh',
		'samsung' => 'Samsung',
		'sanyo' => 'Sanyo',
		'sharp' => 'Sharp',
		'sigma' => 'Sigma',
		'sony ericsson' => 'Sony Ericsson',
		'sony' => 'Sony',
		'toshiba' => 'Toshiba',
		'vivitar' => 'Vivitar',
		'vivo' => 'Vivo',
		'xiaomi' => 'Xiaomi'
	];

	/**
	 * Modèles.
	 *
	 * @var array
	 */
	const MODELS =
	[
		// DJI.
		'FC1102' => 'Spark',
		'FC200' => 'Phantom 2 Vision', # Phantom 2 Vision & Vision+
		'FC2103' => 'Mavic Air',
		'FC220' => 'Mavic Pro',
		'FC2204' => 'Mavic 2 Zoom',
		'FC2220' => 'Mavic 2 Enterprise',
		'FC2403' => 'Mavic 2 Enterprise Dual',
		'FC230' => 'Mavic Air',
		'FC300C' => 'Phantom 3 Standard',
		'FC300S' => 'Phantom 3 Advanced',
		'FC300SE' => 'Phantom 3 SE',
		'FC300X' => 'Phantom 3 Professional',
		'FC300XW' => 'Phantom 3 4K',
		'FC3170' => 'Mavic Air 2',
		'FC330' => 'Phantom 4',
		'FC3305' => 'DJI FPV',
		'FC3411' => 'Air 2S',
		'FC350' => 'Zenmuse X3',
		'FC350H' => 'Osmo',
		'FC350Z' => 'Osmo+',
		'FC3582' => 'Mini 3 Pro',
		'FC3682' => 'Mini 3',
		'FC4170' => 'Mavic 3 Tele Camera',
		'FC4280' => 'Zenmuse X9-8K Air',
		'FC4370' => 'Mavic 3 Pro Tele Camera',
		'FC4382' => 'Mavic 3 Pro Medium Tele Camera',
		'FC550' => 'Zenmuse X5',
		'FC550RAW' => 'Zenmuse X5R',
		'FC6310' => 'Phantom 4 Pro', # Phantom 4 Pro & Advanced
		'FC6310R' => 'Phantom 4 RTK',
		'FC6310S' => 'Phantom 4 Pro V2',
		'FC6360' => 'Phantom 4 Multispectral',
		'FC6510' => 'Zenmuse X4S',
		'FC6520' => 'Zenmuse X5S',
		'FC6540' => 'Zenmuse X7',
		'FC7203' => 'Mavic Mini', # Mavic Mini & Mini SE
		'FC7303' => 'Mini 2',
		'FC7703' => 'Mini 4K',
		'FC8183' => 'Avata',
		'FC8282' => 'Air 3',
		'FC8284' => 'Air 3 Tele Camera',
		'FC8482' => 'Mini 4 Pro',
		'FC8485' => 'Avata 2',
		'FC8582' => 'Flip',
		'FC8671' => 'Neo',
		'FC9113' => 'Air 3S',
		'FC9184' => 'Air 3S Tele Camera',
		'FC9284' => 'Mavic 4 Pro Medium Tele Camera',
		'FC9287' => 'Mavic 4 Pro Tele Camera',
		'FC9313' => 'Mini 5 Pro',
		'FC9470' => 'Neo 2',
		'HG310' => 'Osmo',
		'HG310Z' => 'Osmo+',
		'OT110' => 'Osmo Pocket'
	];



	/**
	 * Données EXIF brutes.
	 *
	 * @var array
	 */
	private $_exifData;

	/**
	 * Chemin du fichier à analyser.
	 *
	 * @var string
	 */
	private $_file;

	/**
	 * Données IPTC brutes.
	 *
	 * @var array
	 */
	private $_iptcData;

	/**
	 * Données XMP brutes.
	 *
	 * @var array
	 */
	private $_xmpData;



	/**
	 * Initialisation.
	 *
	 * @param string $f
	 *   Chemin du fichier à examiner.
	 * @param array $data
	 *   Données brutes.
	 *
	 * @return void
	 */
	public function __construct(string $f, array $data = [])
	{
		if ($data || !file_exists($f))
		{
			foreach (['exif' => [], 'iptc' => [], 'xmp' => ''] as $k => &$v)
			{
				$this->{'_' . $k . 'Data'} =
					($k == 'xmp' && isset($data[$k]) && is_string($data[$k])) ||
					($k != 'xmp' && isset($data[$k]) && is_array($data[$k]))
					? $data[$k]
					: $v;
			}
		}

		$this->_file = $f;
	}

	/**
	 * Retourne la marque d'un appareil à partir
	 * de l'information EXIF $make.
	 *
	 * @param string $make
	 *
	 * @return string
	 */
	public static function getExifCameraMake(string $make): string
	{
		foreach (self::BRANDS as $regex => &$brand)
		{
			if (preg_match('`(^|\W)' . $regex . '($|\W)`i', $make))
			{
				return $brand;
			}
		}

		return $make;
	}

	/**
	 * Récupération des données EXIF brutes.
	 *
	 * @return array
	 */
	public function getExifData(): array
	{
		// Si les données ont déjà été récupérées,
		// inutile d'aller plus loin.
		if (!$this->_file || is_array($this->_exifData))
		{
			return $this->_exifData;
		}

		// L'extension EXIF doit être chargée.
		if (!function_exists('exif_read_data'))
		{
			return [];
		}

		// Seules les images JPEG et WEBP sont concernées.
		if (!in_array(exif_imagetype($this->_file), [IMAGETYPE_JPEG, IMAGETYPE_WEBP]))
		{
			return $this->_exifData = [];
		}

		// Récupération des informations avec la fonction native de PHP.
		if (!is_array($this->_exifData = exif_read_data($this->_file, 'ANY_TAG', TRUE, FALSE)))
		{
			return $this->_exifData = [];
		}

		// On supprime les sections inutiles.
		foreach ($this->_exifData as $s => &$p)
		{
			if (!in_array($s, ['EXIF', 'GPS', 'IFD0']))
			{
				unset($this->_exifData[$s]);
			}
		}

		// On supprime les tags 'MakerNote' et 'UserComment',
		// car ils peuvent contenir de grandes quantités de données inutiles.
		if (isset($this->_exifData['EXIF']['MakerNote']))
		{
			unset($this->_exifData['EXIF']['MakerNote']);
		}
		if (isset($this->_exifData['EXIF']['UserComment']))
		{
			unset($this->_exifData['EXIF']['UserComment']);
		}

		// On nettoie les données du tableau et on les convertit en UTF-8.
		$this->_cleanValues($this->_exifData);

		return $this->_exifData;
	}

	/**
	 * Retourne les informations EXIF formatées
	 * avec les paramètres de configuration $config.
	 *
	 * @param array $config
	 *
	 * @return array
	 */
	public function getExifFormated(array $config): array
	{
		// On récupère les données si cela n'a pas déjà été fait.
		if (!count($this->getExifData()))
		{
			return [];
		}

		// Formatage des données.
		$exif_formated = [];
		foreach ($config as $info => &$params)
		{
			if ($params['status'] && $f = $this->getExifFormatedInfo($info, $config))
			{
				$exif_formated[$info] =
				[
					'name' => $f['name'],
					'value' => $f['value']
				];
			}
		}

		return $exif_formated;
	}

	/**
	 * Retourne l'information EXIF $info formatée.
	 *
	 * @param string $info
	 * @param array $config
	 *
	 * @return array
	 */
	public function getExifFormatedInfo(string $info, array $config): array
	{
		if (!is_array($this->_exifData))
		{
			$this->getExifData();
		}

		if (!$params = self::_getExifParams($info))
		{
			return [];
		}

		// Coordonnées GPS.
		if ($info == 'GPSCoordinates' && $coords = $this->getExifValue('gps_coordinates'))
		{
			$this->_exifData['GPS']['GPSCoordinates'] = $coords;
		}

		// Sensibilité ISO.
		if ($info == 'ISOSpeedRatings')
		{
			if (isset($this->_exifData['EXIF']['ISOSpeedRatings'])
			&& is_array($this->_exifData['EXIF']['ISOSpeedRatings'])
			&& isset($this->_exifData['EXIF']['ISOSpeedRatings'][0]))
			{
				$this->_exifData['EXIF']['ISOSpeedRatings']
					= $this->_exifData['EXIF']['ISOSpeedRatings'][0];
			}
		}

		// Objectif.
		if ($info == 'Lens')
		{
			$lens = '';
			if (isset($this->_exifData['EXIF']['UndefinedTag:0xA433']))
			{
				// Fabricant.
				$lens = $this->_exifData['EXIF']['UndefinedTag:0xA433'];
			}
			if (isset($this->_exifData['EXIF']['UndefinedTag:0xA434']))
			{
				// Modèle.
				$lens .= ($lens === '') ? '' : ' ';
				$lens .= $this->_exifData['EXIF']['UndefinedTag:0xA434'];
			}
			else if (isset($this->_exifData['EXIF']['UndefinedTag:0xA432'])
			&& is_array($this->_exifData['EXIF']['UndefinedTag:0xA432'])
			&& count($this->_exifData['EXIF']['UndefinedTag:0xA432']) == 4)
			{
				// Spécifications.
				$specs = $this->_exifData['EXIF']['UndefinedTag:0xA432'];
				$lens .= ($lens === '') ? '' : ' ';
				$lens .= $this->_exifNumber($specs[0], '%2.2F');
				if ($specs[1] > 0 && $specs[1] != $specs[0])
				{
					$lens .= '-' . $this->_exifNumber($specs[1], '%2.2F');
				}
				$lens .= ' mm';
				if ($specs[2] > 0)
				{
					$lens .= ' f/' . $this->_exifNumber($specs[2], '%2.1F');
					if ($specs[3] > 0 && $specs[3] != $specs[2])
					{
						$lens .= '-' . $this->_exifNumber($specs[3], '%2.1F');
					}
				}
			}
			if ($lens !== '')
			{
				$this->_exifData['EXIF']['Lens'] = $lens;
			}
		}

		// L'information EXIF est-elle présente dans le fichier ?
		if (!isset($this->_exifData[$params['section']][$info]))
		{
			return [];
		}

		// On récupère la valeur.
		$value = $value_original = $this->_exifData[$params['section']][$info];
		if (!is_string($value) && !is_numeric($value))
		{
			return [];
		}

		// Durée d'exposition.
		if ($info == 'ExposureTime')
		{
			if (!($val = (float) $this->_exifNumber($value, '%s')))
			{
				return [];
			}

			$v = L10N::numeric(round($val, 2));

			if ($val < 1)
			{
				$e = explode('/', $value);
				if (isset($e[1]))
				{
					$round = round($e[1] / $e[0]);
					if ($round > 1)
					{
						$v = '1/' . $round;
					}
				}
			}

			return
			[
				'name' => $params['name'],
				'value' => $v . ' s'
			];
		}

		// Pour les autres informations Exif,
		// on formate la valeur selon méthode à utiliser.
		switch ($params['method'])
		{
			// Méthode : date.
			case 'date' :
				if ($value = $this->_exifDate($value))
				{
					$value = L10N::dt(__('%A %d %B %Y à %H:%M:%S'), $value, FALSE);
				}
				break;

			// Méthode : liste.
			case 'list' :
				$value = $params['list'][$value] ?? '';
				break;

			// Méthode : nombre.
			case 'number' :
				$value = $this->_exifNumber((string) $value, $config[$info]['format'], TRUE);
				break;

			// Méthode : version.
			case 'version' :
				$value = $this->_exifVersion($value);
				break;
		}

		// On vérifie que la valeur n'est pas vide.
		if (Utility::isEmpty((string) $value))
		{
			return [];
		}

		// Modification de certaines valeurs.
		if ($info == 'DigitalZoomRatio' && substr($value_original, 0, 2) == '0/')
		{
			$value = __('Non utilisé');
		}
		if ($info == 'ExposureBiasValue' && substr($value_original, 0, 2) == '0/')
		{
			$value = __('Aucune');
		}
		if ($info == 'SubjectDistance' && $value[0] == '-')
		{
			$value = __('Infinie');
		}
		if ($info == 'Make')
		{
			$value = self::getExifCameraMake($value);
		}
		if ($info == 'Model')
		{
			if (isset(self::MODELS[$value]))
			{
				$value .= ' (' . self::MODELS[$value] . ')';
			}
		}

		return
		[
			'name' => $params['name'],
			'value' => $value
		];
	}

	/**
	 * Localisation du nom des informations EXIF.
	 *
	 * @param string $info
	 *
	 * @return string
	 */
	public static function getExifLocale(string $info): string
	{
		return self::_getExifParams($info)['name'] ?? '';
	}

	/**
	 * Récupère l'information EXIF $info.
	 * Informations disponibles :
	 *   - datetime
	 *   - gps_latitude
	 *   - gps_longitude
	 *   - gps_coordinates
	 *   - make
	 *   - model
	 *   - orientation
	 *
	 * @param string $info
	 *
	 * @return mixed
	 *   Retourne l'information demandée (string),
	 *   ou NULL si aucune information n'a été trouvée.
	 */
	public function getExifValue(string $info)
	{
		// On récupère les données EXIF.
		if (!count($this->getExifData()))
		{
			return;
		}

		// Vérification et formatage de l'information.
		switch ($info)
		{
			// Date et heure de la prise de vue.
			case 'datetime' :
				return $this->_getDateTime(
					$this->_exifData['EXIF']['DateTimeOriginal'] ?? '',
					$this->_exifData['EXIF']['DateTime'] ?? ''
				);

			// Coordonnées GPS.
			case 'gps_latitude' :
			case 'gps_longitude' :
			case 'gps_coordinates' :

				if (empty($this->_exifData['GPS'])
				|| !is_array($this->_exifData['GPS'])
				|| empty($this->_exifData['GPS']['GPSLatitudeRef'])
				|| !isset($this->_exifData['GPS']['GPSLatitude'])
				|| !is_array($this->_exifData['GPS']['GPSLatitude'])
				|| count($this->_exifData['GPS']['GPSLatitude']) != 3
				|| empty($this->_exifData['GPS']['GPSLongitudeRef'])
				|| !isset($this->_exifData['GPS']['GPSLongitude'])
				|| !is_array($this->_exifData['GPS']['GPSLongitude'])
				|| count($this->_exifData['GPS']['GPSLongitude']) != 3)
				{
					break;
				}

				foreach (['GPSLatitude', 'GPSLongitude'] as $coord)
				{
					for ($i = $$coord = 0; $i < 3; $i++)
					{
						if (!isset($this->_exifData['GPS'][$coord][$i])
						|| !preg_match('`^(\d+)/(\d+)$`', $this->_exifData['GPS'][$coord][$i], $m))
						{
							unset($$coord);
							break 2;
						}

						if ($m[2] != 0)
						{
							$$coord += $m[1] / pow(60, $i) / $m[2];
						}
					}

					$ref = ($coord == 'GPSLatitude') ? 'S' : 'W';
					if ($this->_exifData['GPS'][$coord . 'Ref'] == $ref)
					{
						$$coord -= $$coord * 2;
					}
				}

				if (isset($GPSLatitude) && isset($GPSLongitude))
				{
					switch ($info)
					{
						case 'gps_coordinates' :
							return vsprintf('%s, %s', Geolocation::getSexagesimalCoordinates(
								$GPSLatitude, $GPSLongitude
							));

						case 'gps_latitude' :
							return (string) $GPSLatitude;

						case 'gps_longitude' :
							return (string) $GPSLongitude;
					}
				}
				break;

			// Fabriquant et modèle de l'appareil.
			case 'make' :
			case 'model' :
				$i = ucfirst($info);
				if (!empty($this->_exifData['IFD0'][$i])
				&& !Utility::isEmpty($this->_exifData['IFD0'][$i])
				&& strtolower($this->_exifData['IFD0'][$i]) != 'exif'
				&& preg_match('`[a-z0-9]`i', $this->_exifData['IFD0'][$i]))
				{
					return $this->_exifData['IFD0'][$i];
				}
				break;

			// Orientation de l'image.
			case 'orientation' :
				if (isset($this->_exifData['IFD0']['Orientation']))
				{
					$o = (int) $this->_exifData['IFD0']['Orientation'];
					if ($o > 1 && $o < 9)
					{
						return (string) $o;
					}
				}
				break;
		}
	}

	/**
	 * Récupération des données IPTC brutes.
	 *
	 * @return array
	 */
	public function getIptcData(): array
	{
		// Si les données ont déjà été récupérées,
		// inutile d'aller plus loin.
		if (!$this->_file || is_array($this->_iptcData))
		{
			return $this->_iptcData;
		}

		// On vérifie la présence de la fonction 'iptcparse'.
		if (!function_exists('iptcparse'))
		{
			return [];
		}

		// Récupération des informations IPTC.
		if (!getimagesize($this->_file, $image_infos)
		|| !is_array($image_infos) || !isset($image_infos['APP13'])
		|| !is_array($this->_iptcData = iptcparse((string) $image_infos['APP13'])))
		{
			return $this->_iptcData = [];
		}

		// On nettoie les données du tableau et on les convertit en UTF-8.
		$this->_cleanValues($this->_iptcData);

		return $this->_iptcData;
	}

	/**
	 * Retourne les informations IPTC formatées
	 * avec les paramètres de configuration $config.
	 *
	 * @param array $config
	 *
	 * @return array
	 */
	public function getIptcFormated(array $config): array
	{
		// On récupère les données si cela n'a pas déjà été fait.
		if (!count($this->getIptcData()))
		{
			return [];
		}

		// On parcours chaque information présente dans la config.
		$iptc_formated = [];
		foreach ($config as $info => &$params)
		{
			if ($params['status'] && $f = $this->getIptcFormatedInfo($info, $config))
			{
				$iptc_formated[$info] =
				[
					'name' => $f['name'],
					'value' => $f['value']
				];
			}
		}

		return $iptc_formated;
	}

	/**
	 * Retourne l'information IPTC $info formatée.
	 *
	 * @param string $info
	 * @param array $config
	 *
	 * @return array
	 */
	public function getIptcFormatedInfo(string $info, array $config): array
	{
		if (!is_array($this->_iptcData))
		{
			$this->getIptcData();
		}

		if (!$f = self::_getIptcParams($info))
		{
			return [];
		}

		// L'information IPTC est-elle présente dans le fichier ?
		if (!isset($this->_iptcData[$f['tag']])
		|| !is_array($this->_iptcData[$f['tag']])
		|| !isset($this->_iptcData[$f['tag']][0])
		|| is_array($this->_iptcData[$f['tag']][0])
		|| Utility::isEmpty($this->_iptcData[$f['tag']][0]))
		{
			return [];
		}

		return
		[
			'name' => $f['name'],
			'value' => implode(', ', $this->_iptcData[$f['tag']])
		];
	}

	/**
	 * Localisation du nom des informations IPTC.
	 *
	 * @param string $info
	 *
	 * @return string
	 */
	public static function getIptcLocale(string $info): string
	{
		return self::_getIptcParams($info)['name'] ?? '';
	}

	/**
	 * Récupère l'information IPTC $info.
	 * Informations disponibles :
	 *   - datetime
	 *   - description
	 *   - keywords
	 *   - title
	 *
	 * @param string $info
	 *
	 * @return string
	 *   Retourne l'information demandée (string),
	 *   ou NULL si aucune information n'a été trouvée.
	 */
	public function getIptcValue(string $info)
	{
		// On récupère les données IPTC.
		if (!count($this->getIptcData()))
		{
			return;
		}

		switch ($info)
		{
			// Date et heure de création.
			case 'datetime' :
				return $this->_getDateTime(
					($this->_iptcData['2#055'][0] ?? '') . ($this->_iptcData['2#060'][0] ?? '')
				);

			// Description.
			case 'description' :
				if (isset($this->_iptcData['2#120'])
				&& is_array($this->_iptcData['2#120'])
				&& isset($this->_iptcData['2#120'][0])
				&& !Utility::isEmpty($this->_iptcData['2#120'][0]))
				{
					return $this->_iptcData['2#120'][0];
				}
				break;

			// Mots-clés.
			case 'keywords' :
				if (!empty($this->_iptcData['2#025']))
				{
					return trim(implode(', ', $this->_iptcData['2#025']));
				}
				break;

			// Titre.
			case 'title' :
				$tag = Config::$params['iptc_title_tag'] == 'ObjectName' ? '2#005' : '2#105';
				if (isset($this->_iptcData[$tag])
				&& is_array($this->_iptcData[$tag])
				&& isset($this->_iptcData[$tag][0])
				&& !Utility::isEmpty($this->_iptcData[$tag][0]))
				{
					return $this->_iptcData[$tag][0];
				}
				break;
		}
	}

	/**
	 * Retourne les informations $i triées
	 * dans l'ordre indiqué par $order.
	 *
	 * @param array $i
	 * @param array $order
	 *
	 * @return array
	 */
	public static function getSorted(array &$i, array $order): array
	{
		$data = [];
		foreach ($order as &$p)
		{
			if (array_key_exists($p, $i))
			{
				$data[$p] = $i[$p];
			}
		}
		return $data;
	}

	/**
	 * Récupération des données XMP brutes.
	 *
	 * @return string
	 */
	public function getXmpData(): string
	{
		// Si les données ont déjà été récupérées, inutile d'aller plus loin.
		if (!$this->_file || is_string($this->_xmpData))
		{
			return $this->_xmpData;
		}

		// Ouverture du fichier.
		if (($fp = fopen($this->_file, 'rb')) === FALSE)
		{
			return '';
		}

		// Parcours du fichier à la recherche des données XMP.
		$xmp_data = '';
		if ($chunk = fread($fp, 500000))
		{
			if (($xmp_start = strpos($chunk, '<x:xmpmeta')) !== FALSE)
			{
				$xmp_data = substr($chunk, $xmp_start);
				$xmp_end = strpos($xmp_data, '</x:xmpmeta>');
				$xmp_data = substr($xmp_data, 0, $xmp_end + 12);
			}
			fclose($fp);
			if (Utility::isEmpty($xmp_data))
			{
				return $this->_xmpData = '';
			}
		}
		$this->_xmpData =& $xmp_data;

		// On nettoie les données et on les convertit en UTF-8.
		$this->_cleanValues($this->_xmpData);

		return $this->_xmpData;
	}

	/**
	 * Retourne les informations XMP formatées
	 * avec les paramètres de configuration $config.
	 *
	 * @param array $config
	 *
	 * @return array
	 */
	public function getXmpFormated(array $config): array
	{
		// On récupère les données si cela n'a pas déjà été fait.
		if (!$this->getXmpData())
		{
			return [];
		}

		// On parcours chaque information présente dans la config.
		$xmp_formated = [];
		foreach ($config as $info => &$params)
		{
			if ($params['status'] && $f = $this->getXmpFormatedInfo($info, $config))
			{
				$xmp_formated[$info] =
				[
					'name' => $f['name'],
					'value' => $f['value']
				];
			}
		}

		return $xmp_formated;
	}

	/**
	 * Retourne l'information XMP $info formatée.
	 *
	 * @param string $info
	 * @param array $config
	 *
	 * @return array
	 */
	public function getXmpFormatedInfo(string $info, array $config): array
	{
		if (!is_string($this->_xmpData))
		{
			$this->getXmpData();
		}

		if (!$f = self::_getXmpParams($info))
		{
			return [];
		}

		if (!$value = $this->_getXmpTag($f['tag']))
		{
			return [];
		}

		switch ($f['type'])
		{
			case 'bag' :
			case 'seq' :
				$value = is_array($value) ? implode(', ', $value) : '';
				break;

			case 'lang' :
				$value = is_array($value)
					? (empty($value['x-default'])
						? implode(', ', $value)
						: $value['x-default'])
					: '';
				break;

			case 'text' :
				$value = is_array($value) && isset($value[0])
					? $value[0]
					: (is_string($value) ? $value : '');
				break;

			default :
				return [];
		}

		return
		[
			'name' => $f['name'],
			'value' => $value
		];
	}

	/**
	 * Localisation du nom des informations XMP.
	 *
	 * @param string $info
	 *
	 * @return string
	 */
	public static function getXmpLocale(string $info): string
	{
		return self::_getXmpParams($info)['name'] ?? '';
	}

	/**
	 * Récupère l'information XMP $info.
	 * Informations disponibles :
	 *   - datetime
	 *   - description
	 *   - keywords
	 *   - orientation
	 *   - title
	 *
	 * @param string $info
	 *
	 * @return mixed
	 *   Retourne l'information demandée (array),
	 *   ou NULL si aucune information n'a été trouvée.
	 */
	public function getXmpValue(string $info)
	{
		// On récupère les données XMP.
		if (!$this->getXmpData())
		{
			return;
		}

		// Vérification et formatage de l'information.
		switch ($info)
		{
			// Date et heure de création.
			case 'datetime' :
				return [$this->_getDateTime(
					$this->_getXmpTag('dc:date')[0] ?? '',
					$this->_getXmpTag('xmp:CreateDate')[0] ?? '',
					$this->_getXmpTag('photoshop:DateCreated')[0] ?? '',
					$this->_getXmpTag('exif:DateTimeOriginal')[0] ?? ''
				)];

			// Description.
			case 'description' :
				return $this->_getXmpTag('dc:description');

			// Coordonnées GPS.
			case 'gps_latitude' :
				return [Geolocation::getDecimalCoordinate(
					$this->_getXmpTag('exif:GPSLatitude')[0] ?? ''
				)];
			case 'gps_longitude' :
				return [Geolocation::getDecimalCoordinate(
					$this->_getXmpTag('exif:GPSLongitude')[0] ?? ''
				)];

			// Mots-clés.
			case 'keywords' :
				return $this->_getXmpTag('dc:subject');

			// Fabriquant et modèle de l'appareil.
			case 'make' :
			case 'model' :
				return $this->_getXmpTag('tiff:' . ucfirst($info));

			// Orientation.
			case 'orientation' :
				$o = $this->_getXmpTag('tiff:Orientation');
				if (isset($o[0]) && (int) $o[0] > 1 && (int) $o[0] < 9)
				{
					return $o;
				}
				break;

			// Titre.
			case 'title' :
				return $this->_getXmpTag('dc:title');
		}
	}



	/**
	 * Nettoie et convertit en UTF-8 les valeurs de $data.
	 *
	 * @param mixed $data
	 *
	 * @return void
	 */
	private function _cleanValues(&$data): void
	{
		$clean = function(&$str)
		{
			if (is_string($str))
			{
				$str = Utility::UTF8($str);
				$str = trim(Utility::deleteInvisibleChars($str));
			}
		};
		if (is_array($data))
		{
			array_walk_recursive($data, $clean);
		}
		else
		{
			$clean($data);
		}
	}

	/**
	 * Formate une information EXIF avec la méthode "date".
	 *
	 * @param string $value
	 *
	 * @return mixed
	 */
	private function _exifDate(string $value): string
	{
		// \W et \s+ à cause de certains formats de date non valide
		// (Samsung).
		if (substr($value, 0, 4) == '0000'
		|| !preg_match('`^\d{4}(\W)\d{2}\W\d{2}([^\d]+)\d{2}(\W)\d{2}\W\d{2}$`', $value, $m))
		{
			return '';
		}
		$value = str_replace([$m[1], $m[2], $m[3]], [':', ' ', ':'], $value);

		if (strtotime($value) === FALSE)
		{
			return '';
		}

		return (string) $value;
	}

	/**
	 * Formate une information EXIF avec la méthode "number".
	 *
	 * @param string $value
	 * @param string $format
	 * @param bool $localize
	 *
	 * @return null|string
	 */
	private function _exifNumber(string $value, string $format, bool $localize = FALSE)
	{
		if (preg_match('`^[-0-9/+\*]{1,255}$`', $value) && substr($value, -2, 2) != '/0')
		{
			eval("\$newval=$value;");

			$value = preg_replace('`\.0+(?=\D|$)`', '', sprintf($format, $newval));
			return $localize ? L10N::numeric($value) : $value;
		}
	}

	/**
	 * Formate une information EXIF avec la méthode "version".
	 *
	 * @param string $value
	 *
	 * @return null|string
	 */
	private function _exifVersion(string $value)
	{
		if (strlen($value) < 5 && is_numeric($value))
		{
			$version = sscanf($value, '%2d%2d');

			return sprintf('%d.%d', $version[0], $version[1]);
		}
	}

	/**
	 * Retourne une date dans le format AAAA-MM-JJ HH:MM:SS
	 * à partir d'un ensemble de dates fournies en arguments.
	 *
	 * @param string $dates
	 *
	 * @return null|string
	 */
	private function _getDateTime(string ...$dates)
	{
		foreach ($dates as &$date)
		{
			if (!Utility::isEmpty($date) && strlen($date) > 1
			&& ($date = strtotime($date)) !== FALSE)
			{
				$date = date('Y-m-d H:i:s', $date);
				if (preg_match('`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$`', $date))
				{
					return $date;
				}
			}
		}
	}

	/**
	 * Retourne les paramètres d'une information EXIF.
	 *
	 * @param string $info
	 *
	 * @return array
	 */
	private static function _getExifParams(string $info): array
	{
		static $params;

		if (!$params)
		{
			$f1 = __('flash non déclenché');
			$f2 = __('flash déclenché');
			$f3 = __('retour de flash non détecté');
			$f4 = __('retour de flash détecté');
			$f5 = __('mode forcé');
			$f6 = __('mode automatique');
			$f7 = __('pas de flash activé');
			$f8 = __('anti-yeux rouges activé');
			$params =
			[
				'Artist' => [
					'name' => __('Auteur'),
					'section' => 'IFD0',
					'method' => 'simple'
				],
				'ColorSpace' => [
					'name' => __('Espace colorimétrique'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						1 => 'sRGB',
						2 => 'Adobe RGB (1998)',
						65535 => __('Non calibré')
					]
				],
				'Contrast' => [
					'name' => __('Contraste'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0 => __('Normal'),
						1 => __('Faible'),
						2 => __('Élevé')
					]
				],
				'Copyright' => [
					'name' => __('Copyright'),
					'section' => 'IFD0',
					'method' => 'simple'
				],
				'CustomRendered' => [
					'name' => __('Rendu personnalisé'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0 => __('Normal'),
						1 => __('Personnalisé')
					]
				],
				'DateTime' => [
					'name' => __('Date de modification'),
					'section' => 'IFD0',
					'method' => 'date'
				],
				'DateTimeDigitized' => [
					'name' => __('Date de numérisation'),
					'section' => 'EXIF',
					'method' => 'date'
				],
				'DateTimeOriginal' => [
					'name' => __('Date de création'),
					'section' => 'EXIF',
					'method' => 'date'
				],
				'DigitalZoomRatio' => [
					'name' => __('Zoom numérique'),
					'section' => 'EXIF',
					'method' => 'number'
				],
				'ExifVersion' => [
					'name' => __('Version Exif'),
					'section' => 'EXIF',
					'method' => 'version'
				],
				'ExposureBiasValue' => [
					'name' => __('Correction de l\'exposition'),
					'section' => 'EXIF',
					'method' => 'number'
				],
				'ExposureMode' => [
					'name' => __('Mode d\'exposition'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0 => __('Automatique'),
						1 => __('Manuel'),
						2 => __('Bracketing automatique')
					]
				],
				'ExposureProgram' => [
					'name' => __('Programme d\'exposition'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0 => __('Non défini'),
						1 => __('Manuel'),
						2 => __('Programme normal'),
						3 => __('Priorité à l\'ouverture'),
						4 => __('Priorité à l\'obturateur'),
						5 => __('Programme \'créatif\''
							. ' (préférence à la profondeur de champ)'),
						6 => __('Programme \'action\''
							. ' (préférence à la vitesse d\'obturation)'),
						7 => __('Mode portrait'
							. ' (pour des clichés de près avec arrière-plan flou)'),
						8 => __('Mode paysage'
							. ' (pour des clichés de paysages avec arrière-plan net)')
					]
				],
				'ExposureTime' => [
					'name' => __('Durée d\'exposition'),
					'section' => 'EXIF',
					'method' => 'number'
				],
				'Flash' => [
					'name' => __('Flash'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0  => ucfirst("$f1"),
						1  => ucfirst("$f2"),
						5  => ucfirst("$f3"),
						7  => ucfirst("$f4"),
						9  => ucfirst("$f2, $f5"),
						13 => ucfirst("$f2, $f5, $f3"),
						15 => ucfirst("$f2, $f5, $f4"),
						16 => ucfirst("$f1, $f5"),
						24 => ucfirst("$f1, $f6"),
						25 => ucfirst("$f2, $f6"),
						29 => ucfirst("$f2, $f6, $f3"),
						31 => ucfirst("$f2, $f6, $f4"),
						32 => ucfirst("$f7"),
						65 => ucfirst("$f2, $f8"),
						69 => ucfirst("$f2, $f8, $f3"),
						71 => ucfirst("$f2, $f8, $f4"),
						73 => ucfirst("$f2, $f5, $f8"),
						77 => ucfirst("$f2, $f5, $f8, $f3"),
						79 => ucfirst("$f2, $f5, $f8, $f4"),
						89 => ucfirst("$f2, $f6, $f8"),
						93 => ucfirst("$f2, $f6, $f8, $f3"),
						95 => ucfirst("$f2, $f6, $f8, $f4")
					]
				],
				'FlashPixVersion' => [
					'name' => __('Version FlashPix'),
					'section' => 'EXIF',
					'method' => 'version'
				],
				'FNumber' => [
					'name' => __('Ouverture'),
					'section' => 'EXIF',
					'method' => 'number'
				],
				'FocalLength' => [
					'name' => __('Longueur de focale'),
					'section' => 'EXIF',
					'method' => 'number'
				],
				'FocalLengthIn35mmFilm' => [
					'name' => __('Longueur de focale équivalente en 35mm'),
					'section' => 'EXIF',
					'method' => 'number'
				],
				'GainControl' => [
					'name' => __('Contrôle du gain'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0 => __('Aucun'),
						1 => __('Faible augmentation'),
						2 => __('Forte augmentation'),
						3 => __('Faible diminution'),
						4 => __('Forte diminution')
					]
				],
				'GPSAltitude' => [
					'name' => __('Altitude GPS'),
					'section' => 'GPS',
					'method' => 'number'
				],
				'GPSCoordinates' => [
					'name' => __('Coordonnées GPS'),
					'section' => 'GPS',
					'method' => 'simple'
				],
				'ISOSpeedRatings' => [
					'name' => __('Sensibilité ISO'),
					'section' => 'EXIF',
					'method' => 'simple'
				],
				'Lens' => [
					'name' => __('Objectif'),
					'section' => 'EXIF',
					'method' => 'simple'
				],
				'LightSource' => [
					'name' => __('Source de lumière'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0 => __('Inconnue'),
						1 => __('Lumière du jour'),
						2 => __('Fluorescent'),
						3 => __('Tungstène (lumière incandescente)'),
						4 => __('Flash'),
						9 => __('Beau temps'),
						10 => __('Temps nuageux'),
						11 => __('Ombre'),
						12 => __('Fluorescent, lumière du jour (D 5700 - 7100K)'),
						13 => __('Fluorescent, teintes blanches (N 4600 - 5400K)'),
						14 => __('Fluorescent, froid (W 3900 - 4500K)'),
						15 => __('Fluorescent, blanc (WW 3200 - 3700K)'),
						17 => __('Lumière standard A'),
						18 => __('Lumière standard B'),
						19 => __('Lumière standard C'),
						20 => 'D55',
						21 => 'D65',
						22 => 'D75',
						23 => 'D50',
						24 => __('ISO tungstène pour le studio'),
						255 => __('Autre source de lumière')
					]
				],
				'Make' => [
					'name' => __('Marque'),
					'section' => 'IFD0',
					'method' => 'simple'
				],
				'MaxApertureValue' => [
					'name' => __('Ouverture maximale'),
					'section' => 'EXIF',
					'method' => 'number'
				],
				'MeteringMode' => [
					'name' => __('Mode de mesure'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0 => __('Inconnu'),
						1 => __('Moyenne'),
						2 => __('Moyenne pondérée au centre'),
						3 => __('Tache'),
						4 => __('Multipoint'),
						5 => __('Motif'),
						6 => __('Partiel'),
						255 => __('Autre')
					]
				],
				'Model' => [
					'name' => __('Modèle'),
					'section' => 'IFD0',
					'method' => 'simple'
				],
				'Orientation' => [
					'name' => __('Orientation'),
					'section' => 'IFD0',
					'method' => 'list',
					'list' => [
						1 => __('Normale'),
						2 => __('Retournement horizontal'),
						3 => __('Rotation de 180°'),
						4 => __('Retournement vertical'),
						5 => __('Rotation à gauche de 90° et retournement vertical'),
						6 => __('Rotation à droite de 90°'),
						7 => __('Rotation à droite de 90° et retournement vertical'),
						8 => __('Rotation à gauche de 90°')
					]
				],
				'ResolutionUnit' => [
					'name' => __('Unité de résolution'),
					'section' => 'IFD0',
					'method' => 'list',
					'list' => [
						1 => __('Pixels'),
						2 => __('Pouces'),
						3 => __('Centimètres')
					]
				],
				'Saturation' => [
					'name' => __('Saturation'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0 => __('Normale'),
						1 => __('Basse'),
						2 => __('Élevée')
					]
				],
				'SceneCaptureType' => [
					'name' => __('Type de capture de scène'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0 => __('Standard'),
						1 => __('Paysage'),
						2 => __('Portrait'),
						3 => __('Nuit')
					]
				],
				'SceneType' => [
					'name' => __('Type de scène'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						1 => __('Une image photographiée directement')
					]
				],
				'SensingMethod' => [
					'name' => __('Type de capteur'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						1 => __('Non défini'),
						2 => __('Capteur couleur à un processeur'),
						3 => __('Capteur couleur à deux processeurs'),
						4 => __('Capteur couleur à trois processeurs'),
						5 => __('Capteur couleur séquentiel'),
						7 => __('Capteur trilinéaire'),
						8 => __('Capteur couleur linéaire séquentiel')
					]
				],
				'Sharpness' => [
					'name' => __('Netteté'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0 => __('Normale'),
						1 => __('Faible'),
						2 => __('Forte')
					]
				],
				'Software' => [
					'name' => __('Logiciel'),
					'section' => 'IFD0',
					'method' => 'simple'
				],
				'SubjectDistance' => [
					'name' => __('Distance de mise au point'),
					'section' => 'EXIF',
					'method' => 'number'
				],
				'SubjectDistanceRange' => [
					'name' => __('Plage de distance de mise au point'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0 => __('Inconnue'),
						1 => __('Macro (<1 mètre)'),
						2 => __('Proche (1 à 3 mètres)'),
						3 => __('Éloignée (>3 mètres)')
					]
				],
				'WhiteBalance' => [
					'name' => __('Balance des blancs'),
					'section' => 'EXIF',
					'method' => 'list',
					'list' => [
						0 => __('Automatique'),
						1 => __('Manuelle')
					]
				],
				'XResolution' => [
					'name' => __('Résolution horizontale'),
					'section' => 'IFD0',
					'method' => 'number'
				],
				'YResolution' => [
					'name' => __('Résolution verticale'),
					'section' => 'IFD0',
					'method' => 'number'
				]
			];
		}

		return $params[$info] ?? [];
	}

	/**
	 * Retourne les paramètres d'une information IPTC.
	 *
	 * @param string $info
	 *
	 * @return array
	 */
	private static function _getIptcParams(string $info): array
	{
		static $params;

		if (!$params)
		{
			$params =
			[
				'City' => [
					'name' => __('Ville'),
					'tag' => '2#090'
				],
				'Contact' => [
					'name' => __('Contact'),
					'tag' => '2#118'
				],
				'Copyright' => [
					'name' => __('Copyright'),
					'tag' => '2#116'
				],
				'Country' => [
					'name' => __('Pays'),
					'tag' => '2#101'
				],
				'CountryCode' => [
					'name' => __('Code pays'),
					'tag' => '2#100'
				],
				'Creator' => [
					'name' => __('Auteur'),
					'tag' => '2#080'
				],
				'CreatorTitle' => [
					'name' => __('Titre de l\'auteur'),
					'tag' => '2#085'
				],
				'Credit' => [
					'name' => __('Crédit'),
					'tag' => '2#110'
				],
				'DateCreated' => [
					'name' => __('Date de création'),
					'tag' => '2#055'
				],
				'Description' => [
					'name' => __('Description'),
					'tag' => '2#120'
				],
				'DescriptionWriter' => [
					'name' => __('Auteur de la description'),
					'tag' => '2#122'
				],
				'DigitalCreationDate' => [
					'name' => __('Date de numérisation'),
					'tag' => '2#062'
				],
				'DigitalCreationTime' => [
					'name' => __('Heure de numérisation'),
					'tag' => '2#063'
				],
				'Headline' => [
					'name' => __('Titre'),
					'tag' => '2#105'
				],
				'Instructions' => [
					'name' => __('Instructions spéciales'),
					'tag' => '2#040'
				],
				'Keywords' => [
					'name' => __('Mots-clés'),
					'tag' => '2#025'
				],
				'ObjectName' => [
					'name' => __('Nom de l\'objet'),
					'tag' => '2#005'
				],
				'Orientation' => [
					'name' => __('Orientation'),
					'tag' => '2#131'
				],
				'ProvinceState' => [
					'name' => __('Province/État'),
					'tag' => '2#095'
				],
				'Software' => [
					'name' => __('Programme'),
					'tag' => '2#065'
				],
				'SoftwareVersion' => [
					'name' => __('Version du programme'),
					'tag' => '2#070'
				],
				'Source' => [
					'name' => __('Source'),
					'tag' => '2#115'
				],
				'SubLocation' => [
					'name' => __('Précision sur le lieu'),
					'tag' => '2#092'
				],
				'TimeCreated' => [
					'name' => __('Heure de création'),
					'tag' => '2#060'
				]
			];
		}

		return $params[$info] ?? [];
	}

	/**
	 * Récupère le contenu d'une propriété XMP.
	 *
	 * @param string $name
	 *
	 * @return array
	 */
	private function _getXmpTag(string $name): array
	{
		// Attribut.
		if (preg_match('`' . $name . '="([^"]+)"`', $this->_xmpData, $i)
		&& !Utility::isEmpty($value = trim(strip_tags($i[1]))))
		{
			return [$value];
		}

		// Balise.
		if (!preg_match('`<' . $name . '>(.+)</' . $name . '>`s', $this->_xmpData, $i))
		{
			return [];
		}

		$lang = strpos($i[1], 'xml:lang');

		// Extraction des données.
		foreach (explode('</rdf:li>', $i[1]) as $val)
		{
			if (preg_match('`<rdf:li(?:\s+xml:lang=[\'"]([a-z_-]+?)[\'"])?>(.+)`i', $val, $m))
			{
				// Type "Lang Alt".
				if ($lang && !Utility::isEmpty($m[1]))
				{
					$data[$m[1]] = $m[2];
				}

				// Types "Bag" et "Seq".
				else if (!$lang)
				{
					$data[] = $m[2];
				}
			}
		}
		$data = $data ?? ($lang ? ['x-default' => $i[1]] : [$i[1]]);

		// Nettoyage.
		foreach ($data as $k => &$v)
		{
			$v = trim(strip_tags($v));
			if (Utility::isEmpty($v))
			{
				unset($data[$k]);
			}
		}

		return $data;
	}

	/**
	 * Retourne les paramètres d'une information XMP.
	 *
	 * @param string $info
	 *
	 * @return array
	 */
	private static function _getXmpParams(string $info): array
	{
		static $params;

		if (!$params)
		{
			$params =
			[
				'Contributor' => [
					'name' => __('Contributeur'),
					'tag' => 'dc:contributor',
					'type' => 'bag'
				],
				'Coverage' => [
					'name' => __('Portée'),
					'tag' => 'dc:coverage',
					'type' => 'text'
				],
				'Creator' => [
					'name' => __('Auteur'),
					'tag' => 'dc:creator',
					'type' => 'seq'
				],
				'Date' => [
					'name' => __('Date'),
					'tag' => 'dc:date',
					'type' => 'seq'
				],
				'Description' => [
					'name' => __('Description'),
					'tag' => 'dc:description',
					'type' => 'lang'
				],
				'Format' => [
					'name' => __('Format'),
					'tag' => 'dc:format',
					'type' => 'text'
				],
				'Identifier' => [
					'name' => __('Identificateur'),
					'tag' => 'dc:identifier',
					'type' => 'text'
				],
				'Language' => [
					'name' => __('Langues'),
					'tag' => 'dc:language',
					'type' => 'bag'
				],
				'Publisher' => [
					'name' => __('Éditeur'),
					'tag' => 'dc:publisher',
					'type' => 'bag'
				],
				'Relation' => [
					'name' => __('Ressources liées'),
					'tag' => 'dc:relation',
					'type' => 'bag'
				],
				'Rights' => [
					'name' => __('Copyright'),
					'tag' => 'dc:rights',
					'type' => 'lang'
				],
				'Source' => [
					'name' => __('Source'),
					'tag' => 'dc:source',
					'type' => 'text'
				],
				'Subject' => [
					'name' => __('Mots-clés'),
					'tag' => 'dc:subject',
					'type' => 'bag'
				],
				'Title' => [
					'name' => __('Titre'),
					'tag' => 'dc:title',
					'type' => 'lang'
				],
				'Type' => [
					'name' => __('Genre (XMP)'),
					'tag' => 'dc:type',
					'type' => 'bag'
				],
			];
		}

		return $params[$info] ?? [];
	}
}
?>