From 194ffa2344455145f7dd4bf71bd1173aff1407ff Mon Sep 17 00:00:00 2001 From: Markku Riekkinen Date: Wed, 19 Sep 2018 12:04:23 +0300 Subject: [PATCH 1/2] MDL-61981 filter_mathjaxloader: nested math environments The MathJax filter used to break mathematics if inline math was used inside display math. Nolink spans were added around the inline math even though it was nested inside another math environment. This fix is aware of the nesting so that the spans may be inserted only at the outer math environment. No regular expressions are used because they do not support detecting arbitrary, unlimited nesting of parentheses in the text, which is now needed. --- filter/mathjaxloader/filter.php | 121 ++++++++++++++++++++++++++------ 1 file changed, 100 insertions(+), 21 deletions(-) diff --git a/filter/mathjaxloader/filter.php b/filter/mathjaxloader/filter.php index 50e568e45d2..0906e18fb49 100644 --- a/filter/mathjaxloader/filter.php +++ b/filter/mathjaxloader/filter.php @@ -128,37 +128,116 @@ class filter_mathjaxloader extends moodle_text_filter { $text = str_replace('\\]', '\\)', $text); } - $hasinline = strpos($text, '\\(') !== false && strpos($text, '\\)') !== false; - $hasdisplay = (strpos($text, '$$') !== false) || - (strpos($text, '\\[') !== false && strpos($text, '\\]') !== false); - $hasextra = false; - foreach ($extradelimiters as $extra) { if ($extra && strpos($text, $extra) !== false) { $hasextra = true; break; } } - if ($hasinline || $hasdisplay || $hasextra) { + + $hasdisplayorinline = false; + if ($hasextra) { + // If custom dilimeters are used, wrap whole text to prevent autolinking. + $text = '' . $text . ''; + } else { + // Wrap display and inline math environments in nolink spans. + // Do not wrap nested environments, i.e., if inline math is nested + // inside display math, only the outer display math is wrapped in + // a span. The span HTML inside a LaTex math environment would break + // MathJax. See MDL-61981. + list($text, $hasdisplayorinline) = $this->wrap_math_in_nolink($text); + } + + if ($hasdisplayorinline || $hasextra) { $PAGE->requires->yui_module('moodle-filter_mathjaxloader-loader', 'M.filter_mathjaxloader.typeset'); - if ($hasextra) { - // If custom dilimeters are used, wrap whole text to prevent autolinking. - $text = '' . $text . ''; - } else { - if ($hasinline) { - // If the default inline TeX delimiters \( \) are present, wrap each pair in nolink. - $text = preg_replace('/\\\\\\([\S\s]*?\\\\\\)/u', - '\0', $text); - } - if ($hasdisplay) { - // If default display TeX is used, wrap $$ $$ or \[ \] individually. - $text = preg_replace('/\$\$[\S\s]*?\$\$|\\\\\\[[\S\s]*?\\\\\\]/u', - '\0', $text); - } - } return '' . $text . ''; } return $text; } + + /** + * Find math environments in the $text and wrap them in no link spans + * (). If math environments are nested, only + * the outer environment is wrapped in the span. + * + * The recognized math environments are \[ \] and $$ $$ for display + * mathematics and \( \) for inline mathematics. + * + * @param string $text The text to filter. + * @return array An array containing the potentially modified text and + * a boolean that is true if any changes were made to the text. + */ + protected function wrap_math_in_nolink($text) { + $i = 1; + $len = strlen($text); + $displaystart = -1; + $displaybracket = false; + $displaydollar = false; + $inlinestart = -1; + $changesdone = false; + // Loop over the $text once. + while ($i < $len) { + if ($displaystart === -1) { + // No display math has started yet. + if ($text[$i - 1] === '\\' && $text[$i] === '[') { + // Display mode \[ begins. + $displaystart = $i - 1; + $displaybracket = true; + } else if ($text[$i - 1] === '$' && $text[$i] === '$') { + // Display mode $$ begins. + $displaystart = $i - 1; + $displaydollar = true; + } else if ($text[$i - 1] === '\\' && $text[$i] === '(') { + // Inline math \( begins, not nested inside display math. + $inlinestart = $i - 1; + } else if ($text[$i - 1] === '\\' && $text[$i] === ')' && $inlinestart > -1) { + // Inline math ends, not nested inside display math. + // Wrap the span around it. + $text = $this->insert_span($text, $inlinestart, $i); + + $inlinestart = -1; // Reset. + $i += 28; // The $text length changed due to the . + $len += 28; + $changesdone = true; + } + } else { + // Display math open. + if (($text[$i - 1] === '\\' && $text[$i] === ']' && $displaybracket) || + ($text[$i - 1] === '$' && $text[$i] === '$' && $displaydollar)) { + // Display math ends, wrap the span around it. + $text = $this->insert_span($text, $displaystart, $i); + + $displaystart = -1; // Reset. + $displaybracket = false; + $displaydollar = false; + $i += 28; // The $text length changed due to the . + $len += 28; + $changesdone = true; + } + } + + ++$i; + } + return array($text, $changesdone); + } + + /** + * Wrap a portion of the $text inside a no link span + * (). The whole text is then returned. + * + * @param string $text The text to modify. + * @param int $start The start index of the substring in $text that should + * be wrapped in the span. + * @param int $end The end index of the substring in $text that should be + * wrapped in the span. + * @return string The whole $text with the span inserted around + * the defined substring. + */ + protected function insert_span($text, $start, $end) { + return substr_replace($text, + ''. substr($text, $start, $end - $start + 1) .'', + $start, + $end - $start + 1); + } } From a776169ee9b973fdccd6c7c41b212e701344bb63 Mon Sep 17 00:00:00 2001 From: Markku Riekkinen Date: Thu, 20 Sep 2018 15:00:52 +0300 Subject: [PATCH 2/2] MDL-61981 filter_mathjaxloader: add unit tests --- .../mathjaxloader/tests/filtermath_test.php | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 filter/mathjaxloader/tests/filtermath_test.php diff --git a/filter/mathjaxloader/tests/filtermath_test.php b/filter/mathjaxloader/tests/filtermath_test.php new file mode 100644 index 00000000000..31bd1ee0677 --- /dev/null +++ b/filter/mathjaxloader/tests/filtermath_test.php @@ -0,0 +1,102 @@ +. +/** + * Provides the {@link filter_mathjaxloader_filtermath_testcase} class. + * + * @package filter_mathjaxloader + * @category test + * @copyright 2018 Markku Riekkinen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +defined('MOODLE_INTERNAL') || die(); +global $CFG; +require_once($CFG->dirroot.'/filter/mathjaxloader/filter.php'); +/** + * Unit tests for the MathJax loader filter. + * + * @copyright 2018 Markku Riekkinen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class filter_mathjaxloader_filtermath_testcase extends advanced_testcase { + + /** + * Test the functionality of {@link filter_mathjaxloader::filter()}. + * + * @param string $inputtext The text given by the user. + * @param string $expected The expected output after filtering. + * + * @dataProvider test_math_filtering_inputs + */ + public function test_math_filtering($inputtext, $expected) { + $filter = new filter_mathjaxloader(context_system::instance(), []); + $this->assertEquals($expected, $filter->filter($inputtext)); + } + + /** + * Data provider for {@link self::test_math_filtering()}. + * + * @return array of [inputtext, expectedoutput] tuples. + */ + public function test_math_filtering_inputs() { + return [ + // One inline formula. + ['Some inline math \\( y = x^2 \\).', + 'Some inline math \\( y = x^2 \\).'], + + // One inline and one display. + ['Some inline math \\( y = x^2 \\) and display formula \\[ S = \\sum_{n=1}^{\\infty} 2^n \\]', + 'Some inline math \\( y = x^2 \\) and ' + . 'display formula \\[ S = \\sum_{n=1}^{\\infty} 2^n \\]'], + + // One display and one inline. + ['Display formula \\[ S = \\sum_{n=1}^{\\infty} 2^n \\] and some inline math \\( y = x^2 \\).', + 'Display formula \\[ S = \\sum_{n=1}^{\\infty} 2^n \\] and ' + . 'some inline math \\( y = x^2 \\).'], + + // One inline and one display (with dollars). + ['Some inline math \\( y = x^2 \\) and display formula $$ S = \\sum_{n=1}^{\\infty} 2^n $$', + 'Some inline math \\( y = x^2 \\) and ' + . 'display formula $$ S = \\sum_{n=1}^{\\infty} 2^n $$'], + + // One display (with dollars) and one inline. + ['Display formula $$ S = \\sum_{n=1}^{\\infty} 2^n $$ and some inline math \\( y = x^2 \\).', + 'Display formula $$ S = \\sum_{n=1}^{\\infty} 2^n $$ and ' + . 'some inline math \\( y = x^2 \\).'], + + // Inline math environment nested inside display environment (using a custom LaTex macro). + ['\\[ \\newcommand{\\False}{\\mathsf{F}} \\newcommand{\\NullF}{\\fbox{\\(\\False\\)}} \\] ' + . 'Text with inline formula using the custom LaTex macro \\( a = \\NullF \\).', + '' + . '\\[ \\newcommand{\\False}{\\mathsf{F}} \\newcommand{\\NullF}{\\fbox{\\(\\False\\)}} \\] ' + . 'Text with inline formula using the custom LaTex macro \\( a = \\NullF \\).'], + + // Nested environments and some more content. + ['\\[ \\newcommand{\\False}{\\mathsf{F}} \\newcommand{\\NullF}{\\fbox{\\(\\False\\)}} \\] ' + . 'Text with inline formula using the custom LaTex macro \\( a = \\NullF \\). Finally, a display formula ' + . '$$ b = \\NullF $$', + '' + . '\\[ \\newcommand{\\False}{\\mathsf{F}} \\newcommand{\\NullF}{\\fbox{\\(\\False\\)}} \\] ' + . 'Text with inline formula using the custom LaTex macro \\( a = \\NullF \\). ' + . 'Finally, a display formula $$ b = \\NullF $$'], + + // Broken math: the delimiters ($$) are not closed. + ['Writing text and starting display math. $$ k = i^3 \\newcommand{\\False}{\\mathsf{F}} \\newcommand{\\NullF}{\\fbox{\\(\\False\\)}} ' + . 'More text and inline math \\( x = \\NullF \\).', + 'Writing text and starting display math. $$ k = i^3 \\newcommand{\\False}{\\mathsf{F}} \\newcommand{\\NullF}{\\fbox{\\(\\False\\)}} ' + . 'More text and inline math \\( x = \\NullF \\).'], + ]; + } +}