Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.22% covered (warning)
62.22%
168 / 270
47.83% covered (danger)
47.83%
11 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
MathMathML
62.22% covered (warning)
62.22%
168 / 270
47.83% covered (danger)
47.83%
11 / 23
581.58
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
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getClassName
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getHtmlOutput
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
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 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    protected function doRender(): StatusValue {
281        if ( $this->isEmpty() ) {
282            $this->logger->debug( 'Rendering was requested, but no TeX string is specified.' );
283            return StatusValue::newFatal( 'math_empty_tex' );
284        }
285        $requestStatus = $this->makeRequest();
286        if ( $requestStatus->isGood() ) {
287            $jsonResult = json_decode( $requestStatus->getValue() );
288            if ( $jsonResult && json_last_error() === JSON_ERROR_NONE ) {
289                if ( $jsonResult->success ) {
290                    return $this->processJsonResult( $jsonResult, $this->host );
291                } else {
292                    $serviceLog = $jsonResult->log ?? wfMessage( 'math_unknown_error' )
293                            ->inContentLanguage()
294                            ->escaped();
295                    $this->logger->warning( 'Mathoid conversion error', [
296                        'post' => $this->getPostData(),
297                        'host' => $this->host,
298                        'result' => $requestStatus->getValue(),
299                        'service_log' => $serviceLog
300                    ] );
301                    return StatusValue::newFatal( 'math_mathoid_error', $this->host, $serviceLog );
302                }
303            } else {
304                $this->logger->error( 'MathML invalid JSON', [
305                    'post' => $this->getPostData(),
306                    'host' => $this->host,
307                    'res' => $requestStatus->getValue(),
308                ] );
309                return StatusValue::newFatal( 'math_invalidjson', $this->host );
310            }
311        } else {
312            return $requestStatus;
313        }
314    }
315
316    /**
317     * Checks if the input is valid MathML,
318     * and if the root element has the name math
319     * @param string $XML
320     * @return bool
321     */
322    public function isValidMathML( $XML ) {
323        $out = false;
324        if ( !$this->XMLValidation ) {
325            return true;
326        }
327
328        $xmlObject = new XmlTypeCheck( $XML, null, false );
329        if ( !$xmlObject->wellFormed ) {
330            $this->logger->error(
331                'XML validation error: ' . var_export( $XML, true ) );
332        } else {
333            $name = $xmlObject->getRootElement();
334            $elementSplit = explode( ':', $name );
335            $localName = end( $elementSplit );
336            if ( in_array( $localName, $this->getAllowedRootElements(), true ) ) {
337                $out = true;
338            } else {
339                $this->logger->error( "Got wrong root element: $name" );
340            }
341        }
342        return $out;
343    }
344
345    /**
346     * @param bool $noRender
347     * @return Title|string
348     */
349    private function getFallbackImageUrl( $noRender = false ) {
350        if ( $this->svgPath ) {
351            return $this->svgPath;
352        }
353        return SpecialPage::getTitleFor( 'MathShowImage' )->getLocalURL( [
354                'hash' => $this->getInputHash(),
355                'mode' => $this->getMode(),
356                'noRender' => $noRender
357            ]
358        );
359    }
360
361    /**
362     * Helper function to correct the style information for a
363     * linked SVG image.
364     * @param string &$style current style information to be updated
365     */
366    public function correctSvgStyle( &$style ) {
367        if ( preg_match( '/style="([^"]*)"/', $this->getSvg(), $styles ) ) {
368            $style .= ' ' . $styles[1]; // merge styles
369            if ( $this->getMathStyle() === 'display' ) {
370                // TODO: Improve style cleaning
371                $style = preg_replace(
372                    '/margin\-(left|right)\:\s*\d+(\%|in|cm|mm|em|ex|pt|pc|px)\;/', '', $style
373                );
374            }
375            $style = trim( preg_replace( '/position:\s*absolute;\s*left:\s*0px;/', '', $style ),
376                "; \t\n\r\0\x0B" ) . '; ';
377
378        }
379        // TODO: Figure out if there is a way to construct
380        // a SVGReader from a string that represents the SVG
381        // content
382        if ( preg_match( "/height=\"(.*?)\"/", $this->getSvg(), $matches ) ) {
383            $style .= 'height: ' . $matches[1] . '; ';
384        }
385        if ( preg_match( "/width=\"(.*?)\"/", $this->getSvg(), $matches ) ) {
386            $style .= 'width: ' . $matches[1] . ';';
387        }
388    }
389
390    /**
391     * Gets img tag for math image
392     * @param bool $noRender if true no rendering will be performed
393     * if the image is not stored in the database
394     * @param false|string $classOverride if classOverride
395     * is false the class name will be calculated by getClassName
396     * @return string XML the image html tag
397     */
398    protected function getFallbackImage( $noRender = false, $classOverride = false ) {
399        $attribs = [
400            'src' => $this->getFallbackImageUrl( $noRender ),
401            'class' => $classOverride === false ? $this->getClassName( true ) : $classOverride,
402        ];
403        if ( !$this->mathoidStyle ) {
404            $this->correctSvgStyle( $this->mathoidStyle );
405        }
406
407        return Html::element( 'img', $this->getAttributes( 'span', $attribs, [
408            'aria-hidden' => 'true',
409            'style' => $this->mathoidStyle,
410            'alt' => $this->tex
411        ] ) );
412    }
413
414    /**
415     * @return string
416     */
417    protected function getMathTableName() {
418        return 'mathoid';
419    }
420
421    /**
422     * Calculates the default class name for a math element
423     * @param bool $fallback
424     * @return string the class name
425     */
426    private function getClassName( $fallback = false ) {
427        $class = 'mwe-math-';
428        if ( $fallback ) {
429            $class .= 'fallback-image-';
430        } else {
431            $class .= 'mathml-';
432        }
433        if ( $this->getMathStyle() == 'display' ) {
434            $class .= 'display';
435        } else {
436            $class .= 'inline';
437        }
438        if ( $fallback ) {
439            // Support 3rd party gadgets and extensions.
440            $class .= ' mw-invert';
441            // Support skins with night theme.
442            $class .= ' skin-invert';
443        } else {
444            $class .= ' mwe-math-mathml-a11y';
445        }
446        return $class;
447    }
448
449    /**
450     * @param bool $svg
451     * @return string Html output that is embedded in the page
452     */
453    public function getHtmlOutput( bool $svg = true ): string {
454        $config = MediaWikiServices::getInstance()->getMainConfig();
455        $enableLinks = $config->get( "MathEnableFormulaLinks" );
456        $className = 'mwe-math-element';
457        if ( $this->getMathStyle() === 'display' ) {
458            $mml_class = 'mwe-math-mathml-display';
459            $className .= ' mwe-math-element-block';
460        } else {
461            $mml_class = 'mwe-math-mathml-inline';
462            $className .= ' mwe-math-element-inline';
463        }
464        $attribs = [ 'class' => $className ];
465        if ( $this->getID() !== '' ) {
466            $attribs['id'] = $this->getID();
467        }
468        $hyperlink = null;
469        if ( isset( $this->params['qid'] ) && preg_match( '/Q\d+/', $this->params['qid'] ) ) {
470            $attribs['data-qid'] = $this->params['qid'];
471            $titleObj = SpecialPage::getTitleFor( 'MathWikibase' );
472            $hyperlink = $titleObj->getLocalURL( [ 'qid' => $this->params['qid'] ] );
473        }
474        $output = '';
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
485        if ( $svg ) {
486            $mml_attribs = [
487                'class' => $this->getClassName(),
488                'style' => 'display: none;'
489            ];
490        } else {
491            $mml_attribs = [
492                'class' => $mml_class,
493            ];
494        }
495        if ( ( $this->params['class'] ?? '' ) === 'mathjax_ignore' ) {
496            $mml_attribs['class'] .= ' mathjax_ignore';
497        }
498        $output .= Html::rawElement( 'span', $mml_attribs, $mml );
499        if ( $svg ) {
500            $output .= $this->getFallbackImage();
501        }
502
503        if ( $hyperlink && $enableLinks ) {
504            $output = Html::rawElement( 'a',
505                [ 'href' => $hyperlink, 'style' => 'color:inherit;' ],
506                $output
507            );
508        }
509
510        return Html::rawElement( 'span', $attribs, $output );
511    }
512
513    /** @inheritDoc */
514    protected function dbOutArray() {
515        $out = parent::dbOutArray();
516        if ( $this->getMathTableName() === 'mathoid' ) {
517            $out['math_input'] = $out['math_inputtex'];
518            unset( $out['math_inputtex'] );
519        }
520        return $out;
521    }
522
523    /** @inheritDoc */
524    protected function dbInArray() {
525        $out = parent::dbInArray();
526        if ( $this->getMathTableName() === 'mathoid' ) {
527            $out = array_diff( $out, [ 'math_inputtex' ] );
528            $out[] = 'math_input';
529        }
530        return $out;
531    }
532
533    /** @inheritDoc */
534    public function initializeFromCache( $rpage ) {
535        // mathoid allows different input formats
536        // therefore the column name math_inputtex was changed to math_input
537        if ( $this->getMathTableName() === 'mathoid' && isset( $rpage['math_input'] ) ) {
538            $this->userInputTex = $rpage['math_input'];
539        }
540        parent::initializeFromCache( $rpage );
541    }
542
543    /**
544     * @param stdClass $jsonResult
545     * @param string $host name
546     *
547     * @return StatusValue
548     */
549    protected function processJsonResult( $jsonResult, $host ): StatusValue {
550        if ( $this->getMode() == MathConfig::MODE_LATEXML || $this->inputType == 'pmml' ||
551             $this->isValidMathML( $jsonResult->mml )
552        ) {
553            if ( isset( $jsonResult->svg ) ) {
554                $xmlObject = new XmlTypeCheck( $jsonResult->svg, null, false );
555                if ( !$xmlObject->wellFormed ) {
556                    return StatusValue::newFatal( 'math_invalidxml', $host );
557                } else {
558                    $this->setSvg( $jsonResult->svg );
559                }
560            } else {
561                $this->logger->error( 'Missing SVG property in JSON result.' );
562            }
563            if ( $this->getMode() != MathConfig::MODE_LATEXML && $this->inputType != 'pmml' ) {
564                $this->setMathml( $jsonResult->mml );
565            }
566            // Avoid PHP 7.1 warning from passing $this by reference
567            $renderer = $this;
568            ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )->onMathRenderingResultRetrieved(
569                $renderer, $jsonResult
570            ); // Enables debugging of server results
571            return StatusValue::newGood(); // FIXME: empty?
572        } else {
573            return StatusValue::newFatal( 'math_unknown_error', $host );
574        }
575    }
576
577    /**
578     * @return bool
579     */
580    protected function isEmpty() {
581        return $this->userInputTex === '';
582    }
583
584}