<?php
declare(strict_types = 1);

/**
 * Méthodes de gestion du HTML.
 *
 * @license https://www.gnu.org/licenses/gpl-3.0.html
 * @link https://www.igalerie.org/
 */
class HTML
{
	/**
	 * Attributs de balises autorisés.
	 *
	 * @var array
	 */
	const ALLOWED_ATTRS =
	[
		'alt'      => '[-_\w\d\s,;:.%]+',
		'cite'     => '[-_\w\d\s,;:.%]+',
		'class'    => '[-_a-z0-9\s]+',
		'data-*'   => '[-_\w\d\s@&=+!?%°~./,;:*#()\[\]\|]+',
		'datetime' => '[-0-9]+',
		'height'   => '\d{1,6}',
		'href'     => '[-_\w\d@&=+?%~./,;:*#]+',
		'hreflang' => '[-a-z]{2,5}',
		'id'       => '[-_a-z0-9]+',
		'lang'     => '[-a-z]{2,5}',
		'max'      => '[-+0-9e.]+',
		'pubdate'  => '[-0-9]+',
		'rel'      => '[-_a-z0-9\s.#]+',
		'src'      => '[-_a-z0-9./:=\?&;]+',
		'target'   => '[-_a-z0-9]+',
		'title'    => '[-_\w\d\s@&=+!?%°~./,;:*#()\[\]\|]+',
		'value'    => '[-+0-9e.]+',
		'width'    => '\d{1,6}'
	];

	/**
	 * Balises autorisées.
	 * Toutes les balises de formulaires (select, etc.),
	 * d'objets (script, etc.) et de structure de page (body, etc.)
	 * ne sont pas autorisées.
	 *
	 * @var array
	 */
	const ALLOWED_TAGS =
	[
		'a', 'abbr', 'address', 'article', 'aside', 'b', 'bdi',
		'bdo', 'blockquote', 'br', 'caption', 'cite', 'code',
		'col', 'colgroup', 'data', 'dd', 'del', 'details', 'dfn',
		'div', 'dl', 'dt', 'em', 'figcaption', 'figure',
		'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
		'hr', 'i', 'img', 'ins', 'kbd', 'legend', 'li', 'mark',
		'nav', 'ol', 'p', 'pre', 'q', 'rp', 'rt', 'ruby', 's',
		'samp', 'section', 'small', 'span', 'strong', 'sub',
		'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th',
		'thead', 'time', 'tr', 'ul', 'var'
	];



	/**
	 * Supprime les balises <br> et <span> inutiles ajoutées pour
	 * conserver les nouvelles lignes, espaces multiples et tabulations.
	 *
	 * @param string $code
	 *
	 * @return string
	 */
	public static function clean(string $code): string
	{
		// Explosion du code dans un tableau.
		$options = PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE;
		$code = preg_split('`(<\s*/?[^>]+>)`i', $code, -1, $options);

		// Liste des tags à analyser.
		$tags_list = array_flip(self::ALLOWED_TAGS);
		unset($tags_list['br']);
		unset($tags_list['col']);
		unset($tags_list['hr']);
		unset($tags_list['img']);
		$tags_list = implode('|', array_keys($tags_list));

		// Suppression des <br   /> et <span class="space_... entre des balises.
		$current = [];
		foreach ($code as $key => &$str)
		{
			$regex = sprintf(
				'`<(?:\s*(/)\s*)?(%s)(?=\s|>)[^>]*>`',
				$current ? key($current) : $tags_list
			);

			if (!preg_match($regex, $str, $m))
			{
				if ($current)
				{
					if (preg_match('`^<span class="space_([^"]+)">`', $str, $m))
					{
						array_splice($code, $key + 1, 2);
						$str = $m[1] == 'tab' ? "\u{0009}" : str_repeat("\u{0020}", (int) $m[1]);
					}
					else
					{
						$str = str_replace('<br   />', '', $str);
					}
				}
				continue;
			}

			if (isset($current[$m[2]]))
			{
				$current[$m[2]][$m[1]]['count']++;
				if ($m[1] == '/' && $current[$m[2]]['']['count'] == $current[$m[2]]['/']['count'])
				{
					$current = [];
				}
			}
			elseif ($m[1] != '/')
			{
				$current[$m[2]] = ['' => ['count' => 1], '/' => ['count' => 0]];
			}
		}

		$code = implode('', $code);

		// Suppression des <br   /> en trop juste après des balises de type bloc.
		$block_tags =
		[
			'address', 'article', 'aside', 'blockquote', 'dd', 'div',
			'dl', 'dt', 'figcaption', 'figure', 'footer', 'h1', 'h2',
			'h3', 'h4', 'h5', 'h6', 'header', 'li', 'nav', 'ol', 'p',
			'pre', 'section', 'table', 'tfoot', 'ul'
		];
		$code = preg_replace_callback('`(<\s*/([^>]+)>)(<br   />)`',
		function($m) use (&$block_tags)
		{
			return in_array($m[2], $block_tags) ? $m[1] : $m[1] . $m[3];
		}, $code);
		$code = preg_replace('`(<hr\s*/?>)<br   />`', '\1', $code);
		$code = str_replace('<br   />', '<br>', $code);

		return $code;
	}

