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