Skip to main content

math_core/
lib.rs

1//! Convert LaTeX math to MathML Core.
2//!
3//! For more background on what that means and on what to do with the resulting MathML code,
4//! see the repo's README: https://github.com/tmke8/math-core
5//!
6//! # Usage
7//!
8//! The main struct of this library is [`LatexToMathML`]. In order to use the library, create an
9//! instance of this struct and then call one of the convert functions. The constructor of the
10//! struct expects a config object in the form of an instance of [`MathCoreConfig`].
11//!
12//! Basic use looks like this:
13//!
14//! ```rust
15//! use math_core::{LatexToMathML, MathCoreConfig, MathDisplay};
16//!
17//! let latex = r#"\erf ( x ) = \frac{ 2 }{ \sqrt{ \pi } } \int_0^x e^{- t^2} \, dt"#;
18//! let config = MathCoreConfig::default();
19//! let converter = LatexToMathML::new(config).unwrap();
20//! let result = converter.convert_with_local_state(latex, MathDisplay::Block).unwrap();
21//! println!("{}", result.mathml);
22//! ```
23//!
24//! # Features
25//!
26//! - `serde`: With this feature, `MathCoreConfig` implements serde's `Deserialize`.
27//!
28mod atof;
29mod character_class;
30mod color_defs;
31mod commands;
32mod environments;
33mod error;
34mod global_state;
35mod html_utils;
36mod lexer;
37mod parser;
38mod predefined;
39mod specifications;
40mod split_on_ascii;
41mod text_parser;
42mod token;
43mod token_queue;
44
45use rustc_hash::{FxBuildHasher, FxHashMap};
46#[cfg(feature = "serde")]
47use serde::{Deserialize, Serialize};
48
49pub use mathml_renderer::ast::{CssClassNames, IndentKeyword, Indentation, Warnings};
50use mathml_renderer::{
51    arena::Arena,
52    ast::{Emitter, Node},
53    attribute::Style,
54    fmt::new_line_and_indent,
55};
56
57pub use self::error::LatexError;
58use self::{
59    error::LatexErrKind, global_state::GlobalState, lexer::Lexer, parser::Parser, token::Token,
60};
61
62/// Display mode for the LaTeX math equations.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum MathDisplay {
65    /// For inline equations, like those in `$...$` in LaTeX.
66    Inline,
67    /// For block equations (or "display style" equations), like those in `$$...$$` in LaTeX.
68    Block,
69}
70
71/// Configuration for pretty-printing the MathML output.
72///
73/// Pretty-printing means that newlines and indentation is added to the MathML output, to make it
74/// easier to read.
75#[derive(Debug, Clone, Copy, Default)]
76#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
77#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
78#[non_exhaustive]
79pub enum PrettyPrint {
80    /// Never pretty print.
81    #[default]
82    Never,
83    /// Always pretty print.
84    Always,
85    /// Pretty print for block equations only.
86    Auto,
87}
88
89/// Configuration for using Unicode symbols in the MathML output.
90///
91/// LaTeX commands like `\coloneqq` can be rendered in MathML either using dedicated Unicode symbols
92/// (in this case, `\coloneqq` would be rendered as `≔`) or using a combination of more basic
93/// symbols (in this case, `\coloneqq` would be rendered as a combination of `:` and `=`).
94/// The former is preferable in terms of semantics but can look a little different from the LaTeX
95/// output, while the latter is more faithful to the LaTeX output but can be less semantically
96/// clear.
97#[derive(Debug, Clone, Copy, Default)]
98#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
99#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
100#[non_exhaustive]
101pub enum UnicodeSubstitution {
102    /// Never subtitute a set of symbols with their Unicode equivalents.
103    Never,
104    /// Substitute whenever the LaTeX package `unicode-math` would substitute, which is a good
105    /// middle ground between semantics and faithfulness to the LaTeX output.
106    #[default]
107    Conventional,
108    // /// Substitute whenever there is a Unicode equivalent, even if the `unicode-math` package
109    // /// does not do so.
110    // Aggressive,
111}
112
113/// Configuration object for the LaTeX to MathML conversion.
114///
115/// # Example usage
116///
117/// ```rust
118/// use math_core::{MathCoreConfig, PrettyPrint};
119///
120/// // Default values
121/// let config = MathCoreConfig::default();
122///
123/// // Specifying pretty-print behavior
124/// let config = MathCoreConfig {
125///     pretty_print: PrettyPrint::Always,
126///     ..Default::default()
127///  };
128///
129/// // Specifying pretty-print behavior and custom macros
130/// let macros = vec![
131///     ("d".to_string(), r"\mathrm{d}".to_string()),
132///     ("bb".to_string(), r"\mathbb{#1}".to_string()), // with argument
133/// ];
134/// let config = MathCoreConfig {
135///     pretty_print: PrettyPrint::Auto,
136///     macros,
137///     ..Default::default()
138/// };
139/// ```
140///
141#[derive(Debug, Default)]
142#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
143#[cfg_attr(feature = "serde", serde(default, rename_all = "kebab-case"))]
144pub struct MathCoreConfig {
145    /// A configuration for pretty-printing the MathML output. See [`PrettyPrint`] for details.
146    pub pretty_print: PrettyPrint,
147    /// A list of LaTeX macros; each tuple contains (macro_name, macro_definition).
148    #[cfg_attr(feature = "serde", serde(with = "tuple_vec_map"))]
149    pub macros: Vec<(String, String)>,
150    /// If `true`, include `xmlns="http://www.w3.org/1998/Math/MathML"` in the `<math>` tag.
151    pub xml_namespace: bool,
152    /// If `true`, unknown commands will be rendered as red text in the output, instead of
153    /// returning an error.
154    pub ignore_unknown_commands: bool,
155    /// If `true`, wrap the MathML output in `<semantics>` tags with an
156    /// `<annotation encoding="application/x-tex">` child containing the original LaTeX source.
157    pub annotation: bool,
158    /// If `true`, allow rendering commands that produce MathML Core output that is unreliably
159    /// rendered by browsers.
160    pub allow_unreliable_rendering: bool,
161    /// If not `UnicodeSubstitution::Never`, substitute certain LaTeX commands with their Unicode
162    /// equivalents in the MathML output.
163    pub unicode_substitution: UnicodeSubstitution,
164    /// CSS class names for various elements in the output.
165    pub css_classes: CssClassNames,
166    /// The indentation unit used when pretty-printing the MathML output. Either a number of spaces
167    /// (e.g. `2`) or the string `"tab"` for a tab character. See [`Indentation`].
168    pub indentation: Indentation,
169}
170
171/// A map from custom command names to their number of arguments and the slice of tokens that
172/// defines the command. The tokens are stored in a separate vector.
173type CustomCmdMap = FxHashMap<String, (u8, (usize, usize))>;
174
175/// Subset of `MathCoreConfig` relevant for the parser.
176#[derive(Debug, Default)]
177struct ParserConfig {
178    custom_cmd_tokens: Vec<Token<'static>>,
179    custom_cmd_map: CustomCmdMap,
180    ignore_unknown_commands: bool,
181    allow_unreliable_rendering: bool,
182    unicode_substitution: UnicodeSubstitution,
183}
184
185impl ParserConfig {
186    pub fn get_command<'config>(&'config self, command: &str) -> Option<Token<'config>> {
187        let (num_args, slice) = *self.custom_cmd_map.get(command)?;
188        let tokens = self.custom_cmd_tokens.get(slice.0..slice.1)?;
189        Some(Token::CustomCmd(num_args, tokens))
190    }
191}
192
193/// Subset of `MathCoreConfig` relevant for the emitter.
194#[derive(Debug, Default)]
195struct EmitterConfig {
196    pretty_print: PrettyPrint,
197    xml_namespace: bool,
198    annotation: bool,
199    css_classes: CssClassNames,
200    indentation: Indentation,
201}
202
203impl From<MathCoreConfig> for EmitterConfig {
204    fn from(config: MathCoreConfig) -> Self {
205        // FIXME: can we use a macro here to avoid repeating the field names?
206        Self {
207            pretty_print: config.pretty_print,
208            xml_namespace: config.xml_namespace,
209            annotation: config.annotation,
210            css_classes: config.css_classes,
211            indentation: config.indentation,
212        }
213    }
214}
215
216type ParseResult<T> = Result<T, Box<LatexError>>;
217
218/// The error type returned when parsing a custom macro definition fails. Contains the parsing
219/// error, the index of the macro definition in the `macros` vector and the macro definition itself.
220pub type MacroParseError = (Box<LatexError>, usize, String);
221
222/// A converter that transforms LaTeX math equations into MathML Core.
223#[derive(Debug, Default)]
224pub struct LatexToMathML {
225    emitter_cfg: EmitterConfig,
226    state: GlobalState,
227    parser_cfg: ParserConfig,
228}
229
230impl LatexToMathML {
231    /// Create a new `LatexToMathML` converter with the given configuration.
232    ///
233    /// This function returns an error if the custom macros in the given configuration could not
234    /// be parsed. The error contains the parsing error, the macro index and the macro definition
235    /// that caused the error.
236    pub fn new(mut config: MathCoreConfig) -> Result<Self, MacroParseError> {
237        let (custom_cmd_tokens, custom_cmd_map) = parse_custom_commands(
238            std::mem::take(&mut config.macros),
239            config.unicode_substitution,
240        )?;
241        let parser_cfg = ParserConfig {
242            custom_cmd_tokens,
243            custom_cmd_map,
244            ignore_unknown_commands: config.ignore_unknown_commands,
245            allow_unreliable_rendering: config.allow_unreliable_rendering,
246            unicode_substitution: config.unicode_substitution,
247        };
248        Ok(Self {
249            emitter_cfg: EmitterConfig::from(config),
250            state: GlobalState::default(),
251            parser_cfg,
252        })
253    }
254
255    /// Convert LaTeX to MathML with a global equation counter.
256    ///
257    /// For basic usage, see the documentation of [`convert_with_local_state`].
258    ///
259    /// This conversion function maintains state, in order to count equations correctly across
260    /// different calls to this function.
261    ///
262    /// The counter can be reset with [`reset_global_state`].
263    pub fn convert_with_global_state(
264        &mut self,
265        latex: &str,
266        display: MathDisplay,
267    ) -> Result<ConvertResult, Box<LatexError>> {
268        convert(
269            latex,
270            display,
271            &self.parser_cfg,
272            &mut self.state,
273            &self.emitter_cfg,
274        )
275    }
276
277    /// Convert LaTeX to MathML.
278    ///
279    /// The second argument specifies whether it is inline-equation or block-equation.
280    ///
281    /// ```rust
282    /// use math_core::{LatexToMathML, MathCoreConfig, MathDisplay};
283    ///
284    /// let latex = r#"(n + 1)! = \Gamma ( n + 1 )"#;
285    /// let config = MathCoreConfig::default();
286    /// let converter = LatexToMathML::new(config).unwrap();
287    /// let result = converter.convert_with_local_state(latex, MathDisplay::Inline).unwrap();
288    /// println!("{}", result.mathml);
289    ///
290    /// let latex = r#"x = \frac{ - b \pm \sqrt{ b^2 - 4 a c } }{ 2 a }"#;
291    /// let result = converter.convert_with_local_state(latex, MathDisplay::Block).unwrap();
292    /// println!("{}", result.mathml);
293    /// ```
294    ///
295    pub fn convert_with_local_state(
296        &self,
297        latex: &str,
298        display: MathDisplay,
299    ) -> Result<ConvertResult, Box<LatexError>> {
300        let mut state = GlobalState::default();
301        convert(
302            latex,
303            display,
304            &self.parser_cfg,
305            &mut state,
306            &self.emitter_cfg,
307        )
308    }
309
310    /// Reset the equation counter and the label map.
311    ///
312    /// This should normally be done at the beginning of a new document or section.
313    pub fn reset_global_state(&mut self) {
314        self.state.equation_count = 0;
315        self.state.label_map.clear();
316    }
317
318    /// Convert a collection of LaTeX snippets to MathML.
319    ///
320    /// This method handles *forward references* correctly, meaning that if an earlier snippet
321    /// contains a reference to an equation in a later snippet, the reference will be resolved
322    /// correctly. However, in order to achieve this, all snippets need to be parsed first and can
323    /// only then be emitted. This means you have to first extract all LaTeX snippets from your
324    /// document and then call this method with the whole set.
325    pub fn convert_all(
326        &self,
327        snippets: &[(&str, MathDisplay)],
328    ) -> Vec<Result<ConvertResult, Box<LatexError>>> {
329        let mut state = GlobalState::default();
330        let arena = Arena::new();
331        let ast_vec: Vec<ParseResult<(Vec<&Node<'_>>, &str, MathDisplay)>> = snippets
332            .iter()
333            .map(|(latex, display)| {
334                parse(latex, &arena, &self.parser_cfg, &mut state, *display)
335                    .map(|ast| (ast, *latex, *display))
336            })
337            .collect::<Vec<_>>();
338        ast_vec
339            .into_iter()
340            .map(|ast_result| {
341                ast_result.map(|(ast, latex, display)| {
342                    emit(
343                        ast,
344                        latex,
345                        display,
346                        &state.label_map,
347                        &arena,
348                        &self.emitter_cfg,
349                    )
350                })
351            })
352            .collect()
353    }
354}
355
356fn convert(
357    latex: &str,
358    display: MathDisplay,
359    parser_cfg: &ParserConfig,
360    state: &mut GlobalState,
361    flags: &EmitterConfig,
362) -> Result<ConvertResult, Box<LatexError>> {
363    let arena = Arena::new();
364    let ast = parse(latex, &arena, parser_cfg, state, display)?;
365    Ok(emit(ast, latex, display, &state.label_map, &arena, flags))
366}
367
368fn emit(
369    ast: Vec<&Node>,
370    latex: &str,
371    display: MathDisplay,
372    label_map: &FxHashMap<Box<str>, Box<str>>,
373    arena: &Arena,
374    flags: &EmitterConfig,
375) -> ConvertResult {
376    let mut output = String::new();
377    output.push_str("<math");
378    if flags.xml_namespace {
379        output.push_str(" xmlns=\"http://www.w3.org/1998/Math/MathML\"");
380    }
381    if matches!(display, MathDisplay::Block) {
382        output.push_str(" display=\"block\"");
383    }
384    output.push('>');
385
386    let pretty_print = matches!(flags.pretty_print, PrettyPrint::Always)
387        || (matches!(flags.pretty_print, PrettyPrint::Auto) && display == MathDisplay::Block);
388
389    let base_indent = if pretty_print { 1 } else { 0 };
390    let warnings: Warnings;
391    if flags.annotation {
392        let children_indent = if pretty_print { 2 } else { 0 };
393        new_line_and_indent(&mut output, base_indent, flags.indentation);
394        output.push_str("<semantics>");
395        let node = parser::node_vec_to_node(arena, &ast, false);
396        let mut emitter = Emitter::new(
397            std::mem::take(&mut output),
398            label_map,
399            &flags.css_classes,
400            flags.indentation,
401        );
402        let _ = emitter.emit(node, children_indent);
403        warnings = emitter.warnings();
404        output = emitter.into_string();
405        new_line_and_indent(&mut output, children_indent, flags.indentation);
406        output.push_str("<annotation encoding=\"application/x-tex\">");
407        html_utils::escape_html_content(&mut output, latex);
408        output.push_str("</annotation>");
409        new_line_and_indent(&mut output, base_indent, flags.indentation);
410        output.push_str("</semantics>");
411    } else {
412        let mut emitter = Emitter::new(
413            std::mem::take(&mut output),
414            label_map,
415            &flags.css_classes,
416            flags.indentation,
417        );
418        for node in ast {
419            // We ignore the result of `emit` here, because the only possible error is a formatting
420            // error when writing to the string, but `String`'s `write_str` implementation never
421            // returns an error.
422            let _ = emitter.emit(node, base_indent);
423        }
424        warnings = emitter.warnings();
425        output = emitter.into_string();
426    }
427    if pretty_print {
428        output.push('\n');
429    }
430    output.push_str("</math>");
431    ConvertResult {
432        mathml: output,
433        warnings,
434    }
435}
436
437/// The result of a LaTeX to MathML conversion.
438pub struct ConvertResult {
439    pub mathml: String,
440    pub warnings: Warnings,
441}
442
443fn parse<'arena>(
444    latex: &'arena str,
445    arena: &'arena Arena,
446    parser_cfg: &'arena ParserConfig,
447    state: &mut GlobalState,
448    display: MathDisplay,
449) -> Result<Vec<&'arena Node<'arena>>, Box<LatexError>> {
450    let style = match display {
451        MathDisplay::Inline => Style::Text,
452        MathDisplay::Block => Style::Display,
453    };
454    let lexer = Lexer::new(
455        latex,
456        false,
457        Some(parser_cfg),
458        parser_cfg.unicode_substitution,
459    );
460    let mut p = Parser::new(lexer, arena, state, style)?;
461    let nodes = p.parse()?;
462    Ok(nodes)
463}
464
465fn parse_custom_commands(
466    macros: Vec<(String, String)>,
467    unicode_substitution: UnicodeSubstitution,
468) -> Result<(Vec<Token<'static>>, CustomCmdMap), MacroParseError> {
469    let mut map = FxHashMap::with_capacity_and_hasher(macros.len(), FxBuildHasher);
470    let mut tokens = Vec::new();
471    for (idx, (name, definition)) in macros.into_iter().enumerate() {
472        if !is_valid_macro_name(name.as_str()) {
473            return Err((
474                Box::new(LatexError(0..0, LatexErrKind::InvalidMacroName(name))),
475                idx,
476                definition,
477            ));
478        }
479
480        // In order to be able to return `definition` in case of an error, we need to ensure
481        // that the lexer (which borrows `definition`) is dropped before we return the error.
482        // Therefore, we put the whole lexing process into its own block.
483        let value = 'value: {
484            let mut lexer: Lexer<'static, '_> =
485                Lexer::new(definition.as_str(), true, None, unicode_substitution);
486            let start = tokens.len();
487            loop {
488                match lexer.next_token_no_unknown_command() {
489                    Ok(tokloc) => {
490                        if matches!(tokloc.token(), Token::Eoi) {
491                            break;
492                        }
493                        tokens.push(tokloc.into_token());
494                    }
495                    Err(err) => {
496                        break 'value Err(err);
497                    }
498                }
499            }
500            let end = tokens.len();
501            let num_args = lexer.parse_cmd_args().unwrap_or(0);
502            Ok((num_args, (start, end)))
503        };
504
505        match value {
506            Err(err) => {
507                return Err((err, idx, definition));
508            }
509            Ok(v) => {
510                map.insert(name, v);
511            }
512        }
513    }
514    Ok((tokens, map))
515}
516
517fn is_valid_macro_name(s: &str) -> bool {
518    if s.is_empty() {
519        return false;
520    }
521    let mut chars = s.chars();
522    match (chars.next(), chars.next()) {
523        // If the name contains only one character, any character is valid.
524        (Some(_), None) => true,
525        // If the name contains more than one character, all characters must be ASCII alphabetic.
526        _ => s.bytes().all(|b| b.is_ascii_alphabetic()),
527    }
528}