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, vec![]).unwrap();
20//! let result = converter.convert_with_local_state(latex, MathDisplay::Block).unwrap();
21//! println!("{}", result.mathml);
22//! ```
23//!
24//! # Features
25//!
26//! - `std` (enabled by default): Uses the Rust standard library. Disabling this feature (with
27//!   `default-features = false`) makes the crate `no_std`; the `alloc` crate is still required.
28//!   Note that disabling `std` also disables some speedups in dependencies (e.g. `memchr` then
29//!   can no longer use runtime CPU feature detection).
30//! - `serde`: With this feature, `MathCoreConfig` implements serde's `Serialize` and
31//!   `Deserialize`.
32//! - `ariadne`: Adds `LatexError::to_report()`, which converts an error into an
33//!   [`ariadne`](https://docs.rs/ariadne) report for pretty-printing the error together with a
34//!   source code snippet. The `ariadne` crate itself requires `std`, so this feature is not
35//!   usable on `no_std` targets.
36//!
37#![cfg_attr(not(any(feature = "std", test)), no_std)]
38
39extern crate alloc;
40
41mod atof;
42mod character_class;
43mod color_defs;
44mod commands;
45mod custom_cmds;
46mod environments;
47mod error;
48mod global_state;
49mod html_utils;
50mod lexer;
51mod parser;
52mod predefined;
53mod semantic;
54mod specifications;
55mod split_on_ascii;
56mod string_pool;
57mod text_parser;
58mod token;
59mod token_queue;
60
61use alloc::boxed::Box;
62use alloc::string::String;
63use alloc::vec::Vec;
64use core::ops::Range;
65
66use kstring::KString;
67use rustc_hash::FxBuildHasher;
68#[cfg(feature = "serde")]
69use serde::{Deserialize, Serialize};
70
71/// Hash map with a fast, non-cryptographic hasher, backed by `hashbrown` so it works in `no_std`.
72pub(crate) type FxHashMap<K, V> = hashbrown::HashMap<K, V, FxBuildHasher>;
73
74pub use mathml_renderer::ast::{CssClassNames, Indentation, Warnings};
75use mathml_renderer::{
76    arena::Arena,
77    ast::{Emitter, Node},
78    attribute::Style,
79    fmt::new_line_and_indent,
80};
81
82pub use self::error::LatexError;
83use self::{
84    commands::resolve_builtin_cmd,
85    custom_cmds::{CmdSource, CustomCmds, RecordedToken, is_valid_macro_name},
86    error::LatexErrKind,
87    global_state::GlobalState,
88    lexer::{Lexer, LexerOutput},
89    parser::Parser,
90    token::Token,
91};
92
93/// Display mode for the LaTeX math equations.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum MathDisplay {
96    /// For inline equations, like those in `$...$` in LaTeX.
97    Inline,
98    /// For block equations (or "display style" equations), like those in `$$...$$` in LaTeX.
99    Block,
100}
101
102/// Configuration for pretty-printing the MathML output.
103///
104/// Pretty-printing means that newlines and indentation is added to the MathML output, to make it
105/// easier to read.
106#[derive(Debug, Clone, Copy, Default)]
107#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
108#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
109#[non_exhaustive]
110pub enum PrettyPrint {
111    /// Never pretty print.
112    #[default]
113    Never,
114    /// Always pretty print.
115    Always,
116    /// Pretty print for block equations only.
117    Auto,
118}
119
120/// Configuration for using Unicode symbols in the MathML output.
121///
122/// LaTeX commands like `\coloneqq` can be rendered in MathML either using dedicated Unicode symbols
123/// (in this case, `\coloneqq` would be rendered as `≔`) or using a combination of more basic
124/// symbols (in this case, `\coloneqq` would be rendered as a combination of `:` and `=`).
125/// The former is preferable in terms of semantics but can look a little different from the LaTeX
126/// output, while the latter is more faithful to the LaTeX output but can be less semantically
127/// clear.
128#[derive(Debug, Clone, Copy, Default)]
129#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
130#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
131#[non_exhaustive]
132pub enum UnicodeSubstitution {
133    /// Never subtitute a set of symbols with their Unicode equivalents.
134    Never,
135    /// Substitute whenever the LaTeX package `unicode-math` would substitute, which is a good
136    /// middle ground between semantics and faithfulness to the LaTeX output.
137    #[default]
138    Conventional,
139    // /// Substitute whenever there is a Unicode equivalent, even if the `unicode-math` package
140    // /// does not do so.
141    // Aggressive,
142}
143
144/// The maximum number of custom command expansions allowed in one snippet.
145///
146/// Names are resolved when a command is expanded, so a definition may refer to itself, directly or
147/// through other definitions, and expanding it would never end. Rather than detecting that, we
148/// simply stop after this many expansions, as LaTeX and KaTeX do.
149///
150/// The default is 1000, which is the same limit that KaTeX uses for its `maxExpand` setting.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152#[cfg_attr(feature = "serde", derive(Serialize, Deserialize), serde(transparent))]
153pub struct MaxExpansions(pub u32);
154
155impl Default for MaxExpansions {
156    fn default() -> Self {
157        MaxExpansions(1000)
158    }
159}
160
161#[derive(Debug, Clone, Copy, Default)]
162#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
163#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
164#[non_exhaustive]
165pub enum TaggingStrategy {
166    // None,
167    /// Equations are numbered as the AMSmath package would do.
168    #[default]
169    Ams,
170    // All,
171}
172
173/// Configuration object for the LaTeX to MathML conversion.
174///
175/// # Example usage
176///
177/// ```rust
178/// use math_core::{MathCoreConfig, PrettyPrint};
179///
180/// // Default values
181/// let config = MathCoreConfig::default();
182///
183/// // Specifying pretty-print behavior and enabling annotations
184/// let config = MathCoreConfig {
185///     pretty_print: PrettyPrint::Always,
186///     annotation: true,
187///     ..Default::default()
188///  };
189/// ```
190///
191#[derive(Debug, Default, Clone)]
192#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
193#[cfg_attr(feature = "serde", serde(default, rename_all = "kebab-case"))]
194pub struct MathCoreConfig {
195    /// A configuration for pretty-printing the MathML output. See [`PrettyPrint`] for details.
196    pub pretty_print: PrettyPrint,
197    /// If `true`, include `xmlns="http://www.w3.org/1998/Math/MathML"` in the `<math>` tag.
198    pub xml_namespace: bool,
199    /// If `true`, unknown commands will be rendered as red text in the output, instead of
200    /// returning an error.
201    pub ignore_unknown_commands: bool,
202    /// If `true`, wrap the MathML output in `<semantics>` tags with an
203    /// `<annotation encoding="application/x-tex">` child containing the original LaTeX source.
204    pub annotation: bool,
205    /// If `true`, allow rendering commands that produce MathML Core output that is unreliably
206    /// rendered by browsers.
207    pub allow_unreliable_rendering: bool,
208    /// If `true`, run the conversion in the global group, which means that commands defined at
209    /// the top level of a snippet with `\newcommand` (and related commands) stay defined for the
210    /// snippets which come after it.
211    ///
212    /// If `false` (the default), such definitions are local to the snippet which contains them.
213    /// This matches LaTeX, where constructs like `\begin{equation}` and `$$` open a local group,
214    /// and it matches the default behavior of KaTeX.
215    pub global_group: bool,
216    /// If not `UnicodeSubstitution::Never`, substitute certain LaTeX commands with their Unicode
217    /// equivalents in the MathML output.
218    pub unicode_substitution: UnicodeSubstitution,
219    /// CSS class names for various elements in the output.
220    pub css_classes: CssClassNames,
221    /// The indentation unit used when pretty-printing the MathML output. Either a number of spaces
222    /// (e.g. `2`) or the string `"tab"` for a tab character. See [`Indentation`].
223    pub indentation: Indentation,
224    /// How many custom commands may be expanded in one snippet before the conversion gives up.
225    /// See [`MaxExpansions`].
226    pub max_expansions: MaxExpansions,
227    /// Add this string to the start of every generated `id` attribute.
228    /// This is *not* URL escaped. Use the
229    /// [percent-encoding](https://crates.io/crates/percent-encoding) crate if you need to.
230    pub id_prefix: String,
231    /// The strategy used for numbering equations. See [`TaggingStrategy`].
232    pub tags: TaggingStrategy,
233}
234
235/// Subset of `MathCoreConfig` relevant for the parser.
236#[derive(Debug, Default)]
237struct ParserConfig {
238    custom_cmds_from_cfg: CustomCmds,
239    ignore_unknown_commands: bool,
240    allow_unreliable_rendering: bool,
241    global_group: bool,
242    unicode_substitution: UnicodeSubstitution,
243    max_expansions: MaxExpansions,
244}
245
246/// Subset of `MathCoreConfig` relevant for the emitter.
247#[derive(Debug, Default)]
248struct EmitterConfig {
249    pretty_print: PrettyPrint,
250    xml_namespace: bool,
251    annotation: bool,
252    css_classes: CssClassNames,
253    indentation: Indentation,
254    id_prefix: String,
255}
256
257impl From<MathCoreConfig> for EmitterConfig {
258    fn from(config: MathCoreConfig) -> Self {
259        // FIXME: can we use a macro here to avoid repeating the field names?
260        Self {
261            pretty_print: config.pretty_print,
262            xml_namespace: config.xml_namespace,
263            annotation: config.annotation,
264            css_classes: config.css_classes,
265            indentation: config.indentation,
266            id_prefix: config.id_prefix,
267        }
268    }
269}
270
271type ParseResult<T> = Result<T, Box<LatexError>>;
272
273/// The error type returned when parsing a custom macro definition fails. Contains the parsing
274/// error, the index of the macro definition in the `macros` vector and the macro definition itself.
275pub type MacroParseError = (Box<LatexError>, usize, String);
276
277/// A converter that transforms LaTeX math equations into MathML Core.
278#[derive(Debug, Default)]
279pub struct LatexToMathML {
280    emitter_cfg: EmitterConfig,
281    state: GlobalState,
282    parser_cfg: ParserConfig,
283}
284
285impl LatexToMathML {
286    /// Create a new `LatexToMathML` converter with the given configuration.
287    ///
288    /// `macros` is a vector of tuples, where each tuple contains a macro name and its definition.
289    /// A macro may use another macro of this list, no matter which of the two comes first.
290    ///
291    /// This function returns an error if the custom macros in the given configuration could not
292    /// be parsed. The error contains the parsing error, the macro index and the macro definition
293    /// that caused the error.
294    ///
295    /// # Example usage
296    ///
297    /// ```rust
298    /// use math_core::{LatexToMathML, MathCoreConfig, PrettyPrint};
299    ///
300    /// let config = MathCoreConfig {
301    ///     pretty_print: PrettyPrint::Auto,
302    ///     ..Default::default()
303    /// };
304    /// let macros = vec![
305    ///     ("d".to_string(), r"\mathrm{d}".to_string()),
306    ///     ("bb".to_string(), r"\mathbb{#1}".to_string()), // with argument
307    /// ];
308    /// let converter = LatexToMathML::new(config, macros).unwrap();
309    /// ```
310    pub fn new(
311        config: MathCoreConfig,
312        macros: Vec<(String, String)>,
313    ) -> Result<Self, MacroParseError> {
314        let custom_cmds = parse_custom_commands(
315            macros,
316            config.unicode_substitution,
317            config.allow_unreliable_rendering,
318        )?;
319        let parser_cfg = ParserConfig {
320            custom_cmds_from_cfg: custom_cmds,
321            ignore_unknown_commands: config.ignore_unknown_commands,
322            allow_unreliable_rendering: config.allow_unreliable_rendering,
323            global_group: config.global_group,
324            unicode_substitution: config.unicode_substitution,
325            max_expansions: config.max_expansions,
326        };
327        Ok(Self {
328            emitter_cfg: EmitterConfig::from(config),
329            state: GlobalState::default(),
330            parser_cfg,
331        })
332    }
333
334    /// Convert LaTeX to MathML with a global equation counter.
335    ///
336    /// For basic usage, see the documentation of [`Self::convert_with_local_state`].
337    ///
338    /// This conversion function maintains state, in order to count equations correctly across
339    /// different calls to this function.
340    ///
341    /// The counter can be reset with [`Self::reset_global_state`].
342    pub fn convert_with_global_state(
343        &mut self,
344        latex: &str,
345        display: MathDisplay,
346    ) -> Result<ConvertResult, Box<LatexError>> {
347        convert(
348            latex,
349            display,
350            &self.parser_cfg,
351            &mut self.state,
352            &self.emitter_cfg,
353        )
354    }
355
356    /// Convert LaTeX to MathML.
357    ///
358    /// The second argument specifies whether it is inline-equation or block-equation.
359    ///
360    /// ```rust
361    /// use math_core::{LatexToMathML, MathCoreConfig, MathDisplay};
362    ///
363    /// let latex = r#"(n + 1)! = \Gamma ( n + 1 )"#;
364    /// let config = MathCoreConfig::default();
365    /// let converter = LatexToMathML::new(config, vec![]).unwrap();
366    /// let result = converter.convert_with_local_state(latex, MathDisplay::Inline).unwrap();
367    /// println!("{}", result.mathml);
368    ///
369    /// let latex = r#"x = \frac{ - b \pm \sqrt{ b^2 - 4 a c } }{ 2 a }"#;
370    /// let result = converter.convert_with_local_state(latex, MathDisplay::Block).unwrap();
371    /// println!("{}", result.mathml);
372    /// ```
373    ///
374    pub fn convert_with_local_state(
375        &self,
376        latex: &str,
377        display: MathDisplay,
378    ) -> Result<ConvertResult, Box<LatexError>> {
379        let mut state = GlobalState::default();
380        convert(
381            latex,
382            display,
383            &self.parser_cfg,
384            &mut state,
385            &self.emitter_cfg,
386        )
387    }
388
389    /// Reset the equation counter, the label map and the commands defined with `\newcommand`.
390    ///
391    /// This should normally be done at the beginning of a new document or section.
392    pub fn reset_global_state(&mut self) {
393        self.state.equation_count = 0;
394        self.state.label_map.clear();
395        self.state.custom_cmds.clear();
396    }
397
398    /// Convert a collection of LaTeX snippets to MathML.
399    ///
400    /// This method handles *forward references* correctly, meaning that if an earlier snippet
401    /// contains a reference to an equation in a later snippet, the reference will be resolved
402    /// correctly. However, in order to achieve this, all snippets need to be parsed first and can
403    /// only then be emitted. This means you have to first extract all LaTeX snippets from your
404    /// document and then call this method with the whole set.
405    pub fn convert_all<S: AsRef<str>>(
406        &self,
407        snippets: &[(S, MathDisplay)],
408    ) -> Vec<Result<ConvertResult, Box<LatexError>>> {
409        let mut state = GlobalState::default();
410        let arena = Arena::new();
411        let ast_vec: Vec<ParseResult<(Vec<&Node<'_>>, &str, MathDisplay)>> = snippets
412            .iter()
413            .map(|(latex, display)| {
414                let latex = latex.as_ref();
415                parse(latex, &arena, &self.parser_cfg, &mut state, *display)
416                    .map(|ast| (ast, latex, *display))
417            })
418            .collect::<Vec<_>>();
419        ast_vec
420            .into_iter()
421            .map(|ast_result| {
422                ast_result.map(|(ast, latex, display)| {
423                    emit(
424                        ast,
425                        latex,
426                        display,
427                        &state.label_map,
428                        &arena,
429                        &self.emitter_cfg,
430                    )
431                })
432            })
433            .collect()
434    }
435}
436
437fn convert(
438    latex: &str,
439    display: MathDisplay,
440    parser_cfg: &ParserConfig,
441    state: &mut GlobalState,
442    flags: &EmitterConfig,
443) -> Result<ConvertResult, Box<LatexError>> {
444    let arena = Arena::new();
445    let ast = parse(latex, &arena, parser_cfg, state, display)?;
446    Ok(emit(ast, latex, display, &state.label_map, &arena, flags))
447}
448
449fn emit(
450    ast: Vec<&Node>,
451    latex: &str,
452    display: MathDisplay,
453    label_map: &FxHashMap<KString, KString>,
454    arena: &Arena,
455    flags: &EmitterConfig,
456) -> ConvertResult {
457    let mut output = String::new();
458    output.push_str("<math");
459    if flags.xml_namespace {
460        output.push_str(" xmlns=\"http://www.w3.org/1998/Math/MathML\"");
461    }
462    if matches!(display, MathDisplay::Block) {
463        output.push_str(" display=\"block\"");
464    }
465    output.push('>');
466
467    let pretty_print = matches!(flags.pretty_print, PrettyPrint::Always)
468        || (matches!(flags.pretty_print, PrettyPrint::Auto) && display == MathDisplay::Block);
469
470    let base_indent = if pretty_print { 1 } else { 0 };
471    let warnings: Warnings;
472    if flags.annotation {
473        let children_indent = if pretty_print { 2 } else { 0 };
474        new_line_and_indent(&mut output, base_indent, flags.indentation);
475        output.push_str("<semantics>");
476        let node = parser::node_vec_to_node(arena, &ast);
477        let mut emitter = Emitter::new(
478            core::mem::take(&mut output),
479            label_map,
480            &flags.css_classes,
481            flags.indentation,
482            &flags.id_prefix,
483        );
484        let _ = emitter.emit(node, children_indent);
485        warnings = emitter.warnings();
486        output = emitter.into_string();
487        new_line_and_indent(&mut output, children_indent, flags.indentation);
488        output.push_str("<annotation encoding=\"application/x-tex\">");
489        html_utils::escape_html_content(&mut output, latex);
490        output.push_str("</annotation>");
491        new_line_and_indent(&mut output, base_indent, flags.indentation);
492        output.push_str("</semantics>");
493    } else {
494        let mut emitter = Emitter::new(
495            core::mem::take(&mut output),
496            label_map,
497            &flags.css_classes,
498            flags.indentation,
499            &flags.id_prefix,
500        );
501        for node in ast {
502            // We ignore the result of `emit` here, because the only possible error is a formatting
503            // error when writing to the string, but `String`'s `write_str` implementation never
504            // returns an error.
505            let _ = emitter.emit(node, base_indent);
506        }
507        warnings = emitter.warnings();
508        output = emitter.into_string();
509    }
510    if pretty_print {
511        output.push('\n');
512    }
513    output.push_str("</math>");
514    ConvertResult {
515        mathml: output,
516        warnings,
517    }
518}
519
520/// The result of a LaTeX to MathML conversion.
521pub struct ConvertResult {
522    pub mathml: String,
523    pub warnings: Warnings,
524}
525
526fn parse<'arena>(
527    latex: &'arena str,
528    arena: &'arena Arena,
529    parser_cfg: &'arena ParserConfig,
530    state: &mut GlobalState,
531    display: MathDisplay,
532) -> Result<Vec<&'arena Node<'arena>>, Box<LatexError>> {
533    let style = match display {
534        MathDisplay::Inline => Style::Text,
535        MathDisplay::Block => Style::Display,
536    };
537    let lexer = Lexer::new(latex);
538    let mut p = Parser::new(lexer, arena, parser_cfg, state, style)?;
539    let nodes = p.parse()?;
540    Ok(semantic::enrich(arena, &nodes))
541}
542
543/// Read the macros of the configuration into a store of custom commands.
544///
545/// As in a body recorded from a `\newcommand`, every command is kept as a
546/// [`RecordedToken::CommandName`] and only resolved when the macro is used. A macro may
547/// therefore refer to a command which is not defined here at all, and in particular to another
548/// macro of the configuration, no matter in which order the two are given. Once all macros have
549/// been read, every one of those references must point at something, which is what the final
550/// check is for; a document definition, which has no such point in time, is only checked when
551/// it is used.
552fn parse_custom_commands(
553    macros: Vec<(String, String)>,
554    unicode_substitution: UnicodeSubstitution,
555    allow_unreliable_rendering: bool,
556) -> Result<CustomCmds, MacroParseError> {
557    let mut custom_cmds = CustomCmds::with_capacity(macros.len());
558    // The names which have to be defined by the time all macros have been read, together with
559    // the macro they appear in and their position within its definition.
560    let mut unresolved: Vec<(usize, KString, Range<usize>)> = Vec::new();
561    let mut body = Vec::new();
562    let parser_cfg = ParserConfig {
563        unicode_substitution,
564        allow_unreliable_rendering,
565        ..Default::default()
566    };
567    // The definitions are kept around, because the check at the end has to be able to report
568    // the one which contains an unresolved name.
569    let mut definitions: Vec<String> = Vec::with_capacity(macros.len());
570    for (idx, (name, definition)) in macros.into_iter().enumerate() {
571        if !is_valid_macro_name(name.as_str()) {
572            return Err((
573                Box::new(LatexError(0..0, LatexErrKind::InvalidMacroName(name))),
574                idx,
575                definition,
576            ));
577        }
578
579        body.clear();
580        let mut num_args = 0;
581        let mut first_class: Option<character_class::Class> = None;
582        let result = 'body: {
583            let mut lexer = Lexer::new(definition.as_str());
584            loop {
585                match lexer.next_token() {
586                    Ok(lexer_output) => {
587                        let token = match lexer_output {
588                            LexerOutput::CommandName(cmd_name, span) => {
589                                // We resolve the command here only to know whether it *can* be
590                                // resolved and to know its class. The actual resolution is done
591                                // when the macro is used.
592                                if let Some(resolved) = resolve_builtin_cmd(&parser_cfg, cmd_name) {
593                                    if first_class.is_none() {
594                                        first_class = resolved.class();
595                                    }
596                                } else {
597                                    unresolved.push((
598                                        idx,
599                                        KString::from_ref(cmd_name),
600                                        span.into(),
601                                    ));
602                                }
603                                body.push(RecordedToken::CommandName(KString::from_ref(cmd_name)));
604                                continue;
605                            }
606                            LexerOutput::Token(tokspan) => tokspan.into_token(),
607                        };
608                        match token {
609                            Token::Eoi => break,
610                            Token::CustomCmdArgInput(n) => {
611                                if n >= num_args {
612                                    num_args = n + 1;
613                                }
614                                body.push(RecordedToken::Token(Token::CustomCmdArg(n)));
615                            }
616                            tok => {
617                                if first_class.is_none() {
618                                    first_class = tok.class();
619                                }
620                                body.push(RecordedToken::Token(tok))
621                            }
622                        }
623                    }
624                    Err(err) => {
625                        break 'body Err(err);
626                    }
627                }
628            }
629            Ok(())
630        };
631
632        if let Err(err) = result {
633            return Err((err, idx, definition));
634        }
635        custom_cmds.insert(name.as_str(), num_args, &body, first_class);
636        // The lexer, which borrows the definition, is gone by now.
637        definitions.push(definition);
638    }
639    // Now that all macros are known, every name which none of them defines is an error.
640    for (idx, name, span) in unresolved {
641        if custom_cmds.get(&name, CmdSource::Config).is_none() {
642            let err = Box::new(LatexError(span, LatexErrKind::UnknownCommand(name)));
643            return Err((err, idx, definitions.swap_remove(idx)));
644        }
645    }
646    Ok(custom_cmds)
647}