	/**
	 * Décode le code HTML autorisé d'une chaîne.
	 *
	 * @param string $str
	 *
	 * @return string
	 */
	public static function decode(string $str): string
	{
		$decode = function(array $str): string
		{
			$str = $str[0];

			// "\" forcément suspect.
			if (strstr($str, '\\'))
			{
				return $str;
			}

			// Suppression des chevrons ouvrant et fermant.
			$s = preg_replace('`^&lt;\s*/?\s*|\s*/?\s*&gt;$`', '', $str);

			// Récupèration du nom de la balise.
			$tag = explode(' ', $s, 2)[0];

			// Est-ce une balise autorisée ?
			if (!in_array($tag, self::ALLOWED_TAGS))
			{
				return $str;
			}

			// Création d'un tableau contenant les attributs de la balise.
			if (!is_string($s = preg_replace('`^' . $tag . '\s*`', '', $s)))
			{
				return $str;
			}
			if (preg_match_all('`([-a-z]{1,30}(?:=&quot;.+?&quot;)?)+`i', $s, $attrs) === FALSE)
			{
				return $str;
			}
			$attrs = $attrs[0];

			// Si la balise contient des attributs, on les analyse un à un.
			if ($count = count($attrs))
			{
				// Vérification de chaque attribut.
				for ($i = 0; $i < $count; $i++)
				{
					// Récupération du nom et de la valeur de l'attribut.
					$attr = strstr($attrs[$i], '=')
						? preg_split('`=`', $attrs[$i], 2)
						: [$attrs[$i], ''];
					list($name, $value) = $attr;

					// Attributs data-*.
					if (substr($name, 0, 5) == 'data-' && strlen($name) > 5)
					{
						$name = 'data-*';
					}

					// Est-ce un attribut autorisé ?
					if (!isset(self::ALLOWED_ATTRS[$name]))
					{
						return $str;
					}
					$attr_regexp = self::ALLOWED_ATTRS[$name];

					// Suppression des guillemets.
					if ($attr[1] !== '')
					{
						$value = preg_replace('`^(&quot;(.+)&quot;)$`', '$2', $value);
						if ($value === $attr[1])
						{
							return $str;
						}
					}

					$value = html_entity_decode($value);

					// Vérification de la présence d'entités HTML.
					if ($value !== html_entity_decode($value) || strstr($value, '&#'))
					{
						return $str;
					}

					// Vérification de la valeur de l'attribut.
					if (!preg_match('`^' . $attr_regexp . '$`ui', $value))
					{
						return $str;
					}

					// Filtrage spécifique de l'attribut "href".
					if ($name == 'href' && preg_match('`javascript\s*:`i', $value))
					{
						return $str;
					}

					// Filtrage spécifique de l'attribut "src".
					if ($name == 'src')
					{
						$regex = '(?:' . preg_quote(GALLERY_HOST) . ')?'
							. preg_quote(CONF_GALLERY_PATH)
							. '/images/[/\da-z_-]+\.(?:jpe?g|gif|png|webp)';
						if (!preg_match('`^' . $regex . '$`i', $value))
						{
							return $str;
						}
					}
				}
			}

			return htmlspecialchars_decode($str, ENT_QUOTES);
		};

		return (string) preg_replace_callback('`&lt;(?:(?!&gt;).){1,255}&gt;`', $decode, $str);
	}

