MDL-65394 filter: only do mathjax parsing on strings that need it
[moodle.git] / filter / mathjaxloader / filter.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * This filter provides automatic support for MathJax
19  *
20  * @package    filter_mathjaxloader
21  * @copyright  2013 Damyon Wiese (damyon@moodle.com)
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 defined('MOODLE_INTERNAL') || die();
27 /**
28  * Mathjax filtering
29  */
30 class filter_mathjaxloader extends moodle_text_filter {
32     /*
33      * Perform a mapping of the moodle language code to the equivalent for MathJax.
34      *
35      * @param string $moodlelangcode - The moodle language code - e.g. en_pirate
36      * @return string The MathJax language code.
37      */
38     public function map_language_code($moodlelangcode) {
40         // List of language codes found in the MathJax/localization/ directory.
41         $mathjaxlangcodes = [
42             'ar', 'ast', 'bcc', 'bg', 'br', 'ca', 'cdo', 'ce', 'cs', 'cy', 'da', 'de', 'diq', 'en', 'eo', 'es', 'fa',
43             'fi', 'fr', 'gl', 'he', 'ia', 'it', 'ja', 'kn', 'ko', 'lb', 'lki', 'lt', 'mk', 'nl', 'oc', 'pl', 'pt',
44             'pt-br', 'qqq', 'ru', 'scn', 'sco', 'sk', 'sl', 'sv', 'th', 'tr', 'uk', 'vi', 'zh-hans', 'zh-hant'
45         ];
47         // List of explicit mappings and known exceptions (moodle => mathjax).
48         $explicit = [
49             'cz' => 'cs',
50             'pt_br' => 'pt-br',
51             'zh_tw' => 'zh-hant',
52             'zh_cn' => 'zh-hans',
53         ];
55         // If defined, explicit mapping takes the highest precedence.
56         if (isset($explicit[$moodlelangcode])) {
57             return $explicit[$moodlelangcode];
58         }
60         // If there is exact match, it will be probably right.
61         if (in_array($moodlelangcode, $mathjaxlangcodes)) {
62             return $moodlelangcode;
63         }
65         // Finally try to find the best matching mathjax pack.
66         $parts = explode('_', $moodlelangcode, 2);
67         if (in_array($parts[0], $mathjaxlangcodes)) {
68             return $parts[0];
69         }
71         // No more guessing, use English.
72         return 'en';
73     }
75     /*
76      * Add the javascript to enable mathjax processing on this page.
77      *
78      * @param moodle_page $page The current page.
79      * @param context $context The current context.
80      */
81     public function setup($page, $context) {
83         if ($page->requires->should_create_one_time_item_now('filter_mathjaxloader-scripts')) {
84             $url = get_config('filter_mathjaxloader', 'httpsurl');
85             $lang = $this->map_language_code(current_language());
86             $url = new moodle_url($url, array('delayStartupUntil' => 'configured'));
88             $moduleconfig = array(
89                 'name' => 'mathjax',
90                 'fullpath' => $url
91             );
93             $page->requires->js_module($moduleconfig);
95             $config = get_config('filter_mathjaxloader', 'mathjaxconfig');
96             $wwwroot = new moodle_url('/');
98             $config = str_replace('{wwwroot}', $wwwroot->out(true), $config);
100             $params = array('mathjaxconfig' => $config, 'lang' => $lang);
102             $page->requires->yui_module('moodle-filter_mathjaxloader-loader', 'M.filter_mathjaxloader.configure', array($params));
103         }
104     }
106     /*
107      * This function wraps the filtered text in a span, that mathjaxloader is configured to process.
108      *
109      * @param string $text The text to filter.
110      * @param array $options The filter options.
111      */
112     public function filter($text, array $options = array()) {
113         global $PAGE;
115         $legacy = get_config('filter_mathjaxloader', 'texfiltercompatibility');
116         $extradelimiters = explode(',', get_config('filter_mathjaxloader', 'additionaldelimiters'));
117         if ($legacy) {
118             // This replaces any of the tex filter maths delimiters with the default for inline maths in MathJAX "\( blah \)".
119             // E.g. "<tex.*> blah </tex>".
120             $text = preg_replace('|<(/?) *tex( [^>]*)?>|u', '[\1tex]', $text);
121             // E.g. "[tex.*] blah [/tex]".
122             $text = str_replace('[tex]', '\\(', $text);
123             $text = str_replace('[/tex]', '\\)', $text);
124             // E.g. "$$ blah $$".
125             $text = preg_replace('|\$\$([\S\s]*?)\$\$|u', '\\(\1\\)', $text);
126             // E.g. "\[ blah \]".
127             $text = str_replace('\\[', '\\(', $text);
128             $text = str_replace('\\]', '\\)', $text);
129         }
131         $hasextra = false;
132         foreach ($extradelimiters as $extra) {
133             if ($extra && strpos($text, $extra) !== false) {
134                 $hasextra = true;
135                 break;
136             }
137         }
139         $hasdisplayorinline = false;
140         if ($hasextra) {
141             // If custom dilimeters are used, wrap whole text to prevent autolinking.
142             $text = '<span class="nolink">' . $text . '</span>';
143         } else if (preg_match('/\\\\[[(]/', $text) || preg_match('/\$\$/', $text)) {
144             // Only parse the text if there are mathjax symbols in it. The recognized
145             // math environments are \[ \] and $$ $$ for display mathematics and \( \)
146             // for inline mathematics.
147             // Note: 2 separate regexes seems to perform better here than using a single
148             // regex with groupings.
150             // Wrap display and inline math environments in nolink spans.
151             // Do not wrap nested environments, i.e., if inline math is nested
152             // inside display math, only the outer display math is wrapped in
153             // a span. The span HTML inside a LaTex math environment would break
154             // MathJax. See MDL-61981.
155             list($text, $hasdisplayorinline) = $this->wrap_math_in_nolink($text);
156         }
158         if ($hasdisplayorinline || $hasextra) {
159             $PAGE->requires->yui_module('moodle-filter_mathjaxloader-loader', 'M.filter_mathjaxloader.typeset');
160             return '<span class="filter_mathjaxloader_equation">' . $text . '</span>';
161         }
162         return $text;
163     }
165     /**
166      * Find math environments in the $text and wrap them in no link spans
167      * (<span class="nolink"></span>). If math environments are nested, only
168      * the outer environment is wrapped in the span.
169      *
170      * The recognized math environments are \[ \] and $$ $$ for display
171      * mathematics and \( \) for inline mathematics.
172      *
173      * @param string $text The text to filter.
174      * @return array An array containing the potentially modified text and
175      * a boolean that is true if any changes were made to the text.
176      */
177     protected function wrap_math_in_nolink($text) {
178         $i = 1;
179         $len = strlen($text);
180         $displaystart = -1;
181         $displaybracket = false;
182         $displaydollar = false;
183         $inlinestart = -1;
184         $changesdone = false;
185         // Loop over the $text once.
186         while ($i < $len) {
187             if ($displaystart === -1) {
188                 // No display math has started yet.
189                 if ($text[$i - 1] === '\\' && $text[$i] === '[') {
190                     // Display mode \[ begins.
191                     $displaystart = $i - 1;
192                     $displaybracket = true;
193                 } else if ($text[$i - 1] === '$' && $text[$i] === '$') {
194                     // Display mode $$ begins.
195                     $displaystart = $i - 1;
196                     $displaydollar = true;
197                 } else if ($text[$i - 1] === '\\' && $text[$i] === '(') {
198                     // Inline math \( begins, not nested inside display math.
199                     $inlinestart = $i - 1;
200                 } else if ($text[$i - 1] === '\\' && $text[$i] === ')' && $inlinestart > -1) {
201                     // Inline math ends, not nested inside display math.
202                     // Wrap the span around it.
203                     $text = $this->insert_span($text, $inlinestart, $i);
205                     $inlinestart = -1; // Reset.
206                     $i += 28; // The $text length changed due to the <span>.
207                     $len += 28;
208                     $changesdone = true;
209                 }
210             } else {
211                 // Display math open.
212                 if (($text[$i - 1] === '\\' && $text[$i] === ']' && $displaybracket) ||
213                         ($text[$i - 1] === '$' && $text[$i] === '$' && $displaydollar)) {
214                     // Display math ends, wrap the span around it.
215                     $text = $this->insert_span($text, $displaystart, $i);
217                     $displaystart = -1; // Reset.
218                     $displaybracket = false;
219                     $displaydollar = false;
220                     $i += 28; // The $text length changed due to the <span>.
221                     $len += 28;
222                     $changesdone = true;
223                 }
224             }
226             ++$i;
227         }
228         return array($text, $changesdone);
229     }
231     /**
232      * Wrap a portion of the $text inside a no link span
233      * (<span class="nolink"></span>). The whole text is then returned.
234      *
235      * @param string $text The text to modify.
236      * @param int $start The start index of the substring in $text that should
237      * be wrapped in the span.
238      * @param int $end The end index of the substring in $text that should be
239      * wrapped in the span.
240      * @return string The whole $text with the span inserted around
241      * the defined substring.
242      */
243     protected function insert_span($text, $start, $end) {
244         return substr_replace($text,
245                 '<span class="nolink">'. substr($text, $start, $end - $start + 1) .'</span>',
246                 $start,
247                 $end - $start + 1);
248     }