diff --git a/stack/cas/parsingrules/180_char_based_superscripts.filter.php b/stack/cas/parsingrules/180_char_based_superscripts.filter.php index a72ec1121c3..5ea3022ea2e 100644 --- a/stack/cas/parsingrules/180_char_based_superscripts.filter.php +++ b/stack/cas/parsingrules/180_char_based_superscripts.filter.php @@ -38,10 +38,18 @@ class stack_ast_filter_180_char_based_superscripts implements stack_cas_astfilte // phpcs:ignore moodle.Commenting.MissingDocblock.Function public function filter(MP_Node $ast, array &$errors, array &$answernotes, stack_cas_security $identifierrules): MP_Node { if (self::$ssmap === null) { - self::$ssmap = json_decode(file_get_contents(__DIR__ . '/../../maximaparser/unicode/superscript-stack.json'), true); + // Option C: Allow only the most common superscripts: ², ³, ¹ + // These are 2-byte UTF-8 characters and work without UTF-8mb4 requirement. + self::$ssmap = ['²' => '2', '³' => '3', '¹' => '1']; } - $process = function($node) use (&$errors, &$answernotes) { + // Loop until no more changes are made. + // This ensures all superscripts are converted, even in expressions like x²+x². + $changed = true; + while ($changed) { + $changed = false; + + $process = function($node) use (&$errors, &$answernotes, &$changed) { if ($node instanceof MP_Identifier && !(isset($node->position['invalid']) && $node->position['invalid'])) { // Iterate over the name to detect when we move from normal to superscript. $norm = true; @@ -109,6 +117,7 @@ public function filter(MP_Node $ast, array &$errors, array &$answernotes, stack_ if (count($parts) === 1) { $node->parentnode->replace($node, $parts[0]); + $changed = true; } else { if (array_search('missing_stars', $answernotes) === false) { $answernotes[] = 'missing_stars'; @@ -120,6 +129,7 @@ public function filter(MP_Node $ast, array &$errors, array &$answernotes, stack_ $a->position['insertstars'] = true; } $node->parentnode->replace($node, $a); + $changed = true; } return false; } @@ -127,7 +137,8 @@ public function filter(MP_Node $ast, array &$errors, array &$answernotes, stack_ return true; }; - $ast->callbackRecurse($process); + $ast->callbackRecurse($process); + } return $ast; } } diff --git a/stack/input/inputbase.class.php b/stack/input/inputbase.class.php index aed1d3f4877..1b25b4b6e5c 100644 --- a/stack/input/inputbase.class.php +++ b/stack/input/inputbase.class.php @@ -975,6 +975,10 @@ protected function validate_contents_filters($basesecurity) { $filterstoapply[] = '101_no_floats'; } + // Filter 180 MUST run BEFORE Filter 150! + // Filter 180: Intelligently converts Unicode superscripts to exponents (x² → x^2) + // Filter 150: Simple replacement of remaining Unicode letters (α → alpha, × → *) + $filterstoapply[] = '180_char_based_superscripts'; $filterstoapply[] = '150_replace_unicode_letters'; if (get_class($this) === 'stack_units_input' || get_class($this) === 'stack_numerical_input') { diff --git a/stack/maximaparser/corrective_parser.php b/stack/maximaparser/corrective_parser.php index 742abc47217..a300fd1c38a 100644 --- a/stack/maximaparser/corrective_parser.php +++ b/stack/maximaparser/corrective_parser.php @@ -117,7 +117,12 @@ public static function parse(string $string, array &$errors, array &$answernote, // Check for invalid chars at this point as they may prove to be difficult to // handle latter, also strings are safe already. + // Option C: Allow only the most common superscripts: ², ³, ¹ + // These are 2-byte UTF-8 characters and work without UTF-8mb4 requirement. + $superscript = ['²' => '2', '³' => '3', '¹' => '1']; + $allowedcharsregex = '~[^' . preg_quote( + implode('', array_keys($superscript)) . // @codingStandardsIgnoreStart // We do really want a backtick here. '0123456789,./\%#&{}[]()$@!"\'?`^~*_+qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM:;=><|: -', '~' diff --git a/tests/fixtures/inputfixtures.class.php b/tests/fixtures/inputfixtures.class.php index 7aca3167055..db22d398395 100644 --- a/tests/fixtures/inputfixtures.class.php +++ b/tests/fixtures/inputfixtures.class.php @@ -520,8 +520,8 @@ function at a point \(f(x)\). Maybe a 'gocha' for the question author....", ['(x/y)/z', 'php_true', '(x/y)/z', 'cas_true', '\frac{\frac{x}{y}}{z}', '', ""], ['x/(y/z)', 'php_true', 'x/(y/z)', 'cas_true', '\frac{x}{\frac{y}{z}}', '', ""], ['x^y', 'php_true', 'x^y', 'cas_true', 'x^{y}', '', "Operations and functions with special TeX"], - ["x\u{00b2}", 'php_false', '', 'cas_false', '', 'forbiddenChar', ""], - ["x\u{00b2}*x\u{00b2}", 'php_false', '', 'cas_false', '', 'forbiddenChar', ""], + ["x\u{00b2}", 'php_true', 'x^2', 'cas_true', 'x^{2}', 'superscriptchars', ""], + ["x\u{00b2}*x\u{00b2}", 'php_true', 'x^2*x^2', 'cas_true', 'x^{2}\cdot x^{2}', 'superscriptchars', ""], ['x^(y+z)', 'php_true', 'x^(y+z)', 'cas_true', 'x^{y+z}', '', ""], ['x^(y/z)', 'php_true', 'x^(y/z)', 'cas_true', 'x^{\frac{y}{z}}', '', ""], ['x^f(x)', 'php_true', 'x^f(x)', 'cas_true', 'x^{f\left(x\right)}', '', ""], @@ -625,7 +625,7 @@ function at a point \(f(x)\). Maybe a 'gocha' for the question author....", ['arsinh(x)', 'php_true', 'asinh(x)', 'cas_true', '{\rm sinh}^{-1}\left( x \right)', 'triginv', ""], ['sin^-1(x)', 'php_false', 'sin^-1(x)', 'cas_false', '', 'missing_stars | trigexp', ""], ['cos^2(x)', 'php_false', 'cos^2(x)', 'cas_false', '', 'missing_stars | trigexp', ""], - ["sin\u{00b2}(x)", 'php_false', 'sin^2(x)', 'cas_false', '', 'forbiddenChar', ""], + ["sin\u{00b2}(x)", 'php_false', 'sin^2(x)', 'cas_false', '', 'trigexp | superscriptchars | forbiddenVariable', ""], ['sin*2*x', 'php_false', 'sin*2*x', 'cas_false', '', 'forbiddenVariable', ""], ['sin[2*x]', 'php_false', 'sin[2*x]', 'cas_false', '', 'trigparens', ""], ['cosh(x)', 'php_true', 'cosh(x)', 'cas_true', '\cosh \left( x \right)', '', ""], diff --git a/tests/fixtures/test_strings.json b/tests/fixtures/test_strings.json index f545dbcdb3e..4639cbd765b 100644 --- a/tests/fixtures/test_strings.json +++ b/tests/fixtures/test_strings.json @@ -1,4 +1,13 @@ [ + "x²", + "x³", + "x¹", + "x²²", + "x²³", + "x²+x²", + "x²+x²+x²", + "x²y³", + "(x²)+(y²)", "\"+\"(a,b)", "\"1+1\"", "\"Hello world\"", diff --git a/tests/input_algebraic_test.php b/tests/input_algebraic_test.php index 60f8ca6000d..2ff72063c8b 100644 --- a/tests/input_algebraic_test.php +++ b/tests/input_algebraic_test.php @@ -2492,14 +2492,13 @@ public function test_validate_student_response_single_var_chars_unicode_superscr $el->set_parameter('insertStars', 2); $state = $el->validate_student_response(['sans1' => 'x²'], $options, 'x^2', new stack_cas_security()); - $this->assertEquals(stack_input::INVALID, $state->status); - // The rest needs to be updated once we know what the expected result is. - $this->assertEquals('forbiddenChar', $state->note); - $this->assertEquals('CAS commands may not contain the following characters: ².', - $state->errors); - $this->assertEquals('', $state->contentsmodified); - $this->assertEquals('', $state->contentsdisplayed); - $this->assertEquals('', $state->lvars); + // Unicode superscripts are now converted to exponents by Filter 180. + $this->assertEquals(stack_input::VALID, $state->status); + $this->assertEquals('superscriptchars', $state->note); + $this->assertEquals('', $state->errors); + $this->assertEquals('x^2', $state->contentsmodified); + $this->assertEquals('\[ x^{2} \]', $state->contentsdisplayed); + $this->assertEquals('x', $state->lvars); } public function test_validate_student_response_km(): void {