1<?php
2/**
3*
4* This file is part of the phpBB Forum Software package.
5*
6* @copyright (c) phpBB Limited <https://www.phpbb.com>
7* @license GNU General Public License, version 2 (GPL-2.0)
8*
9* For full copyright and license information, please see
10* the docs/CREDITS.txt file.
11*
12*/
13
14namespace phpbb\textformatter\s9e;
15
16use s9e\TextFormatter\Configurator;
17use s9e\TextFormatter\Configurator\Items\AttributeFilters\RegexpFilter;
18use s9e\TextFormatter\Configurator\Items\UnsafeTemplate;
19
20/**
21* Creates s9e\TextFormatter objects
22*/
23class factory implements \phpbb\textformatter\cache_interface
24{
25	/**
26	* @var \phpbb\textformatter\s9e\link_helper
27	*/
28	protected $link_helper;
29
30	/**
31	* @var \phpbb\cache\driver\driver_interface
32	*/
33	protected $cache;
34
35	/**
36	* @var string Path to the cache dir
37	*/
38	protected $cache_dir;
39
40	/**
41	* @var string Cache key used for the parser
42	*/
43	protected $cache_key_parser;
44
45	/**
46	* @var string Cache key used for the renderer
47	*/
48	protected $cache_key_renderer;
49
50	/**
51	* @var \phpbb\config\config
52	*/
53	protected $config;
54
55	/**
56	* @var array Custom tokens used in bbcode.html and their corresponding token from the definition
57	*/
58	protected $custom_tokens = array(
59		'email' => array('{DESCRIPTION}' => '{TEXT}'),
60		'flash' => array('{WIDTH}' => '{NUMBER1}', '{HEIGHT}' => '{NUMBER2}'),
61		'img'   => array('{URL}' => '{IMAGEURL}'),
62		'list'  => array('{LIST_TYPE}' => '{HASHMAP}'),
63		'quote' => array('{USERNAME}' => '{TEXT1}'),
64		'size'  => array('{SIZE}' => '{FONTSIZE}'),
65		'url'   => array('{DESCRIPTION}' => '{TEXT}'),
66	);
67
68	/**
69	* @var \phpbb\textformatter\data_access
70	*/
71	protected $data_access;
72
73	/**
74	* @var array Default BBCode definitions
75	*/
76	protected $default_definitions = array(
77		'attachment' => '[ATTACHMENT index={NUMBER} filename={TEXT;useContent}]',
78		'b'     => '[B]{TEXT}[/B]',
79		'code'  => '[CODE lang={IDENTIFIER;optional}]{TEXT}[/CODE]',
80		'color' => '[COLOR={COLOR}]{TEXT}[/COLOR]',
81		'email' => '[EMAIL={EMAIL;useContent} subject={TEXT1;optional;postFilter=rawurlencode} body={TEXT2;optional;postFilter=rawurlencode}]{TEXT}[/EMAIL]',
82		'flash' => '[FLASH={NUMBER1},{NUMBER2} width={NUMBER1;postFilter=#flashwidth} height={NUMBER2;postFilter=#flashheight} url={URL;useContent} /]',
83		'i'     => '[I]{TEXT}[/I]',
84		'img'   => '[IMG src={IMAGEURL;useContent}]',
85		'list'  => '[LIST type={HASHMAP=1:decimal,a:lower-alpha,A:upper-alpha,i:lower-roman,I:upper-roman;optional;postFilter=#simpletext} #createChild=LI]{TEXT}[/LIST]',
86		'li'    => '[* $tagName=LI]{TEXT}[/*]',
87		'quote' =>
88			"[QUOTE
89				author={TEXT1;optional}
90				post_id={UINT;optional}
91				post_url={URL;optional;postFilter=#false}
92				msg_id={UINT;optional}
93				msg_url={URL;optional;postFilter=#false}
94				profile_url={URL;optional;postFilter=#false}
95				time={UINT;optional}
96				url={URL;optional}
97				user_id={UINT;optional}
98				author={PARSE=/^\\[url=(?'url'.*?)](?'author'.*)\\[\\/url]$/i}
99				author={PARSE=/^\\[url](?'author'(?'url'.*?))\\[\\/url]$/i}
100				author={PARSE=/(?'url'https?:\\/\\/[^[\\]]+)/i}
101			]{TEXT2}[/QUOTE]",
102		'size'  => '[SIZE={FONTSIZE}]{TEXT}[/SIZE]',
103		'u'     => '[U]{TEXT}[/U]',
104		'url'   => '[URL={URL;useContent} $forceLookahead=true]{TEXT}[/URL]',
105	);
106
107	/**
108	* @var array Default templates, taken from bbcode::bbcode_tpl()
109	*/
110	protected $default_templates = array(
111		'b'     => '<span style="font-weight: bold"><xsl:apply-templates/></span>',
112		'i'     => '<span style="font-style: italic"><xsl:apply-templates/></span>',
113		'u'     => '<span style="text-decoration: underline"><xsl:apply-templates/></span>',
114		'img'   => '<img src="{IMAGEURL}" class="postimage" alt="{L_IMAGE}"/>',
115		'size'	=> '<span><xsl:attribute name="style"><xsl:text>font-size: </xsl:text><xsl:value-of select="substring(@size, 1, 4)"/><xsl:text>%; line-height: normal</xsl:text></xsl:attribute><xsl:apply-templates/></span>',
116		'color' => '<span style="color: {COLOR}"><xsl:apply-templates/></span>',
117		'email' => '<a>
118			<xsl:attribute name="href">
119				<xsl:text>mailto:</xsl:text>
120				<xsl:value-of select="@email"/>
121				<xsl:if test="@subject or @body">
122					<xsl:text>?</xsl:text>
123					<xsl:if test="@subject">subject=<xsl:value-of select="@subject"/></xsl:if>
124					<xsl:if test="@body"><xsl:if test="@subject">&amp;</xsl:if>body=<xsl:value-of select="@body"/></xsl:if>
125				</xsl:if>
126			</xsl:attribute>
127			<xsl:apply-templates/>
128		</a>',
129	);
130
131	/**
132	* @var \phpbb\event\dispatcher_interface
133	*/
134	protected $dispatcher;
135
136	/**
137	* @var \phpbb\log\log_interface
138	*/
139	protected $log;
140
141	/**
142	* Constructor
143	*
144	* @param \phpbb\textformatter\data_access $data_access
145	* @param \phpbb\cache\driver\driver_interface $cache
146	* @param \phpbb\event\dispatcher_interface $dispatcher
147	* @param \phpbb\config\config $config
148	* @param \phpbb\textformatter\s9e\link_helper $link_helper
149	* @param \phpbb\log\log_interface $log
150	* @param string $cache_dir          Path to the cache dir
151	* @param string $cache_key_parser   Cache key used for the parser
152	* @param string $cache_key_renderer Cache key used for the renderer
153	*/
154	public function __construct(\phpbb\textformatter\data_access $data_access, \phpbb\cache\driver\driver_interface $cache, \phpbb\event\dispatcher_interface $dispatcher, \phpbb\config\config $config, \phpbb\textformatter\s9e\link_helper $link_helper, \phpbb\log\log_interface $log, $cache_dir, $cache_key_parser, $cache_key_renderer)
155	{
156		$this->link_helper = $link_helper;
157		$this->cache = $cache;
158		$this->cache_dir = $cache_dir;
159		$this->cache_key_parser = $cache_key_parser;
160		$this->cache_key_renderer = $cache_key_renderer;
161		$this->config = $config;
162		$this->data_access = $data_access;
163		$this->dispatcher = $dispatcher;
164		$this->log = $log;
165	}
166
167	/**
168	* {@inheritdoc}
169	*/
170	public function invalidate()
171	{
172		$this->regenerate();
173	}
174
175	/**
176	* {@inheritdoc}
177	*
178	* Will remove old renderers from the cache dir but won't touch the current renderer
179	*/
180	public function tidy()
181	{
182		// Get the name of current renderer
183		$renderer_data = $this->cache->get($this->cache_key_renderer);
184		$renderer_file = ($renderer_data) ? $renderer_data['class'] . '.php' : null;
185
186		foreach (glob($this->cache_dir . 's9e_*') as $filename)
187		{
188			// Only remove the file if it's not the current renderer
189			if (!$renderer_file || substr($filename, -strlen($renderer_file)) !== $renderer_file)
190			{
191				unlink($filename);
192			}
193		}
194	}
195
196	/**
197	* Generate and return a new configured instance of s9e\TextFormatter\Configurator
198	*
199	* @return Configurator
200	*/
201	public function get_configurator()
202	{
203		// Create a new Configurator
204		$configurator = new Configurator;
205
206		/**
207		* Modify the s9e\TextFormatter configurator before the default settings are set
208		*
209		* @event core.text_formatter_s9e_configure_before
210		* @var Configurator configurator Configurator instance
211		* @since 3.2.0-a1
212		*/
213		$vars = array('configurator');
214		extract($this->dispatcher->trigger_event('core.text_formatter_s9e_configure_before', compact($vars)));
215
216		// Reset the list of allowed schemes
217		foreach ($configurator->urlConfig->getAllowedSchemes() as $scheme)
218		{
219			$configurator->urlConfig->disallowScheme($scheme);
220		}
221		foreach (array_filter(explode(',', $this->config['allowed_schemes_links'])) as $scheme)
222		{
223			$configurator->urlConfig->allowScheme(trim($scheme));
224		}
225
226		// Convert newlines to br elements by default
227		$configurator->rootRules->enableAutoLineBreaks();
228
229		// Don't automatically ignore text in places where text is not allowed
230		$configurator->rulesGenerator->remove('IgnoreTextIfDisallowed');
231
232		// Don't remove comments and instead convert them to xsl:comment elements
233		$configurator->templateNormalizer->remove('RemoveComments');
234		$configurator->templateNormalizer->add('TransposeComments');
235
236		// Set the rendering engine and configure it to save to the cache dir
237		$configurator->rendering->engine = 'PHP';
238		$configurator->rendering->engine->cacheDir = $this->cache_dir;
239		$configurator->rendering->engine->defaultClassPrefix = 's9e_renderer_';
240		$configurator->rendering->engine->enableQuickRenderer = true;
241
242		// Create custom filters for BBCode tokens that are supported in phpBB but not in
243		// s9e\TextFormatter
244		$filter = new RegexpFilter('#^' . get_preg_expression('relative_url') . '$#Du');
245		$configurator->attributeFilters->add('#local_url', $filter);
246		$configurator->attributeFilters->add('#relative_url', $filter);
247
248		// INTTEXT regexp from acp_bbcodes
249		$filter = new RegexpFilter('!^([\p{L}\p{N}\-+,_. ]+)$!Du');
250		$configurator->attributeFilters->add('#inttext', $filter);
251
252		// Create custom filters for Flash restrictions, which use the same values as the image
253		// restrictions but have their own error message
254		$configurator->attributeFilters
255			->add('#flashheight', __NAMESPACE__ . '\\parser::filter_flash_height')
256			->addParameterByName('max_img_height')
257			->addParameterByName('logger');
258
259		$configurator->attributeFilters
260			->add('#flashwidth', __NAMESPACE__ . '\\parser::filter_flash_width')
261			->addParameterByName('max_img_width')
262			->addParameterByName('logger');
263
264		// Create a custom filter for phpBB's per-mode font size limits
265		$configurator->attributeFilters
266			->add('#fontsize', __NAMESPACE__ . '\\parser::filter_font_size')
267			->addParameterByName('max_font_size')
268			->addParameterByName('logger')
269			->markAsSafeInCSS();
270
271		// Create a custom filter for image URLs
272		$configurator->attributeFilters
273			->add('#imageurl', __NAMESPACE__ . '\\parser::filter_img_url')
274			->addParameterByName('urlConfig')
275			->addParameterByName('logger')
276			->markAsSafeAsURL()
277			->setJS('UrlFilter.filter');
278
279		// Add default BBCodes
280		foreach ($this->get_default_bbcodes($configurator) as $bbcode)
281		{
282			$this->add_bbcode($configurator, $bbcode['usage'], $bbcode['template']);
283		}
284		if (isset($configurator->tags['QUOTE']))
285		{
286			// Remove the nesting limit and let other services remove quotes at parsing time
287			$configurator->tags['QUOTE']->nestingLimit = PHP_INT_MAX;
288		}
289
290		// Modify the template to disable images/flash depending on user's settings
291		foreach (array('FLASH', 'IMG') as $name)
292		{
293			$tag = $configurator->tags[$name];
294			$tag->template = '<xsl:choose><xsl:when test="$S_VIEW' . $name . '">' . $tag->template . '</xsl:when><xsl:otherwise><xsl:apply-templates/></xsl:otherwise></xsl:choose>';
295		}
296
297		// Load custom BBCodes
298		foreach ($this->data_access->get_bbcodes() as $row)
299		{
300			// Insert the board's URL before {LOCAL_URL} tokens
301			$tpl = preg_replace_callback(
302				'#\\{LOCAL_URL\\d*\\}#',
303				function ($m)
304				{
305					return generate_board_url() . '/' . $m[0];
306				},
307				$row['bbcode_tpl']
308			);
309			$this->add_bbcode($configurator, $row['bbcode_match'], $tpl);
310		}
311
312		// Load smilies
313		foreach ($this->data_access->get_smilies() as $row)
314		{
315			$configurator->Emoticons->set(
316				$row['code'],
317				'<img class="smilies" src="{$T_SMILIES_PATH}/' . $this->escape_html_attribute($row['smiley_url']) . '" width="' . $row['smiley_width'] . '" height="' . $row['smiley_height'] . '" alt="{.}" title="' . $this->escape_html_attribute($row['emotion']) . '"/>'
318			);
319		}
320
321		if (isset($configurator->Emoticons))
322		{
323			// Force emoticons to be rendered as text if $S_VIEWSMILIES is not set
324			$configurator->Emoticons->notIfCondition = 'not($S_VIEWSMILIES)';
325
326			// Only parse emoticons at the beginning of the text or if they're preceded by any
327			// one of: a new line, a space, a dot, or a right square bracket
328			$configurator->Emoticons->notAfter = '[^\\n .\\]]';
329
330			// Ignore emoticons that are immediately followed by a "word" character
331			$configurator->Emoticons->notBefore = '\\w';
332		}
333
334		// Load the censored words
335		$censor = $this->data_access->get_censored_words();
336		if (!empty($censor))
337		{
338			// Use a namespaced tag to avoid collisions
339			$configurator->plugins->load('Censor', array('tagName' => 'censor:tag'));
340			foreach ($censor as $row)
341			{
342				$configurator->Censor->add($row['word'], $row['replacement']);
343			}
344		}
345
346		// Load the magic links plugins. We do that after BBCodes so that they use the same tags
347		$this->configure_autolink($configurator);
348
349		// Register some vars with a default value. Those should be set at runtime by whatever calls
350		// the parser
351		$configurator->registeredVars['max_font_size'] = 0;
352		$configurator->registeredVars['max_img_height'] = 0;
353		$configurator->registeredVars['max_img_width'] = 0;
354
355		// Load the Emoji plugin and modify its tag's template to obey viewsmilies
356		$tag = $configurator->Emoji->getTag();
357		$tag->template = '<xsl:choose>
358			<xsl:when test="@tseq">
359				<img alt="{.}" class="emoji" draggable="false" src="//twemoji.maxcdn.com/2/svg/{@tseq}.svg"/>
360			</xsl:when>
361			<xsl:otherwise>
362				<img alt="{.}" class="emoji" draggable="false" src="https://cdn.jsdelivr.net/gh/s9e/emoji-assets-twemoji@11.2/dist/svgz/{@seq}.svgz"/>
363			</xsl:otherwise>
364		</xsl:choose>';
365		$tag->template = '<xsl:choose><xsl:when test="$S_VIEWSMILIES">' . str_replace('class="emoji"', 'class="emoji smilies"', $tag->template) . '</xsl:when><xsl:otherwise><xsl:value-of select="."/></xsl:otherwise></xsl:choose>';
366
367		/**
368		* Modify the s9e\TextFormatter configurator after the default settings are set
369		*
370		* @event core.text_formatter_s9e_configure_after
371		* @var Configurator configurator Configurator instance
372		* @since 3.2.0-a1
373		*/
374		$vars = array('configurator');
375		extract($this->dispatcher->trigger_event('core.text_formatter_s9e_configure_after', compact($vars)));
376
377		return $configurator;
378	}
379
380	/**
381	* Regenerate and cache a new parser and renderer
382	*
383	* @return array Associative array with at least two elements: "parser" and "renderer"
384	*/
385	public function regenerate()
386	{
387		$configurator = $this->get_configurator();
388
389		// Get the censor helper and remove the Censor plugin if applicable
390		if (isset($configurator->Censor))
391		{
392			$censor = $configurator->Censor->getHelper();
393			unset($configurator->Censor);
394			unset($configurator->tags['censor:tag']);
395		}
396
397		$objects = $configurator->finalize();
398
399		/**
400		* Access the objects returned by finalize() before they are saved to cache
401		*
402		* @event core.text_formatter_s9e_configure_finalize
403		* @var array objects Array containing a "parser" object, a "renderer" object and optionally a "js" string
404		* @since 3.2.2-RC1
405		*/
406		$vars = array('objects');
407		extract($this->dispatcher->trigger_event('core.text_formatter_s9e_configure_finalize', compact($vars)));
408
409		$parser   = $objects['parser'];
410		$renderer = $objects['renderer'];
411
412		// Cache the parser as-is
413		$this->cache->put($this->cache_key_parser, $parser);
414
415		// We need to cache the name of the renderer's generated class
416		$renderer_data = array('class' => get_class($renderer));
417		if (isset($censor))
418		{
419			$renderer_data['censor'] = $censor;
420		}
421		$this->cache->put($this->cache_key_renderer, $renderer_data);
422
423		return array('parser' => $parser, 'renderer' => $renderer);
424	}
425
426	/**
427	* Add a BBCode to given configurator
428	*
429	* @param  Configurator $configurator
430	* @param  string       $usage
431	* @param  string       $template
432	* @return void
433	*/
434	protected function add_bbcode(Configurator $configurator, $usage, $template)
435	{
436		try
437		{
438			$configurator->BBCodes->addCustom($usage, new UnsafeTemplate($template));
439		}
440		catch (\Exception $e)
441		{
442			$this->log->add('critical', null, null, 'LOG_BBCODE_CONFIGURATION_ERROR', false, [$usage, $e->getMessage()]);
443		}
444	}
445
446	/**
447	* Configure the Autolink / Autoemail plugins used to linkify text
448	*
449	* @param  Configurator $configurator
450	* @return void
451	*/
452	protected function configure_autolink(Configurator $configurator)
453	{
454		$configurator->plugins->load('Autoemail');
455		$configurator->plugins->load('Autolink', array('matchWww' => true));
456
457		// Add a tag filter that creates a tag that stores and replace the
458		// content of a link created by the Autolink plugin
459		$configurator->Autolink->getTag()->filterChain
460			->add(array($this->link_helper, 'generate_link_text_tag'))
461			->resetParameters()
462			->addParameterByName('tag')
463			->addParameterByName('parser');
464
465		// Create a tag that will be used to display the truncated text by
466		// replacing the original content with the content of the @text attribute
467		$tag = $configurator->tags->add('LINK_TEXT');
468		$tag->attributes->add('text');
469		$tag->template = '<xsl:value-of select="@text"/>';
470
471		$board_url = generate_board_url() . '/';
472		$tag->filterChain
473			->add(array($this->link_helper, 'truncate_local_url'))
474			->resetParameters()
475			->addParameterByName('tag')
476			->addParameterByValue($board_url);
477		$tag->filterChain
478			->add(array($this->link_helper, 'truncate_local_url'))
479			->resetParameters()
480			->addParameterByName('tag')
481			->addParameterByValue(preg_replace('(^\\w+:)', '', $board_url));
482		$tag->filterChain
483			->add(array($this->link_helper, 'truncate_text'))
484			->resetParameters()
485			->addParameterByName('tag');
486		$tag->filterChain
487			->add(array($this->link_helper, 'cleanup_tag'))
488			->resetParameters()
489			->addParameterByName('tag')
490			->addParameterByName('parser');
491	}
492
493	/**
494	* Escape a literal to be used in an HTML attribute in an XSL template
495	*
496	* Escapes "HTML special chars" for obvious reasons and curly braces to avoid them
497	* being interpreted as an attribute value template
498	*
499	* @param  string $value Original string
500	* @return string        Escaped string
501	*/
502	protected function escape_html_attribute($value)
503	{
504		return htmlspecialchars(strtr($value, ['{' => '{{', '}' => '}}']), ENT_COMPAT | ENT_XML1, 'UTF-8');
505	}
506
507	/**
508	* Return the default BBCodes configuration
509	*
510	* @return array 2D array. Each element has a 'usage' key, a 'template' key, and an optional 'options' key
511	*/
512	protected function get_default_bbcodes($configurator)
513	{
514		// For each BBCode, build an associative array matching style_ids to their template
515		$templates = array();
516		foreach ($this->data_access->get_styles_templates() as $style_id => $data)
517		{
518			foreach ($this->extract_templates($data['template']) as $bbcode_name => $template)
519			{
520				$templates[$bbcode_name][$style_id] = $template;
521			}
522
523			// Add default templates wherever missing, or for BBCodes that were not specified in
524			// this template's bitfield. For instance, prosilver has a custom template for b but its
525			// bitfield does not enable it so the default template is used instead
526			foreach ($this->default_templates as $bbcode_name => $template)
527			{
528				if (!isset($templates[$bbcode_name][$style_id]) || !in_array($bbcode_name, $data['bbcodes'], true))
529				{
530					$templates[$bbcode_name][$style_id] = $template;
531				}
532			}
533		}
534
535		// Replace custom tokens and normalize templates
536		foreach ($templates as $bbcode_name => $style_templates)
537		{
538			foreach ($style_templates as $i => $template)
539			{
540				if (isset($this->custom_tokens[$bbcode_name]))
541				{
542					$template = strtr($template, $this->custom_tokens[$bbcode_name]);
543				}
544
545				$templates[$bbcode_name][$i] = $configurator->templateNormalizer->normalizeTemplate($template);
546			}
547		}
548
549		$bbcodes = array();
550		foreach ($this->default_definitions as $bbcode_name => $usage)
551		{
552			$bbcodes[$bbcode_name] = array(
553				'usage'    => $usage,
554				'template' => $this->merge_templates($templates[$bbcode_name]),
555			);
556		}
557
558		return $bbcodes;
559	}
560
561	/**
562	* Extract and recompose individual BBCode templates from a style's template file
563	*
564	* @param  string $template Style template (bbcode.html)
565	* @return array Associative array matching BBCode names to their template
566	*/
567	protected function extract_templates($template)
568	{
569		// Capture the template fragments
570		// Allow either phpBB template or the Twig syntax
571		preg_match_all('#<!-- BEGIN (.*?) -->(.*?)<!-- END .*? -->#s', $template, $matches, PREG_SET_ORDER) ?:
572			preg_match_all('#{% for (.*?) in .*? %}(.*?){% endfor %}#s', $template, $matches, PREG_SET_ORDER);
573
574		$fragments = array();
575		foreach ($matches as $match)
576		{
577			// Normalize the whitespace
578			$fragment = preg_replace('#>\\n\\t*<#', '><', trim($match[2]));
579
580			$fragments[$match[1]] = $fragment;
581		}
582
583		// Automatically recompose templates split between *_open and *_close
584		foreach ($fragments as $fragment_name => $fragment)
585		{
586			if (preg_match('#^(\\w+)_close$#', $fragment_name, $match))
587			{
588				$bbcode_name = $match[1];
589
590				if (isset($fragments[$bbcode_name . '_open']))
591				{
592					$templates[$bbcode_name] = $fragments[$bbcode_name . '_open'] . '<xsl:apply-templates/>' . $fragment;
593				}
594			}
595		}
596
597		// Manually recompose and overwrite irregular templates
598		$templates['list'] =
599			'<xsl:choose>
600				<xsl:when test="not(@type)">
601					' . $fragments['ulist_open_default'] . '<xsl:apply-templates/>' . $fragments['ulist_close'] . '
602				</xsl:when>
603				<xsl:when test="contains(\'upperlowerdecim\',substring(@type,1,5))">
604					' . $fragments['olist_open'] . '<xsl:apply-templates/>' . $fragments['olist_close'] . '
605				</xsl:when>
606				<xsl:otherwise>
607					' . $fragments['ulist_open'] . '<xsl:apply-templates/>' . $fragments['ulist_close'] . '
608				</xsl:otherwise>
609			</xsl:choose>';
610
611		$templates['li'] = $fragments['listitem'] . '<xsl:apply-templates/>' . $fragments['listitem_close'];
612
613		// Replace the regular quote template with the extended quote template if available
614		if (isset($fragments['quote_extended']))
615		{
616			$templates['quote'] = $fragments['quote_extended'];
617		}
618
619		// The [attachment] BBCode uses the inline_attachment template to output a comment that
620		// is post-processed by parse_attachments()
621		$templates['attachment'] = $fragments['inline_attachment_open'] . '<xsl:comment> ia<xsl:value-of select="@index"/> </xsl:comment><xsl:value-of select="@filename"/><xsl:comment> ia<xsl:value-of select="@index"/> </xsl:comment>' . $fragments['inline_attachment_close'];
622
623		// Add fragments as templates
624		foreach ($fragments as $fragment_name => $fragment)
625		{
626			if (preg_match('#^\\w+$#', $fragment_name))
627			{
628				$templates[$fragment_name] = $fragment;
629			}
630		}
631
632		// Keep only templates that are named after an existing BBCode
633		$templates = array_intersect_key($templates, $this->default_definitions);
634
635		return $templates;
636	}
637
638	/**
639	* Merge the templates from any number of styles into one BBCode template
640	*
641	* When multiple templates are available for the same BBCode (because of multiple styles) we
642	* merge them into a single template that uses an xsl:choose construct that determines which
643	* style to use at rendering time.
644	*
645	* @param  array  $style_templates Associative array matching style_ids to their template
646	* @return string
647	*/
648	protected function merge_templates(array $style_templates)
649	{
650		// Return the template as-is if there's only one style or all styles share the same template
651		if (count(array_unique($style_templates)) === 1)
652		{
653			return end($style_templates);
654		}
655
656		// Group identical templates together
657		$grouped_templates = array();
658		foreach ($style_templates as $style_id => $style_template)
659		{
660			$grouped_templates[$style_template][] = '$STYLE_ID=' . $style_id;
661		}
662
663		// Sort templates by frequency descending
664		$templates_cnt = array_map('sizeof', $grouped_templates);
665		array_multisort($grouped_templates, $templates_cnt);
666
667		// Remove the most frequent template from the list; It becomes the default
668		reset($grouped_templates);
669		$default_template = key($grouped_templates);
670		unset($grouped_templates[$default_template]);
671
672		// Build an xsl:choose switch
673		$template = '<xsl:choose>';
674		foreach ($grouped_templates as $style_template => $exprs)
675		{
676			$template .= '<xsl:when test="' . implode(' or ', $exprs) . '">' . $style_template . '</xsl:when>';
677		}
678		$template .= '<xsl:otherwise>' . $default_template . '</xsl:otherwise></xsl:choose>';
679
680		return $template;
681	}
682}
683