Passed
Pull Request — development (#3635)
by Elk
08:02
created

Emoji::findEmojiByCode()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 11
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 4
dl 0
loc 11
c 0
b 0
f 0
rs 10
cc 3
nc 2
nop 1
1
<?php
2
3
/**
4
 * @package   ElkArte Forum
5
 * @copyright ElkArte Forum contributors
6
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
7
 *
8
 * @version 2.0 dev
9
 *
10
 */
11
12
namespace ElkArte;
13
14
use ElkArte\Cache\Cache;
15
16
/**
17
 * Used to add emoji images to text
18
 *
19
 * What it does:
20
 *
21
 * - Searches text for :tag: strings
22
 * - If tag is found to be a known emoji, replaces it with an image tag
23
 */
24
class Emoji extends AbstractModel
25
{
26
	/** @var null|\ElkArte\Emoji holds the instance of this class */
27
	private static $instance;
28
29
	/** @var string holds the url of where the emojis are stored */
30
	public $smileys_url;
31
32
	/** @var string[] Array of keys with known emoji names */
33
	public $shortcode_replace = [];
34
35
	/**
36
	 * Emoji constructor.
37
	 *
38
	 * @param string $smileys_url
39
	 */
40
	public function __construct($smileys_url = '')
41
	{
42
		parent::__construct();
43
44
		if (empty($smileys_url))
45
		{
46
			$smileys_url = htmlspecialchars($this->_modSettings['smileys_url']) . '/' . $this->_modSettings['emoji_selection'];
47
		}
48
49
		$this->smileys_url = $smileys_url;
50
	}
51
52
	/**
53
	 * Simple search and replace function
54
	 *
55
	 * What it does:
56
	 * - Finds emoji tags outside of code tags and converts applicable ones to images
57
	 * - Called from integrate_pre_bbc_parser
58
	 *
59
	 * @param string $string
60
	 * @return string
61
	 */
62
	public function emojiNameToImage($string)
63
	{
64
		$emoji = self::instance();
65
66
		// Only work on the areas outside of code tags
67
		$parts = preg_split('~(\[/code]|\[code(?:=[^]]+)?])~i', $string, -1, PREG_SPLIT_DELIM_CAPTURE);
68
69
		// Only converts :tags: outside.
70
		for ($i = 0, $n = count($parts); $i < $n; $i++)
71
		{
72
			// It goes 0 = outside, 1 = begin tag, 2 = inside, 3 = close tag, repeat.
73
			if ($i % 4 === 0)
74
			{
75
				// :emoji: must be at the start of a line, or have a leading space or be after a bbc ']' tag
76
				$parts[$i] = preg_replace_callback('~(?:\s?|^|]|<br />|<br>)(:([-+\w]+):\s?)~i', [$emoji, 'emojiToImage'], $parts[$i]);
77
78
				// Check for embedded html / hex emoji
79
				$parts[$i] = $this->keyboardEmojiToImage($parts[$i]);
80
			}
81
		}
82
83
		return implode('', $parts);
84
	}
85
86
	/**
87
	 * Find emoji codes that are HTML &#xxx codes or pure 😀 codes. If found
88
	 * replace them with our SVG version.
89
	 *
90
	 * Given &#128512; or 😀, aka grinning face, will convert to 1f600
91
	 * and search for available svg image, retuning <img /> or original
92
	 * string if not found.
93
	 *
94
	 * @param string $string
95
	 * @return string
96
	 */
97
	public function keyboardEmojiToImage($string)
98
	{
99
		$string = $this->emojiFromHTML($string);
100
101
		return $this->emojiFromUni($string);
102
	}
103
104
	/**
105
	 * Search and replace on &#xHEX; &#DEC; style emoji
106
	 *
107
	 * Given &#128512;; aka 😀 grinning face, will search on 1f600 and
108
	 * if found return as <img /> string pointing to SVG
109
	 *
110
	 * @param string $string
111
	 * @return string
112
	 */
113
	public function emojiFromHTML($string)
114
	{
115
		$result = preg_replace_callback('~&#(\d+);|&#x([0-9a-fA-F]+);~', function ($match) {
116
			// See if we have an Emoji version of this HTML entity
117
			$entity = !empty($match[1]) ? dechex($match[1]) : $match[2];
118
			$found = $this->findEmojiByCode($entity);
119
120
			// Replace it with or emoji <img>
121
			if ($found !== false)
122
			{
123
				return $this->emojiToImage([$match[0], ':' . $found . ':', $found]);
124
			}
125
126
			return $match[0];
127
		}, $string);
128
129
		return empty($result) ? $string : $result;
130
	}
131
132
	/**
133
	 * Search the Emoji array by unicode number
134
	 *
135
	 * Given unicode 1f600, aka 😀 grinning face, returns grinning
136
	 *
137
	 * @param $hex
138
	 * @return string|false
139
	 */
140
	public function findEmojiByCode($hex)
141
	{
142
		$this->loadEmoji();
143
144
		// Is it one we have in our library?
145
		if (!empty($hex) && $key = (array_search($hex, $this->shortcode_replace, true)))
146
		{
147
			return $key;
148
		}
149
150
		return false;
151
	}
152
153
	/**
154
	 * Takes a shortcode array and, if available, converts it to an <img> emoji
155
	 *
156
	 * - Uses input array of the form m[2] = 'doughnut' m[1]= ':doughnut:' m[0]= original
157
	 * - If shortcode does not exist in the emoji returns m[0] the preg full match
158
	 *
159
	 * @param array $m results from preg_replace_callback or other array
160
	 * @return string
161
	 */
162
	public function emojiToImage($m)
163
	{
164
		// No :tag: found or not a complete result, return
165
		if (!is_array($m) || empty($m[2]))
0 ignored issues
show
introduced by
The condition is_array($m) is always true.
Loading history...
166
		{
167
			return $m[0];
168
		}
169
170
		// Finally, going to need these
171
		$this->loadEmoji();
172
173
		// It is not a known tag, just return what was passed
174
		if (!isset($this->shortcode_replace[$m[2]]))
175
		{
176
			return $m[0];
177
		}
178
179
		// Otherwise, we have some Emoji :dancer:
180
		$filename = $this->smileys_url . '/' . $this->shortcode_replace[$m[2]] . '.svg';
181
		$alt = trim(strtr($m[1], [':' => '&#58;', '(' => '&#40;', ')' => '&#41;', '$' => '&#36;', '[' => '&#091;']));
182
		$title = ucwords(strtr(htmlspecialchars($m[2]), [':' => '&#58;', '(' => '&#40;', ')' => '&#41;', '$' => '&#36;', '[' => '&#091;', '_' => ' ']));
183
184
		return '<img class="smiley emoji ' . $this->_modSettings['emoji_selection'] . '" src="' . $filename . '" alt="' . $alt . '" title="' . $title . '" data-emoji-name="' . $alt . '" data-emoji-code="' . $this->shortcode_replace[$m[2]] . '" />';
185
	}
186
187
	/**
188
	 * Searches a string for unicode points and replaces them with emoji <img> tags
189
	 *
190
	 * Instead of searching in specific groups of emoji code points, such as:
191
	 *  - flags -> (?:\x{1F3F4}[\x{E0060}-\x{E00FF}]{1,6})|[\x{1F1E0}-\x{1F1FF}]{2}
192
	 *  - dingbats -> [\x{2700}-\x{27bf}]\x{FE0F}
193
	 *  - emoticons -> [\x{1F000}-\x{1F6FF}\x{1F900}-\x{1F9FF}]\x{FE0F}?
194
	 *  - symbols -> [\x{2600}-\x{26ff}]\x{FE0F}?
195
	 *  - peeps -> (?:[\x{1F466}-\x{1F469}]+\x{FE0F}?[\x{1F3FB}-\x{1F3FF}]?)
196
	 *
197
	 * We instead use \p{S} which will match anything in the symbol area including
198
	 * symbols, currency signs, dingbats, box-drawing characters, etc.  This is an
199
	 * easier regex but with more "false" hits for what we want.  Array searching
200
	 * should be faster than multiple detailed regex.
201
	 *
202
	 * @param $string
203
	 * @return string|string[]|null
204
	 */
205
	public function emojiFromUni($string)
206
	{
207
		$result = preg_replace_callback('~\p{S}~u', function ($match) {
208
			$hex_str = $this->unicodeCharacterToNumber($match[0]);
209
			$found = $this->findEmojiByCode($hex_str);
210
211
			// Hey I know you, your :space_invader:
212
			if ($found !== false)
213
			{
214
				return $this->emojiToImage([$match[0], ':' . $found . ':', $found]);
215
			}
216
217
			return $match[0];
218
		}, $string);
219
220
		return empty($result) ? $string : $result;
221
	}
222
223
	/**
224
	 * Given a unicode character, convert to a Unicode number which can be
225
	 * used for emoji array searching
226
	 *
227
	 * Given 😀 aka grinning face returns unicode 1f600
228
	 * Given 😮‍💨 aka face exhaling returns unicode 1f62e-200d-1f4a8
229
	 *
230
	 * @param string $code
231
	 * @return string
232
	 */
233
	public function unicodeCharacterToNumber($code)
234
	{
235
		$points = [];
236
237
		for ($i = 0; $i < Util::strlen($code); $i++)
238
		{
239
			$points[] = str_pad(strtolower(dechex($this->uniord(Util::substr($code, $i, 1)))), 4, '0', STR_PAD_LEFT);
0 ignored issues
show
Bug introduced by
It seems like $this->uniord(ElkArte\Util::substr($code, $i, 1)) can also be of type false; however, parameter $num of dechex() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

239
			$points[] = str_pad(strtolower(dechex(/** @scrutinizer ignore-type */ $this->uniord(Util::substr($code, $i, 1)))), 4, '0', STR_PAD_LEFT);
Loading history...
240
		}
241
242
		return implode('-', $points);
243
	}
244
245
	/**
246
	 * Converts a 4byte char into the corresponding HTML entity code.
247
	 * Subset of function _uniord($c) found in query.php as we are only
248
	 * dealing with the emoji space
249
	 *
250
	 * @param $c
251
	 * @return false|int
252
	 */
253
	private function uniord($c)
254
	{
255
		$ord0 = ord($c[0]);
256
		if ($ord0 >= 0 && $ord0 <= 127)
257
		{
258
			return $ord0;
259
		}
260
261
		$ord1 = ord($c[1]);
262
		if ($ord0 >= 192 && $ord0 <= 223)
263
		{
264
			return ($ord0 - 192) * 64 + ($ord1 - 128);
265
		}
266
267
		$ord2 = ord($c[2]);
268
		if ($ord0 >= 224 && $ord0 <= 239)
269
		{
270
			return ($ord0 - 224) * 4096 + ($ord1 - 128) * 64 + ($ord2 - 128);
271
		}
272
273
		$ord3 = ord($c[3]);
274
		if ($ord0 >= 240 && $ord0 <= 247)
275
		{
276
			return ($ord0 - 240) * 262144 + ($ord1 - 128) * 4096 + ($ord2 - 128) * 64 + ($ord3 - 128);
277
		}
278
279
		return false;
280
	}
281
282
	/**
283
	 * Load the base emoji tags file and load to PHP array
284
	 */
285
	public function loadEmoji()
286
	{
287
		global $settings;
288
289
		$this->_checkCache();
290
291
		if (empty($this->shortcode_replace))
292
		{
293
			$emoji = file_get_contents($settings['default_theme_dir'] . '/scripts/emoji_tags.js');
294
			preg_match_all('~{name:(.*?), key:(.*?)}~s', $emoji, $matches, PREG_SET_ORDER);
295
			foreach ($matches as $match)
296
			{
297
				$name = trim($match[1], "' ");
298
				$key = trim($match[2], "' ");
299
				$this->shortcode_replace[$name] = $key;
300
			}
301
302
			// Stash for an hour, not like this is going to change
303
			Cache::instance()->put('shortcode_replace', $this->shortcode_replace, 3600);
304
			call_integration_hook('integrate_custom_emoji', [&$this->shortcode_replace]);
305
		}
306
	}
307
308
	/**
309
	 * Check the cache to see if we already have these loaded
310
	 *
311
	 * @return void
312
	 */
313
	private function _checkCache()
314
	{
315
		if (!empty($this->shortcode_replace))
316
		{
317
			return;
318
		}
319
320
		if (Cache::instance()->getVar($shortcode_replace, 'shortcode_replace', 480))
321
		{
322
			$this->shortcode_replace = $shortcode_replace;
323
			unset($shortcode_replace);
324
		}
325
	}
326
327
	/**
328
	 * Retrieve the sole instance of this class.
329
	 *
330
	 * @return \ElkArte\Emoji
331
	 */
332
	public static function instance()
333
	{
334
		if (self::$instance === null)
335
		{
336
			self::$instance = new Emoji();
337
		}
338
339
		return self::$instance;
340
	}
341
}
342