<?php
/**
 * @brief		Text Parser
 * @author		<a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
 * @copyright	(c) Invision Power Services, Inc.
 * @license		https://www.invisioncommunity.com/legal/standards/
 * @package		Invision Community
 * @since		12 Jun 2013
 */

namespace IPS\Text;

/* To prevent PHP errors (extending class does not exist) revealing path */

use function preg_replace;

if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * Text Parser
 */
class _Parser
{
	/**
	 * @brief	Regex for detecting an Emoji character
	 * @note	This string is automatically generated by the Emoji Data Builder. Do not modify it manually.
	 */
	const EMOJI_REGEX = '\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F48B}\x{200D}\x{1F468}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F48B}\x{200D}\x{1F468}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F48B}\x{200D}\x{1F469}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F468}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F468}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F469}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F91D}\x{200D}\x{1F9D1}|\x{1F3F4}\x{E0067}\x{E0062}\x{E0065}\x{E006E}\x{E0067}\x{E007F}|\x{1F3F4}\x{E0067}\x{E0062}\x{E0073}\x{E0063}\x{E0074}\x{E007F}|\x{1F3F4}\x{E0067}\x{E0062}\x{E0077}\x{E006C}\x{E0073}\x{E007F}|\x{1F468}\x{200D}\x{1F468}\x{200D}\x{1F466}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F468}\x{200D}\x{1F467}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F468}\x{200D}\x{1F467}\x{200D}\x{1F467}|\x{1F468}\x{200D}\x{1F469}\x{200D}\x{1F466}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F467}|\x{1F469}\x{200D}\x{1F469}\x{200D}\x{1F466}\x{200D}\x{1F466}|\x{1F469}\x{200D}\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F466}|\x{1F469}\x{200D}\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F467}|\x{1F3C3}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3C3}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3C4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3C4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3CA}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3CA}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3CB}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3CB}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3CC}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3CC}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2695}\x{FE0F}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2696}\x{FE0F}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2708}\x{FE0F}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2695}\x{FE0F}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2696}\x{FE0F}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2708}\x{FE0F}|\x{1F46E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F46E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F470}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F470}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F471}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F471}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F473}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F473}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F477}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F477}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F481}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F481}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F482}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F482}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F486}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F486}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F487}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F487}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F575}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F575}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F645}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F645}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F646}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F646}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F647}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F647}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F64B}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F64B}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F64D}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F64D}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F64E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F64E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F6A3}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F6A3}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F6B4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F6B4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F6B5}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F6B5}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F6B6}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F6B6}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F926}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F926}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F935}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F935}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F937}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F937}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F938}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F938}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F939}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F939}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F93D}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F93D}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F93E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F93E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9B8}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9B8}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9B9}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9B9}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9CD}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9CD}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9CE}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9CE}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9CF}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9CF}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2695}\x{FE0F}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2696}\x{FE0F}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2708}\x{FE0F}|\x{1F9D4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9D4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9D6}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9D6}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9D7}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9D7}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9D8}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9D8}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9D9}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9D9}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9DA}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9DA}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9DB}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9DB}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9DC}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9DC}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9DD}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9DD}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{26F9}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{26F9}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F33E}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F373}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F37C}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F393}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F3A4}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F3A8}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F3EB}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F3ED}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F4BB}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F4BC}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F527}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F52C}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F680}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F692}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9AF}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9B0}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9B1}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9B2}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9B3}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9BC}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9BD}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F33E}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F373}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F37C}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F393}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F3A4}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F3A8}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F3EB}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F3ED}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F4BB}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F4BC}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F527}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F52C}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F680}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F692}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9AF}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9B0}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9B1}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9B2}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9B3}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9BC}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9BD}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F33E}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F373}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F37C}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F384}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F393}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F3A4}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F3A8}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F3EB}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F3ED}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F4BB}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F4BC}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F527}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F52C}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F680}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F692}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9AF}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9B0}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9B1}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9B2}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9B3}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9BC}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F9BD}|\x{1F468}\x{200D}\x{1F466}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F467}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F467}\x{200D}\x{1F467}|\x{1F468}\x{200D}\x{1F468}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F468}\x{200D}\x{1F467}|\x{1F468}\x{200D}\x{1F469}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F469}\x{200D}\x{1F467}|\x{1F469}\x{200D}\x{1F466}\x{200D}\x{1F466}|\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F466}|\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F467}|\x{1F469}\x{200D}\x{1F469}\x{200D}\x{1F466}|\x{1F469}\x{200D}\x{1F469}\x{200D}\x{1F467}|\x{1F441}\x{FE0F}\x{200D}\x{1F5E8}\x{FE0F}|\x{1F3F3}\x{FE0F}\x{200D}\x{26A7}\x{FE0F}|\x{1F3CB}[\x{1F3FB}-\x{1F3FF}]?\x{FE0F}|\x{1F3CC}[\x{1F3FB}-\x{1F3FF}]?\x{FE0F}|\x{1F574}[\x{1F3FB}-\x{1F3FF}]?\x{FE0F}|\x{1F575}[\x{1F3FB}-\x{1F3FF}]?\x{FE0F}|\x{1F590}[\x{1F3FB}-\x{1F3FF}]?\x{FE0F}|\x{261D}[\x{1F3FB}-\x{1F3FF}]?\x{FE0F}|\x{26F9}[\x{1F3FB}-\x{1F3FF}]?\x{FE0F}|\x{270C}[\x{1F3FB}-\x{1F3FF}]?\x{FE0F}|\x{270D}[\x{1F3FB}-\x{1F3FF}]?\x{FE0F}|\x{1F3F3}\x{FE0F}\x{200D}\x{1F308}|\x{1F636}\x{200D}\x{1F32B}\x{FE0F}|\x{1F3F4}\x{200D}\x{2620}\x{FE0F}|\x{1F43B}\x{200D}\x{2744}\x{FE0F}|\x{1F46F}\x{200D}\x{2640}\x{FE0F}|\x{1F46F}\x{200D}\x{2642}\x{FE0F}|\x{1F93C}\x{200D}\x{2640}\x{FE0F}|\x{1F93C}\x{200D}\x{2642}\x{FE0F}|\x{1F9DE}\x{200D}\x{2640}\x{FE0F}|\x{1F9DE}\x{200D}\x{2642}\x{FE0F}|\x{1F9DF}\x{200D}\x{2640}\x{FE0F}|\x{1F9DF}\x{200D}\x{2642}\x{FE0F}|\x{2764}\x{FE0F}\x{200D}\x{1F525}|\x{2764}\x{FE0F}\x{200D}\x{1FA79}|\x{1F385}[\x{1F3FB}-\x{1F3FF}]?|\x{1F3C2}[\x{1F3FB}-\x{1F3FF}]?|\x{1F3C3}[\x{1F3FB}-\x{1F3FF}]?|\x{1F3C4}[\x{1F3FB}-\x{1F3FF}]?|\x{1F3C7}[\x{1F3FB}-\x{1F3FF}]?|\x{1F3CA}[\x{1F3FB}-\x{1F3FF}]?|\x{1F442}[\x{1F3FB}-\x{1F3FF}]?|\x{1F443}[\x{1F3FB}-\x{1F3FF}]?|\x{1F446}[\x{1F3FB}-\x{1F3FF}]?|\x{1F447}[\x{1F3FB}-\x{1F3FF}]?|\x{1F448}[\x{1F3FB}-\x{1F3FF}]?|\x{1F449}[\x{1F3FB}-\x{1F3FF}]?|\x{1F44A}[\x{1F3FB}-\x{1F3FF}]?|\x{1F44B}[\x{1F3FB}-\x{1F3FF}]?|\x{1F44C}[\x{1F3FB}-\x{1F3FF}]?|\x{1F44D}[\x{1F3FB}-\x{1F3FF}]?|\x{1F44E}[\x{1F3FB}-\x{1F3FF}]?|\x{1F44F}[\x{1F3FB}-\x{1F3FF}]?|\x{1F450}[\x{1F3FB}-\x{1F3FF}]?|\x{1F466}[\x{1F3FB}-\x{1F3FF}]?|\x{1F467}[\x{1F3FB}-\x{1F3FF}]?|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?|\x{1F46B}[\x{1F3FB}-\x{1F3FF}]?|\x{1F46C}[\x{1F3FB}-\x{1F3FF}]?|\x{1F46D}[\x{1F3FB}-\x{1F3FF}]?|\x{1F46E}[\x{1F3FB}-\x{1F3FF}]?|\x{1F470}[\x{1F3FB}-\x{1F3FF}]?|\x{1F471}[\x{1F3FB}-\x{1F3FF}]?|\x{1F472}[\x{1F3FB}-\x{1F3FF}]?|\x{1F473}[\x{1F3FB}-\x{1F3FF}]?|\x{1F474}[\x{1F3FB}-\x{1F3FF}]?|\x{1F475}[\x{1F3FB}-\x{1F3FF}]?|\x{1F476}[\x{1F3FB}-\x{1F3FF}]?|\x{1F477}[\x{1F3FB}-\x{1F3FF}]?|\x{1F478}[\x{1F3FB}-\x{1F3FF}]?|\x{1F47C}[\x{1F3FB}-\x{1F3FF}]?|\x{1F481}[\x{1F3FB}-\x{1F3FF}]?|\x{1F482}[\x{1F3FB}-\x{1F3FF}]?|\x{1F483}[\x{1F3FB}-\x{1F3FF}]?|\x{1F485}[\x{1F3FB}-\x{1F3FF}]?|\x{1F486}[\x{1F3FB}-\x{1F3FF}]?|\x{1F487}[\x{1F3FB}-\x{1F3FF}]?|\x{1F48F}[\x{1F3FB}-\x{1F3FF}]?|\x{1F491}[\x{1F3FB}-\x{1F3FF}]?|\x{1F4AA}[\x{1F3FB}-\x{1F3FF}]?|\x{1F57A}[\x{1F3FB}-\x{1F3FF}]?|\x{1F595}[\x{1F3FB}-\x{1F3FF}]?|\x{1F596}[\x{1F3FB}-\x{1F3FF}]?|\x{1F645}[\x{1F3FB}-\x{1F3FF}]?|\x{1F646}[\x{1F3FB}-\x{1F3FF}]?|\x{1F647}[\x{1F3FB}-\x{1F3FF}]?|\x{1F64B}[\x{1F3FB}-\x{1F3FF}]?|\x{1F64C}[\x{1F3FB}-\x{1F3FF}]?|\x{1F64D}[\x{1F3FB}-\x{1F3FF}]?|\x{1F64E}[\x{1F3FB}-\x{1F3FF}]?|\x{1F64F}[\x{1F3FB}-\x{1F3FF}]?|\x{1F6A3}[\x{1F3FB}-\x{1F3FF}]?|\x{1F6B4}[\x{1F3FB}-\x{1F3FF}]?|\x{1F6B5}[\x{1F3FB}-\x{1F3FF}]?|\x{1F6B6}[\x{1F3FB}-\x{1F3FF}]?|\x{1F6C0}[\x{1F3FB}-\x{1F3FF}]?|\x{1F6CC}[\x{1F3FB}-\x{1F3FF}]?|\x{1F90C}[\x{1F3FB}-\x{1F3FF}]?|\x{1F90F}[\x{1F3FB}-\x{1F3FF}]?|\x{1F918}[\x{1F3FB}-\x{1F3FF}]?|\x{1F919}[\x{1F3FB}-\x{1F3FF}]?|\x{1F91A}[\x{1F3FB}-\x{1F3FF}]?|\x{1F91B}[\x{1F3FB}-\x{1F3FF}]?|\x{1F91C}[\x{1F3FB}-\x{1F3FF}]?|\x{1F91D}[\x{1F3FB}-\x{1F3FF}]?|\x{1F91E}[\x{1F3FB}-\x{1F3FF}]?|\x{1F91F}[\x{1F3FB}-\x{1F3FF}]?|\x{1F926}[\x{1F3FB}-\x{1F3FF}]?|\x{1F930}[\x{1F3FB}-\x{1F3FF}]?|\x{1F931}[\x{1F3FB}-\x{1F3FF}]?|\x{1F932}[\x{1F3FB}-\x{1F3FF}]?|\x{1F933}[\x{1F3FB}-\x{1F3FF}]?|\x{1F934}[\x{1F3FB}-\x{1F3FF}]?|\x{1F935}[\x{1F3FB}-\x{1F3FF}]?|\x{1F936}[\x{1F3FB}-\x{1F3FF}]?|\x{1F937}[\x{1F3FB}-\x{1F3FF}]?|\x{1F938}[\x{1F3FB}-\x{1F3FF}]?|\x{1F939}[\x{1F3FB}-\x{1F3FF}]?|\x{1F93D}[\x{1F3FB}-\x{1F3FF}]?|\x{1F93E}[\x{1F3FB}-\x{1F3FF}]?|\x{1F977}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9B5}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9B6}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9B8}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9B9}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9BB}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9CD}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9CE}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9CF}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9D2}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9D3}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9D4}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9D5}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9D6}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9D7}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9D8}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9D9}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9DA}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9DB}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9DC}[\x{1F3FB}-\x{1F3FF}]?|\x{1F9DD}[\x{1F3FB}-\x{1F3FF}]?|\x{1FAC3}[\x{1F3FB}-\x{1F3FF}]?|\x{1FAC4}[\x{1F3FB}-\x{1F3FF}]?|\x{1FAC5}[\x{1F3FB}-\x{1F3FF}]?|\x{1FAF0}[\x{1F3FB}-\x{1F3FF}]?|\x{1FAF1}[\x{1F3FB}-\x{1F3FF}]?|\x{1FAF2}[\x{1F3FB}-\x{1F3FF}]?|\x{1FAF3}[\x{1F3FB}-\x{1F3FF}]?|\x{1FAF4}[\x{1F3FB}-\x{1F3FF}]?|\x{1FAF5}[\x{1F3FB}-\x{1F3FF}]?|\x{1FAF6}[\x{1F3FB}-\x{1F3FF}]?|\x{270A}[\x{1F3FB}-\x{1F3FF}]?|\x{270B}[\x{1F3FB}-\x{1F3FF}]?|\x{1F415}\x{200D}\x{1F9BA}|\x{1F468}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F467}|\x{1F469}\x{200D}\x{1F466}|\x{1F469}\x{200D}\x{1F467}|\x{1F62E}\x{200D}\x{1F4A8}|\x{1F635}\x{200D}\x{1F4AB}|\x{1F408}\x{200D}\x{2B1B}|\x{0023}\x{FE0F}\x{20E3}|\x{002A}\x{FE0F}\x{20E3}|\x{0030}\x{FE0F}\x{20E3}|\x{0031}\x{FE0F}\x{20E3}|\x{0032}\x{FE0F}\x{20E3}|\x{0033}\x{FE0F}\x{20E3}|\x{0034}\x{FE0F}\x{20E3}|\x{0035}\x{FE0F}\x{20E3}|\x{0036}\x{FE0F}\x{20E3}|\x{0037}\x{FE0F}\x{20E3}|\x{0038}\x{FE0F}\x{20E3}|\x{0039}\x{FE0F}\x{20E3}|\x{1F1E6}\x{1F1E8}|\x{1F1E6}\x{1F1E9}|\x{1F1E6}\x{1F1EA}|\x{1F1E6}\x{1F1EB}|\x{1F1E6}\x{1F1EC}|\x{1F1E6}\x{1F1EE}|\x{1F1E6}\x{1F1F1}|\x{1F1E6}\x{1F1F2}|\x{1F1E6}\x{1F1F4}|\x{1F1E6}\x{1F1F6}|\x{1F1E6}\x{1F1F7}|\x{1F1E6}\x{1F1F8}|\x{1F1E6}\x{1F1F9}|\x{1F1E6}\x{1F1FA}|\x{1F1E6}\x{1F1FC}|\x{1F1E6}\x{1F1FD}|\x{1F1E6}\x{1F1FF}|\x{1F1E7}\x{1F1E6}|\x{1F1E7}\x{1F1E7}|\x{1F1E7}\x{1F1E9}|\x{1F1E7}\x{1F1EA}|\x{1F1E7}\x{1F1EB}|\x{1F1E7}\x{1F1EC}|\x{1F1E7}\x{1F1ED}|\x{1F1E7}\x{1F1EE}|\x{1F1E7}\x{1F1EF}|\x{1F1E7}\x{1F1F1}|\x{1F1E7}\x{1F1F2}|\x{1F1E7}\x{1F1F3}|\x{1F1E7}\x{1F1F4}|\x{1F1E7}\x{1F1F6}|\x{1F1E7}\x{1F1F7}|\x{1F1E7}\x{1F1F8}|\x{1F1E7}\x{1F1F9}|\x{1F1E7}\x{1F1FB}|\x{1F1E7}\x{1F1FC}|\x{1F1E7}\x{1F1FE}|\x{1F1E7}\x{1F1FF}|\x{1F1E8}\x{1F1E6}|\x{1F1E8}\x{1F1E8}|\x{1F1E8}\x{1F1E9}|\x{1F1E8}\x{1F1EB}|\x{1F1E8}\x{1F1EC}|\x{1F1E8}\x{1F1ED}|\x{1F1E8}\x{1F1EE}|\x{1F1E8}\x{1F1F0}|\x{1F1E8}\x{1F1F1}|\x{1F1E8}\x{1F1F2}|\x{1F1E8}\x{1F1F3}|\x{1F1E8}\x{1F1F4}|\x{1F1E8}\x{1F1F5}|\x{1F1E8}\x{1F1F7}|\x{1F1E8}\x{1F1FA}|\x{1F1E8}\x{1F1FB}|\x{1F1E8}\x{1F1FC}|\x{1F1E8}\x{1F1FD}|\x{1F1E8}\x{1F1FE}|\x{1F1E8}\x{1F1FF}|\x{1F1E9}\x{1F1EA}|\x{1F1E9}\x{1F1EC}|\x{1F1E9}\x{1F1EF}|\x{1F1E9}\x{1F1F0}|\x{1F1E9}\x{1F1F2}|\x{1F1E9}\x{1F1F4}|\x{1F1E9}\x{1F1FF}|\x{1F1EA}\x{1F1E6}|\x{1F1EA}\x{1F1E8}|\x{1F1EA}\x{1F1EA}|\x{1F1EA}\x{1F1EC}|\x{1F1EA}\x{1F1ED}|\x{1F1EA}\x{1F1F7}|\x{1F1EA}\x{1F1F8}|\x{1F1EA}\x{1F1F9}|\x{1F1EA}\x{1F1FA}|\x{1F1EB}\x{1F1EE}|\x{1F1EB}\x{1F1EF}|\x{1F1EB}\x{1F1F0}|\x{1F1EB}\x{1F1F2}|\x{1F1EB}\x{1F1F4}|\x{1F1EB}\x{1F1F7}|\x{1F1EC}\x{1F1E6}|\x{1F1EC}\x{1F1E7}|\x{1F1EC}\x{1F1E9}|\x{1F1EC}\x{1F1EA}|\x{1F1EC}\x{1F1EB}|\x{1F1EC}\x{1F1EC}|\x{1F1EC}\x{1F1ED}|\x{1F1EC}\x{1F1EE}|\x{1F1EC}\x{1F1F1}|\x{1F1EC}\x{1F1F2}|\x{1F1EC}\x{1F1F3}|\x{1F1EC}\x{1F1F5}|\x{1F1EC}\x{1F1F6}|\x{1F1EC}\x{1F1F7}|\x{1F1EC}\x{1F1F8}|\x{1F1EC}\x{1F1F9}|\x{1F1EC}\x{1F1FA}|\x{1F1EC}\x{1F1FC}|\x{1F1EC}\x{1F1FE}|\x{1F1ED}\x{1F1F0}|\x{1F1ED}\x{1F1F2}|\x{1F1ED}\x{1F1F3}|\x{1F1ED}\x{1F1F7}|\x{1F1ED}\x{1F1F9}|\x{1F1ED}\x{1F1FA}|\x{1F1EE}\x{1F1E8}|\x{1F1EE}\x{1F1E9}|\x{1F1EE}\x{1F1EA}|\x{1F1EE}\x{1F1F1}|\x{1F1EE}\x{1F1F2}|\x{1F1EE}\x{1F1F3}|\x{1F1EE}\x{1F1F4}|\x{1F1EE}\x{1F1F6}|\x{1F1EE}\x{1F1F7}|\x{1F1EE}\x{1F1F8}|\x{1F1EE}\x{1F1F9}|\x{1F1EF}\x{1F1EA}|\x{1F1EF}\x{1F1F2}|\x{1F1EF}\x{1F1F4}|\x{1F1EF}\x{1F1F5}|\x{1F1F0}\x{1F1EA}|\x{1F1F0}\x{1F1EC}|\x{1F1F0}\x{1F1ED}|\x{1F1F0}\x{1F1EE}|\x{1F1F0}\x{1F1F2}|\x{1F1F0}\x{1F1F3}|\x{1F1F0}\x{1F1F5}|\x{1F1F0}\x{1F1F7}|\x{1F1F0}\x{1F1FC}|\x{1F1F0}\x{1F1FE}|\x{1F1F0}\x{1F1FF}|\x{1F1F1}\x{1F1E6}|\x{1F1F1}\x{1F1E7}|\x{1F1F1}\x{1F1E8}|\x{1F1F1}\x{1F1EE}|\x{1F1F1}\x{1F1F0}|\x{1F1F1}\x{1F1F7}|\x{1F1F1}\x{1F1F8}|\x{1F1F1}\x{1F1F9}|\x{1F1F1}\x{1F1FA}|\x{1F1F1}\x{1F1FB}|\x{1F1F1}\x{1F1FE}|\x{1F1F2}\x{1F1E6}|\x{1F1F2}\x{1F1E8}|\x{1F1F2}\x{1F1E9}|\x{1F1F2}\x{1F1EA}|\x{1F1F2}\x{1F1EB}|\x{1F1F2}\x{1F1EC}|\x{1F1F2}\x{1F1ED}|\x{1F1F2}\x{1F1F0}|\x{1F1F2}\x{1F1F1}|\x{1F1F2}\x{1F1F2}|\x{1F1F2}\x{1F1F3}|\x{1F1F2}\x{1F1F4}|\x{1F1F2}\x{1F1F5}|\x{1F1F2}\x{1F1F6}|\x{1F1F2}\x{1F1F7}|\x{1F1F2}\x{1F1F8}|\x{1F1F2}\x{1F1F9}|\x{1F1F2}\x{1F1FA}|\x{1F1F2}\x{1F1FB}|\x{1F1F2}\x{1F1FC}|\x{1F1F2}\x{1F1FD}|\x{1F1F2}\x{1F1FE}|\x{1F1F2}\x{1F1FF}|\x{1F1F3}\x{1F1E6}|\x{1F1F3}\x{1F1E8}|\x{1F1F3}\x{1F1EA}|\x{1F1F3}\x{1F1EB}|\x{1F1F3}\x{1F1EC}|\x{1F1F3}\x{1F1EE}|\x{1F1F3}\x{1F1F1}|\x{1F1F3}\x{1F1F4}|\x{1F1F3}\x{1F1F5}|\x{1F1F3}\x{1F1F7}|\x{1F1F3}\x{1F1FA}|\x{1F1F3}\x{1F1FF}|\x{1F1F4}\x{1F1F2}|\x{1F1F5}\x{1F1E6}|\x{1F1F5}\x{1F1EA}|\x{1F1F5}\x{1F1EB}|\x{1F1F5}\x{1F1EC}|\x{1F1F5}\x{1F1ED}|\x{1F1F5}\x{1F1F0}|\x{1F1F5}\x{1F1F1}|\x{1F1F5}\x{1F1F2}|\x{1F1F5}\x{1F1F3}|\x{1F1F5}\x{1F1F7}|\x{1F1F5}\x{1F1F8}|\x{1F1F5}\x{1F1F9}|\x{1F1F5}\x{1F1FC}|\x{1F1F5}\x{1F1FE}|\x{1F1F6}\x{1F1E6}|\x{1F1F7}\x{1F1EA}|\x{1F1F7}\x{1F1F4}|\x{1F1F7}\x{1F1F8}|\x{1F1F7}\x{1F1FA}|\x{1F1F7}\x{1F1FC}|\x{1F1F8}\x{1F1E6}|\x{1F1F8}\x{1F1E7}|\x{1F1F8}\x{1F1E8}|\x{1F1F8}\x{1F1E9}|\x{1F1F8}\x{1F1EA}|\x{1F1F8}\x{1F1EC}|\x{1F1F8}\x{1F1ED}|\x{1F1F8}\x{1F1EE}|\x{1F1F8}\x{1F1EF}|\x{1F1F8}\x{1F1F0}|\x{1F1F8}\x{1F1F1}|\x{1F1F8}\x{1F1F2}|\x{1F1F8}\x{1F1F3}|\x{1F1F8}\x{1F1F4}|\x{1F1F8}\x{1F1F7}|\x{1F1F8}\x{1F1F8}|\x{1F1F8}\x{1F1F9}|\x{1F1F8}\x{1F1FB}|\x{1F1F8}\x{1F1FD}|\x{1F1F8}\x{1F1FE}|\x{1F1F8}\x{1F1FF}|\x{1F1F9}\x{1F1E6}|\x{1F1F9}\x{1F1E8}|\x{1F1F9}\x{1F1E9}|\x{1F1F9}\x{1F1EB}|\x{1F1F9}\x{1F1EC}|\x{1F1F9}\x{1F1ED}|\x{1F1F9}\x{1F1EF}|\x{1F1F9}\x{1F1F0}|\x{1F1F9}\x{1F1F1}|\x{1F1F9}\x{1F1F2}|\x{1F1F9}\x{1F1F3}|\x{1F1F9}\x{1F1F4}|\x{1F1F9}\x{1F1F7}|\x{1F1F9}\x{1F1F9}|\x{1F1F9}\x{1F1FB}|\x{1F1F9}\x{1F1FC}|\x{1F1F9}\x{1F1FF}|\x{1F1FA}\x{1F1E6}|\x{1F1FA}\x{1F1EC}|\x{1F1FA}\x{1F1F2}|\x{1F1FA}\x{1F1F3}|\x{1F1FA}\x{1F1F8}|\x{1F1FA}\x{1F1FE}|\x{1F1FA}\x{1F1FF}|\x{1F1FB}\x{1F1E6}|\x{1F1FB}\x{1F1E8}|\x{1F1FB}\x{1F1EA}|\x{1F1FB}\x{1F1EC}|\x{1F1FB}\x{1F1EE}|\x{1F1FB}\x{1F1F3}|\x{1F1FB}\x{1F1FA}|\x{1F1FC}\x{1F1EB}|\x{1F1FC}\x{1F1F8}|\x{1F1FD}\x{1F1F0}|\x{1F1FE}\x{1F1EA}|\x{1F1FE}\x{1F1F9}|\x{1F1FF}\x{1F1E6}|\x{1F1FF}\x{1F1F2}|\x{1F1FF}\x{1F1FC}|\x{1F170}\x{FE0F}|\x{1F171}\x{FE0F}|\x{1F17E}\x{FE0F}|\x{1F17F}\x{FE0F}|\x{1F202}\x{FE0F}|\x{1F237}\x{FE0F}|\x{1F321}\x{FE0F}|\x{1F324}\x{FE0F}|\x{1F325}\x{FE0F}|\x{1F326}\x{FE0F}|\x{1F327}\x{FE0F}|\x{1F328}\x{FE0F}|\x{1F329}\x{FE0F}|\x{1F32A}\x{FE0F}|\x{1F32B}\x{FE0F}|\x{1F32C}\x{FE0F}|\x{1F336}\x{FE0F}|\x{1F37D}\x{FE0F}|\x{1F396}\x{FE0F}|\x{1F397}\x{FE0F}|\x{1F399}\x{FE0F}|\x{1F39A}\x{FE0F}|\x{1F39B}\x{FE0F}|\x{1F39E}\x{FE0F}|\x{1F39F}\x{FE0F}|\x{1F3CD}\x{FE0F}|\x{1F3CE}\x{FE0F}|\x{1F3D4}\x{FE0F}|\x{1F3D5}\x{FE0F}|\x{1F3D6}\x{FE0F}|\x{1F3D7}\x{FE0F}|\x{1F3D8}\x{FE0F}|\x{1F3D9}\x{FE0F}|\x{1F3DA}\x{FE0F}|\x{1F3DB}\x{FE0F}|\x{1F3DC}\x{FE0F}|\x{1F3DD}\x{FE0F}|\x{1F3DE}\x{FE0F}|\x{1F3DF}\x{FE0F}|\x{1F3F3}\x{FE0F}|\x{1F3F5}\x{FE0F}|\x{1F3F7}\x{FE0F}|\x{1F43F}\x{FE0F}|\x{1F441}\x{FE0F}|\x{1F4FD}\x{FE0F}|\x{1F549}\x{FE0F}|\x{1F54A}\x{FE0F}|\x{1F56F}\x{FE0F}|\x{1F570}\x{FE0F}|\x{1F573}\x{FE0F}|\x{1F576}\x{FE0F}|\x{1F577}\x{FE0F}|\x{1F578}\x{FE0F}|\x{1F579}\x{FE0F}|\x{1F587}\x{FE0F}|\x{1F58A}\x{FE0F}|\x{1F58B}\x{FE0F}|\x{1F58C}\x{FE0F}|\x{1F58D}\x{FE0F}|\x{1F5A5}\x{FE0F}|\x{1F5A8}\x{FE0F}|\x{1F5B1}\x{FE0F}|\x{1F5B2}\x{FE0F}|\x{1F5BC}\x{FE0F}|\x{1F5C2}\x{FE0F}|\x{1F5C3}\x{FE0F}|\x{1F5C4}\x{FE0F}|\x{1F5D1}\x{FE0F}|\x{1F5D2}\x{FE0F}|\x{1F5D3}\x{FE0F}|\x{1F5DC}\x{FE0F}|\x{1F5DD}\x{FE0F}|\x{1F5DE}\x{FE0F}|\x{1F5E1}\x{FE0F}|\x{1F5E3}\x{FE0F}|\x{1F5E8}\x{FE0F}|\x{1F5EF}\x{FE0F}|\x{1F5F3}\x{FE0F}|\x{1F5FA}\x{FE0F}|\x{1F6CB}\x{FE0F}|\x{1F6CD}\x{FE0F}|\x{1F6CE}\x{FE0F}|\x{1F6CF}\x{FE0F}|\x{1F6E0}\x{FE0F}|\x{1F6E1}\x{FE0F}|\x{1F6E2}\x{FE0F}|\x{1F6E3}\x{FE0F}|\x{1F6E4}\x{FE0F}|\x{1F6E5}\x{FE0F}|\x{1F6E9}\x{FE0F}|\x{1F6F0}\x{FE0F}|\x{1F6F3}\x{FE0F}|\x{00A9}\x{FE0F}|\x{00AE}\x{FE0F}|\x{203C}\x{FE0F}|\x{2049}\x{FE0F}|\x{2122}\x{FE0F}|\x{2139}\x{FE0F}|\x{2194}\x{FE0F}|\x{2195}\x{FE0F}|\x{2196}\x{FE0F}|\x{2197}\x{FE0F}|\x{2198}\x{FE0F}|\x{2199}\x{FE0F}|\x{21A9}\x{FE0F}|\x{21AA}\x{FE0F}|\x{2328}\x{FE0F}|\x{23CF}\x{FE0F}|\x{23ED}\x{FE0F}|\x{23EE}\x{FE0F}|\x{23EF}\x{FE0F}|\x{23F1}\x{FE0F}|\x{23F2}\x{FE0F}|\x{23F8}\x{FE0F}|\x{23F9}\x{FE0F}|\x{23FA}\x{FE0F}|\x{24C2}\x{FE0F}|\x{25AA}\x{FE0F}|\x{25AB}\x{FE0F}|\x{25B6}\x{FE0F}|\x{25C0}\x{FE0F}|\x{25FB}\x{FE0F}|\x{25FC}\x{FE0F}|\x{2600}\x{FE0F}|\x{2601}\x{FE0F}|\x{2602}\x{FE0F}|\x{2603}\x{FE0F}|\x{2604}\x{FE0F}|\x{260E}\x{FE0F}|\x{2611}\x{FE0F}|\x{2618}\x{FE0F}|\x{2620}\x{FE0F}|\x{2622}\x{FE0F}|\x{2623}\x{FE0F}|\x{2626}\x{FE0F}|\x{262A}\x{FE0F}|\x{262E}\x{FE0F}|\x{262F}\x{FE0F}|\x{2638}\x{FE0F}|\x{2639}\x{FE0F}|\x{263A}\x{FE0F}|\x{2640}\x{FE0F}|\x{2642}\x{FE0F}|\x{265F}\x{FE0F}|\x{2660}\x{FE0F}|\x{2663}\x{FE0F}|\x{2665}\x{FE0F}|\x{2666}\x{FE0F}|\x{2668}\x{FE0F}|\x{267B}\x{FE0F}|\x{267E}\x{FE0F}|\x{2692}\x{FE0F}|\x{2694}\x{FE0F}|\x{2695}\x{FE0F}|\x{2696}\x{FE0F}|\x{2697}\x{FE0F}|\x{2699}\x{FE0F}|\x{269B}\x{FE0F}|\x{269C}\x{FE0F}|\x{26A0}\x{FE0F}|\x{26A7}\x{FE0F}|\x{26B0}\x{FE0F}|\x{26B1}\x{FE0F}|\x{26C8}\x{FE0F}|\x{26CF}\x{FE0F}|\x{26D1}\x{FE0F}|\x{26D3}\x{FE0F}|\x{26E9}\x{FE0F}|\x{26F0}\x{FE0F}|\x{26F1}\x{FE0F}|\x{26F4}\x{FE0F}|\x{26F7}\x{FE0F}|\x{26F8}\x{FE0F}|\x{2702}\x{FE0F}|\x{2708}\x{FE0F}|\x{2709}\x{FE0F}|\x{270F}\x{FE0F}|\x{2712}\x{FE0F}|\x{2714}\x{FE0F}|\x{2716}\x{FE0F}|\x{271D}\x{FE0F}|\x{2721}\x{FE0F}|\x{2733}\x{FE0F}|\x{2734}\x{FE0F}|\x{2744}\x{FE0F}|\x{2747}\x{FE0F}|\x{2763}\x{FE0F}|\x{2764}\x{FE0F}|\x{27A1}\x{FE0F}|\x{2934}\x{FE0F}|\x{2935}\x{FE0F}|\x{2B05}\x{FE0F}|\x{2B06}\x{FE0F}|\x{2B07}\x{FE0F}|\x{3030}\x{FE0F}|\x{303D}\x{FE0F}|\x{3297}\x{FE0F}|\x{3299}\x{FE0F}|\x{1F004}|\x{1F0CF}|\x{1F18E}|\x{1F191}|\x{1F192}|\x{1F193}|\x{1F194}|\x{1F195}|\x{1F196}|\x{1F197}|\x{1F198}|\x{1F199}|\x{1F19A}|\x{1F201}|\x{1F21A}|\x{1F22F}|\x{1F232}|\x{1F233}|\x{1F234}|\x{1F235}|\x{1F236}|\x{1F238}|\x{1F239}|\x{1F23A}|\x{1F250}|\x{1F251}|\x{1F300}|\x{1F301}|\x{1F302}|\x{1F303}|\x{1F304}|\x{1F305}|\x{1F306}|\x{1F307}|\x{1F308}|\x{1F309}|\x{1F30A}|\x{1F30B}|\x{1F30C}|\x{1F30D}|\x{1F30E}|\x{1F30F}|\x{1F310}|\x{1F311}|\x{1F312}|\x{1F313}|\x{1F314}|\x{1F315}|\x{1F316}|\x{1F317}|\x{1F318}|\x{1F319}|\x{1F31A}|\x{1F31B}|\x{1F31C}|\x{1F31D}|\x{1F31E}|\x{1F31F}|\x{1F320}|\x{1F32D}|\x{1F32E}|\x{1F32F}|\x{1F330}|\x{1F331}|\x{1F332}|\x{1F333}|\x{1F334}|\x{1F335}|\x{1F337}|\x{1F338}|\x{1F339}|\x{1F33A}|\x{1F33B}|\x{1F33C}|\x{1F33D}|\x{1F33E}|\x{1F33F}|\x{1F340}|\x{1F341}|\x{1F342}|\x{1F343}|\x{1F344}|\x{1F345}|\x{1F346}|\x{1F347}|\x{1F348}|\x{1F349}|\x{1F34A}|\x{1F34B}|\x{1F34C}|\x{1F34D}|\x{1F34E}|\x{1F34F}|\x{1F350}|\x{1F351}|\x{1F352}|\x{1F353}|\x{1F354}|\x{1F355}|\x{1F356}|\x{1F357}|\x{1F358}|\x{1F359}|\x{1F35A}|\x{1F35B}|\x{1F35C}|\x{1F35D}|\x{1F35E}|\x{1F35F}|\x{1F360}|\x{1F361}|\x{1F362}|\x{1F363}|\x{1F364}|\x{1F365}|\x{1F366}|\x{1F367}|\x{1F368}|\x{1F369}|\x{1F36A}|\x{1F36B}|\x{1F36C}|\x{1F36D}|\x{1F36E}|\x{1F36F}|\x{1F370}|\x{1F371}|\x{1F372}|\x{1F373}|\x{1F374}|\x{1F375}|\x{1F376}|\x{1F377}|\x{1F378}|\x{1F379}|\x{1F37A}|\x{1F37B}|\x{1F37C}|\x{1F37E}|\x{1F37F}|\x{1F380}|\x{1F381}|\x{1F382}|\x{1F383}|\x{1F384}|\x{1F386}|\x{1F387}|\x{1F388}|\x{1F389}|\x{1F38A}|\x{1F38B}|\x{1F38C}|\x{1F38D}|\x{1F38E}|\x{1F38F}|\x{1F390}|\x{1F391}|\x{1F392}|\x{1F393}|\x{1F3A0}|\x{1F3A1}|\x{1F3A2}|\x{1F3A3}|\x{1F3A4}|\x{1F3A5}|\x{1F3A6}|\x{1F3A7}|\x{1F3A8}|\x{1F3A9}|\x{1F3AA}|\x{1F3AB}|\x{1F3AC}|\x{1F3AD}|\x{1F3AE}|\x{1F3AF}|\x{1F3B0}|\x{1F3B1}|\x{1F3B2}|\x{1F3B3}|\x{1F3B4}|\x{1F3B5}|\x{1F3B6}|\x{1F3B7}|\x{1F3B8}|\x{1F3B9}|\x{1F3BA}|\x{1F3BB}|\x{1F3BC}|\x{1F3BD}|\x{1F3BE}|\x{1F3BF}|\x{1F3C0}|\x{1F3C1}|\x{1F3C5}|\x{1F3C6}|\x{1F3C8}|\x{1F3C9}|\x{1F3CF}|\x{1F3D0}|\x{1F3D1}|\x{1F3D2}|\x{1F3D3}|\x{1F3E0}|\x{1F3E1}|\x{1F3E2}|\x{1F3E3}|\x{1F3E4}|\x{1F3E5}|\x{1F3E6}|\x{1F3E7}|\x{1F3E8}|\x{1F3E9}|\x{1F3EA}|\x{1F3EB}|\x{1F3EC}|\x{1F3ED}|\x{1F3EE}|\x{1F3EF}|\x{1F3F0}|\x{1F3F4}|\x{1F3F8}|\x{1F3F9}|\x{1F3FA}|\x{1F3FB}|\x{1F3FC}|\x{1F3FD}|\x{1F3FE}|\x{1F3FF}|\x{1F400}|\x{1F401}|\x{1F402}|\x{1F403}|\x{1F404}|\x{1F405}|\x{1F406}|\x{1F407}|\x{1F408}|\x{1F409}|\x{1F40A}|\x{1F40B}|\x{1F40C}|\x{1F40D}|\x{1F40E}|\x{1F40F}|\x{1F410}|\x{1F411}|\x{1F412}|\x{1F413}|\x{1F414}|\x{1F415}|\x{1F416}|\x{1F417}|\x{1F418}|\x{1F419}|\x{1F41A}|\x{1F41B}|\x{1F41C}|\x{1F41D}|\x{1F41E}|\x{1F41F}|\x{1F420}|\x{1F421}|\x{1F422}|\x{1F423}|\x{1F424}|\x{1F425}|\x{1F426}|\x{1F427}|\x{1F428}|\x{1F429}|\x{1F42A}|\x{1F42B}|\x{1F42C}|\x{1F42D}|\x{1F42E}|\x{1F42F}|\x{1F430}|\x{1F431}|\x{1F432}|\x{1F433}|\x{1F434}|\x{1F435}|\x{1F436}|\x{1F437}|\x{1F438}|\x{1F439}|\x{1F43A}|\x{1F43B}|\x{1F43C}|\x{1F43D}|\x{1F43E}|\x{1F440}|\x{1F444}|\x{1F445}|\x{1F451}|\x{1F452}|\x{1F453}|\x{1F454}|\x{1F455}|\x{1F456}|\x{1F457}|\x{1F458}|\x{1F459}|\x{1F45A}|\x{1F45B}|\x{1F45C}|\x{1F45D}|\x{1F45E}|\x{1F45F}|\x{1F460}|\x{1F461}|\x{1F462}|\x{1F463}|\x{1F464}|\x{1F465}|\x{1F46A}|\x{1F46F}|\x{1F479}|\x{1F47A}|\x{1F47B}|\x{1F47D}|\x{1F47E}|\x{1F47F}|\x{1F480}|\x{1F484}|\x{1F488}|\x{1F489}|\x{1F48A}|\x{1F48B}|\x{1F48C}|\x{1F48D}|\x{1F48E}|\x{1F490}|\x{1F492}|\x{1F493}|\x{1F494}|\x{1F495}|\x{1F496}|\x{1F497}|\x{1F498}|\x{1F499}|\x{1F49A}|\x{1F49B}|\x{1F49C}|\x{1F49D}|\x{1F49E}|\x{1F49F}|\x{1F4A0}|\x{1F4A1}|\x{1F4A2}|\x{1F4A3}|\x{1F4A4}|\x{1F4A5}|\x{1F4A6}|\x{1F4A7}|\x{1F4A8}|\x{1F4A9}|\x{1F4AB}|\x{1F4AC}|\x{1F4AD}|\x{1F4AE}|\x{1F4AF}|\x{1F4B0}|\x{1F4B1}|\x{1F4B2}|\x{1F4B3}|\x{1F4B4}|\x{1F4B5}|\x{1F4B6}|\x{1F4B7}|\x{1F4B8}|\x{1F4B9}|\x{1F4BA}|\x{1F4BB}|\x{1F4BC}|\x{1F4BD}|\x{1F4BE}|\x{1F4BF}|\x{1F4C0}|\x{1F4C1}|\x{1F4C2}|\x{1F4C3}|\x{1F4C4}|\x{1F4C5}|\x{1F4C6}|\x{1F4C7}|\x{1F4C8}|\x{1F4C9}|\x{1F4CA}|\x{1F4CB}|\x{1F4CC}|\x{1F4CD}|\x{1F4CE}|\x{1F4CF}|\x{1F4D0}|\x{1F4D1}|\x{1F4D2}|\x{1F4D3}|\x{1F4D4}|\x{1F4D5}|\x{1F4D6}|\x{1F4D7}|\x{1F4D8}|\x{1F4D9}|\x{1F4DA}|\x{1F4DB}|\x{1F4DC}|\x{1F4DD}|\x{1F4DE}|\x{1F4DF}|\x{1F4E0}|\x{1F4E1}|\x{1F4E2}|\x{1F4E3}|\x{1F4E4}|\x{1F4E5}|\x{1F4E6}|\x{1F4E7}|\x{1F4E8}|\x{1F4E9}|\x{1F4EA}|\x{1F4EB}|\x{1F4EC}|\x{1F4ED}|\x{1F4EE}|\x{1F4EF}|\x{1F4F0}|\x{1F4F1}|\x{1F4F2}|\x{1F4F3}|\x{1F4F4}|\x{1F4F5}|\x{1F4F6}|\x{1F4F7}|\x{1F4F8}|\x{1F4F9}|\x{1F4FA}|\x{1F4FB}|\x{1F4FC}|\x{1F4FF}|\x{1F500}|\x{1F501}|\x{1F502}|\x{1F503}|\x{1F504}|\x{1F505}|\x{1F506}|\x{1F507}|\x{1F508}|\x{1F509}|\x{1F50A}|\x{1F50B}|\x{1F50C}|\x{1F50D}|\x{1F50E}|\x{1F50F}|\x{1F510}|\x{1F511}|\x{1F512}|\x{1F513}|\x{1F514}|\x{1F515}|\x{1F516}|\x{1F517}|\x{1F518}|\x{1F519}|\x{1F51A}|\x{1F51B}|\x{1F51C}|\x{1F51D}|\x{1F51E}|\x{1F51F}|\x{1F520}|\x{1F521}|\x{1F522}|\x{1F523}|\x{1F524}|\x{1F525}|\x{1F526}|\x{1F527}|\x{1F528}|\x{1F529}|\x{1F52A}|\x{1F52B}|\x{1F52C}|\x{1F52D}|\x{1F52E}|\x{1F52F}|\x{1F530}|\x{1F531}|\x{1F532}|\x{1F533}|\x{1F534}|\x{1F535}|\x{1F536}|\x{1F537}|\x{1F538}|\x{1F539}|\x{1F53A}|\x{1F53B}|\x{1F53C}|\x{1F53D}|\x{1F54B}|\x{1F54C}|\x{1F54D}|\x{1F54E}|\x{1F550}|\x{1F551}|\x{1F552}|\x{1F553}|\x{1F554}|\x{1F555}|\x{1F556}|\x{1F557}|\x{1F558}|\x{1F559}|\x{1F55A}|\x{1F55B}|\x{1F55C}|\x{1F55D}|\x{1F55E}|\x{1F55F}|\x{1F560}|\x{1F561}|\x{1F562}|\x{1F563}|\x{1F564}|\x{1F565}|\x{1F566}|\x{1F567}|\x{1F5A4}|\x{1F5FB}|\x{1F5FC}|\x{1F5FD}|\x{1F5FE}|\x{1F5FF}|\x{1F600}|\x{1F601}|\x{1F602}|\x{1F603}|\x{1F604}|\x{1F605}|\x{1F606}|\x{1F607}|\x{1F608}|\x{1F609}|\x{1F60A}|\x{1F60B}|\x{1F60C}|\x{1F60D}|\x{1F60E}|\x{1F60F}|\x{1F610}|\x{1F611}|\x{1F612}|\x{1F613}|\x{1F614}|\x{1F615}|\x{1F616}|\x{1F617}|\x{1F618}|\x{1F619}|\x{1F61A}|\x{1F61B}|\x{1F61C}|\x{1F61D}|\x{1F61E}|\x{1F61F}|\x{1F620}|\x{1F621}|\x{1F622}|\x{1F623}|\x{1F624}|\x{1F625}|\x{1F626}|\x{1F627}|\x{1F628}|\x{1F629}|\x{1F62A}|\x{1F62B}|\x{1F62C}|\x{1F62D}|\x{1F62E}|\x{1F62F}|\x{1F630}|\x{1F631}|\x{1F632}|\x{1F633}|\x{1F634}|\x{1F635}|\x{1F636}|\x{1F637}|\x{1F638}|\x{1F639}|\x{1F63A}|\x{1F63B}|\x{1F63C}|\x{1F63D}|\x{1F63E}|\x{1F63F}|\x{1F640}|\x{1F641}|\x{1F642}|\x{1F643}|\x{1F644}|\x{1F648}|\x{1F649}|\x{1F64A}|\x{1F680}|\x{1F681}|\x{1F682}|\x{1F683}|\x{1F684}|\x{1F685}|\x{1F686}|\x{1F687}|\x{1F688}|\x{1F689}|\x{1F68A}|\x{1F68B}|\x{1F68C}|\x{1F68D}|\x{1F68E}|\x{1F68F}|\x{1F690}|\x{1F691}|\x{1F692}|\x{1F693}|\x{1F694}|\x{1F695}|\x{1F696}|\x{1F697}|\x{1F698}|\x{1F699}|\x{1F69A}|\x{1F69B}|\x{1F69C}|\x{1F69D}|\x{1F69E}|\x{1F69F}|\x{1F6A0}|\x{1F6A1}|\x{1F6A2}|\x{1F6A4}|\x{1F6A5}|\x{1F6A6}|\x{1F6A7}|\x{1F6A8}|\x{1F6A9}|\x{1F6AA}|\x{1F6AB}|\x{1F6AC}|\x{1F6AD}|\x{1F6AE}|\x{1F6AF}|\x{1F6B0}|\x{1F6B1}|\x{1F6B2}|\x{1F6B3}|\x{1F6B7}|\x{1F6B8}|\x{1F6B9}|\x{1F6BA}|\x{1F6BB}|\x{1F6BC}|\x{1F6BD}|\x{1F6BE}|\x{1F6BF}|\x{1F6C1}|\x{1F6C2}|\x{1F6C3}|\x{1F6C4}|\x{1F6C5}|\x{1F6D0}|\x{1F6D1}|\x{1F6D2}|\x{1F6D5}|\x{1F6D6}|\x{1F6D7}|\x{1F6DD}|\x{1F6DE}|\x{1F6DF}|\x{1F6EB}|\x{1F6EC}|\x{1F6F4}|\x{1F6F5}|\x{1F6F6}|\x{1F6F7}|\x{1F6F8}|\x{1F6F9}|\x{1F6FA}|\x{1F6FB}|\x{1F6FC}|\x{1F7E0}|\x{1F7E1}|\x{1F7E2}|\x{1F7E3}|\x{1F7E4}|\x{1F7E5}|\x{1F7E6}|\x{1F7E7}|\x{1F7E8}|\x{1F7E9}|\x{1F7EA}|\x{1F7EB}|\x{1F7F0}|\x{1F90D}|\x{1F90E}|\x{1F910}|\x{1F911}|\x{1F912}|\x{1F913}|\x{1F914}|\x{1F915}|\x{1F916}|\x{1F917}|\x{1F920}|\x{1F921}|\x{1F922}|\x{1F923}|\x{1F924}|\x{1F925}|\x{1F927}|\x{1F928}|\x{1F929}|\x{1F92A}|\x{1F92B}|\x{1F92C}|\x{1F92D}|\x{1F92E}|\x{1F92F}|\x{1F93A}|\x{1F93C}|\x{1F93F}|\x{1F940}|\x{1F941}|\x{1F942}|\x{1F943}|\x{1F944}|\x{1F945}|\x{1F947}|\x{1F948}|\x{1F949}|\x{1F94A}|\x{1F94B}|\x{1F94C}|\x{1F94D}|\x{1F94E}|\x{1F94F}|\x{1F950}|\x{1F951}|\x{1F952}|\x{1F953}|\x{1F954}|\x{1F955}|\x{1F956}|\x{1F957}|\x{1F958}|\x{1F959}|\x{1F95A}|\x{1F95B}|\x{1F95C}|\x{1F95D}|\x{1F95E}|\x{1F95F}|\x{1F960}|\x{1F961}|\x{1F962}|\x{1F963}|\x{1F964}|\x{1F965}|\x{1F966}|\x{1F967}|\x{1F968}|\x{1F969}|\x{1F96A}|\x{1F96B}|\x{1F96C}|\x{1F96D}|\x{1F96E}|\x{1F96F}|\x{1F970}|\x{1F971}|\x{1F972}|\x{1F973}|\x{1F974}|\x{1F975}|\x{1F976}|\x{1F978}|\x{1F979}|\x{1F97A}|\x{1F97B}|\x{1F97C}|\x{1F97D}|\x{1F97E}|\x{1F97F}|\x{1F980}|\x{1F981}|\x{1F982}|\x{1F983}|\x{1F984}|\x{1F985}|\x{1F986}|\x{1F987}|\x{1F988}|\x{1F989}|\x{1F98A}|\x{1F98B}|\x{1F98C}|\x{1F98D}|\x{1F98E}|\x{1F98F}|\x{1F990}|\x{1F991}|\x{1F992}|\x{1F993}|\x{1F994}|\x{1F995}|\x{1F996}|\x{1F997}|\x{1F998}|\x{1F999}|\x{1F99A}|\x{1F99B}|\x{1F99C}|\x{1F99D}|\x{1F99E}|\x{1F99F}|\x{1F9A0}|\x{1F9A1}|\x{1F9A2}|\x{1F9A3}|\x{1F9A4}|\x{1F9A5}|\x{1F9A6}|\x{1F9A7}|\x{1F9A8}|\x{1F9A9}|\x{1F9AA}|\x{1F9AB}|\x{1F9AC}|\x{1F9AD}|\x{1F9AE}|\x{1F9AF}|\x{1F9B4}|\x{1F9B7}|\x{1F9BA}|\x{1F9BC}|\x{1F9BD}|\x{1F9BE}|\x{1F9BF}|\x{1F9C0}|\x{1F9C1}|\x{1F9C2}|\x{1F9C3}|\x{1F9C4}|\x{1F9C5}|\x{1F9C6}|\x{1F9C7}|\x{1F9C8}|\x{1F9C9}|\x{1F9CA}|\x{1F9CB}|\x{1F9CC}|\x{1F9D0}|\x{1F9DE}|\x{1F9DF}|\x{1F9E0}|\x{1F9E1}|\x{1F9E2}|\x{1F9E3}|\x{1F9E4}|\x{1F9E5}|\x{1F9E6}|\x{1F9E7}|\x{1F9E8}|\x{1F9E9}|\x{1F9EA}|\x{1F9EB}|\x{1F9EC}|\x{1F9ED}|\x{1F9EE}|\x{1F9EF}|\x{1F9F0}|\x{1F9F1}|\x{1F9F2}|\x{1F9F3}|\x{1F9F4}|\x{1F9F5}|\x{1F9F6}|\x{1F9F7}|\x{1F9F8}|\x{1F9F9}|\x{1F9FA}|\x{1F9FB}|\x{1F9FC}|\x{1F9FD}|\x{1F9FE}|\x{1F9FF}|\x{1FA70}|\x{1FA71}|\x{1FA72}|\x{1FA73}|\x{1FA74}|\x{1FA78}|\x{1FA79}|\x{1FA7A}|\x{1FA7B}|\x{1FA7C}|\x{1FA80}|\x{1FA81}|\x{1FA82}|\x{1FA83}|\x{1FA84}|\x{1FA85}|\x{1FA86}|\x{1FA90}|\x{1FA91}|\x{1FA92}|\x{1FA93}|\x{1FA94}|\x{1FA95}|\x{1FA96}|\x{1FA97}|\x{1FA98}|\x{1FA99}|\x{1FA9A}|\x{1FA9B}|\x{1FA9C}|\x{1FA9D}|\x{1FA9E}|\x{1FA9F}|\x{1FAA0}|\x{1FAA1}|\x{1FAA2}|\x{1FAA3}|\x{1FAA4}|\x{1FAA5}|\x{1FAA6}|\x{1FAA7}|\x{1FAA8}|\x{1FAA9}|\x{1FAAA}|\x{1FAAB}|\x{1FAAC}|\x{1FAB0}|\x{1FAB1}|\x{1FAB2}|\x{1FAB3}|\x{1FAB4}|\x{1FAB5}|\x{1FAB6}|\x{1FAB7}|\x{1FAB8}|\x{1FAB9}|\x{1FABA}|\x{1FAC0}|\x{1FAC1}|\x{1FAC2}|\x{1FAD0}|\x{1FAD1}|\x{1FAD2}|\x{1FAD3}|\x{1FAD4}|\x{1FAD5}|\x{1FAD6}|\x{1FAD7}|\x{1FAD8}|\x{1FAD9}|\x{1FAE0}|\x{1FAE1}|\x{1FAE2}|\x{1FAE3}|\x{1FAE4}|\x{1FAE5}|\x{1FAE6}|\x{1FAE7}|\x{231A}|\x{231B}|\x{23E9}|\x{23EA}|\x{23EB}|\x{23EC}|\x{23F0}|\x{23F3}|\x{25FD}|\x{25FE}|\x{2614}|\x{2615}|\x{2648}|\x{2649}|\x{264A}|\x{264B}|\x{264C}|\x{264D}|\x{264E}|\x{264F}|\x{2650}|\x{2651}|\x{2652}|\x{2653}|\x{267F}|\x{2693}|\x{26A1}|\x{26AA}|\x{26AB}|\x{26BD}|\x{26BE}|\x{26C4}|\x{26C5}|\x{26CE}|\x{26D4}|\x{26EA}|\x{26F2}|\x{26F3}|\x{26F5}|\x{26FA}|\x{26FD}|\x{2705}|\x{2728}|\x{274C}|\x{274E}|\x{2753}|\x{2754}|\x{2755}|\x{2757}|\x{2795}|\x{2796}|\x{2797}|\x{27B0}|\x{27BF}|\x{2B1B}|\x{2B1C}|\x{2B50}|\x{2B55}';

