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