Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
58.53% covered (warning)
58.53%
151 / 258
34.78% covered (danger)
34.78%
8 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
MathMathML
58.53% covered (warning)
58.53%
151 / 258
34.78% covered (danger)
34.78%
8 / 23
709.96
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 addTrackingCategories
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
 batchEvaluate
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getAllowedRootElements
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setXMLValidation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAllowedRootElements
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
39.29% covered (danger)
39.29%
11 / 28
0.00% covered (danger)
0.00%
0 / 1
27.13
 renderingRequired
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 makeRequest
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
3
 getPostData
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
9.79
 doRender
11.54% covered (danger)
11.54%
3 / 26
0.00% covered (danger)
0.00%
0 / 1
30.92
 isValidMathML
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 getFallbackImageUrl
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 correctSvgStyle
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
5.39
 getFallbackImage
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getMathTableName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getClassName
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 getHtmlOutput
89.66% covered (warning)
89.66%
26 / 29
0.00% covered (danger)
0.00%
0 / 1
10.11
 dbOutArray
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 dbInArray
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 initializeFromCache
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 processJsonResult
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 isEmpty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * MediaWiki math extension
4 *
5 * @copyright 2002-2015 various MediaWiki contributors
6 * @license GPL-2.0-or-later
7 */
8
9namespace MediaWiki\Extension\Math;
10
11use MediaWiki\Extension\Math\Hooks\HookRunner;
12use MediaWiki\Html\Html;
13use MediaWiki\Logger\LoggerFactory;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\SpecialPage\SpecialPage;
16use MediaWiki\Title\Title;
17use Psr\Log\LoggerInterface;
18use StatusValue;
19use stdClass;
20use Throwable;
21use Xml;
22use XmlTypeCheck;
23
24/**
25 * Converts LaTeX to MathML using the mathoid-server
26 */
27class MathMathML extends MathRenderer {
28
29    /** @var string[] */
30    protected $defaultAllowedRootElements = [ 'math' ];
31    /** @var string[] */
32    protected $restbaseInputTypes = [ 'tex', 'inline-tex', 'chem' ];
33    /** @var string[] */
34    protected $restbaseRenderingModes = [ MathConfig::MODE_MATHML ];
35    /** @var string[] */
36    protected $allowedRootElements = [];
37    /** @var string */
38    protected $host;
39
40    /** @var LoggerInterface */
41    protected $logger;
42
43    /** @var bool if false MathML output is not validated */
44    private $XMLValidation = true;
45
46    /**
47     * @var string|bool
48     */
49    private $svgPath = false;
50
51    /** @var string|null */
52    private $mathoidStyle;
53
54    public function __construct( string $tex = '', array $params = [], $cache = null ) {
55        global $wgMathMathMLUrl;
56        parent::__construct( $tex, $params, $cache );
57        $this->setMode( MathConfig::MODE_MATHML );
58        $this->host = $wgMathMathMLUrl;
59        if ( isset( $params['type'] ) ) {
60            $allowedTypes = [ 'pmml', 'ascii', 'chem' ];
61            if ( in_array( $params['type'], $allowedTypes, true ) ) {
62                $this->inputType = $params['type'];
63            }
64            if ( $params['type'] == 'pmml' ) {
65                $this->setMathml( '<math>' . $tex . '</math>' );
66            }
67        }
68        if ( !isset( $params['display'] ) && $this->getMathStyle() == 'inlineDisplaystyle' ) {
69            // default preserve the (broken) layout as it was
70            $this->tex = '{\\displaystyle ' . $tex . '}';
71        }
72        $this->logger = LoggerFactory::getInstance( 'Math' );
73    }
74
75    /**
76     * @inheritDoc
77     */
78    public function addTrackingCategories( $parser ) {
79        parent::addTrackingCategories( $parser );
80        if ( $this->hasWarnings() ) {
81            foreach ( $this->warnings as $warning ) {
82                if ( isset( $warning->type ) ) {
83                    switch ( $warning->type ) {
84                        case 'mhchem-deprecation':
85                            $parser->addTrackingCategory( 'math-tracking-category-mhchem-deprecation' );
86                            break;
87                        case 'texvc-deprecation':
88                            $parser->addTrackingCategory( 'math-tracking-category-texvc-deprecation' );
89                    }
90                }
91            }
92        }
93    }
94
95    /**
96     * @param MathRenderer[] $renderers
97     */
98    public static function batchEvaluate( array $renderers ) {
99        $rbis = [];
100        foreach ( $renderers as $renderer ) {
101            $rbi = new MathRestbaseInterface( $renderer->getTex(), $renderer->getInputType() );
102            $renderer->setRestbaseInterface( $rbi );
103            $rbis[] = $rbi;
104        }
105        MathRestbaseInterface::batchEvaluate( $rbis );
106    }
107
108    /**
109     * Gets the allowed root elements the rendered math tag might have.
110     *
111     * @return string[]
112     */
113    public function getAllowedRootElements() {
114        if ( $this->allowedRootElements ) {
115            return $this->allowedRootElements;
116        } else {
117            return $this->defaultAllowedRootElements;
118        }
119    }
120
121    /**
122     * Sets the XML validation.
123     * If set to false the output of MathML is not validated.
124     * @param bool $validation
125     */
126    public function setXMLValidation( $validation = true ) {
127        $this->XMLValidation = $validation;
128    }
129
130    /**
131     * Sets the allowed root elements the rendered math tag might have.
132     * An empty value indicates to use the default settings.
133     * @param string[] $settings
134     */
135    public function setAllowedRootElements( $settings ) {
136        $this->allowedRootElements = $settings;
137    }
138
139    public function render() {
140        global $wgMathFullRestbaseURL;
141        try {
142            if ( in_array( $this->inputType, $this->restbaseInputTypes, true ) &&
143                 in_array( $this->mode, $this->restbaseRenderingModes, true )
144            ) {
145                if ( !$this->rbi ) {
146                    $this->rbi =
147                        new MathRestbaseInterface( $this->getTex(), $this->getInputType() );
148                    $this->rbi->setPurge( $this->isPurge() );
149                }
150                $rbi = $this->rbi;
151                if ( $rbi->getSuccess() ) {
152                    $this->mathml = $rbi->getMathML();
153                    $this->mathoidStyle = $rbi->getMathoidStyle();
154                    $this->svgPath = $rbi->getFullSvgUrl();
155                    $this->warnings = $rbi->getWarnings();
156                } elseif ( $this->lastError === '' ) {
157                    $this->doCheck();
158                }
159                $this->changed = false;
160                return $rbi->getSuccess();
161            }
162            if ( $this->renderingRequired() ) {
163                $renderResult = $this->doRender();
164                if ( !$renderResult->isGood() ) {
165                    // TODO: this is a hacky hack, lastError will not exist soon.
166                    $renderError = $renderResult->getErrors()[0];
167                    $this->lastError = $this->getError( $renderError['message'], ...$renderError['params'] );
168                }
169                return $renderResult->isGood();
170            }
171            return true;
172        } catch ( Throwable $e ) {
173            $this->lastError = $this->getError( 'math_mathoid_error',
174                $wgMathFullRestbaseURL, $e->getMessage() );
175            $this->logger->error( $e->getMessage(), [ $e, $this ] );
176            return false;
177        }
178    }
179
180    /**
181     * Helper function to checks if the math tag must be rendered.
182     * @return bool
183     */
184    private function renderingRequired() {
185        if ( $this->isPurge() ) {
186            $this->logger->debug( 'Rerendering was requested.' );
187            return true;
188        }
189
190        $dbres = $this->isInDatabase();
191        if ( $dbres ) {
192            if ( $this->isValidMathML( $this->getMathml() ) ) {
193                $this->logger->debug( 'Valid MathML entry found in database.' );
194                if ( $this->getSvg( 'cached' ) ) {
195                    $this->logger->debug( 'SVG-fallback found in database.' );
196                    return false;
197                } else {
198                    $this->logger->debug( 'SVG-fallback missing.' );
199                    return true;
200                }
201            } else {
202                $this->logger->debug( 'Malformatted entry found in database' );
203                return true;
204            }
205        } else {
206            $this->logger->debug( 'No entry found in database.' );
207            return true;
208        }
209    }
210
211    /**
212     * Performs a HTTP Post request to the given host.
213     * Uses $wgMathLaTeXMLTimeout as timeout.
214     *
215     * @return StatusValue result with response body as a value
216     */
217    public function makeRequest() {
218        // TODO: Change the timeout mechanism.
219        global $wgMathLaTeXMLTimeout;
220        $post = $this->getPostData();
221        $options = [ 'method' => 'POST', 'postData' => $post, 'timeout' => $wgMathLaTeXMLTimeout ];
222        $req = MediaWikiServices::getInstance()->getHttpRequestFactory()->create( $this->host, $options );
223        $status = $req->execute();
224        if ( $status->isGood() ) {
225            return StatusValue::newGood( $req->getContent() );
226        } else {
227            if ( $status->hasMessage( 'http-timed-out' ) ) {
228                $this->logger->warning( 'Math service request timeout', [
229                    'post' => $post,
230                    'host' => $this->host,
231                    'timeout' => $wgMathLaTeXMLTimeout
232                ] );
233                return StatusValue::newFatal( 'math_timeout', $this->getModeName(), $this->host );
234            } else {
235                $errormsg = $req->getContent();
236                $this->logger->warning( 'Math service request failed', [
237                    'post' => $post,
238                    'host' => $this->host,
239                    'errormsg' => $errormsg
240                ] );
241                return StatusValue::newFatal(
242                    'math_invalidresponse',
243                    $this->getModeName(),
244                    $this->host,
245                    $errormsg,
246                    $this->getModeName()
247                );
248            }
249        }
250    }
251
252    /**
253     * Calculates the HTTP POST Data for the request. Depends on the settings
254     * and the input string only.
255     * @return string HTTP POST data
256     */
257    public function getPostData() {
258        $input = $this->getTex();
259        if ( $this->inputType == 'pmml' ||
260            ( $this->getMode() == MathConfig::MODE_LATEXML && $this->getMathml() )
261        ) {
262            $out = 'type=mml&q=' . rawurlencode( $this->getMathml() );
263        } elseif ( $this->inputType == 'ascii' ) {
264            $out = 'type=asciimath&q=' . rawurlencode( $input );
265        } else {
266            if ( $this->getMathStyle() === 'inlineDisplaystyle' ) {
267                // default preserve the (broken) layout as it was
268                $out = 'type=inline-TeX&q=' . rawurlencode( '{\\displaystyle ' . $input . '}' );
269            } elseif ( $this->getMathStyle() === 'inline' ) {
270                $out = 'type=inline-TeX&q=' . rawurlencode( $input );
271            } else {
272                $out = 'type=tex&q=' . rawurlencode( $input );
273            }
274        }
275        $this->logger->debug( 'Get post data: ' . $out );
276        return $out;
277    }
278
279    /**
280     * Does the actual web request to convert TeX to MathML.
281     *
282     * @return StatusValue
283     */
284    protected function doRender(): StatusValue {
285        if ( $this->isEmpty() ) {
286            $this->logger->debug( 'Rendering was requested, but no TeX string is specified.' );
287            return StatusValue::newFatal( 'math_empty_tex' );
288        }
289        $requestStatus = $this->makeRequest();
290        if ( $requestStatus->isGood() ) {
291            $jsonResult = json_decode( $requestStatus->getValue() );
292            if ( $jsonResult && json_last_error() === JSON_ERROR_NONE ) {
293                if ( $jsonResult->success ) {
294                    return $this->processJsonResult( $jsonResult, $this->host );
295                } else {
296                    $serviceLog = $jsonResult->log ?? wfMessage( 'math_unknown_error' )
297                            ->inContentLanguage()
298                            ->escaped();
299                    $this->logger->warning( 'Mathoid conversion error', [
300                        'post' => $this->getPostData(),
301                        'host' => $this->host,
302                        'result' => $requestStatus->getValue(),
303                        'service_log' => $serviceLog
304                    ] );
305                    return StatusValue::newFatal( 'math_mathoid_error', $this->host, $serviceLog );
306                }
307            } else {
308                $this->logger->error( 'MathML invalid JSON', [
309                    'post' => $this->getPostData(),
310                    'host' => $this->host,
311                    'res' => $requestStatus->getValue(),
312                ] );
313                return StatusValue::newFatal( 'math_invalidjson', $this->host );
314            }
315        } else {
316            return $requestStatus;
317        }
318    }
319
320    /**
321     * Checks if the input is valid MathML,
322     * and if the root element has the name math
323     * @param string $XML
324     * @return bool
325     */
326    public function isValidMathML( $XML ) {
327        $out = false;
328        if ( !$this->XMLValidation ) {
329            return true;
330        }
331
332        $xmlObject = new XmlTypeCheck( $XML, null, false );
333        if ( !$xmlObject->wellFormed ) {
334            $this->logger->error(
335                'XML validation error: ' . var_export( $XML, true ) );
336        } else {
337            $name = $xmlObject->getRootElement();
338            $elementSplit = explode( ':', $name );
339            $localName = end( $elementSplit );
340            if ( in_array( $localName, $this->getAllowedRootElements(), true ) ) {
341                $out = true;
342            } else {
343                $this->logger->error( "Got wrong root element: $name" );
344            }
345        }
346        return $out;
347    }
348
349    /**
350     * @param bool $noRender
351     * @return Title|string
352     */
353    private function getFallbackImageUrl( $noRender = false ) {
354        if ( $this->svgPath ) {
355            return $this->svgPath;
356        }
357        return SpecialPage::getTitleFor( 'MathShowImage' )->getLocalURL( [
358                'hash' => $this->getInputHash(),
359                'mode' => $this->getMode(),
360                'noRender' => $noRender
361            ]
362        );
363    }
364
365    /**
366     * Helper function to correct the style information for a
367     * linked SVG image.
368     * @param string &$style current style information to be updated
369     */
370    public function correctSvgStyle( &$style ) {
371        if ( preg_match( '/style="([^"]*)"/', $this->getSvg(), $styles ) ) {
372            $style .= ' ' . $styles[1]; // merge styles
373            if ( $this->getMathStyle() === 'display' ) {
374                // TODO: Improve style cleaning
375                $style = preg_replace(
376                    '/margin\-(left|right)\:\s*\d+(\%|in|cm|mm|em|ex|pt|pc|px)\;/', '', $style
377                );
378            }
379            $style = trim( preg_replace( '/position:\s*absolute;\s*left:\s*0px;/', '', $style ),
380                "; \t\n\r\0\x0B" ) . '; ';
381
382        }
383        // TODO: Figure out if there is a way to construct
384        // a SVGReader from a string that represents the SVG
385        // content
386        if ( preg_match( "/height=\"(.*?)\"/", $this->getSvg(), $matches ) ) {
387            $style .= 'height: ' . $matches[1] . '; ';
388        }
389        if ( preg_match( "/width=\"(.*?)\"/", $this->getSvg(), $matches ) ) {
390            $style .= 'width: ' . $matches[1] . ';';
391        }
392    }
393
394    /**
395     * Gets img tag for math image
396     * @param bool $noRender if true no rendering will be performed
397     * if the image is not stored in the database
398     * @param false|string $classOverride if classOverride
399     * is false the class name will be calculated by getClassName
400     * @return string XML the image html tag
401     */
402    protected function getFallbackImage( $noRender = false, $classOverride = false ) {
403        $attribs = [
404            'src' => $this->getFallbackImageUrl( $noRender ),
405            'class' => $classOverride === false ? $this->getClassName( true ) : $classOverride,
406        ];
407        if ( !$this->mathoidStyle ) {
408            $this->correctSvgStyle( $this->mathoidStyle );
409        }
410
411        return Html::element( 'img', $this->getAttributes( 'span', $attribs, [
412            'aria-hidden' => 'true',
413            'style' => $this->mathoidStyle,
414            'alt' => $this->tex
415        ] ) );
416    }
417
418    /**
419     * @return string
420     */
421    protected function getMathTableName() {
422        return 'mathoid';
423    }
424
425    /**
426     * Calculates the default class name for a math element
427     * @param bool $fallback
428     * @return string the class name
429     */
430    private function getClassName( $fallback = false ) {
431        $class = 'mwe-math-';
432        if ( $fallback ) {
433            $class .= 'fallback-image-';
434        } else {
435            $class .= 'mathml-';
436        }
437        if ( $this->getMathStyle() == 'display' ) {
438            $class .= 'display';
439        } else {
440            $class .= 'inline';
441        }
442        if ( $fallback ) {
443            $class .= ' mw-invert';
444        } else {
445            $class .= ' mwe-math-mathml-a11y';
446        }
447        return $class;
448    }
449
450    /**
451     * @return string Html output that is embedded in the page
452     */
453    public function getHtmlOutput() {
454        $config = MediaWikiServices::getInstance()->getMainConfig();
455        $enableLinks = $config->get( "MathEnableFormulaLinks" );
456        if ( $this->getMathStyle() == 'display' ) {
457            $element = 'div';
458        } else {
459            $element = 'span';
460        }
461        $attribs = [ 'class' => 'mwe-math-element' ];
462        if ( $this->getID() !== '' ) {
463            $attribs['id'] = $this->getID();
464        }
465        $hyperlink = null;
466        if ( isset( $this->params['qid'] ) && preg_match( '/Q\d+/', $this->params['qid'] ) ) {
467            $attribs['data-qid'] = $this->params['qid'];
468            $titleObj = SpecialPage::getTitleFor( 'MathWikibase' );
469            $hyperlink = $titleObj->getLocalURL( [ 'qid' => $this->params['qid'] ] );
470        }
471        $output = Html::openElement( $element, $attribs );
472        if ( $hyperlink && $enableLinks ) {
473            $output .= Html::openElement( 'a', [ 'href' => $hyperlink, 'style' => 'color:inherit;' ] );
474        }
475        // MathML has to be wrapped into a div or span in order to be able to hide it.
476        // Remove displayStyle attributes set by the MathML converter
477        // (Beginning from Mathoid 0.2.5 block is the default layout.)
478        $mml = preg_replace(
479            '/(<math[^>]*)(display|mode)=["\'](inline|block)["\']/', '$1', $this->getMathml()
480        );
481        if ( $this->getMathStyle() == 'display' ) {
482            $mml = preg_replace( '/<math/', '<math display="block"', $mml );
483        }
484        $output .= Xml::tags( $element, [
485            'class' => $this->getClassName(), 'style' => 'display: none;'
486        ], $mml );
487        $output .= $this->getFallbackImage();
488        if ( $hyperlink && $enableLinks ) {
489            $output .= Html::closeElement( 'a' );
490        }
491        $output .= Html::closeElement( $element );
492        return $output;
493    }
494
495    protected function dbOutArray() {
496        $out = parent::dbOutArray();
497        if ( $this->getMathTableName() === 'mathoid' ) {
498            $out['math_input'] = $out['math_inputtex'];
499            unset( $out['math_inputtex'] );
500        }
501        return $out;
502    }
503
504    protected function dbInArray() {
505        $out = parent::dbInArray();
506        if ( $this->getMathTableName() === 'mathoid' ) {
507            $out = array_diff( $out, [ 'math_inputtex' ] );
508            $out[] = 'math_input';
509        }
510        return $out;
511    }
512
513    public function initializeFromCache( $rpage ) {
514        // mathoid allows different input formats
515        // therefore the column name math_inputtex was changed to math_input
516        if ( $this->getMathTableName() === 'mathoid' && isset( $rpage['math_input'] ) ) {
517            $this->userInputTex = $rpage['math_input'];
518        }
519        parent::initializeFromCache( $rpage );
520    }
521
522    /**
523     * @param stdClass $jsonResult
524     * @param string $host name
525     *
526     * @return StatusValue
527     */
528    protected function processJsonResult( $jsonResult, $host ): StatusValue {
529        if ( $this->getMode() == MathConfig::MODE_LATEXML || $this->inputType == 'pmml' ||
530             $this->isValidMathML( $jsonResult->mml )
531        ) {
532            if ( isset( $jsonResult->svg ) ) {
533                $xmlObject = new XmlTypeCheck( $jsonResult->svg, null, false );
534                if ( !$xmlObject->wellFormed ) {
535                    return StatusValue::newFatal( 'math_invalidxml', $host );
536                } else {
537                    $this->setSvg( $jsonResult->svg );
538                }
539            } else {
540                $this->logger->error( 'Missing SVG property in JSON result.' );
541            }
542            if ( $this->getMode() != MathConfig::MODE_LATEXML && $this->inputType != 'pmml' ) {
543                $this->setMathml( $jsonResult->mml );
544            }
545            // Avoid PHP 7.1 warning from passing $this by reference
546            $renderer = $this;
547            ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )->onMathRenderingResultRetrieved(
548                $renderer, $jsonResult
549            ); // Enables debugging of server results
550            return StatusValue::newGood(); // FIXME: empty?
551        } else {
552            return StatusValue::newFatal( 'math_unknown_error', $host );
553        }
554    }
555
556    /**
557     * @return bool
558     */
559    protected function isEmpty() {
560        return $this->userInputTex === '';
561    }
562}