	/**
	 * @brief	Regex for detecting email addresses
	 */
	const EMAIL_REGEX = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,9}';

	/* !Parser: Bootstrap */

	/**
	 * @brief	If parsing BBCode, the supported BBCode tags
	 */
	protected $bbcode = NULL;
	
	/**
	 * @brief	Attachment IDs
	 */
	protected $attachIds = NULL;
		
	/**
	 * @brief	Attachment Lang
	 */
	protected $attachIdsLang = NULL;
		
	/**
	 * @brief	Rows from core_attachments_map containing attachments which belong to the content being edited - as they are found by the parser, they will be removed so we are left with attachments that have been removed
	 */
	public $existingAttachments = array();
	
	/**
	 * @brief	Attachment IDs
	 */
	public $mappedAttachments = array();
	
	/**
	 * @brief	If parsing BBCode or attachments, the member posting
	 */
	protected $member = NULL;
	
	/**
	 * @brief	If parsing BBCode or attachments, the Editor area we're parsing in. e.g. "core_Signatures". A boolean value will allow or disallow all BBCodes that are dependant on area.
	 */
	protected $area = NULL;
	
	/**
	 * @brief	Loose Profanity Filters
	 */
	protected $looseProfanity = array();
	
	/**
	 * @brief	Exact Profanity Filters
	 */
	protected $exactProfanity = array();
	
	/**
	 * @brief	Case-sensitive Acronyms
	 */
	public $caseSensitiveAcronyms = array();
	
	/**
	 * @brief	Case-insensitive Acronyms
	 */
	public $caseInsensitiveAcronyms = array();
	
	/**
	 * @brief	If cleaning HTML, the HTMLPurifier object
	 */
	protected $htmlPurifier = NULL;

	/**
	 * @brief Save on queries and fetch the alt label would just the once
	 */
	protected $_altLabelWord = NULL;
				
	/**
	 * Constructor
	 *
	 * @param	bool				$bbcode				Parse BBCode?
	 * @param	array|null			$attachIds			array of ID numbers to idenfity content for attachments if the content has been saved - the first two must be int or null, the third must be string or null. If content has not been saved yet, an MD5 hash used to claim attachments after saving.
	 * @param	\IPS\Member|null		$member				The member posting, NULL will use currently logged in member.
	 * @param	string|bool			$area				If parsing BBCode or attachments, the Editor area we're parsing in. e.g. "core_Signatures". A boolean value will allow or disallow all BBCodes that are dependant on area.
	 * @param	bool				$filterProfanity	Remove profanity?
	 * @param	bool				$cleanHtml			If TRUE, HTML will be cleaned through HTMLPurifier
	 * @param	callback			$htmlPurifierConfig	A function which will be passed the HTMLPurifier_Config object to customise it - see example
	 * @param	bool				$parseAcronyms		Parse acronyms?
	 * @param	?int				$attachIdsLang		Language ID number if this Editor is part of a Translatable field.
	 * @return	void
	 */
	public function __construct( $bbcode=FALSE, $attachIds=NULL, \IPS\Member $member=NULL, $area=FALSE, $filterProfanity=TRUE, $cleanHtml=TRUE, $htmlPurifierConfig=NULL, $parseAcronyms=TRUE, $attachIdsLang=NULL )
	{
		/*  Set the Member */
		$this->member = $member ?: \IPS\Member::loggedIn();

		/* Set the member and area */
		if ( $bbcode or $attachIds )
		{
			$this->area = $area;
		}
		
		/* Get available BBCodes */
		if ( $bbcode )
		{
			$this->bbcode = static::bbcodeTags( $this->member, $this->area );
		}
		
		/* Get attachments */
		$this->attachIds = $attachIds;
		$this->attachIdsLang = $attachIdsLang;
		if( $attachIds !== NULL )
		{
			$where = array( array( 'location_key=?', $area ) );
			if ( \is_array( $attachIds ) )
			{
				$i = 1;
				foreach ( $attachIds as $id )
				{
					$where[] = array("id{$i}=?", $id);
					$i++;
				}
			}
			elseif ( \is_string( $attachIds ) )
			{
				$where[] = array( 'temp=?', $attachIds );
			}

			$this->existingAttachments = iterator_to_array( \IPS\Db::i()->select( '*', 'core_attachments_map', $where )->setKeyField( 'attachment_id' ) );
			$this->mappedAttachments = array_keys( $this->existingAttachments );
		}

		/* Get profanity filters */
		if ( $filterProfanity )
		{
			foreach( \IPS\core\Profanity::getProfanity() AS $profanity )
			{
				if ( $profanity->action == 'swap' )
				{
					if ( $profanity->m_exact )
					{
						$this->exactProfanity[ $profanity->type ] = $profanity->swop;
					}
					else
					{
						$this->looseProfanity[ $profanity->type ] = $profanity->swop;
					}
				}
			}
		}
		
		/* Get HTMLPurifier Configuration */
		if ( $cleanHtml )
		{
			if ( !\function_exists('idn_to_ascii') )
			{
				\IPS\IPS::$PSR0Namespaces['TrueBV'] = \IPS\ROOT_PATH . "/system/3rd_party/php-punycode";
				require_once \IPS\ROOT_PATH . "/system/3rd_party/php-punycode/polyfill.php";
			}
			require_once \IPS\ROOT_PATH . "/system/3rd_party/HTMLPurifier/HTMLPurifier.auto.php";
			$this->htmlPurifier = new \HTMLPurifier( $this->_htmlPurifierConfiguration( $htmlPurifierConfig ) );
		}
				
		/* Get acronyms */
		if ( $parseAcronyms )
		{
			$this->caseSensitiveAcronyms = iterator_to_array( \IPS\Db::i()->select( array( 'a_short', 'a_long', 'a_type' ), 'core_acronyms', array( 'a_casesensitive=1' ) )->setKeyField( 'a_short' ) );
			
			$this->caseInsensitiveAcronyms = array();
			foreach ( \IPS\Db::i()->select( array( 'a_short', 'a_long', 'a_type' ), 'core_acronyms', array( 'a_casesensitive=0' ) )->setKeyField( 'a_short' ) as $k => $v )
			{
				$this->caseInsensitiveAcronyms[ mb_strtolower( $k ) ] = $v;
			} 
		}
	}

	/**
	 * Force bbcode parsing enabled
	 *
	 * @return void
	 */
	public function __set( $name, $enabled = TRUE )
	{
		if( $name == 'forceBbcodeEnabled' )
		{
			$this->forceBbcodeEnabled = $enabled;

			if( $enabled )
			{
				$this->bbcode = static::bbcodeTags( $this->member, $this->area );
			}
			else
			{
				$this->bbcode = NULL;
			}
		}
	}
	
	/**
	 * Parse
	 *
	 * @param	string	$value	HTML to parse
	 * @return	string
	 */
	public function parse( $value )
	{		
		/* Clean HTML */
		$value = $this->purify( $value );

		/* BBCode, Profanity, etc. */
		if ( $value )
		{
			$value = $this->_parseContent( $value );
		}
						
		/* Clean HTML */
		$value = $this->purify( $value );

		/* Replace any {fileStore.whatever} tags with <fileStore.whatever> */
		$value = static::replaceFileStoreTags( $value );

		/* Return */
		return $value;
	}

	/**
	 * Parse
	 *
	 * @param	string	$value	HTML to run through HTML purifier
	 * @return	string
	 */
	public function purify( $value )
	{		
		/* CKEditor sometimes includes these for markers. HTMLPurifier will remove the style attribute so we need to strip them first */
		$value = str_replace( '<span style="display: none;">&nbsp;</span>', '', $value );
				
		/* Clean HTML */
		if ( $value and $this->htmlPurifier )
		{
			$value = $this->htmlPurifier->purify( $value );
		}

		return $value;
	}

	/**
	 * Returns the blank image used as a placeholder to facilitate lazy loading
	 *
	 * @return	string
	 */
	public static function blankImage()
	{
		return (string) \IPS\Http\Url::internal( "applications/core/interface/js/spacer.png", 'none', NULL, array(), \IPS\Http\Url::PROTOCOL_RELATIVE );
	}

	/**
	 * Returns a url to a blank page used as a placeholder to facilitate lazy loading in frames
	 *
	 * @return	string
	 */
	public static function blankPage()
	{
		return (string) \IPS\Http\Url::internal( "applications/core/interface/index.html", 'none', NULL, array(), \IPS\Http\Url::PROTOCOL_RELATIVE );
	}

	/**
	 * Remove image proxy
	 *
	 * @param	string		$content		HTML to parse
	 * @param	bool		$useProxyUrl	Use the proxied image URL (the locally stored image) instead of the original URL
	 * @return	stinrg
	 */
	public static function removeImageProxy( $content, $useProxyUrl = FALSE )
	{
		$source = new \IPS\Xml\DOMDocument( '1.0', 'UTF-8' );
		$source->loadHTML( \IPS\Xml\DOMDocument::wrapHtml( $content ) );

		/* Get document images */
		$contentImages = $source->getElementsByTagName( 'img' );

		foreach( $contentImages as $element )
		{
			static::_removeImageProxy( $element, $useProxyUrl );
		}

		/* Get DOMDocument output */
		$content = \IPS\Text\DOMParser::getDocumentBodyContents( $source );

		/* Replace file storage tags */
		$content = preg_replace( '/&lt;fileStore\.([\d\w\_]+?)&gt;/i', '<fileStore.$1>', $content );

		/* DOMDocument::saveHTML will encode the base_url brackets, so we need to make sure it's in the expected format. */
		return str_replace( '&lt;___base_url___&gt;', '<___base_url___>', $content );
	}

	/**
	 * Parse content to add or remove lazy loading
	 *
	 * @param	string		$content	HTML to parse
	 * @param	bool			$status		Add or remove lazy loading
	 * @return	stinrg
	 */
	public static function parseLazyLoad( $content, $status=TRUE )
	{
		/* Lazy loading applies to images, iframes and videos - return now if we don't detect any with basic string checks */
		if( mb_strpos( $content, '<img' ) === FALSE AND mb_strpos( $content, '<iframe' ) === FALSE AND mb_strpos( $content, '<source' ) === FALSE AND mb_strpos( $content, '<video' ) === FALSE )
		{
			return $content;
		}

		/* Are we adding lazy loading? */
		if( $status )
		{
			return static::addLazyLoad( $content );
		}
		/* No, we're removing */
		else
		{
			return static::removeLazyLoad( $content );
		}
	}

	/**
	 * Add lazy-loading to content
	 *
	 * @param	string		$content	HTML to parse
	 * @return	stinrg
	 */
	public static function addLazyLoad( $content )
	{		
		/* Load source */
		$source = new \IPS\Xml\DOMDocument( '1.0', 'UTF-8' );
		$source->loadHTML( \IPS\Xml\DOMDocument::wrapHtml( $content ) );
		
		/* Swap src for data-src */
		$contentImages = $source->getElementsByTagName( 'img' );
		foreach( $contentImages as $element )
		{
			if ( $element->hasAttribute('src') )
			{
				$imgSrc = $element->hasAttribute('data-src') ? $element->getAttribute( 'data-src' ) : $element->getAttribute( 'src' );

				$element->removeAttribute( 'data-loaded' );
				$element->setAttribute( 'data-src', $imgSrc );
				$element->setAttribute( 'src', static::blankImage() ); // A blank pixel

				if ( $element->hasAttribute('data-fileid') AND $attachment = static::_getAttachment( $element->getAttribute('src'), $element->getAttribute('data-fileid') ) )
				{
					$width = $attachment['attach_thumb_width'] ?: $attachment['attach_img_width'];
					$height = ( $attachment['attach_thumb_height'] AND $attachment['attach_thumb_width'] ) ? $attachment['attach_thumb_height'] : $attachment['attach_img_height'];

					if( !$element->hasAttribute('width') )
					{
						$element->setAttribute( 'width', $width );
					}

					$element->setAttribute( 'data-ratio', number_format( (float) round( ( $height / $width ) * 100, 2 ), 2, '.', '' ) );
				}
			}
		}

		/* Set flags on videos */
		$contentVideos = $source->getElementsByTagName( 'video' );
		foreach( $contentVideos as $element )
		{
			if ( $element->hasAttribute('data-controller') )
			{
				$element->setAttribute( 'data-video-embed', '' );
				$element->removeAttribute( 'data-controller' );
			}
		}

		/* Swap src for data-video-src */
		$contentVideos = $source->getElementsByTagName( 'source' );
		foreach( $contentVideos as $element )
		{
			if ( $element->parentNode->tagName === 'video' and $element->hasAttribute('src') )
			{
				$videoSrc = $element->hasAttribute('data-video-src') ? $element->getAttribute( 'data-video-src' ) : $element->getAttribute( 'src' );

				$element->setAttribute( 'data-video-src', $videoSrc );
				$element->setAttribute( 'src', static::blankImage() ); // A blank pixel
			}
		}

		/* Swap src for data-embed-src */
		$contentEmbeds = $source->getElementsByTagName( 'iframe' );
		foreach( $contentEmbeds as $element )
		{
			if ( $element->hasAttribute('src') )
			{
				$embedSrc = $element->hasAttribute('data-embed-src') ? $element->getAttribute( 'data-embed-src' ) : $element->getAttribute( 'src' );

				$element->setAttribute( 'data-embed-src', $embedSrc );
				$element->removeAttribute( 'data-controller' );
				$element->setAttribute( 'src', static::blankPage() ); // A blank pixel
			}
		}
		
		/* Get DOMDocument output */
		$content = \IPS\Text\DOMParser::getDocumentBodyContents( $source );

		/* Replace file storage tags */
		$content = preg_replace( '/&lt;fileStore\.([\d\w\_]+?)&gt;/i', '<fileStore.$1>', $content );

		/* DOMDocument::saveHTML will encode the base_url brackets, so we need to make sure it's in the expected format. */
		return str_replace( '&lt;___base_url___&gt;', '<___base_url___>', $content );
	}

	/**
	 * Remove lazy-loading from content
	 *
	 * @param	string		$content	HTML to parse
	 * @return	stinrg
	 */
	public static function removeLazyLoad( $content )
	{		
		/* Load source */
		$source = new \IPS\Xml\DOMDocument( '1.0', 'UTF-8' );
		$source->loadHTML( \IPS\Xml\DOMDocument::wrapHtml( $content ) );
		
		/* Swap data-src for src */
		$contentImages = $source->getElementsByTagName( 'img' );
		foreach( $contentImages as $element )
		{
			if ( $element->hasAttribute('data-src') )
			{
				$element->setAttribute( 'src', $element->getAttribute('data-src') );
				$element->removeAttribute( 'data-src' );
			}
		}

		$contentVideos = $source->getElementsByTagName( 'video' );
		foreach( $contentVideos as $element )
		{
			if ( $element->hasAttribute('data-video-embed') )
			{
				$element->setAttribute( 'data-controller', 'core.global.core.embeddedvideo' );
				$element->removeAttribute( 'data-video-embed' );
			}
		}
		
		/* Swap data-video-src for src */
		$contentVideos = $source->getElementsByTagName( 'source' );
		foreach( $contentVideos as $element )
		{
			if ( $element->parentNode->tagName === 'video' and $element->hasAttribute('data-video-src') )
			{
				$element->setAttribute( 'src', $element->getAttribute('data-video-src') );
				$element->removeAttribute( 'data-video-src' );
			}
		}

		/* Swap data-embed-src for src */
		$contentEmbeds = $source->getElementsByTagName( 'iframe' );
		foreach( $contentEmbeds as $element )
		{
			if ( $element->hasAttribute('data-embed-src') )
			{
				$element->setAttribute( 'src', $element->getAttribute('data-embed-src') );
				$element->removeAttribute( 'data-embed-src' );
			}
		}
		
		/* Get DOMDocument output */
		$content = \IPS\Text\DOMParser::getDocumentBodyContents( $source );

		/* Replace file storage tags */
		$content = preg_replace( '/&lt;fileStore\.([\d\w\_]+?)&gt;/i', '<fileStore.$1>', $content );

		/* DOMDocument::saveHTML will encode the base_url brackets, so we need to make sure it's in the expected format. */
		return str_replace( '&lt;___base_url___&gt;', '<___base_url___>', $content );
	}
	
	/**
	 * Replace {fileStore.xxx} with <fileStore.xxx> 
	 *
	 * @param	string	$value	HTML to parse
	 * @return	string
	 */
	public static function replaceFileStoreTags( $value )
	{		
		/* Some tags have multiple __base_url__ replacements, so we have to replace this in a safe way ensuring we only match inside A, IMG, IFRAME and VIDEO tags to prevent tampering */
		preg_match_all( '#<(img|a|iframe|video|audio|source)([^>]+?)%7B___base_url___%7D([^>]+?)>#i', $value, $matches, PREG_SET_ORDER );
		foreach( $matches as $val )
		{
			$changed = $val[0];
			
			/* srcset can have multiple urls in it */
			preg_match( '#srcset=(\'|")([^\'"]+?)(\1)#i', $changed, $srcsetMatches );
			
			if ( isset( $srcsetMatches[2] ) )
			{
				if ( mb_stristr( $srcsetMatches[2], '%7B___base_url___%7D' ) )
				{
					$changed = str_replace( $srcsetMatches[2], str_replace( '%7B___base_url___%7D', '<___base_url___>', $srcsetMatches[2] ), $changed );
				}
			}
			
			$changed = preg_replace( '#(href|src|data\-src|data\-video\-src|data\-audio\-src|data\-fileid|data\-ipshover\-target)=(\'|")%7B___base_url___%7D/#i', '\1=\2<___base_url___>/', $changed );
			if ( $changed != $val[0] )
			{
				$value = str_replace( $val[0], $changed, $value );
			}
		}
		
		/* Replace {fileStore.xxx} with <fileStore.xxx> */
		$value = preg_replace( '#(srcset|href|data\-src|src|data\-video\-src|data\-video\-src)=(\'|")(%7B|\{)fileStore\.([\d\w\_]+?)(%7D|\})/#i', '\1=\2<fileStore.\4>/', $value );

		/* Return */
		return $value;
	}
	
	/* !Parser: HTMLPurifier */
	
	/**
	 * Get HTML Purifier Configuration
	 *
	 * @param	callback			$callback	A function which will be passed the HTMLPurifier_Config object to customise it
	 * @return	\HTMLPurifier_Config
	 */
	protected function _htmlPurifierConfiguration( $callback = NULL )
	{
		/* Start with a base configruation */
		$config = \HTMLPurifier_Config::createDefault();

		/* HTMLPurifier by default caches data to disk which we cannot allow. Register our custom
			cache definiton to use \IPS\Data\Store instead */
		$definitionCacheFactory	= \HTMLPurifier_DefinitionCacheFactory::instance();
		$definitionCacheFactory->register( 'IPSCache', "HtmlPurifierDefinitionCache" );
		require_once( \IPS\ROOT_PATH . '/system/Text/HtmlPurifierDefinitionCache.php' );
		$config->set( 'Cache.DefinitionImpl', 'IPSCache' );
		
		/* Allow iFrames from services we allow. We limit this to a whitelist because to allow any iframe would
			open us to phishing and other such security issues */
		$config->set( 'HTML.SafeIframe', true );
		$config->set( 'URI.SafeIframeRegexp', static::safeIframeRegexp() );
		$config->set( 'Output.Newline', "\n" );

		/* If we cannot post remote images, make sure we don't try to using the background-image style property */
		if( !\IPS\Settings::i()->allow_remote_images )
		{
			$config->set( 'CSS.ForbiddenProperties', array( 'background-image' ) );
		}
		
		/* Set allowed CSS classes.  We limit this to a whitelist because to allow any iframe would open
			us to phishing (for example, someone posts something which, by using our CSS classes, looks like a
			login form), and general annoyances */
		$config->set( 'Attr.AllowedClasses', static::getAllowedCssClasses() );

		/* Increase default image width */
		$config->set( 'CSS.MaxImgLength', "4800px" );
		$config->set( 'HTML.MaxImgLength', "4800" );

		/* Callback */
		if ( $callback )
		{
			$callback( $config );
		}
		
		/* HTML Definition */
		$htmlDefinition = $config->getHTMLDefinition( TRUE );
		$this->_htmlPurifierModifyHtmlDefinition( $htmlDefinition );
		
		/* CSS Definition */
		$cssDefinition = $config->getCSSDefinition();
		$this->_htmlPurifierModifyCssDefinition( $cssDefinition, $config );

		if( \IPS\Settings::i()->allow_remote_images AND \IPS\Settings::i()->allow_only_https_remote_images )
		{
			$uri = $config->getDefinition('URI');
			$uri->addFilter( new \IPS\Text\HtmlPurifierHttpsImages(), $config );
		}
		
		/* Return */
		return $config;
	}
	
	/**
	 * Customize HTML Purifier HTML Definition
	 *
	 * @param	HTMLPurifier_HTMLDefinition	$def	The definition
	 * @return	void
	 */
	protected function _htmlPurifierModifyHtmlDefinition( \HTMLPurifier_HTMLDefinition $def )
	{
		/* Links (set by _parseAElement) */
		$def->addAttribute( 'a', 'rel', 'Text' );
		
		/* srcset for emoticons (used by _parseImgElement) */
		$def->addAttribute( 'img', 'srcset', new HtmlPurifierSrcsetDef( TRUE ) );
		
		/* Quotes (used by BBCode and ipsquote editor plugin) */
		$def->addAttribute( 'blockquote', 'data-ipsquote', 'Bool' );
		$def->addAttribute( 'blockquote', 'data-ipsquote-timestamp', 'Number' );
		$def->addAttribute( 'blockquote', 'data-ipsquote-username', 'Text' );
		$def->addAttribute( 'blockquote', 'data-ipsquote-contentapp', 'Text' );
		$def->addAttribute( 'blockquote', 'data-ipsquote-contentclass', 'Text' );
		$def->addAttribute( 'blockquote', 'data-ipsquote-contenttype', 'Text' );
		$def->addAttribute( 'blockquote', 'data-ipsquote-contentid', 'Number' );
		$def->addAttribute( 'blockquote', 'data-ipsquote-contentcommentid', 'Number' );
		$def->addAttribute( 'blockquote', 'data-ipsquote-userid', 'Number' );
		$def->addAttribute( 'blockquote', 'data-cite', 'Text' );
		
		/* Spoilers (used by BBCode and ipsspoiler editor plugin) */
		$def->addAttribute( 'div', 'data-ipsspoiler', 'Bool' );
		
		/* Mentions (used by BBCode and ipsmentions editor plugin) */
		$def->addAttribute( 'a', 'data-ipshover', new HtmlPurifierSwitchAttrDef( 'a', array( 'data-ipshover-target' ), new \HTMLPurifier_AttrDef_HTML_Bool(''), new \HTMLPurifier_AttrDef_Enum( array() ) ) );
		$def->addAttribute( 'a', 'data-ipshover-target', new HtmlPurifierInternalLinkDef( TRUE, array( array( 'app' => 'core', 'module' => 'members', 'controller' => 'profile', 'do' => 'hovercard' ) ) ) );
		$def->addAttribute( 'a', 'data-mentionid', 'Number' );
		$def->addAttribute( 'a', 'contenteditable', 'Enum#false' );
		
		/* Emoticons (used by the ipsautolink plugin) */
		$def->addAttribute( 'img', 'data-emoticon', 'Bool' ); // Identifies emoticons and stops lightbox running on them
		
		/* Attachments (set by _parseAElement, _parseImgElement and "insert existing attachment") - Gallery/Downloads use the full URL rather than an ID, hence Text */
		$def->addAttribute( 'a', 'data-fileid', new HtmlPurifierIntOrInternalLink() );
		$def->addAttribute( 'img', 'data-fileid', new HtmlPurifierIntOrInternalLink() );
		$def->addAttribute( 'a', 'data-fileext', 'Text' );
		
		/* Existing media (inserted with data-extension by the JS so that _getFile is able to locate) */
		$def->addAttribute( 'img', 'data-extension', 'Text' );
		$def->addAttribute( 'a', 'data-extension', 'Text' );

		/* Lazy loading */
		$def->addAttribute( 'img', 'data-ratio', 'Text' );
		$def->addAttribute( 'img', 'data-src', new \HTMLPurifier_AttrDef_URI( TRUE ) );
		$def->addAttribute( 'iframe', 'data-embed-src', new \HTMLPurifier_AttrDef_URI( TRUE ) );
		
		/* iFrames (used by embeddableMedia) */
		$def->addAttribute( 'iframe', 'data-controller', new \HTMLPurifier_AttrDef_Enum( array( 'core.front.core.autosizeiframe' ) ) ); // used in core/global/embed/iframe.phtml
        $def->addAttribute( 'iframe', 'data-embedid', 'Text' ); //  used in core/global/embed/iframe.phtml
		$def->addAttribute( 'iframe', 'data-embedauthorid', 'Number' ); //  used for embed notifications
		$def->addAttribute( 'iframe', 'data-embedcontent', 'Text' ); // used in embeddableMedia
        $def->addAttribute( 'iframe', 'allowfullscreen', 'Text' ); // Some services will specify this property
        		
		/* data-controllers */
		$allowedDivDataControllers = array(
			'core.front.core.articlePages', 	// [page] (set by _parseContent)
		);
		if( \IPS\Settings::i()->editor_allowed_datacontrollers )
		{
			$allowedDivDataControllers = array_merge( $allowedDivDataControllers, explode( ',', \IPS\Settings::i()->editor_allowed_datacontrollers ) );
		}
		$def->addAttribute( 'div', 'data-controller', new \HTMLPurifier_AttrDef_Enum( $allowedDivDataControllers, TRUE ) );

		/* [page] (set by _parseContent) */
		$def->addAttribute( 'div', 'data-role', new \HTMLPurifier_AttrDef_Enum( array( 'contentPage' ), TRUE ) );
		$def->addAttribute( 'hr', 'data-role', new \HTMLPurifier_AttrDef_Enum( array( 'contentPageBreak' ), TRUE ) );
		
		/* data-munge-src used by _removeMunge() */
		$def->addAttribute( 'img', 'data-munge-src', 'Text' );
		$def->addAttribute( 'iframe', 'data-munge-src', 'Text' );
		
		/* Videos */
		$def->addElement( 'video', 'Block', 'Optional: (source, Flow) | (Flow, source) | Flow', 'Common', array(
			'controls' 			=> 'Bool',
			'data-controller'	=> new \HTMLPurifier_AttrDef_Enum( array( 'core.global.core.embeddedvideo' ) ),
			'data-video-embed'	=> 'Text'
		) );
		$def->addElement( 'source', 'Block', 'Flow', 'Common',  array(
			'src' 				=> new \HTMLPurifier_AttrDef_URI( TRUE ),
			'srcset'			=> new HtmlPurifierSrcsetDef( TRUE ),
			'media'				=> 'Text',
			'type' 				=> 'Text',
			'data-video-src'	=> new \HTMLPurifier_AttrDef_URI( TRUE )
		) );

		/* Audio */
		$def->addElement('audio', 'Block', 'Optional: (source, Flow) | (Flow, source) | Flow', 'Common', array(
			'controls' 			=> 'Bool',
			'data-controller'	=> new \HTMLPurifier_AttrDef_Enum( array( 'core.global.core.embeddedaudio' ) ),
			'src' 				=> new \HTMLPurifier_AttrDef_URI( TRUE ),
			'srcset'			=> new HtmlPurifierSrcsetDef( TRUE ),
			'media'				=> 'Text',
			'type' 				=> 'Text',
			'data-audio-embed'	=> 'Text',
			'data-audio-src'	=> new \HTMLPurifier_AttrDef_URI( TRUE )
		));

		/* Picture tag - we don't use it, but RSS imports might */
		$def->addElement( 'picture', 'Inline', 'Optional: (source, Flow) | (Flow, source) | Flow', 'Common', array() );
	}

	/**
	 * @brief	Maximum allowed width/height px values (to prevent causing page layout oddities)
	 */
	protected $cssMaxWidthHeight	= 1000;

	/**
	 * @brief	Maximum allowed border width px value (to prevent causing page layout oddities)
	 */
	protected $cssMaxBorderWidth	= 50;
	
	/**
	 * Customize HTML Purifier CSS Definition
	 *
	 * @param	HTMLPurifier_HTMLDefinition	$def	The definition
	 * @param	HTMLPurifier_Config			$config	HTML Purifier configuration object
	 * @return	void
	 */
	protected function _htmlPurifierModifyCssDefinition( \HTMLPurifier_CSSDefinition $def, \HTMLPurifier_Config $config )
	{
		/* Do not allow negative margins */
		$margin = $def->info['margin-right'] = $def->info['margin-left'] = $def->info['margin-bottom'] = $def->info['margin-top'] = new \HTMLPurifier_AttrDef_CSS_Composite(
            array(
                new \HTMLPurifier_AttrDef_CSS_Length( 0 ),
                new \HTMLPurifier_AttrDef_CSS_Percentage( TRUE ),
                new \HTMLPurifier_AttrDef_Enum(array('auto'))
            )
        );
        $def->info['margin'] = new \HTMLPurifier_AttrDef_CSS_Multiple( $margin );
        
        /* Don't allow white-space:nowrap */
        $def->info['white-space'] = new \HTMLPurifier_AttrDef_Enum(
            array( 'normal', 'pre', 'pre-wrap', 'pre-line')
        );

        /* Limit the maximum width and height allowed */
		$def->info['width'] = $def->info['height'] = new \HTMLPurifier_AttrDef_CSS_Composite( array(
			new \HTMLPurifier_AttrDef_CSS_Length( '0px', $this->cssMaxWidthHeight . 'px' ),
			new \HTMLPurifier_AttrDef_CSS_Percentage( true ),
			new \HTMLPurifier_AttrDef_Enum( array( 'auto' ) )
		) );

		/* Limit the maximum border width allowed */
		$border_width =
			$def->info['border-top-width'] =
			$def->info['border-bottom-width'] =
			$def->info['border-left-width'] =
			$def->info['border-right-width'] = new \HTMLPurifier_AttrDef_CSS_Composite(array(
				new \HTMLPurifier_AttrDef_Enum( array( 'thin', 'medium', 'thick' ) ),
				new \HTMLPurifier_AttrDef_CSS_Length( '0px', $this->cssMaxBorderWidth . 'px' )
		) );

		$def->info['border-width'] = new \HTMLPurifier_AttrDef_CSS_Multiple( $border_width );

		/* We have to reset this so the constructor picks up the new values we just specified */
		$def->info['border'] = $def->info['border-bottom'] = $def->info['border-top'] = $def->info['border-left'] = $def->info['border-right'] = new \HTMLPurifier_AttrDef_CSS_Border( $config );
	}
	
	/**
	 * Get URL bases (whout schema) that we'll allow iframes from
	 *
	 * @return	array
	 */
	protected static function safeIframeRegexp()
	{
		$return = array();

		/* 3rd party sites (YouTube, etc.) */
		foreach ( static::allowedIFrameBases() as $base )
		{
			$return[] = '(https?:)?//' . preg_quote( $base, '%' );
		}
		
		/*
			Some, but not all local URLs
			Allowed: Any URLs which go through the front-end, e.g.:
				site.com/?app=core&module=system&controller=embed&url=whatever
				site.com/index.php?app=core&module=system&controller=embed&url=whatever
				site.com/topic/1-test/?do=embed
				site.com/index.php?/topic/1-test/?do=embed
				site.com/index.php?app=forums&module=forums&controller=topic&id=1&do=embed
			Not Allowed: Anything which goes to anything in an /interface directory - e.g.:
				site.com/applications/core/interface/file/attachment.php - this would automatically cause files to be downloaded
		    Not Allowed: Any file traversal to expose FileSystem directories, e.g:
				site.com/applications/core/../uploads/monthly_xx_xx/file.js?
			Not Allowed: URLs to the open proxy:
				site.com/index.php?app=core&module=system&controller=redirect
		 */
		$notAllowed = array();
		foreach( \IPS\Application::enabledApplications() as $app )
		{
			$notAllowed[] = str_replace( '/', '(?:/{1,})', '(?:/{0,})' . preg_quote( 'applications/' . $app->directory . '/interface/', '%' ) );
		}

		foreach( \IPS\File::getStore() as $configuration )
		{
			if ( $configuration['method'] == 'FileSystem' and ! empty( $configuration['configuration'] ) and $config = json_decode( $configuration['configuration'], TRUE ) )
			{
				if ( !empty( $config['dir'] ) and mb_strpos( $config['dir'], '{root}' ) !== false )
				{
					if ( $path = trim( str_replace( '{root}', '', $config['dir'] ), '\/' ) )
					{
						$notAllowed[] = '(?:.+?/{1,})\.{1,}(?:/{1,})' . $path;
					}
				}
			}
		}

		$return[] = '(https?:)?//' . preg_quote( str_replace( array( 'http://', 'https://' ), '', \IPS\Settings::i()->base_url ), '%' ) . '\/?(\?|index\.php\?|(?!' . implode( '|', $notAllowed ) . ').+?\?)((?!(controller|section)=redirect).)*$';
		$return[] = preg_quote( '%7B___base_url___%7D', '%' ) . '\/?(\?|index\.php\?|(?!' . implode( '|', $notAllowed ) . ').+?\?)((?!(controller|section)=redirect).)*$';

		/* Return */	
		return '%^(' . implode( '|', $return ) . ')%';
	}
			
	/**
	 * Get URL bases (whout schema) that we'll allow iframes from
	 *
	 * @return	array
	 * @note	When updating this list, be sure to update the editor_embeds_desc lang string
	 */
	protected static function allowedIFrameBases()
	{
		$return = array();
				
		/* Our default embed options */		
		$return = array_merge( $return, array(
			'www.youtube.com/embed/',
			'www.youtube-nocookie.com/embed/',
			'player.vimeo.com/video/',
			'www.hulu.com/embed.html',
			'www.collegehumor.com/e/',
			'embed-ssl.ted.com/',
			'embed.ted.com',
			'vine.co/v/',
			'embed.spotify.com/',
			'www.dailymotion.com/embed/',
			'www.funnyordie.com/',
			'coub.com/',
			'www.reverbnation.com/',
			'www.ustream.tv/embed/',
			'api.smugmug.com/services/embed/',
			'www.google.com/maps/',
			'www.screencast.com/users/',
			'fast.wistia.net/embed/',
			'www.screencast.com/users/',
			'players.brightcove.net/',
		) );
		
		/* Extra admin-defined options */
		if ( \IPS\Settings::i()->editor_allowed_iframe_bases )
		{
			$return = array_merge( $return, explode( ',', \IPS\Settings::i()->editor_allowed_iframe_bases ) );
		}
		
		return $return;
	}
	
	/**
	 * Get allowed CSS classes
	 *
	 * @return	array
	 */
	protected function getAllowedCssClasses()
	{		
		/* Init */
		$return = array();
		
		/* Quotes (used by ipsquote editor plugin) */
		$return[] = 'ipsQuote';
		$return[] = 'ipsQuote_citation';
		$return[] = 'ipsQuote_contents';
		
		/* Code (used by ipscode editor plugin) */
		$return[] = 'ipsCode';
		$return[] = 'prettyprint';
		$return[] = 'prettyprinted';
		$return[] = 'lang-auto';
		$return[] = 'lang-javascript';
		$return[] = 'lang-php';
		$return[] = 'lang-css';
		$return[] = 'lang-html';
		$return[] = 'lang-xml';
		$return[] = 'lang-c';
		$return[] = 'lang-sql';
		$return[] = 'lang-lua';
		$return[] = 'lang-swift';
		$return[] = 'lang-perl';
		$return[] = 'lang-python';
		$return[] = 'lang-ruby';
		$return[] = 'lang-latex';
		$return[] = 'tag';
		$return[] = 'pln';
		$return[] = 'atn';
		$return[] = 'atv';
		$return[] = 'pun';
		$return[] = 'com';
		$return[] = 'kwd';
		$return[] = 'str';
		$return[] = 'lit';
		$return[] = 'typ';
		$return[] = 'dec';
		$return[] = 'src';
		$return[] = 'nocode';
		
		/* Spoiler (used by ipsspoiler editor plugin) */
		$return[] = 'ipsSpoiler';
		$return[] = 'ipsSpoiler_header';
		$return[] = 'ipsSpoiler_contents';
		$return[] = 'ipsStyle_spoiler';
		
		/* Images and attachments (used when attachments are inserted into the editor) */
		$return[] = 'ipsImage';
		$return[] = 'ipsImage_thumbnailed';
		$return[] = 'ipsAttachLink';
		$return[] = 'ipsAttachLink_image';
		$return[] = 'ipsAttachLink_left';
		$return[] = 'ipsAttachLink_right';
		$return[] = 'ipsEmoji';
		
		/* Embeds (used by various return values of embeddedMedia) */
		$return[] = 'ipsEmbedded';
		$return[] = 'ipsEmbeddedVideo';
		$return[] = 'ipsEmbeddedVideo_limited';
		$return[] = 'ipsEmbeddedOther';
		$return[] = 'ipsEmbeddedOther_limited';

		/* Links (Used to replace disallowed URLs */
		$return[] = 'ipsType_noLinkStyling';
		
		/* Brightcove */
		$return[] = 'ipsEmbeddedBrightcove';
		$return[] = 'ipsEmbeddedBrightcove_inner';
		$return[] = 'ipsEmbeddedBrightcove_frame';
		
		/* Custom */
		if( \IPS\Settings::i()->editor_allowed_classes )
		{
			$return = array_merge( $return, explode( ',', \IPS\Settings::i()->editor_allowed_classes ) );
		}
		
		return $return;
	}
	
	/* !Parser: Main Parser */
	
	/**
	 * @brief	The closing BBCode tags we are looking for and how many are open
	 */
	protected $closeTagsForOpenBBCode = array();
	
	/**
	 * @brief	Open Inline BBCode tags
	 */
	protected $openInlineBBCode = array();
	
	/**
	 * @brief	All open Block-Level BBCode tags
	 */
	protected $openBlockBBCodeByTag = array();
	
	/**
	 * @brief	All open Block-Level BBCode tags in the order they were created
	 */
	protected $openBlockBBCodeInOrder = array();
		
	/**
	 * @brief	Open Block-Level BBCode tags
	 */
	protected $openBlockDepth = NULL;

	/**
	 * @brief	Force BBCode enabled (used by 4.0 upgrader parsing and converters)
	 * @see		set_forceBbcodeEnabled()
	 */
	protected $forceBbcodeEnabled = FALSE;

	/**
	 * @brief	This is used to stop BBCode parsing temporarily (such as in [code] tags)
	 */
	protected $bbcodeParse = TRUE;
	
	/**
	 * @brief	If we have opened a BBCode tag which we don't parse other BBCode inside, the string at which we will resume parsing
	 */
	protected $resumeBBCodeParsingOn = NULL;
	
	/**
	 * @brief	Does the content contain [page] tags?
	 */
	protected $containsPageTags = FALSE;
	
	/**
	 * @brief	Open <abbr> tags
	 */
	protected $openAbbrTags = array();
	
	/**
	 * Parse BBCode, Profanity, etc. by loading into a DOMDocument
	 *
	 * @param	string	$value	HTML to parse
	 * @return	string
	 */
	protected function _parseContent( $value )
	{
		/* This fix resolves an issue using <br> mode where BBCode tags are wrapped in P tags like so: <p>[tag]</p><p>Content</p><p>[/tag]</p> tags.
		   The fix just removes the </p><p> tags inside block BBCode tags, so our example ends up parsing like so: <p>[tag]<br><br>Content<br><br>[/tag]</p>
		   We will want to find a more elegant fix for this at some point */
		if ( $this->bbcode !== NULL and ! \IPS\Settings::i()->editor_paragraph_padding )
		{
			$blockTags = array();
			foreach( $this->bbcode as $tag => $data )
			{
				if ( ! empty( $data['block'] ) )
				{
					$blockTags[] = $tag;
				}
			}

			if ( \count( $blockTags ) )
			{
				/* If we are inside block tags, ensure that </p> <p> tags are converted to <br> to prevent parser confusion */
				preg_match_all( '#\[(' . implode( '|', $blockTags ) . ')\](.+?)\[/\1\]#si', $value, $matches, PREG_SET_ORDER );
				
				foreach( $matches as $id => $match )
				{
					$value = str_replace( $match[0], preg_replace( '#</p>\s{0,}<p([^>]+?)?'.'>#i', '<br><br>', $match[0] ), $value );
				}
			}
		}
		
		/* The editor button just drops in a <hr>, so we need to make sure that the structure is correct, as follows:
			<div data-controller="core.front.core.articlePages">
				<div data-role="contentPage">
					<hr data-role="contentPageBreak" />
					<p>
						Page one
					</p>
				</div>
				<div data-role="contentPage">
					<hr data-role="contentPageBreak" />
					<p>
						Page two
					</p>
				</div>
			</div>
			
			The editor will just have
			<p>
				Page one
			</p>
			<hr data-role="contentPageBreak">
			<p>
				Page two
			</p>
		*/
		if ( static::canUse( $this->member, 'Page', $this->area ) )
		{
			if ( ! preg_match( '#<div\s+?data-controller=["\']core.front.core.articlePages["\']#', $value ) and preg_match( '#<hr\s+?data-role=["\']contentPageBreak["\']\s{0,}/?>#', $value ) )
			{
				$value = preg_replace( '#<hr\s+?data-role=["\']contentPageBreak["\']\s{0,}/?>#', '</div>' . "\n" . '<div data-role="contentPage">' . "\n" . '<hr data-role="contentPageBreak">', $value );
								
				$value = '<div data-controller="core.front.core.articlePages"><div data-role="contentPage">' . $value . '</div></div>';
			}
		}

		/* Parse */
		$parser = new DOMParser( array( $this, '_parseDomElement' ), array( $this, '_parseDomText' ) );
		$document = $parser->parseValueIntoDocument( $value );
				
		/* [page] tags need to be handled specially */
		if ( $this->bbcode !== NULL and static::canUse( $this->member, 'Page', $this->area ) and $this->containsPageTags )
		{
			$body = DOMParser::getDocumentBody( $document );
			
			$bodyWithPages = $this->_parseContentWithSeparationTag(
				$body,
				function ( \DOMDocument $document ) {
					$mainDiv = $document->createElement('div');
					$mainDiv->setAttribute( 'data-controller', 'core.front.core.articlePages' );
					return $mainDiv;
				},
				function ( \DOMDocument $document ) {
					$subDiv = $document->createElement('div');
					$subDiv->setAttribute( 'data-role', 'contentPage' );
					$hr = $document->createElement('hr');
					$hr->setAttribute( 'data-role', 'contentPageBreak' );
					$subDiv->appendChild( $hr );
					return $subDiv;
				},
				'[page]'
			);
			
			$newBody = new \DOMElement('body');
			$body->parentNode->replaceChild( $newBody, $body );
			$newBody->appendChild( $bodyWithPages );			
 		}

 		/* Return */
 		return DOMParser::getDocumentBodyContents( $document );

	}
	
	/**
	 * Parse HTML element (e.g. <html>, <p>, <a>, etc.)
	 *
	 * @param	\DOMElement			$element	The element from the source document to parse
	 * @param	\DOMNode			$parent		The node from the new document which will be this node's parent
	 * @param	\IPS\Text\DOMParser	$parser		DOMParser Object
	 * @return	void
	 */
	public function _parseDomElement( \DOMElement $element, \DOMNode $parent, \IPS\Text\DOMParser $parser )
	{		
		/* Adjust parent for block BBCode */
		$this->_adjustParentForBlockBBCodeAtStartOfNode( $parent );
		
		/* Start of an <abbr>? */
		$okayToParse = TRUE;
		if ( $element->tagName === 'abbr' and $element->hasAttribute('title') )
		{
			$title = $element->getAttribute('title');
			if ( !\in_array( $title, $this->openAbbrTags ) )
			{
				$this->openAbbrTags[] = $title;
			}
			else
			{
				$okayToParse = FALSE;
			}
		}
		
		/* Import */
		if ( $okayToParse )
		{
			/* Import the element as it is */
			$ownerDocument = $parent->ownerDocument ?: $parent;
			$newElement = $ownerDocument->importNode( $element );
					
			/* Element-specific parsing */
			if ( $okayToParse )
			{
				$newElement = $this->_parseElement( $newElement, $element );
			}
			
			/* Append */
			$parent->appendChild( $newElement );
			
			/* Swap out emoticons that should be plaintext (meaning we hit the maximum limit of emoticons per editor) */
			foreach( $parent->getElementsByTagName( 'img' ) AS $img )
			{
				if ( $img->hasAttribute( 'data-ipsEmoticon-plain' ) )
				{
					$replace = $parent->appendChild( new \DOMText( $img->getAttribute( 'data-ipsEmoticon-plain' ) ) );
					$parent->replaceChild( $replace, $img );
				}
			}
		
			/* <pre> tags don't parse BBCode inside */
			$resumeBBCodeAfterPre = FALSE;
			if ( $newElement->tagName == 'pre' and $this->bbcodeParse )
			{
				$this->bbcodeParse = FALSE;
				$resumeBBCodeAfterPre = TRUE;
			}
		}
		else
		{
			$newElement = $parent;
		}
		
		/* Loop children */
		$parser->_parseDomNodeList( $element->childNodes, $newElement );
		
		/* Finish */
		if ( $okayToParse )
		{
			/* <pre> tags don't parse BBCode inside */
			if ( $newElement->tagName == 'pre' and $resumeBBCodeAfterPre )
			{
				$this->bbcodeParse = TRUE;
			}
			
			/* End of an <abbr>? */
			if ( $okayToParse and $element->tagName === 'abbr' and $element->hasAttribute('title') )
			{
				$k = array_search( $element->getAttribute('title'), $this->openAbbrTags );
				if ( $k !== FALSE )
				{
					unset( $this->openAbbrTags[ $k ] );
				}
			}
			
			/* If we did have children, but now we don't (for example, the entire content is a block-level BBCode), drop this element to avoid unintentional whitespace */
			if ( $newElement->parentNode and $element->childNodes->length and !$newElement->childNodes->length )
			{
				$parent->removeChild( $newElement );
			}
		}
		
		/* Adjust parent for block BBCode */
		$this->_adjustParentForBlockBBCodeAtEndOfNode( $parent );
	}
		
	/**
	 * Parse Text
	 *
	 * @param	\DOMText	$textNode	The text from the source document to parse
	 * @param	\DOMNode	$parent		The node from the new document which will be this node's parent - passed by reference and may be modified for siblings
	 * @return	void
	 */
	public function _parseDomText( \DOMText $textNode, \DOMNode &$parent, \IPS\Text\DOMParser $parser )
	{		
		/* Adjust parent for block BBCode */
		$this->_adjustParentForBlockBBCodeAtStartOfNode( $parent );
				
		/* Init */
		$text = $textNode->wholeText;
		$breakPoints = array( '(' . static::EMOJI_REGEX . ')' );
		
		/* Contains [page] tags? */
		if ( mb_strpos( $text, '[page]' ) !== FALSE )
		{
			$this->containsPageTags = TRUE;
		}
		
		/* If we are parsing BBCode, we will look for opening (e.g. "[foo=bar]") and closing (e.g. "[/foo]") tags */
		if ( $this->bbcode !== NULL and \count( $this->bbcode ) )
		{
			/* First, if we have any single-tag BBCodes (e.g. "[img=URL]") expressed as normal BBCodes (e.g. "[img]URL[/img]") - fix that */
			foreach ( $this->bbcode as $tag => $bbcode )
			{
				if ( isset( $bbcode['single'] ) and $bbcode['single'] )
				{
					if ( isset( $bbcode['attributes'] ) and \in_array( '{option}', $bbcode['attributes'] ) )
					{
						$text = preg_replace( '/\[(' . preg_quote( $tag, '/' ) . ')\](.+?)\[\/' . preg_quote( $tag, '/' ) . '\]/i', '[$1=$2]', $text );
					}
					else
					{
						$text = preg_replace( '/\[(' . preg_quote( $tag, '/' ) . ')\]\s*\[\/' . preg_quote( $tag, '/' ) . '\]/i', '[$1]', $text );
					}
				}
			}
			
			/* And add our regex to the breakpoints */
			$breakPoints[] = '(\[\/?(?:' . implode( '|', array_map( function ( $value ) { return preg_quote( $value, '/' ); }, array_keys( $this->bbcode ) ) ) . ')(?:[=\s].+?)?\])';
		}
		
		/* If we have any acronyms, they also need to be breakpoints */
		if ( \count( $this->caseSensitiveAcronyms ) or \count( $this->caseInsensitiveAcronyms ) )
		{
			$breakPoints[] = '((?=<^|\b|\W)(?:' . implode( '|', array_merge( array_map( function ( $value ) { return preg_quote( $value, '/' ); }, array_keys( $this->caseSensitiveAcronyms ) ), array_map( function ( $value ) { return preg_quote( $value, '/' ); }, array_keys( $this->caseInsensitiveAcronyms ) ) ) ) . ')(?=\b|\W|$))';
		}
						
		/* Loop through each section */
		if ( \count( $breakPoints ) )
		{
			$sections = array_values( array_filter( preg_split( '/' . implode( '|', $breakPoints ) . '/iu', $text, null, PREG_SPLIT_DELIM_CAPTURE ), function( $val ) { return $val !== ''; } ) );
			foreach( $sections as $sectionId => $section )
			{
				$this->_parseTextSection( $section, $parent, ++$sectionId, \count( $sections ) );
			}		
		}
		else
		{
			$this->_parseTextSection( $textNode->wholeText, $parent, 1, 1 );
		}
				
		/* Adjust parent for block BBCode */
		$this->_adjustParentForBlockBBCodeAtEndOfNode( $parent );
	}
	
	/**
	 * Parse a section of text after it has been split into relevant sections
	 *
	 * @param	string		$section		The text from the source document to parse
	 * @param	\DOMNode	$parent			The node from the new document which will be this node's parent - passed by reference and may be modified for siblings
	 * @param	int			$sectionId		The position of this section out of all the sections in the node - used to indicate if there's text before/after this section
	 * @param	int			$sectionCount	The total number of sections in the node - used to indicate if there's text before/after this section
	 * @return	void
	 */
	protected function _parseTextSection( $section, \DOMNode &$parent, $sectionId, $sectionCount )
	{		
		/* If it's empty, skip it */
		if ( $section === '' )
		{
			return;
		}
								
		/* If this restarts parsing, do that */
		if ( $section == $this->resumeBBCodeParsingOn )
		{
			$this->bbcodeParse = TRUE;
			$this->resumeBBCodeParsingOn = NULL;
		}
		
		/* Start of BBCode tag? */
		if (
			$this->bbcode !== NULL and $this->bbcodeParse and // BBCode is enabled
			preg_match( '/^\[([a-z\*]+?)(?:([=\s])(.+?))?\]$/i', $section, $matches ) and // It looks like a BBCode tag
			array_key_exists( mb_strtolower( $matches[1] ), $this->bbcode ) and // The tag is in the list
			( !isset( $this->bbcode[ mb_strtolower( $matches[1] ) ]['allowOption'] ) or $this->bbcode[ mb_strtolower( $matches[1] ) ]['allowOption'] === TRUE or !isset( $matches[3] ) or !$matches[3] ) // If options aren't allowed for this tag, there isn't one
		)
		{			
			/* What was the option? */
			$option = NULL;
			if ( isset( $matches[3] ) )
			{
				$option = $matches[3];
				
				/* If it's [foo="bar"] then we strip the quotes, (if it's [foo bar="baz"] then we don't) */
				if ( !preg_match( '/^\s*$/', $matches[2] ) )
				{
					$option = trim( $option, '"\'' );
				}
			}
			
			/* Send to _openBBcode */
			$this->_openBBCode( mb_strtolower( $matches[1] ), $option, $parent, $sectionId, $sectionCount );
		}
		
		/* End of BBCode tag? */
		elseif ( $this->bbcodeParse and array_key_exists( mb_strtolower( $section ), $this->closeTagsForOpenBBCode ) )
		{
			$this->_closeBBCode( mb_substr( mb_strtolower( $section ), 2, -1 ), $parent, $sectionId, $sectionCount );
		}
		
		/* Normal text */
		else
		{	
			/* HTMLPurifier will strip carrage returns, but if HTML posting is enabled this doesn't happen which
				leaves blank spaces - so we need to strip here */
			if ( !$this->htmlPurifier )
			{
				$section = str_replace( "\r", '', $section );
			}

			/* Profanity */
			foreach ( $this->exactProfanity as $bad => $good )
			{
				$section = preg_replace( '/(^|\b|\s)' . preg_quote( $bad, '/' ) . '(\b|\s|!|\?|\.|,|$)/iu', "\\1" . $good . "\\2", $section );
			}
			$section = str_ireplace( array_keys( $this->looseProfanity ), array_values( $this->looseProfanity ), $section );

			/* Note what $parent is */
			$originalParent = $parent;
						
			/* Acronym? */
			if ( $this->bbcodeParse and array_key_exists( $section, $this->caseSensitiveAcronyms ) and !\in_array( $this->caseSensitiveAcronyms[ $section ], $this->openAbbrTags ) )
			{
				switch( $this->caseSensitiveAcronyms[ $section ]['a_type'] )
				{
					case 'acronym':
							$parent = $parent->appendChild( new \DOMElement( 'abbr' ) );
							$parent->setAttribute( 'title', $this->caseSensitiveAcronyms[ $section ]['a_long'] );
						break;
					case 'link':
							$replace = ( $parent->tagName != 'a' );

							$parentNode = $parent;
							while( ( $parentNode = $parentNode->parentNode ) !== NULL )
							{
								if( $parentNode instanceof \DOMElement AND $parentNode->tagName == 'a' )
								{
									$replace = FALSE;
									break;
								}
							}

							if( $replace )
							{
								$parent = $parent->appendChild( new \DOMElement( 'a' ) );
								$parent->setAttribute( 'href', $this->caseSensitiveAcronyms[ $section ]['a_long'] );

								try
								{
									$rels	= $this->_getRelAttributes( \IPS\Http\Url::createFromString( $this->caseSensitiveAcronyms[ $section ]['a_long'] ) );
								}
								catch( \IPS\Http\Url\Exception $e )
								{
									$rels	= array(); 
								}
								
								/* Add rels */
								$parent->setAttribute( 'rel', implode( ' ', $rels ) );
							}
						break;
				}
			}
			elseif ( $this->bbcodeParse and array_key_exists( mb_strtolower( $section ), $this->caseInsensitiveAcronyms ) and !\in_array( $this->caseInsensitiveAcronyms[ mb_strtolower( $section ) ], $this->openAbbrTags ) )
			{
				switch( $this->caseInsensitiveAcronyms[ mb_strtolower( $section ) ]['a_type'] )
				{
					case 'acronym':
							$parent = $parent->appendChild( new \DOMElement( 'abbr' ) );
							$parent->setAttribute( 'title', $this->caseInsensitiveAcronyms[ mb_strtolower( $section ) ]['a_long'] );
						break;
					case 'link':
							$replace = ( $parent->tagName != 'a' );

							$parentNode = $parent;
							while( ( $parentNode = $parentNode->parentNode ) !== NULL )
							{
								if( $parentNode instanceof \DOMElement AND $parentNode->tagName == 'a' )
								{
									$replace = FALSE;
									break;
								}
							}

							if( $replace )
							{
								$parent = $parent->appendChild( new \DOMElement( 'a' ) );
								$parent->setAttribute( 'href', $this->caseInsensitiveAcronyms[ mb_strtolower( $section ) ]['a_long'] );

								try
								{
									$rels	= $this->_getRelAttributes( \IPS\Http\Url::createFromString( $this->caseInsensitiveAcronyms[ mb_strtolower( $section ) ]['a_long'] ) );
								}
								catch( \IPS\Http\Url\Exception $e )
								{
									$rels	= array(); 
								}
								
								/* Add rels */
								$parent->setAttribute( 'rel', implode( ' ', $rels ) );
							}
						break;
				}			
			}

			/* Emoji? */
			if ( ( !$originalParent->getAttribute('class') or !\in_array( 'ipsEmoji', explode( ' ', $originalParent->getAttribute('class') ) ) ) and preg_match( '/' . static::EMOJI_REGEX . '/u', $section ) )
			{
				$parent = $parent->appendChild( new \DOMElement( 'span' ) );
				$parent->setAttribute( 'class', 'ipsEmoji' );
			}

			/* Check for emails */
			if ( \IPS\Settings::i()->email_filter_action == 'replace' and preg_match( '/' . static::EMAIL_REGEX . '/u', $section ) )
			{
				$section = preg_replace( '/' . static::EMAIL_REGEX . '/u', \IPS\Settings::i()->email_filter_replace_text, $section );
			}
			
			/* Insert the text */
			$this->_insertNodeApplyingInlineBBcode( new \DOMText( $section ), $parent );
			
			/* Restore the parent */
			$parent = $originalParent;
		}
	}
	
	/**
	 * Open BBCode tag
	 *
	 * @param	string		$tag			The tag (e.g. "b")
	 * @param	string|NULL	$option			If an option was provided (e.g. "[foo=bar]"), it's value
	 * @param	\DOMNode	$parent			The node from the new document which will be this node's parent - passed by reference and may be modified for siblings
	 * @param	int			$sectionId		The position of this section out of all the sections in the node - used to indicate if there's text before/after this section
	 * @param	int			$sectionCount	The total number of sections in the node - used to indicate if there's text before/after this section
	 * @return	void
	 */
	protected function _openBBCode( $tag, $option, \DOMNode &$parent, $sectionId, $sectionCount )
	{
		/* Get definiton */
		$bbcode = $this->bbcode[ $tag ];
		
		/* Get the document */
		$document = $parent->ownerDocument ?: $parent;
				
		/* Create the element */
		$bbcodeElement = $document->createElement( $bbcode['tag'] );
		
		/* Add any attributes */
		if ( isset( $bbcode['attributes'] ) )
		{
			foreach ( $bbcode['attributes'] as $k => $v )
			{				
				$bbcodeElement->setAttribute( $k, str_replace( '{option}', ( $option ?: ( isset( $bbcode['defaultOption'] ) ? $bbcode['defaultOption'] : '' ) ), $v ) );
			}
		}
		
		/* Callback */
		if ( isset( $bbcode['callback'] ) )
		{
			$callback  = $bbcode['callback'];
			$bbcodeElement = $callback( $bbcodeElement, array( 2 => $option ), $document );
		}
				
		/* Stop parsing? ([code] blocks make it so BBCode isn't parsed inside them) */
		if ( isset( $bbcode['noParse'] ) and $bbcode['noParse'] )
		{
			$this->bbcodeParse = FALSE;
			$this->resumeBBCodeParsingOn = "[/{$tag}]";
		}
		
		/* Parse it */
		$bbcodeElement = $this->_parseElement( $bbcodeElement );
		
		/* Single only? */
		if ( isset( $bbcode['single'] ) and $bbcode['single'] )
		{			
			$this->_insertNodeApplyingInlineBBcode( $bbcodeElement, $parent );
		}
		
		/* Or with content? */
		else
		{
			/* Block level? */
			if ( isset( $bbcode['block'] ) and $bbcode['block'] )
			{
				/* Insert the block level element */
				$lastOpennedBlockId = NULL;
				if ( !empty( $this->openBlockBBCodeInOrder ) )
				{
					$openBBCodeBlocks = array_keys( $this->openBlockBBCodeInOrder );
					$lastOpennedBlockId = array_pop( $openBBCodeBlocks );
				}
				if ( $lastOpennedBlockId and list( $id, $tagName ) = explode( '-', $lastOpennedBlockId ) and isset( $this->bbcode[ $tagName ]['noChildren'] ) and $this->bbcode[ $tagName ]['noChildren'] )
				{
					$parent->appendChild( $bbcodeElement );
				}
				else
				{
					$parent->parentNode->appendChild( $bbcodeElement );
				}
				
				/* Callback */
				$blockElement = $bbcodeElement;
				if ( isset( $bbcode['getBlockContentElement'] ) )
				{
					$callback = $bbcode['getBlockContentElement'];
					$blockElement = $callback( $bbcodeElement );
				}
				
				/* Create an element of the same type (normally <p>) to go in the block-level element for any content left (e.g. "<p>[center]This needs to be centered</p>") and set the parent being used to it */
				if ( $sectionId != $sectionCount )
				{
					if ( !isset( $bbcode['noChildren'] ) or !$bbcode['noChildren'] )
					{
						$contentElement = $parent->cloneNode( FALSE );
						$blockElement->appendChild( $contentElement );
						$parent = $contentElement;
					}
					else
					{
						$parent = $bbcodeElement;
					}
				}
	
				/* Add to $openBlockBBcode for closing later */
				$id = mt_rand() . '-' . $tag;
				$this->openBlockBBCodeByTag[ $tag ][ $id ] = $bbcodeElement;
				
				/* Add to $penBlockBBCodeInOrder to that so _parseDomElement() will use that as the parent for subsequent elements */
				$this->openBlockBBCodeInOrder[ $id ] = $blockElement;
			}
			
			/* Inline */
			else
			{
				$this->openInlineBBCode[ $tag ][] = $bbcodeElement;
			}
						
			/* Add it to the array */
			if ( !isset( $this->closeTagsForOpenBBCode[ "[/{$tag}]" ] ) )
			{
				$this->closeTagsForOpenBBCode[ "[/{$tag}]" ] = 0;
			}
			$this->closeTagsForOpenBBCode[ "[/{$tag}]" ]++;
		
		}
	}
		
	/**
	 * Close BBCode tag
	 *
	 * @param	string		$tag			The tag (e.g. "b")
	 * @param	\DOMNode	$parent			The node from the new document which will be this node's parent - passed by reference and may be modified for siblings
	 * @param	int			$sectionId		The position of this section out of all the sections in the node - used to indicate if there's text before/after this section
	 * @param	int			$sectionCount	The total number of sections in the node - used to indicate if there's text before/after this section
	 * @return	void
	 */
	protected function _closeBBCode( $tag, \DOMNode &$parent, $sectionId, $sectionCount )
	{
		/* Get definition */
		$bbcode = $this->bbcode[ $tag ];
		
		/* Block level? */
		if ( isset( $bbcode['block'] ) and $bbcode['block'] )
		{
			/* Find the block we're closing */
			foreach ( $this->openBlockBBCodeByTag[ $tag ] as $key => $block ) { } // Just sets $key and $block for the last one
						
			/* Create a content element to go after the block-level element for any remaining text in this DOMText node (e.g. "<p>[/center]This should not be centered</p>") and set the parent being used to it */
			if ( $block->previousSibling and $block->previousSibling instanceof \DOMText ) // Happens for noChildren tags - e.g. "[list]Foo[list]Bar[/list]Baz[/list]"
			{
				$parent = $block->parentNode;
			}
			else
			{
				if ( $block->previousSibling )
				{
					$contentElement = $block->previousSibling->cloneNode( FALSE );
				}
				else
				{
					$contentElement = $parent->ownerDocument->createElement('p');
				}
				$block->parentNode->appendChild( $contentElement );
				$parent = $contentElement;
			}
			
			/* Remove it from the list of open blocks */
			unset( $this->openBlockBBCodeByTag[ $tag ][ $key ] );
			unset( $this->openBlockBBCodeInOrder[ $key ] );
			
			/* Finished callback? */
			if ( isset( $bbcode['finishedCallback'] ) and $bbcode['finishedCallback'] )
			{
				$callback = $bbcode['finishedCallback'];
				$newBlock = $callback( $block );
				if ( $block->parentNode )
				{
					$block->parentNode->replaceChild( $newBlock, $block );
				}
				else
				{
					$parent->ownerDocument->getElementsByTagName('body')->item(0)->appendChild( $newBlock );
				}
			}
		}
		
		/* Inline */
		else
		{
			array_pop( $this->openInlineBBCode[ $tag ] );
			if ( empty( $this->openInlineBBCode[ $tag ] ) )
			{
				unset( $this->openInlineBBCode[ $tag ] );
			}
		}
		
		/* Remove from array of open BBCodes */
		$this->closeTagsForOpenBBCode["[/{$tag}]"]--;
		if ( !$this->closeTagsForOpenBBCode["[/{$tag}]"] )
		{
			unset( $this->closeTagsForOpenBBCode["[/{$tag}]"]  );
		}
	}
	
	/**
	 * Insert a node to a parent while applying inline BBCode 
	 *
	 * @param	\DOMNode	$node	Node to insert
	 * @param	\DOMNode	$parent	Parent to insert into
	 * @return	void
	 */
	protected function _insertNodeApplyingInlineBBcode( \DOMNode $node, \DOMNode $parent )
	{
		/* Apply any open inline BBCode elements */
		if ( $this->bbcodeParse )
		{
			foreach ( $this->openInlineBBCode as $tag => $elements )
			{
				foreach ( $elements as $bbcodeElement )
				{
					$parent = $parent->appendChild( $bbcodeElement->cloneNode( TRUE ) );
				}
			}
		}
		
		/* Insert the text */
		$parent->appendChild( $node );
	}
	
	/**
	 * Adjust for Block-Level BBCode if necessary at start of the node
	 *
	 * @param	\DOMNode	$parent		The node from the new document which will be the working node's parent. Passed by reference and will be modified if there is an open block-level BBCode
	 * @return	void
	 */
	protected function _adjustParentForBlockBBCodeAtStartOfNode( \DOMNode &$parent )
	{
		/* If we have an open block-level BBCode element, and we're not already on a child
			of one we have already moved, insert this element into that instead of the
			defined parent */
		if ( \count( $this->openBlockBBCodeInOrder ) )
		{
			if ( !$this->openBlockDepth )
			{
				$openBlocks = $this->openBlockBBCodeInOrder;
				$parent = array_pop( $openBlocks );
			}
			$this->openBlockDepth++;
		}
	}
	
	/**
	 * Adjust for Block-Level BBCode if necessary at end of the node
	 *
	 * @param	\DOMNode	$parent		The node from the new document which will be the working node's parent. Passed by reference and will be modified if there is an open block-level BBCode
	 * @return	void
	 */
	protected function _adjustParentForBlockBBCodeAtEndOfNode( \DOMNode &$parent )
	{
		/* If we have an open block-level BBCode element, decrease the depth we're at */
		if ( $this->openBlockDepth )
		{
			if ( $this->openBlockDepth == 1 )
			{
				$parent = $parent->parentNode;
			}
			$this->openBlockDepth--;
		}
	}
	
	/* !Parser: Element-Specific Parsing */
	
	/**
	 * Element-Specific Parsing
	 *
	 * @param	\DOMElement			$element			The element
	 * @param	\DOMElement|NULL	$originalElement	The original element.
	 * @note	_parseDomElement() creates a new element and imports it into the document. You can inspect $originalElement if you need to check context.
	 * @return	\DOMNode|NULL
	 */
	protected function _parseElement( \DOMElement $element, \DOMElement $originalElement = NULL )
	{
		/* Element-Specific */
		switch ( $element->tagName )
		{
			case 'a':
				$element = $this->_parseAElement( $element );
				break;
				
			case 'img':
				$element = $this->_parseImgElement( $element );
				break;
				
			case 'iframe':
				$element = $this->_parseIframeElement( $element );
				break;

			case 'video':
				$element = $this->_parseVideoElement( $element );
				break;

			case 'audio':
				$element = $this->_parseAudioElement( $element, $originalElement );
				break;

			case 'source':
				$element = $this->_parseSourceElement( $element, $originalElement );
				break;
		}
		
		/* Anything which has a URL may need swapping out */
		foreach ( array( 'href', 'src', 'data-src', 'data-video-src', 'data-embed-src', 'srcset', 'data-ipshover-target', 'data-fileid', 'cite', 'action', 'longdesc', 'usemap', 'poster' ) as $attribute )
		{
			if ( $element->hasAttribute( $attribute ) )
			{				
				if ( preg_match( '#^(https?:)?//(' . preg_quote( rtrim( str_replace( array( 'http://', 'https://' ), '', \IPS\Settings::i()->base_url ), '/' ), '#' ) . ')/(.+?)$#', $element->getAttribute( $attribute ), $matches ) )
				{
					$element->setAttribute( $attribute, '%7B___base_url___%7D/' . $matches[3] );
				}
			}
		}
		foreach ( array( 'srcset', 'style' ) as $attribute )
		{
			if ( $element->hasAttribute( $attribute ) )
			{
				if ( mb_strpos( $element->getAttribute( $attribute ), \IPS\Settings::i()->base_url ) )
				{
					$element->setAttribute( $attribute, str_replace( \IPS\Settings::i()->base_url, '%7B___base_url___%7D/', $element->getAttribute( $attribute ) ) );
				}
			}
		}
		
		/* Return */
		return $element;
	}
	
	/**
	 * Parse <a> element
	 *
	 * @param	\DOMElement	$element	The element
	 * @return	void
	 */
	protected function _parseAElement( \DOMElement $element )
	{
		/* Punycode it if necessary */
		if ( !preg_match( '/^[\x00-\x7F]*$/', $element->getAttribute('href') ) )
		{
			try
			{
				$punycodeEncoded = (string) \IPS\Http\Url::createFromString( $element->getAttribute('href') );
				$element->setAttribute( 'href', $punycodeEncoded );
			}
			catch( \IPS\Http\Url\Exception $e ) { }
		}
		
		/* If it's not allowed, remove the href */
		if ( !static::isAllowedUrl( $element->getAttribute('href') ) )
		{
			$element->removeAttribute( 'href' );
			$element->setAttribute( 'class', 'ipsType_noLinkStyling' );
			return $element;
		}
				
		/* Attachment? */
		if ( $attachment = static::_getAttachment( $element->getAttribute('href'), $element->hasAttribute('data-fileid') ? $element->getAttribute('data-fileid') : NULL ) )
		{
			$element->setAttribute( 'data-fileid', $attachment['attach_id'] );
			$element->setAttribute( 'href', str_replace( array( 'http:', 'https:' ), '', str_replace( static::$fileObjectClasses['core_Attachment']->baseUrl(), '{fileStore.core_Attachment}', $element->getAttribute('href') ) ) );
			$element->setAttribute( 'data-fileext', $attachment['attach_ext'] );

			if ( ! empty( $attachment['attach_labels'] ) and $labels = static::getAttachmentLabels( $attachment ) )
			{
				if ( \count( $labels ) )
				{
					if ( $this->_altLabelWord === NULL )
					{
						$this->_altLabelWord = \IPS\Lang::load( \IPS\Lang::defaultLanguage() )->get( 'alt_label_could_be' );
					}

					$element->setAttribute( 'alt', $this->_altLabelWord . ' ' . implode( ', ', $labels ) );
				}
			}
			
			$this->_logAttachment( $attachment );
		}
		
		/* Some other media? */
		elseif ( $element->getAttribute('data-extension') and $file = $this->_getFile( $element->getAttribute('data-extension'), $element->getAttribute('href') ) )
		{
			$element->setAttribute( 'href', '{fileStore.' . $file->storageExtension . '}/' . (string) $file );
		}
		
		try
		{
			$rels	= $this->_getRelAttributes( \IPS\Http\Url::createFromString( $element->getAttribute('href') ) );
		}
		catch( \IPS\Http\Url\Exception $e )
		{
			$rels	= array(); 
		}
		
		/* Add rels */
		$element->setAttribute( 'rel', implode( ' ', $rels ) );
		
		return $element;
	}
	
	/**
	 * @brief Emoticon Count
	 */
	protected $_emoticons = 0;
	
	/**
	 * Parse <img> element
	 *
	 * @param	\DOMElement	$element	The element
	 * @return	bool
	 */
	protected function _parseImgElement( \DOMElement $element )
	{
		/* When editing content in the AdminCP, images and iframes get the src munged. When we save, we need to put that back */
		$this->_removeMunge( $element );

		/* Lazy-loaded? */
		$srcAttribute = ( $element->hasAttribute( 'src' ) AND $element->hasAttribute( 'data-src' ) ) ? 'data-src' : 'src';

		/* Is it an emoji? */
		if ( $element->getAttribute('class') and \in_array( 'ipsEmoji', explode( ' ', $element->getAttribute('class') ) ) and $element->getAttribute('alt') )
		{
			$newElement = $element->ownerDocument->importNode( new \DOMElement( 'span' ) );
			$newElement->setAttribute( 'class', 'ipsEmoji' );
			$newElement->appendChild( new \DOMText( $element->getAttribute('alt') ) );
			return $newElement;
		}

		/* If it's not allowed, remove the src */
		try
		{
			static::isAllowedImageUrl( $element->getAttribute( $srcAttribute ) );
		}
		catch( \UnexpectedValueException $e )
		{
			$newElement = $element->ownerDocument->importNode( new \DOMElement( 'span' ) );
			$newElement->appendChild( new \DOMText( $element->getAttribute( $srcAttribute ) ) );

			return $newElement;
		}

		/* Is it an emoticon? */
		if ( $element->hasAttribute('data-emoticon') )
		{
			if ( $this->_emoticons < 75 )
			{
				if ( !isset( static::$fileObjectClasses['core_Emoticons'] ) )
				{
					static::$fileObjectClasses['core_Emoticons'] = \IPS\File::getClass('core_Emoticons' );
				}

				$element->setAttribute( 'src', str_replace( array( 'http:', 'https:' ), '', str_replace( static::$fileObjectClasses['core_Emoticons']->baseUrl(), '{fileStore.core_Emoticons}', $element->getAttribute('src') ) ) );

				if ( $srcSet = $element->getAttribute('srcset') )
				{
					$element->setAttribute( 'srcset', str_replace( array( 'http:', 'https:' ), '', str_replace( static::$fileObjectClasses['core_Emoticons']->baseUrl(), '%7BfileStore.core_Emoticons%7D', $srcSet ) ) );
				}

				$this->_emoticons++;
			}
			else
			{
				/* Set an attribute on the element - we'll need to know this later */
				$element->setAttribute( 'data-ipsEmoticon-plain', $element->getAttribute('title') );
			}
		}
		
		/* Or an attachment? */
		elseif ( $attachment = static::_getAttachment( $element->getAttribute($srcAttribute ), $element->hasAttribute('data-fileid') ? $element->getAttribute('data-fileid') : NULL ) )
		{
			$file = $this->_getFile( 'core_Attachment', $element->getAttribute( $srcAttribute ) );

			$element->setAttribute( 'data-fileid', $attachment['attach_id'] );
			$element->setAttribute( $srcAttribute, str_replace( array( 'http:', 'https:' ), '', str_replace( static::$fileObjectClasses['core_Attachment']->baseUrl(), '{fileStore.core_Attachment}', $element->getAttribute( $srcAttribute ) ) ) );


			if ( ( !$element->hasAttribute('alt') or $element->getAttribute('alt') == $file->filename ) and ! empty( $attachment['attach_labels'] ) and $labels = static::getAttachmentLabels( $attachment ) )
			{
				if ( \count( $labels ) )
				{
					if ( $this->_altLabelWord === NULL )
					{
						$this->_altLabelWord = \IPS\Lang::load( \IPS\Lang::defaultLanguage() )->get( 'alt_label_could_be' );
					}

					$element->setAttribute( 'alt', $this->_altLabelWord . ' ' . implode( ', ', $labels ) );
				}
			}

			if ( !$element->getAttribute('alt') )
			{
				$element->setAttribute( 'alt', $attachment['attach_file'] );
			}

			$this->_logAttachment( $attachment );
		}

		/* Or some other media? */
		elseif ( $element->getAttribute('data-extension') and $file = $this->_getFile( $element->getAttribute('data-extension'), $element->getAttribute( $srcAttribute ) ) )
		{
			$element->setAttribute(  $srcAttribute, '{fileStore.' . $file->storageExtension . '}/' . (string) $file );
			if ( !$element->getAttribute('alt') )
			{
				$element->setAttribute( 'alt', $file->originalFilename );
			}
		}
		
		/* Nope, regular image */
		else
		{
			/* We need an alt (HTMLPurifier handles this normally, but it may not always run) */
			if ( !$element->getAttribute('alt') )
			{
				$element->setAttribute( 'alt', mb_substr( basename( $element->getAttribute('src') ), 0, 40 ) );
			}
		}

		/* If this isn't an emoticon, replace the src and add attributes to enable lazy loading */
		if ( !$element->hasAttribute('data-emoticon') )
		{			
			/* If this image already has a data-src, we need to use that because it's the 
			 	original image url. `src` will likely contain our base64 blank image instead.
			 	We need to do this regardless of whether lazy-loading is enabled now, for 
			 	situations where content has data-src, but lazy loading has been disabled since,
			 	without a post rebuild. In that case, we'll want to swap the data-src for a real src. */
			$imgSrc = $element->hasAttribute('data-src') ? $element->getAttribute( 'data-src' ) : $element->getAttribute( 'src' );
			$element->removeAttribute( 'data-src' );
			$element->removeAttribute( 'src' );

			if ( \IPS\Settings::i()->lazy_load_enabled )
			{
				$element->setAttribute( 'data-src', $imgSrc );
				$element->setAttribute( 'src', static::blankImage() ); // A blank pixel
			}
			else
			{
				$element->setAttribute( 'src', $imgSrc );
			}

			// We might still have this from the editor, but it'll stop lazy-load from running
			// Ensure this is removed regardless of whether lazy loading is enabled right now
			$element->removeAttribute( 'data-loaded' );
		}

		return $element;
	}

	/**
	 * Parse <video> element
	 *
	 * @param	\DOMElement	$element	The element
	 * @return	\DOMElement
	 */
	protected function _parseVideoElement( \DOMElement $element )
	{
		if ( \IPS\Settings::i()->lazy_load_enabled )
		{
			$element->removeAttribute( 'data-controller' );
			$element->setAttribute( 'data-video-embed', '' );
		}
		else
		{
			$element->setAttribute( 'data-controller', 'core.global.core.embeddedvideo' );
			$element->removeAttribute( 'data-video-embed' );
		}

		return $element;
	}

		/**
	 * Parse <audio> element
	 *
	 * @param	\DOMElement	$element	The element
	 * @return	\DOMElement
	 */
	 protected function _parseAudioElement( \DOMElement $element )
	 {
		 if ( \IPS\Settings::i()->lazy_load_enabled )
		 {
			 $element->removeAttribute( 'data-controller' );
			 $element->setAttribute( 'data-audio-embed', '' );
		 }
		 else
		 {
			 $element->setAttribute( 'data-controller', 'core.global.core.embeddedaudio' );
			 $element->removeAttribute( 'data-audio-embed' );
		 }
 
		 return $element;
	 }

	/**
	 * Parse <source> element
	 *
	 * @param	\DOMElement			$element			The element
	 * @param	\DOMElement|NULL	$originalElement	The original element
	 * @return	\DOMElement
	 */
	protected function _parseSourceElement( \DOMElement $element, \DOMElement $originalElement = NULL )
	{
		$parentIsVideo = function( \DOMElement $element ) use ( &$parentIsVideo ) {
			if( $element->tagName === 'video' )
			{
				return TRUE;
			}
			elseif( $element->parentNode !== NULL AND $element->parentNode instanceof \DOMElement )
			{
				return $parentIsVideo( $element->parentNode );
			}

			return FALSE;
		};

		/* We only want to do this for videos */
		if( $originalElement !== NULL AND !$parentIsVideo( $originalElement ) )
		{
			return $element;
		}

		/* As with images, do this regardless of whether lazy loading is presently enabled */
		$videoUrl = $element->hasAttribute('data-video-src') ? $element->getAttribute('data-video-src') : $element->getAttribute('src');
		$element->removeAttribute('data-video-src');
		$element->removeAttribute('src');
		
		if ( \IPS\Settings::i()->lazy_load_enabled )
		{
			$element->setAttribute( 'data-video-src', $videoUrl );
		}
		else
		{
			$element->setAttribute( 'src', $videoUrl );
		}	

		return $element;
	}

	/**
	 * Parse <iframe> element
	 *
	 * @param	\DOMElement	$element	The element
	 * @return	\DOMElement
	 */
	protected function _parseIframeElement( \DOMElement $element )
	{
		try
		{
			$src = \IPS\Http\Url::createFromString( $element->getAttribute('src') );

			if( mb_strpos( $src->data['host'], 'youtube.com' ) !== FALSE OR mb_strpos( $src->data['host'], 'youtube-nocookie.com' ) !== FALSE )
			{
				/* If this is a youtube link, let's strip auto-play... */
				$src = $src->stripQueryString( 'autoplay' );
				$element->setAttribute( 'data-embed-src', (string) $src );
				// removing the src attrib causes a FOUC in firefox, so we need to set it to an empty page instead (see #2178)
				$element->setAttribute( 'src', static::blankPage() );
			} 
			else if ( $src instanceof \IPS\Http\Url\Internal )
			{
				/* If this is an internal embed, replace src and controller for lazy loading */
				if ( \IPS\Settings::i()->lazy_load_enabled )
				{
					$element->setAttribute( 'data-embed-src', (string) $src );
					$element->removeAttribute( 'data-controller' );
					// removing the src attrib causes a FOUC in firefox, so we need to set it to an empty page instead (see #2178)
					$element->setAttribute( 'src', static::blankPage() );
				}
			}
		}
		catch ( \IPS\Http\Url\Exception $e ) { }

		$this->_removeMunge( $element );
		
		return $element;
	}

	/**
	 * Remove Image Proxy URL
	 *
	 * @param	\DOMElement		$element		The element we are working with
	 * @param	bool			$useProxyUrl	Use the proxied image URL (the locally stored image) instead of the original URL
	 * @return	void
	 */
	protected static function _removeImageProxy( \DOMElement $element, $useProxyUrl = FALSE )
	{
		$imageProxyUrl = "<___base_url___>/applications/core/interface/imageproxy/imageproxy.php";
		$attributeName = ( \IPS\Settings::i()->lazy_load_enabled AND $element->hasAttribute( 'data-src' ) ) ? 'data-src' : 'src';

		$imageSrc = $element->getAttribute( $attributeName );

		/* If it's a local placeholder, we don't need to process it.
		 * - There was a minor bug in 4.2.5 that could affect some internal images,
		 * this specifically only checks that the string starts with the file storage replacement
		 * so that it can still fix the issue by disabling the image proxy.
		 */
		if( preg_match( '#^({|%7B|\<|%3C)fileStore\.#', $imageSrc ) )
		{
			return;
		}
		
		if( mb_stristr( $imageSrc, (string) $imageProxyUrl ) )
		{
			try
			{
				$srcUrl = \IPS\Http\Url::createFromString( str_replace( array( '<___base_url___>', '%7B___base_url___%7D' ), rtrim( \IPS\Settings::i()->base_url, '/' ), $imageSrc ) )->queryString['img'];
			}
			catch( \IPS\Http\Url\Exception $e )
			{
				parse_str( parse_url( str_replace( array( '<___base_url___>', '%7B___base_url___%7D' ), rtrim( \IPS\Settings::i()->base_url, '/' ), $imageSrc ), PHP_URL_QUERY ), $queryString );

				$srcUrl = static::_getProxiedImageUrl( $queryString['img'], $useProxyUrl );
			}

			$element->setAttribute( $attributeName, static::_getProxiedImageUrl( $srcUrl, $useProxyUrl ) );
			
			/* We also need to remove image proxy from the parent A tag if it exists */
			if( $element->parentNode->tagName === 'a' AND mb_stristr( $element->parentNode->getAttribute( 'href' ), (string) $imageProxyUrl ) )
			{
				try
				{
					$hrefSrcUrl = \IPS\Http\Url::createFromString( str_replace( array( '<___base_url___>', '%7B___base_url___%7D' ), rtrim( \IPS\Settings::i()->base_url, '/' ), $element->parentNode->getAttribute( 'href' ) ) )->queryString['img'];
				}
				catch( \IPS\Http\Url\Exception $e )
				{
					parse_str( parse_url( str_replace( array( '<___base_url___>', '%7B___base_url___%7D' ), rtrim( \IPS\Settings::i()->base_url, '/' ), $element->parentNode->getAttribute( 'href' ) ), PHP_URL_QUERY ), $queryString );

					$srcUrl = static::_getProxiedImageUrl( $queryString['img'], $useProxyUrl );
				}

				$element->parentNode->setAttribute( 'href', static::_getProxiedImageUrl( $hrefSrcUrl, $useProxyUrl ) );
			}
		}

		/* Remove data attribute */
		$element->removeAttribute('data-imageproxy-source');

		if( $element->getAttribute('srcset') )
		{
			$urls = explode( ',', $element->getAttribute('srcset') );
			$fixedUrls = array();

			foreach( $urls as $url )
			{
				/* Format is: http://url.com/img.png size */
				$data = explode( ' ', trim( $url ) );

				if( \count( $data ) <= 2 )
				{
					/* If for some reason we're processing an existing imageproxy URL, set the full URL. */
					try
					{
						$imageSrcUrl = \IPS\Http\Url::createFromString( str_replace( array( '<___base_url___>', '%7B___base_url___%7D' ), rtrim( \IPS\Settings::i()->base_url, '/' ), $data[0] ) );

						if( isset( $imageSrcUrl->queryString['img'] ) )
						{
							$imageSrcUrl = $imageSrcUrl->queryString['img'];
						}
						else
						{
							$imageSrcUrl = $data[0];
						}
					}
					catch( \IPS\Http\Url\Exception $e )
					{
						/* If we're here just return the original URL. Legacy data can result in many different problems that just can't be accounted for in every case. */
						parse_str( parse_url( str_replace( array( '<___base_url___>', '%7B___base_url___%7D' ), rtrim( \IPS\Settings::i()->base_url, '/' ), $data[0] ), PHP_URL_QUERY ), $queryString );

						$imageSrcUrl = ( isset( $queryString['img'] ) ) ? $queryString['img'] : $data[0];
					}

					$fixedUrls[] = static::_getProxiedImageUrl( $imageSrcUrl, $useProxyUrl ) . ( ! empty( $data[1] ) ? ' ' . $data[1] : '' );
				}
			}

			if ( \count( $fixedUrls ) )
			{
				$element->setAttribute( 'srcset', implode( ', ', $fixedUrls ) );
			}
		}
	}

	/**
	 * Return the URL to restore when image proxy is disabled.
	 *
	 * @param	string		$url			Url to use
	 * @param	bool		$useProxyUrl	Use the proxied image URL (the locally stored image) instead of the original URL
	 * @return	string
	 */
	protected static function _getProxiedImageUrl( $url, $useProxyUrl )
	{
		/* We want the original URL */
		if( !$useProxyUrl )
		{
			return $url;
		}

		/* If the table no longer exists because it was dropped but we are re-running a remove image proxy step, just use original URL */
		if( !\IPS\Db::i()->checkForTable( 'core_image_proxy' ) )
		{
			return $url;
		}

		try
		{
			$cacheEntry = \IPS\Db::i()->select( '*', 'core_image_proxy', array( 'md5_url=?', md5( $url ) ) )->first();

			if( $cacheEntry['location'] )
			{
				return (string) \IPS\File::get( 'core_Imageproxycache', $cacheEntry['location'] )->url;
			}
		}
		catch( \UnderflowException $e ) {}

		/* If we're here, we couldn't find the proxied image URL so just return the original URL */
		return $url;
	}
	
	/**
	 * When editing content in the AdminCP, images and iframes get the src munged. When we save, we need to put that back
	 *
	 * @param	\DOMElement	$element	The element
	 * @return	bool
	 */
	protected function _removeMunge( \DOMElement $element )
	{
		if ( $originalUrl = $element->getAttribute('data-munge-src') )
		{
			$element->removeAttribute( 'src' );
			$element->setAttribute( 'src', $originalUrl );
			$element->removeAttribute( 'data-munge-src' );
		}
	}
	
	/* !Parser: Element-Specific Parsing: URLs */
	
	/**
	 * Get "rel" attribute values for a URL
	 *
	 * @param	\IPS\Http\Url	$url	The URL
	 * @return	array
	 */
	protected function _getRelAttributes( \IPS\Http\Url $url )
	{
		$rels = array();
		
		/* We add external/nofollow rel attributes for non-internal non-local links */
		if ( !( $url instanceof \IPS\Http\Url\Internal ) )
		{
			$rels[] = 'external';
			
			/* Do we also want to add nofollow? */
			if( \IPS\Settings::i()->posts_add_nofollow )
			{
				/* If we aren't excluding any domains, then add it */
				if( !\IPS\Settings::i()->posts_add_nofollow_exclude )
				{
					$rels[] = 'nofollow';
				}
				else
				{
					/* HTML Purifier converts IDN to punycode, so we need to do the same with our 'follow' domains */
					if ( !\function_exists('idn_to_ascii') )
					{
						\IPS\IPS::$PSR0Namespaces['TrueBV'] = \IPS\ROOT_PATH . "/system/3rd_party/php-punycode";
						require_once \IPS\ROOT_PATH . "/system/3rd_party/php-punycode/polyfill.php";
					}

					$follow	= array_map( function( $val ) {
						return idn_to_ascii( preg_replace( '/^www\./', '', $val ) );
					}, json_decode( \IPS\Settings::i()->posts_add_nofollow_exclude ) );

					if( isset( $url->data['host'] ) AND !\in_array( preg_replace( '/^www\./', '', $url->data['host'] ), $follow ) )
					{
						$rels[] = 'nofollow';
					}
				}
			}
		}
		
		return $rels;
	}

	/**
	 * Is allowed URL for an image?
	 *
	 * @param	string	$url	The URL
	 * @return	bool	Returns TRUE on success, or throws an exception with the reason on failure
	 * @throws	UnexpectedValueException
	 */
	static public function isAllowedImageUrl( $url )
	{
		/* We want a URL object */
		try
		{
			$url = \IPS\Http\Url::createFromString( $url );
		}
		catch( \IPS\Http\Url\Exception $e )
		{
			/* The URL is not valid */
			throw new \UnexpectedValueException( $e->getMessage() );
		}

		/* We will always allow internal URLs */
		if( $url instanceof \IPS\Http\Url\Internal )
		{
			return true;
		}

		/* If the URL is blacklisted, just return it */
		if( !static::isAllowedUrl( $url ) )
		{
			throw new \UnexpectedValueException( 'embed_bad_url' );
		}

		/* If we're not allowing remote embeds, fail if this isn't one of our file storage configured URLs */
		if ( !\IPS\Settings::i()->allow_remote_images )
		{
			$isAllowed = FALSE;
			foreach( \IPS\File::getStore() as $fileStorage )
			{
				$fileStorage = \IPS\File::getClass( $fileStorage['id'] );
				
				if( mb_strpos( (string) $url, $fileStorage->baseUrl() ) === 0 )
				{
					$isAllowed = TRUE;
					break;
				}

			}

			if( !$isAllowed )
			{
				throw new \UnexpectedValueException( 'embed_bad_url' );
			}
		}

		/* Or if we're not allowing remotely embedded http images, just return it */
		if( \IPS\Settings::i()->allow_remote_images AND \IPS\Settings::i()->allow_only_https_remote_images )
		{
			if( parse_url( $url, \PHP_URL_SCHEME ) !== NULL AND parse_url( $url, \PHP_URL_SCHEME ) !== 'https' )
			{
				throw new \UnexpectedValueException( 'embed_only_https' );
			}
		}

		return true;
	}

	/**
	 * Is allowed URL
	 *
	 * @param	string	$url	The URL
	 * @return	bool
	 */
	static public function isAllowedUrl( $url )
	{
		if ( \IPS\Settings::i()->url_filter_action == 'moderate' or \IPS\Member::loggedIn()->group['g_bypass_badwords']  )
		{
			/* We want to moderate content with this URL, so do nothing here, so it returns a full <a> tag */
			return true;	
		}
		
		if ( \IPS\Settings::i()->ipb_url_filter_option != 'none' )
		{
			$links = \IPS\Settings::i()->ipb_url_filter_option == "black" ? \IPS\Settings::i()->ipb_url_blacklist : \IPS\Settings::i()->ipb_url_whitelist;
	
			if( $links )
			{
				$linkValues = array();
				$linkValues = explode( "," , $links );
	
				if( \IPS\Settings::i()->ipb_url_filter_option == 'white' )
				{
					$linkValues[]	= "http://" . parse_url( \IPS\Settings::i()->base_url, PHP_URL_HOST ) . "/*";
					$linkValues[]	= "https://" . parse_url( \IPS\Settings::i()->base_url, PHP_URL_HOST ) . "/*";
				}
	
				if ( !empty( $linkValues ) )
				{
					$goodUrl = FALSE;
					
					if ( \count( $linkValues ) )
					{
						foreach( $linkValues as $link )
						{
							if( !trim($link) )
							{
								continue;
							}
		
							$link = preg_quote( $link, '/' );
							$link = str_replace( '\*', "(.*?)", $link );
		
							if ( \IPS\Settings::i()->ipb_url_filter_option == "black" )
							{
								if( preg_match( '/' . $link . '/i', $url ) )
								{
									return false;
								}
							}
							else
							{
								if ( preg_match( '/' . $link . '/i', $url ) )
								{
									$goodUrl = TRUE;
								}
							}
						}
					}
	
					if ( ! $goodUrl AND \IPS\Settings::i()->ipb_url_filter_option == "white" )
					{
						return false;
					}
				}
			}
		}
	
		return true;
	}
	
	/* !Parser: Element-Specific Parsing: File System */
	
	/**
	 * @brief	Stored file object classes
	 */
	protected static $fileObjectClasses = array();
	
	/**
	 * Get attachment data from URL
	 *
	 * @param	string		$url		The URL
	 * @param	int|null	$fileId		If we are editing and the fileid is already set, that's even better!
	 * @return	array|NULL
	 */
	protected static function _getAttachment( $url, $fileId = NULL )
	{
		/* We need the storage extension */
		if ( !isset( static::$fileObjectClasses['core_Attachment'] ) )
		{
			static::$fileObjectClasses['core_Attachment'] = \IPS\File::getClass('core_Attachment');
		}

		/* If we have the fileid, we can do this the easy way */
		if( $fileId !== NULL )
		{
			try
			{
				return \IPS\Db::i()->select( '*', 'core_attachments', array( 'attach_id=?', $fileId ) )->first();
			}
			catch( \UnderflowException $e ){}
		}

		/* De-munge it */
		if ( preg_match( '#^(?:http:|https:)?' . preg_quote( rtrim( str_replace( array( 'http://', 'https://' ), '//', \IPS\Settings::i()->base_url ), '/' ), '#' ) . '/index.php\?app=core&module=system&controller=redirect&url=(.+?)&key=.+?(?:&resource=[01])?$#', $url, $matches ) )
		{
			$url = urldecode( $matches[1] );
		}
		
		/* If it's URL to applications/core/interface/file/attachment.php, it's definitely an attachment */
		if ( preg_match( '#^(?:http:|https:)?' . preg_quote( rtrim( str_replace( array( 'http://', 'https://' ), '//', \IPS\Settings::i()->base_url ), '/' ), '#' ) . '/applications/core/interface/file/attachment\.php\?id=(\d+)(?:&key=[a-f0-9]{32})?$#', $url, $matches ) )
		{
			try
			{
				return \IPS\Db::i()->select( '*', 'core_attachments', array( 'attach_id=?', $matches[1] ) )->first();
			}
			catch ( \UnderflowException $e ) { }
		}
		
		/* Otherwise, we need to see if it matches the actual attachment storage URL */
		if ( preg_match( '#^(' . preg_quote( rtrim( static::$fileObjectClasses['core_Attachment']->baseUrl(), '/' ), '#' ) . ')/(.+?)$#', $url, $matches ) )
		{
			try
			{
				return \IPS\Db::i()->select( '*', 'core_attachments', array( 'attach_location=? OR attach_thumb_location=?', $matches[2], $matches[2] ) )->first();
			}
			catch ( \UnderflowException $e ) { }
		}

		/* No, but it may have the URL replacement instead of the URL - Further refined since curly braces may be present instead if this is running in the AdminCP */
		if ( preg_match( '#^(({|%7B)fileStore.core_Attachment(%7D|}))/(.+?)$#', $url, $matches ) )
		{
			try
			{
				return \IPS\Db::i()->select( '*', 'core_attachments', array( 'attach_location=? OR attach_thumb_location=?', $matches[4], $matches[4] ) )->first();
			}
			catch ( \UnderflowException $e ) { }
		}
		
		/* Nope, not an attachment */
		return NULL;
	}
	
	/**
	 * Log that at attachment is being used in the content
	 *
	 * @param	array	$attachment	Attachment data
	 * @return	void
	 */
	protected function _logAttachment( $attachment )
	{        
		if ( isset( $this->existingAttachments[ $attachment['attach_id'] ] ) )
		{
			unset( $this->existingAttachments[ $attachment['attach_id'] ] );
		}
		elseif ( $attachment['attach_member_id'] === (int) $this->member->member_id and !\in_array( $attachment['attach_id'], $this->mappedAttachments ) )
		{
			if( $this->area and !isset( \IPS\Request::i()->_previewField ) )
			{			
				\IPS\Db::i()->replace( 'core_attachments_map', array(
					'attachment_id'	=> $attachment['attach_id'],
					'location_key'	=> $this->area,
					'id1'			=> ( \is_array( $this->attachIds ) and isset( $this->attachIds[0] ) ) ? $this->attachIds[0] : NULL,
					'id2'			=> ( \is_array( $this->attachIds ) and isset( $this->attachIds[1] ) ) ? $this->attachIds[1] : NULL,
					'id3'			=> ( \is_array( $this->attachIds ) and isset( $this->attachIds[2] ) ) ? $this->attachIds[2] : NULL,
					'temp'			=> \is_string( $this->attachIds ) ? $this->attachIds : NULL,
					'lang'			=> $this->attachIdsLang
				) );
			}
			
			$this->mappedAttachments[] = $attachment['attach_id'];
		}
	}
	
	/**
	 * Get file data
	 *
	 * @param	string	$extension	The extension
	 * @param	string	$url		The URL
	 * @return	\IPS\File|NULL
	 */
	protected function _getFile( $extension, $url )
	{
		if ( !isset( static::$fileObjectClasses[ $extension ] ) )
		{
			static::$fileObjectClasses[ $extension ] = \IPS\File::getClass( $extension );
		}
		
		if ( preg_match( '#^(' . preg_quote( rtrim( static::$fileObjectClasses[ $extension ]->baseUrl(), '/' ), '#' ) . ')/(.+?)$#', $url, $matches ) )
		{
			return \IPS\File::get( $extension, $url );
		}
		
		return NULL;
	}
	
	/* !BBCode */
	
	/**
	 * Get BBCode Tags
	 *
	 * @param	\IPS\Member	$member	The member
	 * @param	string|bool	$area	The Editor area we're parsing in. e.g. "core_Signatures". A boolean value will allow or disallow all BBCodes that are dependant on area.
	 * @code
	 	return array(
	 		'font'	=> array(																	// Key represents the BBCode tag (e.g. [font])
		 		'tag'			=> 'span',															// The HTML tag to use
		 		'attributes'	=> array( ... )														// Key/Value pairs of attributes to use (optional) - can use {option} to get the [tag=option] value
		 		'defaultOption'	=> '..',															// Value to use for {option} if one isn't specified
		 		'block'			=> FALSE,															// If this is a block-level tag (optional, default false)
		 		'single'		=> FALSE,															// If this is a single tag, with no content (optional, default false)
		 		'noParse'		=> FALSE,															// If other BBCode shouldn't be parsed inside (optional, default false)
		 		'noChildren'	=> FALSE,															// If it is not appropriate for this element to have child elements (for example <pre> can't have <p>s inside) (optional, default false)
		 		'callback'		=> function( \DOMElement $node, $matches, \DOMDocument $document )	// A callback to modify the DOMNode object (optional)
		 		{
		 			...
		 			return $node;
		 		},
		 		'getBlockContentElement' => function( \DOMElement $node )							// If the callback modifies, an additional callback can be specified to provide the node which children should go into (for example, spoilers have a header, and we want children to go into the content body) (optional)
		 		{
			 		...
			 		return $node;
			 	},
		 		'finishedCallback'	=> function( \DOMElement $originalNode )						// A callback which is ran after all children have been parsed for any additional parsing (optional)
		 		{
			 		...
			 		return $node;
			 	},
			 	'allowOption'	=> FALSE,															// If options are allowed. Defaults to TRUE
	 	)
	 * @endcode
	 * @return	array|NULL
	 */
	public function bbcodeTags( \IPS\Member $member, $area )
	{
		$return = array();

		/* If BBCode parsing is disabled and we aren't forcing it (e.g. for converters) then return no tags to parse now */
		if( !\IPS\Settings::i()->enable_bbcode AND !$this->forceBbcodeEnabled )
		{
			return NULL;
		}
		
		/* Acronym */
		$return['acronym'] = array( 'tag' => 'abbr', 'attributes' => array( 'title' => '{option}' ), 'allowOption' => TRUE );
		
		/* Background */
		if ( static::canUse( $member, 'BGColor', $area ) )
		{
			$return['background'] = array( 'tag' => 'span', 'attributes' => array( 'style' => 'background-color:{option}' ), 'allowOption' => TRUE );
		}
		
		/* Bold */
		if ( static::canUse( $member, 'Bold', $area ) )
		{
			$return['b'] = array( 'tag' => 'strong', 'allowOption' => FALSE );
		}
		
		/* Code */
		if ( static::canUse( $member, 'ipsCode', $area ) )
		{
			$code = array( 'tag' => 'pre', 'attributes' => array( 'class' => 'ipsCode' ), 'block' => TRUE, 'noParse' => TRUE, 'noChildren' => TRUE, 'allowOption' => TRUE, 'finishedCallback' => function( \DOMElement $originalNode ) {
								
				/* Parse breaks - with BBCode we'll be getting things like [code]line1<br>line2[/code] so we need to make sure they're formatted properly */
				$contents = \substr( trim( \IPS\Text\DOMParser::parse(
					$originalNode->ownerDocument->saveHtml( $originalNode ),
					/* DOMElement Parse */
					function ( \DOMElement $element, \DOMNode $parent, \IPS\Text\DOMParser $parser )
					{
						/* Control elements get insetred normally */
						if ( $parent instanceof \DOMDocument or \in_array( $parent->tagName, array( 'html', 'head', 'body' ) ) )
						{
							$ownerDocument = $parent->ownerDocument ?: $parent;
							$newElement = $ownerDocument->importNode( $element );
							
							$parent->appendChild( $newElement );
						
							$parser->_parseDomNodeList( $element->childNodes, $newElement );
						}
						/* Everything else becomes a direct child of the <pre> */
						else
						{
							/* With "\n"s inserted appropriately */
							if ( $element->tagName == 'br' or $element->tagName == 'p' )
							{
								$parent->appendChild( new \DOMText("\n") );
							}
							
							$parser->_parseDomNodeList( $element->childNodes, $parent );
						}
					},
					/* DOMText Parse */
					function ( \DOMText $textNode, \DOMNode $parent, \IPS\Text\DOMParser $parser ) {
						/* CKEditor will send "\n<br>" so just strip those so we don't get double breaks */
						$text = str_replace( "\n", '', $textNode->textContent );
						
						/* CKEditor will also send "<br>\t" and "<br></p><p>\t" so also strip any whitespace after a break
							CKEditor doesn't actually have a way to indent individual lines */
						if (
							( $previousSibling = $textNode->previousSibling and $previousSibling instanceof \DOMElement and $previousSibling->tagName == 'br' )
							or
							( $textNode->parentNode instanceof \DOMElement and $textNode->parentNode->tagName == 'p' and $textNode->parentNode->lastChild and $textNode->parentNode->lastChild instanceof \DOMElement and $textNode->parentNode->lastChild->tagName == 'br' )
						) {
							$text = preg_replace( '/^\s/', '', $text );
						}
						
						/* Insert */
						$parent->appendChild( new \DOMText( $text ) );
					}
				) ), 21, -6 );
				
				/* Create a new <pre> with those contents */
				$return = $originalNode->ownerDocument->createElement( 'pre' );
				$return->appendChild( new \DOMText( html_entity_decode( $contents ) ) ); // We have to decode HTML entities otherwise they'll be double-encoded. Test with "[code]<strong>Test</strong>[/code]"
				$return->setAttribute( 'class', 'ipsCode' );
				return $return;
			} );
						
			$return['code'] = $code;
			$return['codebox'] = $code;
			$return['html'] = $code;
			$return['php'] = $code;
			$return['sql'] = $code;
			$return['xml'] = $code;
		}
		
		/* Color */
		if ( static::canUse( $member, 'TextColor', $area ) )
		{
			$return['color'] = array( 'tag' => 'span', 'attributes' => array( 'style' => 'color:{option}' ), 'allowOption' => TRUE );
		}
		
		/* Font */
		if ( static::canUse( $member, 'Font', $area ) )
		{
			$return['font'] = array( 'tag' => 'span', 'attributes' => array( 'style' => 'font-family:{option}' ), 'allowOption' => TRUE );
		}
		
		/* HR */
		$return['hr'] = array( 'tag' => 'hr', 'single' => TRUE, 'allowOption' => FALSE );

		/* Image */
		if ( static::canUse( $member, 'ipsImage', $area ) )
		{
			$return['img'] = array( 'tag' => 'img', 'attributes' => array( 'src' => '{option}', 'class' => 'ipsImage' ), 'single' => TRUE, 'allowOption' => TRUE );
		}
		
		/* Indent */
		if ( static::canUse( $member, 'Indent', $area ) )
		{
			$return['indent'] = array( 'tag' => 'div', 'attributes' => array( 'style' => 'margin-left:{option}px' ), 'block' => FALSE, 'defaultOption' => 25, 'allowOption' => TRUE );
		}
		
		/* Italics */
		if ( static::canUse( $member, 'Italic', $area ) )
		{
			$return['i'] = array( 'tag' => 'em', 'allowOption' => FALSE );
		}
		
		/* Justify */
		if ( static::canUse( $member, 'JustifyLeft', $area ) )
		{
			$return['left'] = array( 'tag' => 'div', 'attributes' => array( 'style' => 'text-align:left' ), 'block' => TRUE, 'allowOption' => FALSE );
		}
		if ( static::canUse( $member, 'JustifyCenter', $area ) )
		{
			$return['center'] = array( 'tag' => 'div', 'attributes' => array( 'style' => 'text-align:center' ), 'block' => TRUE, 'allowOption' => FALSE );
		}
		if ( static::canUse( $member, 'JustifyRight', $area ) )
		{
			$return['right'] = array( 'tag' => 'div', 'attributes' => array( 'style' => 'text-align:right' ), 'block' => TRUE, 'allowOption' => FALSE );
		}
		
		/* Links */
		if ( static::canUse( $member, 'ipsLink', $area ) )
		{
			/* Email */
			$return['email'] = array( 'tag' => 'a', 'attributes' => array( 'href' => 'mailto:{option}' ), 'allowOption' => TRUE );
			
			/* Member */
			$return['member'] = array(
				'tag'		=> 'a',
				'attributes'=> array( 'contenteditable' => 'false', 'data-ipsHover' => '' ),
				'callback'	=> function( \DOMElement $node, $matches, \DOMDocument $document )
				{
					try
					{
						$member = \IPS\Member::load( $matches[2], 'name' );
						if ( $member->member_id != 0 )
						{
							$node->setAttribute( 'href',  $member->url() );
							$node->setAttribute( 'data-ipsHover-target',  $member->url()->setQueryString( 'do', 'hovercard' ) );
							$node->setAttribute( 'data-mentionid',  $member->member_id );
							$node->appendChild( $document->createTextNode( '@' . $member->name ) );
						}

					}
					catch ( \Exception $e ) {}
					
					return $node;
				},
				'single'	=> TRUE,
				'allowOption' => TRUE
			);
			
			/* Links */
			$return['url'] = array( 'tag' => 'a', 'attributes' => array( 'href' => '{option}' ), 'allowOption' => TRUE );
		}
				
		/* List */
		if ( static::canUse( $member, 'BulletedList', $area ) or static::canUse( $member, 'NumberedList', $area ) )
		{
			$return['list'] = array(
				'tag' => 'ul',
				'callback' => function( $node, $matches, $document )
				{
					/* Set the main attributes for our <ul> or <ol> element */
					if ( isset( $matches[2] ) )
					{
						$node = $document->createElement( 'ol' );
						switch ( $matches[2] )
						{
							case '1':
								$node->setAttribute( 'style', 'list-style-type: decimal' );
								break;
							case '0':
								$node->setAttribute( 'style', 'list-style-type: decimal-leading-zero' );
								break;
							case 'a':
								$node->setAttribute( 'style', 'list-style-type: lower-alpha' );
								break;
							case 'A':
								$node->setAttribute( 'style', 'list-style-type: upper-alpha' );
								break;
							case 'i':
								$node->setAttribute( 'style', 'list-style-type: lower-roman' );
								break;
							case 'I':
								$node->setAttribute( 'style', 'list-style-type: upper-roman' );
								break;
						}
					}
															
					return $node;
				},
				'finishedCallback'	=> function( \DOMElement $originalNode ) {
					
					/* If the [/list] was in it's own paragraph, that empty paragraph will be present. Remove it */
					if ( $originalNode->lastChild and $originalNode->lastChild->nodeType === XML_ELEMENT_NODE and !$originalNode->lastChild->childNodes->length )
					{
						$originalNode->removeChild( $originalNode->lastChild );
					}
					
					/* Do it */
					return $this->_parseContentWithSeparationTag(
						$originalNode,
						function ( \DOMDocument $document ) use ( $originalNode ) {
							return $document->importNode( $originalNode->cloneNode() );
						},
						function ( \DOMDocument $document ) {
							return new \DOMElement('li');
						},
						'[*]'
					);
				},
				'block' => TRUE,
				'noChildren' => TRUE,
				'allowOption' => TRUE
			);
		}
				
		/* Quote */
		if ( static::canUse( $member, 'ipsQuote', $area ) )
		{
			$return['quote'] = array(
				'tag' => 'blockquote',
				'callback' => function( \DOMElement $node, $matches, \DOMDocument $document )
				{
					/* What options do we have? */
					$options = array();
					if ( isset( $matches[2] ) and $matches[2] )
					{
						preg_match_all('/\s?(.+?)=[\'"](.+?)[\'"$]/', trim( $matches[2] ), $_options );
						foreach ( $_options[0] as $k => $v )
						{
							$options[ $_options[1][ $k ] ] = $_options[2][ $k ];
						}
					}
					
					/* Set the main attributes for our <blockquote> element */
					$node->setAttribute( 'class', 'ipsQuote' );
					$node->setAttribute( 'data-ipsQuote', '' );
					if ( isset( $options['name'] ) and $options['name'] )
					{
						$node->setAttribute( 'data-ipsQuote-username', $options['name'] );
					}
					if ( isset( $options['date'] ) and $options['date'] )
					{
						$node->setAttribute( 'data-ipsQuote-timestamp', strtotime( $options['date'] ) );
					}
					if ( \IPS\Application::appIsEnabled('forums') and isset( $options['post'] ) and $options['post'] )
					{
						try
						{
							$post = \IPS\forums\Topic\Post::load( $options['post'] );
							
							$node->setAttribute( 'data-ipsQuote-contentapp', 'forums' );
							$node->setAttribute( 'data-ipsQuote-contenttype', 'forums' );
							$node->setAttribute( 'data-ipsQuote-contentclass', 'forums_Topic' );
							$node->setAttribute( 'data-ipsQuote-contentid', $post->item()->tid );
							$node->setAttribute( 'data-ipsQuote-contentcommentid', $post->pid );
						}
						catch ( \OutOfRangeException $e ) {}
					}
					
					/* Create the citation element */
					$citation = $document->createElement('div');
					$citation->setAttribute( 'class', 'ipsQuote_citation' );
					$node->appendChild( $citation );
										
					/* Create the content element */
					$contents = $document->createElement('div');
					$contents->setAttribute( 'class', 'ipsQuote_contents ipsClearfix' );
					$node->appendChild( $contents );
					
					return $node;
				},
				'getBlockContentElement' => function( \DOMElement $node )
				{
					foreach ( $node->childNodes as $child )
					{
						if ( $child instanceof \DOMElement and mb_strpos( $child->getAttribute('class'), 'ipsQuote_contents' ) !== FALSE )
						{
							return $child;
						}
					}
					return $node;
				},
				'block' => TRUE,
				'allowOption' => TRUE
			);
		}
		
		/* Size */
		if ( static::canUse( $member, 'FontSize', $area ) )
		{
			$return['size'] = array(
				'tag'		=> 'span',
				'callback'	=> function( $node, $matches )
				{
					switch ( $matches[2] )
					{
						case 1:
							$node->setAttribute( 'style', 'font-size:8px' );
							break;
						case 2:
							$node->setAttribute( 'style', 'font-size:10px' );
							break;
						case 3:
							$node->setAttribute( 'style', 'font-size:12px' );
							break;
						case 4:
							$node->setAttribute( 'style', 'font-size:14px' );
							break;
						case 5:
							$node->setAttribute( 'style', 'font-size:18px' );
							break;
						case 6:
							$node->setAttribute( 'style', 'font-size:24px' );
							break;
						case 7:
							$node->setAttribute( 'style', 'font-size:36px' );
							break;
						case 8:
							$node->setAttribute( 'style', 'font-size:48px' );
							break;
					}
					return $node;
				},
				'allowOption' => TRUE
			);
		}
		
		/* Spoiler */
		if ( static::canUse( $member, 'ipsSpoiler', $area ) )
		{
			$return['spoiler'] = array(
				'tag' => 'div',
				'callback' => function( \DOMElement $node, $matches, \DOMDocument $document )
				{
					/* Set the main attributes for our <div> element */
					$node->setAttribute( 'class', 'ipsSpoiler' );
					$node->setAttribute( 'data-ipsSpoiler', '' );
					
					/* Create the citation element */
					$header = $document->createElement('div');
					$header->setAttribute( 'class', 'ipsSpoiler_header' );
					$node->appendChild( $header );
					$headerSpan = $document->createElement('span');
					$header->appendChild( $headerSpan );
					
					/* Create the content element */
					$contents = $document->createElement('div');
					$contents->setAttribute( 'class', 'ipsSpoiler_contents' );
					$node->appendChild( $contents );
					
					return $node;		
				},
				'getBlockContentElement' => function( \DOMElement $node )
				{
					foreach ( $node->childNodes as $child )
					{
						if ( $child instanceof \DOMElement and $child->getAttribute('class') == 'ipsSpoiler_contents' )
						{
							return $child;
						}
					}
					return $node;
				},
				'block' => TRUE,
				'allowOption' => FALSE
			);
		}
		
		/* Strike */
		if ( static::canUse( $member, 'Strike', $area ) )
		{
			$return['s'] = array( 'tag' => 'span', 'attributes' => array( 'style' => 'text-decoration:line-through' ), 'allowOption' => FALSE );
			$return['strike'] = array( 'tag' => 'span', 'attributes' => array( 'style' => 'text-decoration:line-through' ), 'allowOption' => FALSE );
		}
		
		/* Subscript */
		if ( static::canUse( $member, 'Subscript', $area ) )
		{
			$return['sub'] = array( 'tag' => 'sub', 'allowOption' => FALSE );
		}
		
		/* Superscript */
		if ( static::canUse( $member, 'Superscript', $area ) )
		{
			$return['sup'] = array( 'tag' => 'sup', 'allowOption' => FALSE );
		}
		
		/* Underline */
		if ( static::canUse( $member, 'Underline', $area ) )
		{
			$return['u'] = array( 'tag' => 'span', 'attributes' => array( 'style' => 'text-decoration:underline' ), 'allowOption' => FALSE );
		}
		
		/* App-Specific */
		foreach ( \IPS\Application::allExtensions( 'core', 'BBCode', $member ) as $key => $bbcode )
		{
			if ( $bbcode->permissionCheck( $member, $area ) )
			{
				[ $app, $tag ] = explode( '_', $key );
				$return[ $tag ] = $bbcode->getConfiguration();
			}
		}
		
		return $return;
	}
	
	/**
	 * @brief	Cached permissions
	 */
	protected static $permissions = array();
	
	/**
	 * Can use plugin?
	 *
	 * @param	\IPS\Member	$member	The member
	 * @param	string		$key	Plugin key
	 * @param	string		$area	The Editor area
	 * @return	bool
	 */
	public static function canUse( \IPS\Member $member, $key, $area )
	{
		$permissionSettings = json_decode( \IPS\Settings::i()->ckeditor_permissions, TRUE );
		
		if ( !isset( static::$permissions[ $member->member_id ][ $key ] ) )
		{
			if ( !isset( $permissionSettings[ $key ] ) )
			{
				static::$permissions[ $member->member_id ][ $key ] = TRUE;
			}
			else
			{
				$val = TRUE;
				if ( $permissionSettings[ $key ]['groups'] !== '*' )
				{
					if ( !$member->inGroup( $permissionSettings[ $key ]['groups'] ) )
					{
						$val = FALSE;
					}
				}
				if ( $permissionSettings[ $key ]['areas'] !== '*' )
				{
					if ( !\in_array( $area, $permissionSettings[ $key ]['areas'] ) )
					{
						$val = FALSE;
					}
				}
				static::$permissions[ $member->member_id ][ $key ] = $val;
			}
		}
		
		return static::$permissions[ $member->member_id ][ $key ];
	}
	
	/**
	 * Parse content looking for a separation tag
	 * Used for lists where [*] breaks and whole content where [page] breaks
	 *
	 * @param	\DOMNode	$originalNode	The node containing the content we want to examine
	 * @param	callback	$mainElementCreator	A callback which returns a \DOMElement to be the main element. Is passed \DOMDocument as a parameter
	 * @param	callback	$subElementCreator		A callback which returns a \DOMElement to be a new sub element. Is passed \DOMDocument as a parameter
	 * @return	\DOMElement
	 */
	protected function _parseContentWithSeparationTag( \DOMNode $originalNode, $mainElementCreator, $subElementCreator, $separator )
	{
		/* Create a copy of the node */
		$workingDocument = new \DOMDocument;
		$workingNode = $workingDocument->importNode( $originalNode, TRUE );
		
		/* Create a fresh <ul> with a single <li> inside */
		$mainElement = $mainElementCreator( $originalNode->ownerDocument );
		$currentSubElement = $mainElement->appendChild( $subElementCreator( $originalNode->ownerDocument ) );
		
		/* Parse */
		$ignoreNextSeparator = TRUE;
		foreach ( $workingNode->childNodes as $node )
		{
			$this->_parseContentWithSeparationTagLoop( $mainElement, $currentSubElement, $separator, $subElementCreator, $node, $mainElement, $ignoreNextSeparator );
		}
				
		/* Return */
		return $mainElement;
	}
	
	/**
	 * Loop for _parseContentWithSeparationTag
	 *
	 * @param	\DOMElement	$mainElement			The main element all of our content is going into
	 * @param	\DOMElement	$currentSubElement		The current sub element that nodes go into. When a $separator is detected, a new one is created
	 * @param	string		$separator				The separator, e.g. "[*]" or "[page]"
	 * @param	callback	$subElementCreator		A callback which returns a \DOMElement to be a new sub element
	 * @param	\DOMNode	$node					The current node we're examining
	 * @param	\DOMNode	$parent					The parent of the current node we're examining
	 * @param	bool		$ignoreNextSeparator	If we have [list][*]Foo[/list] We want to ignore the first [*] so we don't end up with <ul><li></li><li>Foo</li></ul> - this keeps track of that
	 * @return	void
	 */
	protected function _parseContentWithSeparationTagLoop( \DOMElement $mainElement, \DOMElement &$currentSubElement, $separator, $subElementCreator, \DOMNode $node, \DOMNode &$parent, &$ignoreNextSeparator )
	{
		/* If the node is an element... */
		if ( $node->nodeType === XML_ELEMENT_NODE )
		{			
			/* Ignore any preceeding <br>s */
			if ( $ignoreNextSeparator and $node->tagName == 'br' )
			{
				return;
			}
			
			/* Import and insert */
			$newElement = $mainElement->ownerDocument->importNode( $node );
			if ( $parent->isSameNode( $mainElement ) )
			{
				$currentSubElement->appendChild( $newElement );
			}
			else
			{
				$parent->appendChild( $newElement );
			}
			
			/* Loop children */
			foreach ( $node->childNodes as $child )
			{
				$this->_parseContentWithSeparationTagLoop( $mainElement, $currentSubElement, $separator, $subElementCreator, $child, $newElement, $ignoreNextSeparator );
			}
		}
		
		/* Or if it's text... */
		elseif ( $node->nodeType === XML_TEXT_NODE )
		{			
			/* Ignore any closing tag */
			$text = $node->wholeText;
			$text = str_replace( preg_replace( '/\[(.+?)\]/', '[/$1]', $separator ), '', $text );
			
			/* Break it up where we find the separator... */			
			foreach ( array_filter( preg_split( '/(' . preg_quote( $separator, '/' ) . ')/', $text, null, PREG_SPLIT_DELIM_CAPTURE ), 'trim' ) as $textSection )
			{
				/* If this section the separator... */
				if ( $textSection === $separator )
				{
					/* Unless we're ignoring it, create a new element... */
					if ( !$ignoreNextSeparator )
					{
						/* Strip any extrenous <br> from the last one */
						if ( $currentSubElement->lastChild and $currentSubElement->lastChild->nodeType === XML_ELEMENT_NODE and $currentSubElement->lastChild->tagName == 'br' )
						{
							$currentSubElement->removeChild( $currentSubElement->lastChild );
						}
						
						/* Create a new one */
						$currentSubElement = $mainElement->appendChild( $subElementCreator( $mainElement->ownerDocument ) );
						if ( !$parent->isSameNode( $mainElement ) )
						{
							$parent = $parent->cloneNode( FALSE );
							$currentSubElement->appendChild( $parent );
						}
					}
					/* Or if we are, then don't ignore the next one */
					else
					{
						$ignoreNextSeparator = FALSE;
					}
				}
				else
				{
					/* Insert */				
					if ( $parent->isSameNode( $mainElement ) )
					{
						$currentSubElement->appendChild( new \DOMText( $textSection ) );
					}
					else
					{
						$parent->appendChild( new \DOMText( $textSection ) );
					}
					
					/* If we're meant to be ignoring the next separator, but we have found content before it, remove that flag */
					if ( $ignoreNextSeparator )
					{
						$ignoreNextSeparator = FALSE;
					}
				}
			}
		}
	}	
		
	/* !Embeddable Media */
	
	/**
	 * Get OEmbed Services
	 * Implemented in this way so it's easy for hook authors to override if they wanted to
	 *
	 * @see		<a href="http://www.oembed.com">oEmbed</a>
	 * @return	array
	 */
	protected static function oembedServices()
	{
		$services = array(
			'youtube.com'					=> array( 'https://www.youtube.com/oembed', static::EMBED_VIDEO ),
			'm.youtube.com'					=> array( 'https://www.youtube.com/oembed', static::EMBED_VIDEO ),
			'youtu.be'						=> array( 'https://www.youtube.com/oembed', static::EMBED_VIDEO ),
			'flickr.com'					=> array( 'https://www.flickr.com/services/oembed/', static::EMBED_IMAGE ),
			'flic.kr'						=> array( 'https://www.flickr.com/services/oembed/', static::EMBED_IMAGE ),
			'hulu.com'						=> array( 'http://www.hulu.com/api/oembed.json', static::EMBED_VIDEO ),
			'vimeo.com'						=> array( 'https://vimeo.com/api/oembed.json', static::EMBED_VIDEO ),
			'collegehumor.com'				=> array( 'http://www.collegehumor.com/oembed.json', static::EMBED_VIDEO ),
			'twitter.com'					=> array( 'https://publish.twitter.com/oembed', static::EMBED_TWEET ),
			'mobile.twitter.com'			=> array( 'https://publish.twitter.com/oembed', static::EMBED_TWEET ),
			'x.com' 						=> array( 'https://publish.x.com/oembed', static::EMBED_TWEET ),
			'x.twitter.com' 				=> array( 'https://publish.x.com/oembed', static::EMBED_TWEET ),
			'soundcloud.com'				=> array( 'https://soundcloud.com/oembed', static::EMBED_VIDEO ),
			'open.spotify.com'				=> array( 'https://embed.spotify.com/oembed', static::EMBED_VIDEO ),
			'play.spotify.com'				=> array( 'https://embed.spotify.com/oembed', static::EMBED_VIDEO ),
			'ted.com'						=> array( 'https://www.ted.com/services/v1/oembed', static::EMBED_VIDEO ),
			'vine.co'						=> array( 'https://vine.co/oembed.json', static::EMBED_VIDEO ),
			'dailymotion.com'				=> array( 'https://www.dailymotion.com/services/oembed', static::EMBED_VIDEO ),
			'dai.ly'						=> array( 'https://www.dailymotion.com/services/oembed', static::EMBED_VIDEO ),
			'coub.com'						=> array( 'http://coub.com/api/oembed.json' . ( \IPS\Settings::i()->max_video_width ? ( '?maxwidth=' . \IPS\Settings::i()->max_video_width ) : '' ), static::EMBED_VIDEO ),
			'*.deviantart.com'				=> array( 'https://backend.deviantart.com/oembed', static::EMBED_IMAGE ),
			'docs.com'						=> array( 'http://docs.com/api/oembed', static::EMBED_LINK ),
			'funnyordie.com'				=> array( 'http://www.funnyordie.com/oembed.json', static::EMBED_VIDEO ),
			'gettyimages.com'				=> array( 'http://embed.gettyimages.com/oembed', static::EMBED_IMAGE ),
			'ifixit.com'					=> array( 'http://www.ifixit.com/Embed', static::EMBED_LINK ),
			'kickstarter.com'				=> array( 'http://www.kickstarter.com/services/oembed', static::EMBED_LINK ),
			'meetup.com'					=> array( 'https://api.meetup.com/oembed', static::EMBED_LINK ),
			'mixcloud.com'					=> array( 'https://www.mixcloud.com/oembed/', static::EMBED_VIDEO ),
			'mix.office.com'				=> array( 'https://mix.office.com/oembed', static::EMBED_VIDEO ),
			'reddit.com'					=> array( 'http://www.reddit.com/oembed', static::EMBED_LINK ),
			'reverbnation.com'				=> array( 'https://www.reverbnation.com/oembed', static::EMBED_VIDEO ),
			'screencast.com'				=> array( 'https://api.screencast.com/external/oembed', static::EMBED_IMAGE ),
			'slideshare.net'				=> array( 'http://www.slideshare.net/api/oembed/2', static::EMBED_VIDEO ),
			'*.smugmug.com'					=> array( 'http://api.smugmug.com/services/oembed', static::EMBED_IMAGE ),
			'ustream.tv'					=> array( 'https://www.ustream.tv/oembed', static::EMBED_VIDEO ),
			'*.wistia.com'					=> array( 'http://fast.wistia.com/oembed', static::EMBED_VIDEO ),
			'*.wi.st'						=> array( 'http://fast.wistia.com/oembed', static::EMBED_VIDEO ),
			'tiktok.com'					=> array( 'https://www.tiktok.com/oembed', static::EMBED_VIDEO ),
			'm.tiktok.com'					=> array( 'https://www.tiktok.com/oembed', static::EMBED_VIDEO ),
			'vm.tiktok.com'					=> array( 'https://www.tiktok.com/oembed', static::EMBED_VIDEO ),
			'bsky.app'                      => [ 'https://embed.bsky.app/oembed', static::EMBED_TWEET ]
		);

		/* Can we support Facebook and Instagram oembeds? */
		if( $token = static::getFacebookToken() )
		{
			$services['instagram.com'] = array(
				array( 'https://graph.facebook.com/v9.0/instagram_oembed?access_token=' . $token, static::EMBED_IMAGE )
			);

			$services['instagr.am'] = $services['instagram.com'];

			$services['facebook.com'] = array(
				'(\/.+?\/videos\/|video.php)'	=> array( 'https://graph.facebook.com/v9.0/oembed_video?access_token=' . $token, static::EMBED_VIDEO ),
				'(\/.+?\/posts\/|\/.+?\/activity\/|\/.+?\/photos?\/|photo.php|\/.+?\/media\/|permalink.php|\/.+?\/questions\/|\/.+?\/notes\/|\/.+?\/media\/)'	=> array( 'https://graph.facebook.com/v9.0/oembed_post?access_token=' . $token, static::EMBED_STATUS ),
				0								=> array( 'https://graph.facebook.com/v9.0/oembed_page?access_token=' . $token, static::EMBED_STATUS ),
			);
		}

		return $services;
	}

	/**
	 * Get an application token from Facebook for oembed support
	 *
	 * @return	NULL|string
	 */
	protected static function getFacebookToken()
	{
		try
		{
			if( !\IPS\Settings::i()->fb_ig_oembed_token AND \IPS\Settings::i()->fb_ig_oembed_appid AND \IPS\Settings::i()->fb_ig_oembed_appsecret )
			{
				$value = \IPS\Http\Url::external( "https://graph.facebook.com/oauth/access_token?client_id=" . \IPS\Settings::i()->fb_ig_oembed_appid . "&client_secret=" . \IPS\Settings::i()->fb_ig_oembed_appsecret . "&grant_type=client_credentials" )->request()->get()->decodeJson();

				if( !empty( $value['access_token'] ) )
				{
					\IPS\Settings::i()->changeValues( array( 'fb_ig_oembed_token' => $value['access_token'] ) );
				}
				else
				{
					\IPS\Log::debug( "Facebook access token could not be retrieved for oembed:\n" . var_export( $value, true ), 'facebook_oembed' );
				}
			}
		}
		catch( \Exception $e )
		{
			\IPS\Log::debug( $e, 'facebook_oembed' );
		}

		return \IPS\Settings::i()->fb_ig_oembed_token ? \IPS\Settings::i()->fb_ig_oembed_token : NULL;
	}
	
	/**
	 * @brief	External link request timeout
	 */
	public static $requestTimeout	= \IPS\DEFAULT_REQUEST_TIMEOUT;

	/**
	 * Convert URL to embed HTML
	 *
	 * @param	\IPS\Http\Url		$url		URL
	 * @param	bool				$iframe		Some services need to be in iFrames so they cannot be edited in the editor. If TRUE, will return contents for iframe, if FALSE, return the iframe.
	 * @param	\IPS\Member|NULL	$member		Member to check permissions against or NULL for currently logged in member
	 * @return	string|null	HTML embded code, or NULL if URL is not embeddable
	 */
	public static function embeddableMedia( $url, $iframe=FALSE, $member=NULL )
	{
		/* Internal */
		if ( $url instanceof \IPS\Http\Url\Internal )
		{
			/* Internal Embed */
			if ( $embedCode = static::_internalEmbed( $url, $iframe, $member ) )
			{
				return $embedCode;
			}
		}
		
		/* External */
		else
		{
			/* oEmbed? */
			if ( $embedCode = static::_oembedEmbed( $url, $iframe ) )
			{
				return $embedCode;
			}
			
			/* Other services */
			if ( $embedCode = static::_customEmbed( $url, $iframe ) )
			{
				return $embedCode;
			}
		}
		
		/* Still here? It's not embeddable */
		return NULL;
	}

	/**
	 * @brief	Embed type constants
	 */
	const EMBED_IMAGE	= 1;
	const EMBED_VIDEO	= 2;
	const EMBED_STATUS	= 3;
	const EMBED_TWEET	= 4;
	const EMBED_LINK	= 5;
	
	/**
	 * oEmbed Embed Code
	 *
	 * @param	\IPS\Http\Url	$url			URL
	 * @param	bool			$iframe			Some services need to be in iFrames so they cannot be edited in the editor. If TRUE, will return contents for iframe, if FALSE, return the iframe.
	 * @param	int				$attemptNumber	The attempt number, useful for automatically retrying a service
	 * @return	string|null
	 */
	protected static function _oembedEmbed( \IPS\Http\Url $url, $iframe=FALSE, $attemptNumber=1 )
	{
		/* Strip the "www." from the domain */
		$domain = $url->data['host'];
		if ( mb_substr( $domain, 0, 4 ) === 'www.' )
		{
			$domain = mb_substr( $domain, 4 );
		}
		if( !$domain )
		{
			return null;
		}

		/* TikTok does not support oembed for their short links, but will redirect to the full URL which does */
		if( $domain == 'vm.tiktok.com' )
		{
			try
			{
				$response = $url->request( null, null, 0 )->get();

				if( $response->httpResponseCode == 301 )
				{
					$url = \IPS\Http\Url::external( $response->httpHeaders['Location'] )->stripQueryString();
					$domain = $url->data['host'];
				}
				else
				{
					throw new \IPS\Http\Request\Exception;
				}
			}
			catch( \IPS\Http\Request\Exception $e )
			{
				throw new \UnexpectedValueException( 'embed__fail_500',  (float) static::EMBED_VIDEO );
			}
		}
		
		/* TikTok doesn't return logical response codes for invalid URLs */
		if( $domain == 'tiktok.com' AND mb_strpos( $url->data['path'], '/video/' ) === FALSE )
		{
			return NULL;
		}
		elseif( $domain == 'm.tiktok.com' AND mb_strpos( $url->data['path'], '/v/' ) === FALSE )
		{
			return NULL;
		}
		
		/* Get oEmbed Services */
		$oembedServices = static::oembedServices();
				
		/* If the URL's domain is in the list... */
		$entry		= NULL;
		$entryKey	= NULL;

		if ( array_key_exists( $domain, $oembedServices ) )
		{
			$entry		= $oembedServices[ $domain ];
			$entryKey	= $domain;
		}
		else
		{
			foreach( $oembedServices as $k => $v )
			{
				if( mb_strpos( $k, '*' ) !== FALSE )
				{
					if( preg_match( "/^" . str_replace( array( '.', '*' ), array( '\\.', '.*?' ), $k ) . "$/i", $domain ) )
					{
						$entry		= $v;
						$entryKey	= $k;
					}
				}
			}
		}

		if( $entry )
		{
			$endtype	= static::EMBED_LINK;
			$endpoint	= NULL;

			/* If we have multiple possible ones, find the best */
			if ( \is_array( $entry[0] ) )
			{
				foreach ( $entry as $regex => $endpoints )
				{
					if ( \is_string( $regex ) and preg_match( $regex, $url ) )
					{
						$endpoint	= $endpoints[0];
						$endtype	= $endpoints[1];
						break;
					}
				}

				if( $endpoint === NULL )
				{
					$endpoint	= $entry[0][0];
					$endtype	= $entry[0][1];
				}
			}
			else
			{
				$endpoint = $entry[0];
				$endtype  = $entry[1];
			}

			/* Youtube shorts feature doesn't return a valid response in its own oembed API so we need to tweak it */
			if ( ( $domain == 'youtube.com' or $domain == 'm.youtube.com' or $domain == 'youtu.be' ) and mb_strpos( $url->data['path'], '/shorts/' ) !== FALSE)
			{
				$url->queryString['v'] = preg_replace( '#/shorts/(\w+?)#', '$1', $url->data['path'] );
				$url->data['path'] = '/watch';
			}

			/* Call oEmbed Service */
			try
			{
				$language = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? $_SERVER['HTTP_ACCEPT_LANGUAGE'] : \IPS\Lang::load( \IPS\Lang::defaultLanguage() )->bcp47();

				$response = \IPS\Http\Url::external( $endpoint )
					->setQueryString( array(
						'format'	=> 'json',
						'url'		=> (string) $url->stripQueryString( 'autoplay' ),
						'scheme'	=> ( $url->data[ \IPS\Http\Url::COMPONENT_SCHEME ] === 'https' or \IPS\Request::i()->isSecure() ) ? 'https' : null
					) )
					->request( static::$requestTimeout )
					->setHeaders( array( 'Accept-Language' => $language ) )
					->get();

				if( $response->httpResponseCode != '200' )
				{
					/* If this is FB/IG we can reattempt one more time in case the stored access token was incorrect or expired */
					if( \in_array( $entryKey, array( 'instagram.com', 'instagr.am', 'facebook.com' ) ) AND $attemptNumber < 2 )
					{
						\IPS\Settings::i()->changeValues( array( 'fb_ig_oembed_token' => '' ) );
						return static::_oembedEmbed( $url, $iframe, 2 );
					}

					switch( $response->httpResponseCode )
					{
						case '404':
							throw new \UnexpectedValueException( 'embed__fail_404', (float) $endtype );
						break;

						case '401':
						case '403':
							throw new \UnexpectedValueException( 'embed__fail_403', (float) $endtype );
						break;

						default:
							throw new \UnexpectedValueException( 'embed__fail_500',  (float) $endtype );
						break;
					}
				}

				$response = $response->decodeJson();
			}
			/* If it error'd (connection error or unexpected response), we'll not embed this */
			catch ( \IPS\Http\Request\Exception $e )
			{
				throw new \UnexpectedValueException( 'embed__fail_500', (float) $endtype );
			}
			
			/* Flickr when used in the video template is a bit quirky. Requires rich. */
			if ( $domain == 'flickr.com' OR $domain == 'flic.kr' )
			{
				$response['type'] = ( $response['type'] == 'video' ) ? 'rich' : 'photo';

				/* If we embed an album, no 'url' is returned but a thumbnail URL is so use that but stick with "photo" as the template. */
				if( $response['type'] == 'photo' AND !isset( $response['url'] ) AND isset( $response['thumbnail_url'] ) )
				{
					$response['url'] = $response['thumbnail_url'];
				}
			}

			/* Coub returns 'video' as the type, but points to an iframe with the video embedded, so switch to rich */
			if( $domain == 'coub.com' )
			{
				$response['type'] = ( $response['type'] == 'video' ) ? 'rich' : $response['type'];
			}

			/* Coub returns 'video' as the type, but points to an iframe with the video embedded, so switch to rich */
			if( $domain == 'coub.com' )
			{
				$response['type'] = ( $response['type'] == 'video' ) ? 'rich' : $response['type'];
			}

			/* For Youtube, we need to be a bit hacky to pass rel=0 */
			if ( $domain == 'youtube.com' or $domain == 'm.youtube.com' or $domain == 'youtu.be' )
			{
				/* YouTube has no way to make it return youtube-nocookie from the oEmbed endpoint, so we need to swap it manually. */
				if ( mb_strpos( $response['html'], 'youtube-nocookie.com/embed' ) === FALSE )
				{
					$response['html'] = str_replace( 'youtube.com', 'youtube-nocookie.com', $response['html'] );
				}
				
				if ( isset( $url->queryString['rel'] ) )
				{
					$response['html'] = str_replace( '?feature=oembed', '?feature=oembed&rel=0', $response['html'] );
				}
				
				if ( isset( $url->queryString['start'] ) )
				{
					$response['html'] = str_replace( '?feature=oembed', '?feature=oembed&start=' . \intval( $url->queryString['start'] ), $response['html'] );
				}
			}

			/* Tiktok tries to embed a <script src="https://tiktok.com/embed.js"></script> tag which isn't triggered by CKEditor and doesn't survive HTMLPurifier, so force it to load in an iframe */
			if ( $domain == 'tiktok.com' OR $domain == 'm.tiktok.com' )
			{
				$response['type'] = 'rich';
			}
			
			/* We need a type otherwise we can't embed */
			if( !isset( $response['type'] ) )
			{
				throw new \UnexpectedValueException( 'embed_no_oembed_type' );
			}

			/* The "type" parameter is a way for services to indicate the type of content they are retruning. It is not strict, but we use it to identify the best styles to apply. */
			switch ( $response['type'] )
			{
				/* Static photo - show an <img> tag, linked if necessary, using .ipsImage to be responsive. Similar outcome to if a user had used the "insert image from URL" button */
				case 'photo':
					return \IPS\Theme::i()->getTemplate( 'embed', 'core', 'global' )->photo( $response['url'], $url, $response['title'] );
				
				/* Video - insert the provided HTML directly (it will be a video so there's nothing we need to prevent from being edited), using .ipsEmbeddedVideo to make it responsive */
				case 'video':
                    $response['html'] = str_replace( 'allowfullscreen', 'allowfullscreen=""', $response['html'] );
					return \IPS\Theme::i()->getTemplate( 'embed', 'core', 'global' )->video( $response['html'] );
				
				/* Other - show an <iframe> with the provided HTML inside, using .ipsEmbeddedOther to make the width right and data-controller="core.front.core.autoSizeIframe" to make the height right */
				case 'rich':						
					if ( $iframe )
					{
						return $response['html'];
					}
					else
					{
						$embedId = md5( mt_rand() );

						return \IPS\Theme::i()->getTemplate( 'embed', 'core', 'global' )->iframe( (string) \IPS\Http\Url::internal( 'app=core&module=system&controller=embed', 'front' )->setQueryString( 'url', (string) $url ), NULL, NULL, $embedId );
					}
				
				/* Link - none of the defautl services use this, but provided for completeness. Just inserts an <a> tag */
				case 'link':
					return \IPS\Theme::i()->getTemplate( 'embed', 'core', 'global' )->link( $response['url'], $response['title'] );
			}
		}
		
		/* Still here? It's not an oEmbed URL */
		return NULL;
	}
	
	/**
	 * Custom (services which don't support oEmbed but we still want to support) Embed Code
	 *
	 * @param	\IPS\Http\Url	$url		URL
	 * @param	bool			$iframe		Some services need to be in iFrames so they cannot be edited in the editor. If TRUE, will return contents for iframe, if FALSE, return the iframe.
	 * @return	string|null
	 */
	protected static function _customEmbed( \IPS\Http\Url $url, $iframe=FALSE )
	{
		/* Google Maps */
		if ( \IPS\Settings::i()->googlemaps and \IPS\Settings::i()->google_maps_api_key )
		{
			$googleTLDs = array(
				'.com',
				'.ac',
				'.ad',
				'.ae',
				'.com.af',
				'.com.ag',
				'.com.ai',
				'.al',
				'.am',
				'.co.ao',
				'.com.ar',
				'.as',
				'.at',
				'.com.au',
				'.az',
				'.ba',
				'.com.bd',
				'.be',
				'.bf',
				'.bg',
				'.com.bh',
				'.bi',
				'.bj',
				'.com.bn',
				'.com.bo',
				'.com.br',
				'.bs',
				'.bt',
				'.co.bw',
				'.by',
				'.com.bz',
				'.ca',
				'.com.kh',
				'.cc',
				'.cd',
				'.cf',
				'.cat',
				'.cg',
				'.ch',
				'.ci',
				'.co.ck',
				'.cl',
				'.cm',
				'.cn',
				'.com.co',
				'.co.cr',
				'.com.cu',
				'.cv',
				'.com.cy',
				'.cz',
				'.de',
				'.dj',
				'.dk',
				'.dm',
				'.com.do',
				'.dz',
				'.com.ec',
				'.ee',
				'.com.eg',
				'.es',
				'.com.et',
				'.fi',
				'.com.fj',
				'.fm',
				'.fr',
				'.ga',
				'.ge',
				'.gf',
				'.gg',
				'.com.gh',
				'.com.gi',
				'.gl',
				'.gm',
				'.gp',
				'.gr',
				'.com.gt',
				'.gy',
				'.com.hk',
				'.hn',
				'.hr',
				'.ht',
				'.hu',
				'.co.id',
				'.iq',
				'.ie',
				'.co.il',
				'.im',
				'.co.in',
				'.io',
				'.is',
				'.it',
				'.je',
				'.com.jm',
				'.jo',
				'.co.jp',
				'.co.ke',
				'.ki',
				'.kg',
				'.co.kr',
				'.com.kw',
				'.kz',
				'.la',
				'.com.lb',
				'.com.lc',
				'.li',
				'.lk',
				'.co.ls',
				'.lt',
				'.lu',
				'.lv',
				'.com.ly',
				'.co.ma',
				'.md',
				'.me',
				'.mg',
				'.mk',
				'.ml',
				'.com.mm',
				'.mn',
				'.ms',
				'.com.mt',
				'.mu',
				'.mv',
				'.mw',
				'.com.mx',
				'.com.my',
				'.co.mz',
				'.com.na',
				'.ne',
				'.com.nf',
				'.com.ng',
				'.com.ni',
				'.nl',
				'.no',
				'.com.np',
				'.nr',
				'.nu',
				'.co.nz',
				'.com.om',
				'.com.pk',
				'.com.pa',
				'.com.pe',
				'.com.ph',
				'.pl',
				'.com.pg',
				'.pn',
				'.com.pr',
				'.ps',
				'.pt',
				'.com.py',
				'.com.qa',
				'.ro',
				'.rs',
				'.ru',
				'.rw',
				'.com.sa',
				'.com.sb',
				'.sc',
				'.se',
				'.com.sg',
				'.sh',
				'.si',
				'.sk',
				'.com.sl',
				'.sn',
				'.sm',
				'.so',
				'.st',
				'.sr',
				'.com.sv',
				'.td',
				'.tg',
				'.co.th',
				'.com.tj',
				'.tk',
				'.tl',
				'.tm',
				'.to',
				'.tn',
				'.com.tr',
				'.tt',
				'.com.tw',
				'.co.tz',
				'.com.ua',
				'.co.ug',
				'.co.uk',
				'.us',
				'.com.uy',
				'.co.uz',
				'.com.vc',
				'.co.ve',
				'.vg',
				'.co.vi',
				'.com.vn',
				'.vu',
				'.ws',
				'.co.za',
				'.co.zm',
				'.co.zw',
			);

			if ( preg_match( '/^https:\/\/[a-z]+?\.?google(' . implode( '|', array_map( 'preg_quote', $googleTLDs ) ) . ')\/maps\/(.+)/i', (string) $url, $matches ) )
			{
				/* Extract the address and gps coordinates from the query string */
				$qbits = explode( "/", $matches[2] );

				switch ( $qbits[0] )
				{
					case 'place':
						/* This seems odd but sometimes the place names can already be url encoded and we don't want to double encode */
						return \IPS\Theme::i()->getTemplate( 'embed', 'core', 'global' )->googleMaps( urlencode( urldecode( $qbits[1] ) ), 'place' );
						break;

					case 'dir':
						/* Let's do some cleanup - we may have 'waypoints' in between the origin and destination, but Google doesn't tell us that from the URL */
						$route = array();
						foreach( $qbits AS $bit )
						{
							/* Skip these as we know for sure that they are not a part of the route. */
							if (
								$bit === 'dir' # This is safe because we already know we're requesting directions
								OR mb_substr( $bit, 0, 1 ) === '@' # This is safe because Google only looks for three value types to find a place in a route (Name, Address, or PlaceID) - Lat/Long is not one of them
								OR mb_substr( $bit, 0, 5 ) === 'data=' # This is simply miscellaneous data which we do not need
							)
							{
								continue;
							}
							
							$route[] = $bit;
						}
						
						/* Assign our origin, which will always be at the start */
						$origin = array_shift( $route );
						
						/* And our destination, which will always be at the end */
						$destination = array_pop( $route );
						
						/* If we have anything left, then they are waypoints which need to be shown between the origin and destination - do that */
						if ( \count( $route ) )
						{
							$params = array( 'origin' => $origin, 'waypoints' => implode( '|', $route ), 'destination' => $destination );
						}
						else
						/* Otherwise, just pass the start and end */
						{
							$params = array( 'origin' => $origin, 'destination' => $destination );
						}
						
						return \IPS\Theme::i()->getTemplate( 'embed', 'core', 'global' )->googleMaps( $params, 'dir' );
						break;

					case 'search':
						return \IPS\Theme::i()->getTemplate( 'embed', 'core', 'global' )->googleMaps( urlencode( urldecode( $qbits[1] ) ), 'search' );
						break;

					default:
						$params = explode( ",", mb_substr( $qbits[0], 1, -1 ) );
						$coordinates = implode( "," , array( $params[0], $params[1]) );
						return  \IPS\Theme::i()->getTemplate( 'embed', 'core', 'global' )->googleMaps( $coordinates, 'coordinates', $params[2] );
						break;
				}
			}
		}
		
		/* Brightcove */
		$domain = $url->data['host'];
		if( !$domain )
		{
			return null;
		}

		if( $domain == 'bcove.video' or $domain == 'players.brightcove.net' )
		{
			/* If using a shortcode we need to redirect to the full URL */
			if( $domain == 'bcove.video' )
			{
				try
				{
					$response = $url->request( null, null, 0 )->get();

					if( $response->httpResponseCode == 301 )
					{
						$url = \IPS\Http\Url::external( $response->httpHeaders['Location'] );
					}
					else
					{
						throw new \IPS\Http\Request\Exception;
					}
				}
				catch( \IPS\Http\Request\Exception $e )
				{
					throw new \UnexpectedValueException( 'embed__fail_500',  (float) static::EMBED_VIDEO );
				}
			}
			
			return \IPS\Theme::i()->getTemplate( 'embed', 'core', 'global' )->brightcove( $url );
		}

		/* So if it's not that, just return */
		return NULL;
	}
	
	/**
	 * Internal Embed Code
	 *
	 * @param	\IPS\Http\Url		$url		URL
	 * @param	bool				$iframe		Some services need to be in iFrames so they cannot be edited in the editor. If TRUE, will return contents for iframe, if FALSE, return the iframe.
	 * @param	\IPS\Member|NULL	$member		Member to check permissions against or NULL for currently logged in member
	 * @return	string|null
	 */
	protected static function _internalEmbed( \IPS\Http\Url $url, $iframe=FALSE, $member=NULL )
	{
		/* If this URL has a #comment-123 fragment, change it to the findComment URL so the comment embeds rather than the item */
		if ( isset( $url->data['fragment'] ) and mb_stristr( $url->data['fragment'], 'comment-' ) and empty( $url->queryString['do'] ) )
		{
			$url = $url->setQueryString( array( 'do' => 'findComment', 'comment' => preg_replace( '#(find)?comment-#i', '', $url->data['fragment'] ) ) );
		}

		/* And for reviews */
		if ( isset( $url->data['fragment'] ) and mb_stristr( $url->data['fragment'], 'review-' ) and empty( $url->queryString['do'] ) )
		{
			$url = $url->setQueryString( array( 'do' => 'findReview', 'review' => preg_replace( '#(find)?review-#i', '', $url->data['fragment'] ) ) );
		}
		
		/* Get the "real" query string (whatever the query string is, plus what we can get from decoding the FURL) */
		$qs = array_merge( $url->queryString, $url->hiddenQueryString );
		
		/* We need an app, and it needs to not be an RSS link */
		if ( !isset( $qs['app'] ) )
		{
			return NULL;
		}
		
		/* It needs to not be an RSS link */
		if ( isset( $qs['do'] ) and $qs['do'] === 'rss' )
		{
			return NULL;
		}
		
		/* Load the application, but be aware it could be an old invalid URL if this is an upgrade */
		try
		{
			$application = \IPS\Application::load( $qs['app'] );
		}
		catch( \OutOfRangeException $e ) // Application does not exist
		{
			return NULL;
		}
		catch( \UnexpectedValueException $e ) // Application is out of date
		{
			return NULL;
		}
		
		/* Loop through our content classes and see if we can find one that matches */
		foreach ( $application->extensions( 'core', 'ContentRouter' ) as $key => $extension )
		{
			/* We need to check the class itself, along with owned nodes (Blogs, etc.) and anything else which isn't 
				normally part of the content item system but the app wants to be embeddable (Commerce product reviews, etc) */
			$classes = $extension->classes;
			if ( isset( $extension->ownedNodes ) )
			{
				$classes = array_merge( $classes, $extension->ownedNodes );
			}
			if ( isset( $extension->embeddableContent ) )
			{
				$classes = array_merge( $classes, $extension->embeddableContent );
			}
			
			/* But we're only interested in classes which implement IPS\Content\Embeddable */
			$classes = array_filter( $classes, function( $class ) {
				return \in_array( 'IPS\Content\Embeddable', class_implements( $class ) );
			} );
						
			/* So for each of those... */
			foreach ( $classes as $class )
			{
				/* Try to load it */
				try
				{
					$item = $class::loadFromURL( $url );

					$member = $member ?? \IPS\Member::loggedIn();

					$canView = TRUE;
					if ( $item instanceof \IPS\Content or $item instanceof \IPS\Member\Club )
					{
						$canView = $item->canView( $member );
					}
					elseif ( $item instanceof \IPS\Node\Model )
					{
						$canView = $item->can( 'view', $member );
					}

					if( !$canView )
					{
						throw new \InvalidArgumentException;
					}
				}
				catch ( \Exception $e )
				{
					continue;
				}
				
				/* It needs to be embeddable... */
				if( !( $item instanceof \IPS\Content\Embeddable ) )
				{
					continue;
				}
				
				/* The URL needs to actually match... */
				$urlDiff = array_diff_assoc( $qs, array_merge( $item->url()->queryString, $item->url()->hiddenQueryString ) );
				if ( \count( array_intersect( array( 'app', 'module', 'controller' ), array_keys( $urlDiff ) ) ) )
				{
					continue;
				}
				
				/* Okay, get the correct embed URL! */
				try
				{
					$preview = \IPS\Http\Url::createFromString( (string) $url );
				}
				catch ( \IPS\Http\Url\Exception $e )
				{
					throw new \UnexpectedValueException( 'embed__fail_500', static::EMBED_LINK );
				}

				/* Add the author ID for notifications */
				try
				{
					if ( $item instanceof \IPS\Member\Club )
					{
						$author = $item->owner->member_id;
					}
					else
					{
						$author = ( method_exists( $item, 'author' ) ) ? $item->author()->member_id : 0;
					}
				}
				catch ( \Exception $e )
				{
					$author = 0;
				}

				/* Strip the CSRF key if present - normally shouldnt't be, but just in case..*/
				$preview = $preview->setQueryString( 'do', 'embed' )->stripQueryString( 'csrfKey' );
				if( isset( $url->queryString['do'] ) and $url->queryString['do'] == 'findComment' )
				{
					$preview = $preview->setQueryString( 'embedComment', $url->queryString['comment'] );

					/* Get the correct author ID */
					$commentClass = $item::$commentClass;

					try
					{
						$comment	= $commentClass::loadAndCheckPerms( $url->queryString['comment'] );
						$author		= $comment->author()->member_id;
					}
					catch( \Exception $e ){}
				}
				elseif( isset( $url->queryString['do'] ) and $url->queryString['do'] == 'findReview' )
				{
					$preview = $preview->setQueryString( 'embedReview', $url->queryString['review'] );

					/* Get the correct author ID */
					$reviewClass = $item::$reviewClass;

					try
					{
						$review		= $reviewClass::loadAndCheckPerms( $url->queryString['review'] );
						$author		= $review->author()->member_id;
					}
					catch( \Exception $e ){}
				}
				if( isset( $url->queryString['do'] ) )
				{
					$preview = $preview->setQueryString( 'embedDo', $url->queryString['do'] );
				}
				if ( isset( $url->queryString['page'] ) AND $url->queryString['page'] > 1 )
				{
					$preview = $preview->setPage( 'page', $url->queryString['page'] );
				}

				/* And return */
				return "<iframe src='{$preview}' data-embedContent data-controller='core.front.core.autosizeiframe' data-embedauthorid='{$author}' allowfullscreen=''></iframe>";
			}
		}
		
		/* Still here? Not an internal embed */
		return NULL;
	}
	
	/**
	 * Image Embed Code
	 *
	 * @param	\IPS\Http\Url	$url		URL to image (that you know is an image)
	 * @param	int				$width		Image width (the actual value, which this method will auto-adjust if it exceeds our allowed size)
	 * @param	int				$height		Image height (the actual value, which this method will auto-adjust if it exceeds our allowed size)
	 * @return	string|null
	 */
	public static function imageEmbed( \IPS\Http\Url $url, $width, $height )
	{
		/* If the URL is blacklisted, just return it */
		try
		{
			static::isAllowedImageUrl( $url );
		}
		catch( \UnexpectedValueException $e )
		{
			return (string) $url;
		}
		
		$width = (int) $width;
		$height = (int) $height;

		$maxImageDims = \IPS\Settings::i()->attachment_image_size ? explode( 'x', \IPS\Settings::i()->attachment_image_size ) : array( 1000, 750 );
		$widthToUse = $width;
		$heightToUse = $height;

		$maxImageDims = array_map('intval', $maxImageDims);

		/* 0x0 means unlimited, so only do these calculations if a specific size has been set */
		if( \intval( $maxImageDims[0] ) !== 0 || \intval( $maxImageDims[1] ) !== 0 )
		{
			/* Adjust the width/height according to our maximum dimensions */
			if ( $width > $maxImageDims[0] )
			{
				$widthToUse	= $maxImageDims[0];
				$heightToUse = floor( $height / $width * $widthToUse );

				if ( $heightToUse > $maxImageDims[1] )
				{
					$widthToUse	= floor( $maxImageDims[1] * ( $widthToUse / $heightToUse ) );
					$heightToUse = $maxImageDims[1];
				}
			}
			elseif( $height > $maxImageDims[1] )
			{
				$heightToUse	= $maxImageDims[1];
				$widthToUse = floor( $width / $height * $heightToUse );

				if ( $widthToUse > $maxImageDims[0] )
				{
					$heightToUse	= floor(  $maxImageDims[0] * ( $heightToUse / $widthToUse ) );
					$widthToUse = $maxImageDims[0];
				}
			}
		}
		
		/* And return the embed */
		return \IPS\Theme::i()->getTemplate( 'embed', 'core', 'global' )->photo( $url, NULL, NULL, $widthToUse, $heightToUse );
	}
	
	/* !Utility Methods */
		
	/**
	 * Parse statically
	 *
	 * @param	string				$value				The value to parse
	 * @param	bool				$bbcode				Parse BBCode?
	 * @param	array|null			$attachIds			array of ID numbers to idenfity content for attachments if the content has been saved - the first two must be int or null, the third must be string or null. If content has not been saved yet, an MD5 hash used to claim attachments after saving.
	 * @param	\IPS\Member|null		$member				The member posting, NULL will use currently logged in member.
	 * @param	string|bool			$area				If parsing BBCode or attachments, the Editor area we're parsing in. e.g. "core_Signatures". A boolean value will allow or disallow all BBCodes that are dependant on area.
	 * @param	bool				$filterProfanity	Remove profanity?
	 * @param	bool				$cleanHtml			If TRUE, HTML will be cleaned through HTMLPurifier
	 * @param	callback			$htmlPurifierConfig	A function which will be passed the HTMLPurifier_Config object to customise it - see example
	 * @return	string
	 * @see		__construct
	 */
	public static function parseStatic( $value, $bbcode=FALSE, $attachIds=NULL, $member=NULL, $area=FALSE, $filterProfanity=TRUE, $cleanHtml=TRUE, $htmlPurifierConfig=NULL )
	{
		$obj = new static( $bbcode, $attachIds, $member, $area, $filterProfanity, $cleanHtml, $htmlPurifierConfig );
		return $obj->parse( $value );
	}
		
	/**
	 * Remove specific elements, useful for cleaning up content for display or truncating
	 *
	 * @param	string				$value			The value to parse
	 * @param	array|string		$elements		Element to remove, or array of elements to remove. Can be in format "element[attribute=value]"
	 * @return	string
	 */
	public static function removeElements( $value, $elements=array( 'blockquote', 'img', 'a' ) )
	{
		/* Init */
		$elementsToRemove = \is_string( $elements ) ? array( $elements ) : $elements;
		
		/* Do it */
		return DOMParser::parse( $value, function( \DOMElement $element, \DOMNode $parent, \IPS\Text\DOMParser $parser ) use ( $elementsToRemove )
		{
			/* Check all of the $elementsToRemove */
			foreach( $elementsToRemove as $definition )
			{
				/* If this is in the element[attribute=value] format... */
				if ( mb_strstr( $definition, '[' ) and mb_strstr( $definition, '=' ) )
				{
					/* Break it up */
					preg_match( '#^([a-z]+?)\[([^\]]+?)\]$#i', $definition, $matches );
					
					/* If the element tag name matches the first bit... */
					if( $element->tagName == $matches[1] )
					{
						/* Break up the definition into name and value */
						[ $attribute, $value ] = explode( '=', trim( $matches[2] ) );
						
						/* Remove quotes */
						$value = str_replace( array( '"', "'" ), '', $value );
						
						/* If it matches, return to skip this element. */
						if ( $element->getAttribute( $attribute ) == $value )
						{
							return;
						}
					}
				}
				/* Or if it's just in normal format, check it and if it matches, return to skip this element. */
				else if ( $element->tagName == $definition )
				{
					return;
				}
			}
			
			/* If we're still here, it's fine and we can import it */
			$ownerDocument = $parent->ownerDocument ?: $parent;
			$newElement = $ownerDocument->importNode( $element );
			$parent->appendChild( $newElement );
			
			/* And continue to children */
			$parser->_parseDomNodeList( $element->childNodes, $newElement );
		} );		
	}
	
	/**
	 * Removes HTML and optionally truncates content
	 *
	 * @param	bool		$oneLine	If TRUE, will use spaces instead of line breaks. Useful if using a single line display.
	 * @param	int|null	$length		If supplied, and $oneLine is set to TRUE, the returned content will be truncated to this length
	 * @return	string
	 * @note	For now we are removing all HTML. If we decide to change this to remove specific tags in future, we can use \IPS\Text\Parser::removeElements( $this->content() )
	 */
	public static function truncate( $content, $oneLine=FALSE, $length=500 )
	{	
		/* Specifically remove quotes, any scripts (which someone with HTML posting allowed may have legitimately enabled, and spoilers (to prevent contents from being revealed) */
		$text = static::removeElements( $content, array( 'blockquote', 'script', 'div[class=ipsSpoiler]' ) );
		
		/* Convert headers and paragraphs into line breaks or just spaces */
		$text = str_replace( array( '</p>', '</h1>', '</h2>', '</h3>', '</h4>', '</h5>', '</h6>' ), ( $oneLine ? ' ' : '<br>' ), $text );

		if( $oneLine === TRUE )
		{
			$text = str_replace( '<br>', ' ', $text );
		}

		/* Add a space at the end of list items to prevent two list items from running into each other */
		$text = str_replace( '</li>', ' </li>', $text );
		
		/* Remove all HTML apart from <br>s*/
		$text = strip_tags( $text, ( $oneLine === TRUE ) ? NULL : '<br>' );
		
		/* Remove any <br>s from the start so there isn't just blank space at the top, but maintaining <br>s elsewhere */
		$text = preg_replace( '/^(\s|<br>|' . \chr(0xC2) . \chr(0xA0) . ')+/', '', $text );

		/* Truncate to length, if appropriate */
		if( $oneLine === TRUE AND $length > 0 )
		{
			$text = mb_substr( $text, 0, $length );
		}
		
		/* Return */
		return $text;
	}
	
	/**
	 * Munge resources in ACP
	 *
	 * @deprecated	No longer needed as of 4.5, ACP URLs no longer have the session ID in the URL
	 * @param	string	$value				The value to parse
	 * @param	bool	$makeLinksAcpSafe	If TRUE, will also make links safe for ACP
	 * @return	string
	 */
	public static function mungeResources( $value, $makeLinksAcpSafe=FALSE )
	{
		return $value;
	}
	
	/**
	 * @brief	Emoticons
	 */
	protected static $emoticons = NULL;
	
	/**
	 * Rebuild attachment urls
	 *
	 * @param	string		$textContent	Content
	 * @return	mixed	False, or rebuilt content
	 */
	public static function rebuildAttachmentUrls( $textContent )
	{
		$rebuilt	= FALSE;
		
		$textContent = preg_replace( '#<([^>]+?)(href|src)=(\'|")<fileStore\.([\d\w\_]+?)>/#i', '<\1\2=\3%7BfileStore.\4%7D/', $textContent );
		$textContent = preg_replace( '#<([^>]+?)(href|src)=(\'|")<___base_url___>/#i', '<\1\2=\3%7B___base_url___%7D/', $textContent );
		$textContent = preg_replace( '#<([^>]+?)(data-(fileid|ipshover\-target))=(\'|")<___base_url___>/#i', '<\1\2=\3%7B___base_url___%7D/', $textContent );
		
		/* srcset can have multiple urls in it */
		preg_match_all( '#<(?:[^>]+?)srcset=(\'|")([^\'"]+?)(\1)#i', $textContent, $srcsetMatches, PREG_SET_ORDER );
		
		foreach( $srcsetMatches as $val )
		{
			if ( mb_stristr( $val[2], '<___base_url___>' ) )
			{
				$textContent = str_replace( $val[2], str_replace( '<___base_url___>', '%7B___base_url___%7D', $val[2] ), $textContent );
			}
		}
		
		/* Create DOMDocument */
		$content = new \IPS\Xml\DOMDocument( '1.0', 'UTF-8' );
		@$content->loadHTML( \IPS\Xml\DOMDocument::wrapHtml( $textContent ) );
		
		$xpath = new \DOMXpath( $content );
		
		foreach ( $xpath->query('//img') as $image )
		{
			if( $image->getAttribute( 'data-fileid' ) )
			{
				try
				{
					$attachment	= \IPS\Db::i()->select( '*', 'core_attachments', array( 'attach_id=?', $image->getAttribute( 'data-fileid' ) ) )->first();
					$image->setAttribute( 'src', '{fileStore.core_Attachment}/' . ( $attachment['attach_thumb_location'] ? $attachment['attach_thumb_location'] : $attachment['attach_location'] ) );
					
					$anchor = $image->parentNode;
					
					/* Make sure it's actually an anchor */
					if ( $anchor->tagName !== 'a' )
					{
						/* It's not, so create one and add the image to it */
						$parent = $image->parentNode;
						$clonedImage = clone $image;
						$anchor = $content->createElement( 'a' );
						$anchor->setAttribute( 'href', '{fileStore.core_Attachment}/' . $attachment['attach_location'] );
						$anchor->appendChild( $clonedImage );
						$parent->replaceChild( $anchor, $image );
					}
					else
					{
						$anchor->setAttribute( 'href', '{fileStore.core_Attachment}/' . $attachment['attach_location'] );
					}
					
					$rebuilt = TRUE;
				}
				catch ( \Exception $e ) { }
			}
			else
			{
				if ( ! isset( static::$fileObjectClasses['core_Emoticons'] ) )
				{
					static::$fileObjectClasses['core_Emoticons'] = \IPS\File::getClass('core_Emoticons' );
				}
				
				if ( static::$emoticons === NULL )
				{
					static::$emoticons = array();
		
					try
					{
						foreach ( \IPS\Db::i()->select( 'image, image_2x, width, height', 'core_emoticons' ) as $row )
						{
							static::$emoticons[] = $row;
						}
					}
					catch( \IPS\Db\Exception $ex )
					{
						/* The image_2x column was added in 4.1 so may not exist if Parser is used in previous upgrade modules */
						foreach ( \IPS\Db::i()->select( 'image, NULL as image_2x, 0 as width, 0 as height', 'core_emoticons' ) as $row )
						{
							static::$emoticons[] = $row;
						}
					}
				}

				if ( ( $image->tagName === 'img' and preg_match( '#^(' . preg_quote( rtrim( static::$fileObjectClasses['core_Emoticons']->baseUrl(), '/' ), '#' ) . ')/(.+?)$#', $image->getAttribute('src'), $matches ) ) )
				{
					foreach( static::$emoticons as $emo )
					{
						if ( $emo['image'] == $matches[2] )
						{
							$image->setAttribute( 'src', '{fileStore.core_Emoticons}/' . $matches[2] );

							if( $emo['image_2x'] && $emo['width'] && $emo['height'] )
							{
								/* Retina emoticons require a width and height for proper scaling */
								$image->setAttribute( 'srcset', '%7BfileStore.core_Emoticons%7D/' . $emo['image_2x'] . ' 2x' );
								$image->setAttribute( 'width', $emo['width'] );
								$image->setAttribute( 'height', $emo['height'] );
							}
							$rebuilt = TRUE;
						}
					}
				}
			}
		}

		if( $rebuilt )
		{
			$value = $content->saveHTML();
			
			$value = preg_replace( '/<meta http-equiv(?:[^>]+?)>/i', '', preg_replace( '/^<!DOCTYPE.+?>/', '', str_replace( array( '<html>', '</html>', '<body>', '</body>', '<head>', '</head>' ), '', $value ) ) );
			
			
			/* Replace any {fileStore.whatever} tags with <fileStore.whatever> */
			return static::replaceFileStoreTags( $value );
		}

		return FALSE;
	}
	
	/**
	 * Rebuild rel tag contents for posts
	 *
	 * @param	string	$textContent	The content of the text, and they say comments aren't useful
	 * @param	\IPS\Member	$member		The author of the content
	 * @return mixed	FALSE or changed content
	 */
	public static function rebuildUrlRels( $textContent, $member )
	{
		$obj = new static( TRUE, NULL, $member );
		$rebuilt = FALSE;
		
		/* Create DOMDocument */
		$content = new \IPS\Xml\DOMDocument( '1.0', 'UTF-8' );
		@$content->loadHTML( \IPS\Xml\DOMDocument::wrapHtml( $textContent ) );
		
		$xpath = new \DOMXpath( $content );
		foreach( $xpath->query('//a') as $link )
		{
			if ( $link->getAttribute('href') )
			{
				try
				{
					$url = \IPS\Http\Url::createFromString( str_replace( array( '<___base_url___>', '%7B___base_url___%7D' ), rtrim( \IPS\Settings::i()->base_url, '/' ), $link->getAttribute('href') ) );
					$rels = $obj->_getRelAttributes( $url );
					$link->setAttribute( 'rel', implode( ' ', $rels ) );
					
					$rebuilt = TRUE;
				}
				catch( \Exception $e ) { }
			}
		}
		
		if ( $rebuilt )
		{
			$value = $content->saveHTML();
			$value = preg_replace( '/<meta http-equiv(?:[^>]+?)>/i', '', preg_replace( '/^<!DOCTYPE.+?>/', '', str_replace( array( '<html>', '</html>', '<body>', '</body>', '<head>', '</head>' ), '', $value ) ) );

			/* Replace file storage tags */
			$value = preg_replace( '/&lt;fileStore\.([\d\w\_]+?)&gt;/i', '<fileStore.$1>', $value );

			/* DOMDocument::saveHTML will encode the base_url brackets, so we need to make sure it's in the expected format. */
			return str_replace( '&lt;___base_url___&gt;', '<___base_url___>', $value );
		}
		
		return FALSE;
	}
	
	/**
	 * Perform a safe html_entity_decode if you are not using UTF-8 MB4
	 *
	 * @param	string	$value	Value to html entity decode
	 * @return	string
	 */
	public static function utf8mb4SafeDecode( $value )
	{
		$value = html_entity_decode( $value, ENT_QUOTES, 'UTF-8' );

		if ( \IPS\Settings::i()->getFromConfGlobal('sql_utf8mb4') !== TRUE )
		{
			$value = preg_replace_callback( '/[\x{10000}-\x{10FFFF}]/u', function( $mb4Character ) {
				return mb_convert_encoding( $mb4Character[0], 'HTML-ENTITIES', 'UTF-8' );
			}, $value );
		}

		return $value;
	}
	
	/**
	 * Does this post contain a quote by member?
	 *
	 * @param	int			$memberId		I dunno, take a wild guess. We don't pass a member object as they may have been deleted.
	 * @param	string		$content		Content, that's "content", not "content". I'm data, not relaxed.
	 * @return	boolean
	 */
	public static function containsQuoteBy( $memberId, $content )
	{
		if ( $memberId and $content )
		{
			return preg_match( '#data-ipsquote-userid=[\'"]' . $memberId . '[\'"]#i', $content );
		}
	
		return FALSE;
	}
	 
	/**
	 * Remove quote attribution (sets it to guest )
	 *
	 * @param	int			$memberId	Member ID. We don't pass a member object as they may have been deleted.
	 * @param	string		$content	Still Content
	 * @return	boolean
	 */
	public static function removeQuoteAttributionBy( $memberId, $content )
	{
		/* Do it */
		return DOMParser::parse( $content, function( \DOMElement $element, \DOMNode $parent, \IPS\Text\DOMParser $parser ) use( $memberId )
		{
			/* If the element tag name matches the first bit... */
			if( $element->tagName == 'blockquote' )
			{
				if ( $element->getAttribute('data-ipsquote-userid') == $memberId )
				{
					$element->setAttribute('data-ipsquote-userid', 0 );
					
					if ( $element->hasAttribute('data-ipsquote-username') )
					{
						$element->setAttribute('data-ipsquote-username', '' );
					}
					
					foreach( $element->childNodes AS $child )
					{ 
						if ( $child instanceof \DOMElement and mb_strpos( $child->getAttribute('class'), 'ipsQuote_citation' ) !== FALSE )
						{
							$replace = new \DOMElement( 'div' );
							$element->replaceChild( $replace, $child );
							$replace->setAttribute('class', 'ipsQuote_citation' );
						}
					}
				}
			}
			
			/* If we're still here, it's fine and we can import it */
			$ownerDocument = $parent->ownerDocument ?: $parent;
			$newElement = $ownerDocument->importNode( $element );
			$parent->appendChild( $newElement );
			
			/* And continue to children */
			$parser->_parseDomNodeList( $element->childNodes, $newElement );
		} );
	}

	/**
	 * Checks if there is actual content or if this was just an empty editor submission
	 *
	 * @param	string	$text	Text that came from an editor
	 * @return	bool
	 */
	public static function hasContent( $text )
	{
		if( !trim( $text ) )
		{
			return FALSE;
		}

		if( preg_match( "/^\s*<p>\s*(<br[^>]*>|&nbsp;|\u00A0|&#160;)?\s*<\/p>\s*$/i", $text ) )
		{
			return FALSE;
		}

		return TRUE;
	}

	/**
	 * Parse attachment labels and pull the required data
	 *
	 * @param  array	$attachment	Row from core_attachments
	 * @return array
	 */
	public static function getAttachmentLabels( $attachment ): array
	{
		return [];
	}
}
