UNPKG

mathoid

Version:

Render TeX to SVG and MathML using MathJax. Based on svgtex.

366 lines (346 loc) 9.86 kB
'use strict'; const BBPromise = require( 'bluebird' ); const texvcInfo = require( 'texvcinfo' ); const sre = require( 'speech-rule-engine' ); const SVGO = require( 'svgo' ); const Readable = require( 'stream' ).Readable; const HTTPError = require( './util' ).HTTPError; // TODO: Parsoid uses a more elaborated approach to determine the content version // via a middleware environment variable // cf. https://github.com/wikimedia/parsoid/blob/c596a3afae8080247911a6ed58dd08951b7bcc5e/lib/api/routes.js#L169 const contentVersion = require( '../package.json' )[ 'content-version' ]; const svgo = new SVGO( { plugins: [ { convertTransform: false } ] } ); function emitError( txt, detail ) { if ( detail === undefined ) { detail = txt; } throw new HTTPError( { status: 400, success: false, title: 'Bad Request', type: 'bad_request', detail, error: txt } ); } function emitFormatError( format ) { emitError( `Output format ${format} is disabled via config, try setting "${ format}: true" to enable ${format}rendering.` ); } function optimizeSvg( data, logger ) { return BBPromise.resolve( svgo.optimize( data.svg ) ) .then( ( result ) => { if ( !result.error ) { data.svg = result.data; } else { logger.log( 'warn/svgo', result.error ); } } ) .catch( ( e ) => { logger.log( 'warn/svgo', e ); } ); } function verifyOutFormat( fmt, type, conf ) { if ( !fmt ) { return 'json'; } let outFormat; function setOutFormat( format ) { if ( conf[ format ] || ( format === 'graph' && conf.texvcinfo ) ) { outFormat = format; } else { emitFormatError( format ); } } switch ( fmt.toLowerCase() ) { case 'svg': setOutFormat( 'svg' ); break; case 'png': setOutFormat( 'png' ); break; case 'texvcinfo': setOutFormat( 'texvcinfo' ); if ( !/(chem|tex$)/i.test( type ) ) { emitError( `texvcinfo accepts only tex, inline-tex, or chem as the input type, "${type}" given!` ); } break; case 'graph': setOutFormat( 'graph' ); if ( !/tex$/i.test( type ) ) { emitError( `graph accepts only tex or inline-tex as the input type, "${type}" given!` ); } break; case 'json': outFormat = 'json'; break; case 'complete': outFormat = 'complete'; break; case 'mml': case 'mathml': outFormat = 'mml'; break; case 'speech': setOutFormat( 'speech' ); break; default: emitError( `Output format "${fmt}" is not recognized!` ); } return outFormat; } // From https://github.com/pkra/mathjax-node-sre/blob/master/lib/main.js function srePostProcessor( config, result ) { if ( result.error ) { throw result.error; } if ( !result.mml ) { throw new Error( 'No MathML found. Please check the mathjax-node configuration' ); } if ( !result.svgNode && !result.htmlNode && !result.mmlNode ) { throw new Error( 'No suitable output found. Please check the mathjax-node configuration' ); } // add semantic tree if ( config.semantic ) { result.streeJson = sre.toJson( result.mml ); const xml = sre.toSemantic( result.mml ).toString(); result.streeXml = xml; } // return if no speakText is requested if ( !config.speakText ) { return result; } // enrich output sre.setupEngine( config ); result.speakText = sre.toSpeech( result.mml ); if ( result.svgNode && result.svg ) { result.svgNode.querySelector( 'title' ).innerHTML = result.speakText; // update serialization // HACK add lost xlink namespaces TODO file jsdom bug result.svg = result.svgNode.outerHTML .replace( /(<(?:use|image) [^>]*)(href=)/g, ' $1xlink:$2' ); } // mathoid currently does not support html Node output // if (result.htmlNode) { // result.htmlNode.firstChild.setAttribute("aria-label", result.speakText); // // update serialization // if (result.html) result.html = result.htmlNode.outerHTML; // } if ( result.mmlNode && result.mml ) { result.mmlNode.setAttribute( 'alttext', result.speakText ); // update serialization result.mml = result.mmlNode.outerHTML; } if ( config.enrich ) { result.mml = sre.toEnriched( result.mml ).toString(); } return result; } // // Create the PNG file asynchronously, reporting errors. // function getPNG( result, resolve, conf ) { // eslint-disable-next-line node/no-missing-require const Rsvg = require( 'librsvg' ).Rsvg; const s = new Readable(); const pngScale = conf.dpi / 90; // 90 DPI is the effective setting used by librsvg const ex = 6; const width = result.svgNode.getAttribute( 'width' ).replace( 'ex', '' ) * ex; const height = result.svgNode.getAttribute( 'height' ).replace( 'ex', '' ) * ex; // Do not generate zero-width png images BUG T288846 if ( width === 0 ) { result.png = Buffer.from( [] ); return resolve( result ); } const svgRenderer = new Rsvg(); // eslint-disable-next-line no-underscore-dangle s._read = function () { s.push( result.svg.replace( /="currentColor"/g, '="black"' ) ); s.push( null ); }; svgRenderer.on( 'finish', () => { try { const buffer = svgRenderer.render( { format: 'png', width: width * pngScale, height: height * pngScale } ); result.png = buffer.data || Buffer.from( [] ); } catch ( e ) { result.errors = e.message; } resolve( result ); } ); s.pipe( svgRenderer ); return resolve; // This keeps the queue from continuing until the readFile() is complete } /* The response headers for different render types */ function outHeaders( data ) { return { svg: { 'content-type': `image/svg+xml; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/SVG/${contentVersion}"` }, png: { 'content-type': `image/png; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/PNG/${contentVersion}"` }, mml: { 'content-type': `application/mathml+xml; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/MathML/${contentVersion}"`, 'x-mathoid-style': data.mathoidStyle } }; } function verifyRequestType( type ) { type = ( type || 'tex' ).toLowerCase(); switch ( type ) { case 'tex': type = 'TeX'; break; case 'inline-tex': type = 'inline-TeX'; break; case 'mml': case 'mathml': type = 'MathML'; break; case 'ascii': case 'asciimathml': case 'asciimath': type = 'AsciiMath'; break; case 'chem': type = 'chem'; break; default: emitError( `Input format "${type}" is not recognized!` ); } return type; } function handleRequest( res, q, type, outFormat, features, logger, conf, mjAPI ) { // First some rudimentary input validation if ( !q ) { emitError( 'q (query) parameter is missing!' ); } type = verifyRequestType( type ); outFormat = verifyOutFormat( outFormat, type, conf ); features = features || { speech: conf.speech_on }; let sanitizedTex; let feedback; const svg = conf.svg && /^svg|json|complete|png$/.test( outFormat ); const mml = ( type !== 'MathML' ) && /^mml|json|complete$/.test( outFormat ); const png = conf.png && /^png|json|complete$/.test( outFormat ); const info = conf.texvcinfo && /^graph|texvcinfo$/.test( outFormat ); const img = conf.img && /^mml|json|complete$/.test( outFormat ); const speech = ( outFormat !== 'png' ) && features.speech || outFormat === 'speech'; const chem = type === 'chem'; if ( chem ) { type = 'inline-TeX'; } if ( ( !conf.no_check && /^TeX|inline-TeX|chem$/.test( type ) ) || info ) { feedback = texvcInfo.feedback( q, { usemhchem: chem } ); // XXX properly handle errors here! if ( feedback.success ) { sanitizedTex = feedback.checked || ''; q = sanitizedTex; } else { emitError( `${feedback.error.name}: ${feedback.error.message}`, feedback ); } if ( info ) { if ( outFormat === 'graph' ) { res.json( texvcInfo.texvcinfo( q, { format: 'json', compact: true } ) ); return; } if ( outFormat === 'texvcinfo' ) { res.json( feedback ).end(); return; } } } const mathJaxOptions = { math: q, format: type, svg, svgNode: img || png, mml }; if ( speech ) { mathJaxOptions.mmlNode = true; mathJaxOptions.mml = true; } return new BBPromise( ( ( resolve ) => { mjAPI.typeset( mathJaxOptions, ( data ) => resolve( data ) ); } ) ).then( ( data ) => { return new BBPromise( ( ( resolve ) => { if ( png ) { getPNG( data, resolve, conf ); } else { return resolve( data ); } } ) ); } ).then( ( data ) => { if ( data.errors ) { emitError( data.errors ); } if ( speech ) { data = srePostProcessor( conf.speech_config, data ); } data.success = true; // @deprecated data.log = 'success'; if ( data.svgNode ) { data.mathoidStyle = [ data.svgNode.style.cssText, ' width:', data.svgNode.getAttribute( 'width' ), '; height:', data.svgNode.getAttribute( 'height' ), ';' ].join( '' ); } // make sure to delete non serializable objects data.svgNode = undefined; data.mmlNode = undefined; // Return the sanitized TeX to the client if ( sanitizedTex !== undefined ) { data.sanetex = sanitizedTex; } if ( speech ) { data.speech = data.speakText; } function outputResponse() { switch ( outFormat ) { case 'complete': { const headers = outHeaders( data ); Object.keys( headers ).forEach( ( outType ) => { if ( data[ outType ] ) { data[ outType ] = { headers: headers[ outType ], body: data[ outType ] }; } } ); if ( feedback && feedback.warnings ) { data.warnings = feedback.warnings; } res.json( data ).end(); break; } case 'json': res.json( data ).end(); break; default: res.set( outHeaders( data )[ outFormat ] ); res.send( data[ outFormat ] ).end(); } } if ( data.svg && conf.svgo ) { optimizeSvg( data, logger ).then( () => outputResponse() ); } else { outputResponse(); } } ); } module.exports = { handleRequest, emitError };