	/**
	 * Convertit les URLs d'une chaîne en liens HTML.
	 *
	 * @param string $str
	 * @param int $limit
	 *   Nombre maximum de caractères du lien.
	 *
	 * @return string
	 */
	public static function linkify(string $str, int $limit = 0): string
	{
		$regexp = '`((?<!&quot;)(?<!")' . Utility::URLRegexp() . ')`i';
		$str = preg_replace($regexp, '<a href="$1">$1</a>', $str);

		return $limit
			? preg_replace(
				'`(<a[^>]+>)(.{' . $limit . '}).*(</a>)`ui',
				'\\1\\2...\\3',
				$str)
			: $str;
	}

	/**
	 * Insère une balise <br> avant chaque nouvelle ligne.
	 *
	 * @param string $str
	 *
	 * @return string
	 */
	public static function nl2br(string $str): string
	{
		$str = str_replace(["\r\n", "\r", "\n"], "<br   />", $str);
		return str_replace('<br   />', "<br   />\n", $str);
	}

	/**
	 * Ajoute des entités HTML à la place
	 * des espaces multiples et des tabulations.
	 *
	 * @param string $str
	 *
	 * @return string
	 */
	public static function spaces(string $str): string
	{
		return preg_replace_callback('`&lt;(?:(?!&gt;).){1,255}&gt;`', function($str)
		{
			if ($str[0] == '&lt;br<span class="space_3">&ensp;&nbsp;</span>/&gt;')
			{
				return '&lt;br   /&gt;';
			}
			return preg_replace('`<span class="space_[^<]+</span>`', ' ', $str[0]);
		},
		strtr($str,
		[
			"\u{0009}" => '<span class="space_tab">&#9;</span>',
			"\u{0020}\u{0020}\u{0020}\u{0020}" => '<span class="space_4">&emsp;</span>',
			"\u{0020}\u{0020}\u{0020}" => '<span class="space_3">&ensp;&nbsp;</span>',
			"\u{0020}\u{0020}" => '<span class="space_2">&ensp;</span>',
		]));
	}

	/**
	 * Applique la fonction htmlspecialchars() à une chaîne
	 * ou aux clés et valeurs d'un tableau.
	 *
	 * @param mixed $s
	 * @param bool $double_encode
	 *
	 * @return mixed
	 */
	public static function specialchars(&$s, bool $double_encode = TRUE)
	{
		if (is_array($s))
		{
			$a = [];
			foreach ($s as $k => &$v)
			{
				if (is_string($k))
				{
					self::{__FUNCTION__}($k, $double_encode);
				}
				if (is_string($v) || is_array($v))
				{
					self::{__FUNCTION__}($v, $double_encode);
				}
				$a[$k] = $v;
			}
			$s = $a;
		}

		if (is_string($s))
		{
			$s = Utility::deleteInvisibleChars($s);
			$s = defined('ENT_DISALLOWED')
				? htmlspecialchars($s, ENT_DISALLOWED | ENT_QUOTES, 'UTF-8', $double_encode)
				: htmlspecialchars($s, ENT_QUOTES, 'UTF-8', $double_encode);
		}

		return $s;
	}

	/**
	 * Applique la fonction htmlspecialchars_decode() à une chaîne
	 * ou aux clés et valeurs d'un tableau.
	 *
	 * @param mixed $s
	 *
	 * @return mixed
	 */
	public static function specialchars_decode(&$s)
	{
		if (is_array($s))
		{
			foreach ($s as $k => &$v)
			{
				if (is_string($k))
				{
					self::{__FUNCTION__}($k);
				}
				if (is_string($v) || is_array($v))
				{
					self::{__FUNCTION__}($v);
				}
			}
		}

		if (is_string($s))
		{
			$s = htmlspecialchars_decode($s, ENT_QUOTES);
		}

		return $s;
	}
}
?>