katex-rs 0.2.4

A Rust implementation of KaTeX - Fast math typesetting for anywhere, more than just the web.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
//! MathML rendering utilities for KaTeX
//!
//! This module provides functions to convert KaTeX parse trees into MathML
//! format, which is the W3C standard for representing mathematical expressions
//! in XML.

use crate::namespace::KeyMap;
use core::mem;
use strum::IntoDiscriminant as _;

use crate::ParseError;
use crate::build_common::{FONT_MAP, make_span};
use crate::context::KatexContext;
use crate::dom_tree::{DomSpan, HtmlDomNode};
use crate::font_metrics::get_character_metrics;
use crate::mathml_tree::{MathDomNode, MathNode, MathNodeType, TextNode};
use crate::options::{FontShape, FontWeight, Options};
use crate::parser::parse_node::AnyParseNode;
use crate::symbols::{Symbols, is_ligature};
use crate::types::ClassList;
use crate::types::Mode;
use crate::types::ParseErrorKind;

/// Creates a MathML text node with optional symbol replacement
///
/// This function creates a MathML text node, applying symbol replacements based
/// on the current mode and rendering options. It handles special cases like
/// ligatures in typewriter fonts and Unicode Mathematical Alphanumeric Symbols.
///
/// # Parameters
/// * `text` - The text content to be placed in the MathML text node
/// * `mode` - The current rendering mode (Math or Text) affecting symbol
///   replacement
/// * `options` - Optional rendering options that may affect symbol replacement
///   behavior
/// * `symbols` - The symbol table containing replacement mappings
///
/// # Returns
/// A `TextNode` containing the processed text content.
///
/// # Symbol Replacement Logic
/// - Applies symbol replacements from the symbol table if available
/// - Skips replacement for ligatures in typewriter fonts (font family ending
///   with "tt")
/// - Preserves Unicode Mathematical Alphanumeric Symbols (U+1D400-U+1D7FF)
///   without replacement
/// - Handles special cases for exact compatibility with KaTeX JavaScript
///   implementation
///
/// # Behavior
/// - Returns the original text if no replacement is found or applicable
/// - Applies font-specific logic for ligature handling
/// - Ensures Unicode math symbols are not inadvertently replaced
#[must_use]
pub fn make_text(text: &str, mode: Mode, options: Option<&Options>, symbols: &Symbols) -> TextNode {
    let mut final_text = text.to_owned();

    // Apply symbol replacements if available (matching JS logic exactly)
    if let Some(char_info) = symbols.get(mode, text)
        && let Some(replace) = &char_info.replace
    {
        // Check if first character is not in the Unicode Mathematical Alphanumeric
        // Symbols block (U+1D400-U+1D7FF)
        let char_code = text.chars().next().unwrap_or('\0') as u32;
        if !(0x1D400..=0x1D7FF).contains(&char_code) {
            // Check for ligature + typewriter font condition
            let skip_replacement = options.is_some_and(|opts| {
                let font_family = &opts.font_family;
                let font = &opts.font;

                // Check if font family or font ends with "tt" (typewriter)
                let is_tt_font = (font_family.len() >= 6 && &font_family[4..6] == "tt")
                    || (font.len() >= 6 && &font[4..6] == "tt");

                // Skip replacement only for ligatures in typewriter fonts
                is_ligature(text) && is_tt_font
            });

            if !skip_replacement {
                final_text = replace.to_string();
            }
        }
    }

    TextNode { text: final_text }
}

/// Wraps nodes in an `<mrow>` element if there are multiple nodes
///
/// This function implements the MathML convention of grouping multiple elements
/// within an `<mrow>` container. Single elements are returned directly without
/// unnecessary wrapping for optimal MathML structure.
///
/// # Parameters
/// * `body` - Slice of MathML DOM nodes to be potentially wrapped
///
/// # Returns
/// - If `body` contains a single element: Returns that element directly
/// - If `body` contains multiple elements: Returns an `<mrow>` element
///   containing all elements
/// - If `body` is empty: Returns an empty `<mrow>` element
///
/// # MathML Convention
/// According to MathML specification, multiple elements should be grouped
/// within an `<mrow>` element to maintain proper structure and spacing. This
/// function automatically applies this convention while avoiding unnecessary
/// nesting for single elements.
///
/// # Use Cases
/// - Building complex mathematical expressions with multiple terms
/// - Ensuring proper MathML structure for operator precedence
/// - Grouping elements for spacing and layout control
#[must_use]
pub fn make_row(mut body: Vec<MathDomNode>) -> MathDomNode {
    if body.len() == 1
        && let Some(node) = body.pop()
    {
        return node;
    }

    MathDomNode::Math(MathNode {
        node_type: MathNodeType::Mrow,
        attributes: KeyMap::default(),
        children: body,
        classes: ClassList::Empty,
    })
}

/// Determines the MathML mathvariant attribute for font styling
///
/// This function maps KaTeX font specifications to the appropriate MathML
/// `mathvariant` attribute values. MathML uses a standardized set of variant
/// names for font styling, which differ from KaTeX's internal font naming
/// conventions.
///
/// # Parameters
/// * `group` - The parse node containing text that may need font variant
///   determination
/// * `options` - Rendering options containing font family, shape, and weight
///   settings
/// * `symbols` - Symbol table for checking character metrics and replacements
///
/// # Returns
/// An `Option<&'static str>` containing the MathML mathvariant value:
/// - `Some("monospace")` - For typewriter fonts
/// - `Some("sans-serif")` - For sans-serif fonts
/// - `Some("bold")` - For bold weight
/// - `Some("italic")` - For italic shape
/// - `Some("bold-italic")` - For bold italic combination
/// - Various other MathML variant names for specific fonts
/// - `None` - When no variant should be applied (default math font)
///
/// # Font Mapping Logic
/// - Handles special cases like `\imath` and `\jmath` (always return `None`)
/// - Maps `\text...` font specifiers to MathML variants
/// - Considers font family, shape, and weight combinations
/// - Checks character metrics for font-specific variants
/// - Falls back to symbol replacement information when available
///
/// # MathML Specification Compliance
/// Follows the W3C MathML 3.0 specification for allowable `mathvariant` values
/// as defined at: <https://www.w3.org/TR/MathML3/chapter3.html#presm.commatt>
pub fn get_variant(
    ctx: &KatexContext,
    group: &AnyParseNode,
    options: &Options,
) -> Result<Option<&'static str>, ParseError> {
    // First, get the text and check for special cases that always return None
    let Some(text) = group.text() else {
        return Ok(None);
    };

    // Handle special cases FIRST - \imath and \jmath should never have variants
    if text == "\\imath" || text == "\\jmath" {
        return Ok(None);
    }

    // Handle \text... font specifiers
    // MathML has a limited list of allowable mathvariant specifiers; see
    // https://www.w3.org/TR/MathML3/chapter3.html#presm.commatt
    if options.font_family == "texttt" {
        return Ok(Some("monospace"));
    } else if options.font_family == "textsf" {
        return Ok(Some(match (&options.font_shape, &options.font_weight) {
            (FontShape::TextIt, FontWeight::TextBf) => "sans-serif-bold-italic",
            (FontShape::TextIt, _) => "sans-serif-italic",
            (_, FontWeight::TextBf) => "bold-sans-serif",
            _ => "sans-serif",
        }));
    } else if options.font_shape == FontShape::TextIt && options.font_weight == FontWeight::TextBf {
        return Ok(Some("bold-italic"));
    } else if options.font_shape == FontShape::TextIt {
        return Ok(Some("italic"));
    } else if options.font_weight == FontWeight::TextBf {
        return Ok(Some("bold"));
    }

    let font = &options.font;
    if font.is_empty() || font == "mathnormal" {
        return Ok(None);
    }

    let mode = group.mode();

    if let Some(result) = match font.as_str() {
        "mathit" => Some("italic"),
        "boldsymbol" => match group {
            AnyParseNode::TextOrd(_) => Some("bold"),
            _ => Some("bold-italic"),
        },
        "mathbf" => Some("bold"),
        "mathbb" => Some("double-struck"),
        "mathsfit" => Some("sans-serif-italic"),
        "mathfrak" => Some("fraktur"),
        "mathscr" | "mathcal" => {
            // MathML makes no distinction between script and calligraphic
            Some("script")
        }
        "mathsf" => Some("sans-serif"),
        "mathtt" => Some("monospace"),
        _ => None,
    } {
        return Ok(Some(result));
    }

    // Check for symbol replacement
    let final_text = if let Some(char_info) = ctx.symbols.get(mode, text)
        && let Some(replaced) = char_info.replace
    {
        replaced.to_string()
    } else {
        text.to_owned()
    };

    // Check if we have metrics for this character in the specified font
    if let Some(font_entry) = FONT_MAP.get(font)
        && let Some(final_char) = final_text.chars().next()
        && get_character_metrics(ctx, final_char, font_entry.font_name, mode)?.is_some()
    {
        return Ok(Some(font_entry.variant));
    }

    Ok(None)
}

/// Checks if a node represents number punctuation (dot or comma)
fn is_number_punctuation(group: Option<&MathNode>) -> bool {
    if let Some(node) = group {
        if node.node_type == MathNodeType::Mi && node.children.len() == 1 {
            if let Some(child) = node.children.first()
                && let MathDomNode::Text(text_node) = child
            {
                return text_node.text == ".";
            }
        } else if node.node_type == MathNodeType::Mo && node.children.len() == 1 {
            let has_separator = node
                .attributes
                .get("separator")
                .is_some_and(|s| s == "true");
            let lspace = node.attributes.get("lspace").is_some_and(|s| s == "0em");
            let rspace = node.attributes.get("rspace").is_some_and(|s| s == "0em");

            if has_separator
                && lspace
                && rspace
                && let Some(child) = node.children.first()
                && let MathDomNode::Text(text_node) = child
            {
                return text_node.text == ",";
            }
        }
    }
    false
}

/// Builds a list of MathML nodes from parse nodes with concatenation logic
///
/// This function converts a sequence of parse nodes into MathML DOM nodes,
/// applying various concatenation optimizations for better MathML output
/// structure.
///
/// # Parameters
/// * `ctx` - The KaTeX context containing group builders and rendering state
/// * `expression` - Slice of parse nodes to be converted to MathML DOM nodes
/// * `options` - Rendering options including style, size, and font settings
/// * `is_ordgroup` - Whether this expression represents a ord group that
///   affects spacing
///
/// # Returns
/// A `Result` containing either a vector of MathML DOM nodes or a `ParseError`
/// if building fails.
///
/// # Concatenation Logic
/// The function applies several concatenation optimizations:
/// - Adjacent `<mtext>` elements with matching `mathvariant` attributes
/// - Adjacent `<mn>` elements (numbers)
/// - Numbers followed by number punctuation (dots, commas)
/// - Superscript/subscript operations on numbers or punctuation
/// - `\not` combining with operators (combining long solidus U+0338)
///
/// # Behavior
/// - Single-element expressions return the element directly
/// - Multi-element expressions may be concatenated based on the rules above
/// - Operators in ordgroups have their spacing suppressed (`lspace` and
///   `rspace` set to "0em")
pub fn build_expression(
    ctx: &KatexContext,
    expression: &[AnyParseNode],
    options: &Options,
    is_ordgroup: Option<bool>,
) -> Result<Vec<MathDomNode>, ParseError> {
    if expression.is_empty() {
        return Ok(Vec::new());
    }

    if expression.len() == 1 {
        let group = build_group(ctx, &expression[0], options)?;
        if let Some(math_node) = group.as_math_node()
            && is_ordgroup.unwrap_or(false)
            && math_node.node_type == MathNodeType::Mo
        {
            // Suppress spacing on operators in ordgroups
            let mut new_node = math_node.clone();
            new_node
                .attributes
                .insert("lspace".to_owned(), "0em".to_owned());
            new_node
                .attributes
                .insert("rspace".to_owned(), "0em".to_owned());
            return Ok(vec![MathDomNode::Math(new_node)]);
        }
        return Ok(vec![group]);
    }

    // Use MathDomNodeEnum internally for better performance
    let mut groups_enum: Vec<MathDomNode> = Vec::with_capacity(expression.len());

    for node in expression {
        let mut group = build_group(ctx, node, options)?;

        if let Some(mut last_group) = groups_enum.pop() {
            let mut repush_last = true;
            let mut push_current = true;

            if let (Some(last_math), Some(current_math)) =
                (last_group.as_math_node_mut(), group.as_math_node_mut())
            {
                // Concatenate adjacent <mtext> elements
                if current_math.node_type == MathNodeType::Mtext
                    && last_math.node_type == MathNodeType::Mtext
                {
                    let mathvariant_match = current_math.attributes.get("mathvariant")
                        == last_math.attributes.get("mathvariant");
                    if mathvariant_match {
                        last_math.children.append(&mut current_math.children);
                        push_current = false;
                    }
                }
                // Concatenate adjacent <mn> elements
                // Concatenate <mn> followed by number punctuation
                else if (is_number_punctuation(Some(&*current_math))
                    || current_math.node_type == MathNodeType::Mn)
                    && last_math.node_type == MathNodeType::Mn
                {
                    last_math.children.append(&mut current_math.children);
                    push_current = false;
                }
                // Concatenate number punctuation followed by <mn>
                else if current_math.node_type == MathNodeType::Mn
                    && is_number_punctuation(Some(&*last_math))
                {
                    let prefix = mem::take(&mut last_math.children);
                    current_math.children.splice(0..0, prefix);
                    repush_last = false;
                }
                // Handle msup/msub with preceding mn or punctuation
                else if (current_math.node_type == MathNodeType::Msup
                    || current_math.node_type == MathNodeType::Msub)
                    && !current_math.children.is_empty()
                    && (last_math.node_type == MathNodeType::Mn
                        || is_number_punctuation(Some(&*last_math)))
                {
                    if let Some(base) = current_math.children.first_mut()
                        && let Some(base_math) = base.as_math_node_mut()
                        && base_math.node_type == MathNodeType::Mn
                    {
                        let mut prefix = mem::take(&mut last_math.children);
                        prefix.append(&mut base_math.children);
                        base_math.children = prefix;
                        repush_last = false;
                    }
                }
                // Handle \not combining with operators
                else if last_math.node_type == MathNodeType::Mi
                    && last_math.children.len() == 1
                    && let Some(last_child) = last_math.children.first()
                    && let Some(text_node) = last_child.as_text_node()
                    && text_node.text == "\u{0338}"
                    && (current_math.node_type == MathNodeType::Mo
                        || current_math.node_type == MathNodeType::Mi
                        || current_math.node_type == MathNodeType::Mn)
                    && let Some(child) = current_math.children.first_mut()
                    && let Some(text_child) = child.as_text_node_mut()
                    && !text_child.text.is_empty()
                    && let Some(first_char) = text_child.text.chars().next()
                {
                    let insert_pos = first_char.len_utf8();
                    text_child.text.insert(insert_pos, '\u{0338}');
                    repush_last = false;
                }
            }

            if repush_last {
                groups_enum.push(last_group);
            }
            if push_current {
                groups_enum.push(group);
            }
        } else {
            groups_enum.push(group);
        }
    }

    Ok(groups_enum)
}

/// Builds a single MathML node from parse nodes, wrapped in mrow if multiple
///
/// This function converts a sequence of parse nodes into a single MathML DOM
/// node, automatically wrapping multiple elements in an `<mrow>` container as
/// required by MathML specification.
///
/// # Parameters
/// * `ctx` - The KaTeX context containing group builders and rendering state
/// * `expression` - Slice of parse nodes to be converted to MathML
/// * `options` - Rendering options including style, size, and font settings
/// * `is_ordgroup` - Whether this expression represents a ord group that
///   affects spacing
///
/// # Returns
/// A `Result` containing either a single MathML DOM node or a `ParseError` if
/// building fails.
///
/// # Behavior
/// - Calls `build_expression` to convert parse nodes to MathML DOM nodes
/// - Applies `make_row` to ensure proper MathML structure:
///   - Single elements are returned directly (no unnecessary `<mrow>`)
///   - Multiple elements are wrapped in an `<mrow>` container
/// - Handles spacing and concatenation logic through the underlying
///   `build_expression` call
///
/// # Use Cases
/// - Building subexpressions that need to be treated as single units
/// - Ensuring proper MathML structure for complex expressions
/// - Creating properly grouped elements for operator precedence
pub fn build_expression_row(
    ctx: &KatexContext,
    expression: &[AnyParseNode],
    options: &Options,
    is_ordgroup: Option<bool>,
) -> Result<MathDomNode, ParseError> {
    let body = build_expression(ctx, expression, options, is_ordgroup)?;
    Ok(make_row(body))
}

/// Builds a MathML node from a single parse node using the appropriate group
/// builder
///
/// This function is the central dispatcher for converting individual parse
/// nodes into MathML DOM nodes. It determines the appropriate builder function
/// based on the node type and delegates the actual conversion work.
///
/// # Parameters
/// * `ctx` - The KaTeX context containing registered MathML group builders and
///   rendering state
/// * `group` - The parse node to be converted to MathML DOM
/// * `options` - Rendering options including style, size, and font settings
///
/// # Returns
/// A `Result` containing either the built MathML DOM node or a `ParseError` if
/// building fails.
///
/// # Behavior
/// - Determines the node type using `group.discriminant()`
/// - Looks up the appropriate builder function in `ctx.mathml_group_builders`
/// - Calls the registered builder with the provided options
/// - Returns the result from the specific builder function
///
/// # Error Handling
/// Returns `ParseError` if:
/// - No builder is registered for the given node type
/// - The registered builder function fails during execution
///
/// # Builder Registration
/// Builders are registered in the KaTeX context during initialization.
/// Each parse node type (e.g., fractions, symbols, operators) has its own
/// specialized builder function that handles the conversion to appropriate
/// MathML elements.
pub fn build_group(
    ctx: &KatexContext,
    group: &AnyParseNode,
    options: &Options,
) -> Result<MathDomNode, ParseError> {
    let group_type = group.discriminant();
    ctx.mathml_group_builders.get(&group_type).map_or_else(
        || {
            Err(ParseError::new(ParseErrorKind::UnknownGroupType {
                group_type,
            }))
        },
        |builder| builder(group, options, ctx),
    )
}

/// Main entry point for building MathML from a parse tree
///
/// This function converts a complete mathematical expression parse tree into
/// a properly structured MathML document wrapped in an HTML span for rendering.
/// It handles the full MathML document structure including semantics and
/// annotations.
///
/// # Parameters
/// * `ctx` - The KaTeX context containing all necessary builders and rendering
///   state
/// * `tree` - The complete parse tree representing the mathematical expression
/// * `tex_expression` - The original TeX/LaTeX source string for annotation
/// * `options` - Rendering options including style, size, color, and display
///   settings
/// * `is_display_mode` - Whether to render in display mode (block) or inline
///   mode
/// * `for_mathml_only` - Whether this is for MathML-only output (affects
///   wrapper class)
///
/// # Returns
/// A `Result` containing either a `DomSpan` with the complete MathML structure
/// or a `ParseError` if building fails.
///
/// # Behavior
/// - Builds the expression using build_expression with root group settings
/// - Wraps content in proper MathML structure with namespace and display
///   attributes
/// - Adds semantic annotations with the original TeX source
/// - Handles both display and inline math modes
/// - Applies appropriate CSS wrapper classes for styling
///
/// # Error Handling
/// Returns `ParseError` if:
/// - Expression building fails
/// - MathML structure creation encounters issues
/// - DOM manipulation fails
pub fn build_mathml(
    ctx: &KatexContext,
    tree: &[AnyParseNode],
    tex_expression: &str,
    options: &Options,
    is_display_mode: bool,
    for_mathml_only: bool,
) -> Result<DomSpan, ParseError> {
    let expression = build_expression(ctx, tree, options, None)?;

    // Expression is already MathDomNodeEnum
    let expression_enum = expression;

    // Wrap in mrow if needed using MathDomNodeEnum
    let wrapper_enum = if expression_enum.len() == 1 {
        if let Some(math_node) = expression_enum[0].as_math_node() {
            if matches!(
                math_node.node_type,
                MathNodeType::Mrow | MathNodeType::Mtable
            ) {
                expression_enum[0].clone()
            } else {
                MathDomNode::Math(MathNode {
                    node_type: MathNodeType::Mrow,
                    attributes: KeyMap::default(),
                    children: expression_enum.clone(),
                    classes: ClassList::Empty,
                })
            }
        } else {
            MathDomNode::Math(MathNode {
                node_type: MathNodeType::Mrow,
                attributes: KeyMap::default(),
                children: expression_enum,
                classes: ClassList::Empty,
            })
        }
    } else {
        MathDomNode::Math(MathNode {
            node_type: MathNodeType::Mrow,
            attributes: KeyMap::default(),
            children: expression_enum,
            classes: ClassList::Empty,
        })
    };

    // Create annotation using MathDomNodeEnum
    let annotation_enum = MathDomNode::Math(MathNode {
        node_type: MathNodeType::Annotation,
        attributes: KeyMap::default(),
        children: vec![MathDomNode::Text(TextNode {
            text: tex_expression.to_owned(),
        })],
        classes: ClassList::Empty,
    });

    // Set encoding
    let annotation_with_encoding = if let MathDomNode::Math(mut node) = annotation_enum {
        node.attributes
            .insert("encoding".to_owned(), "application/x-tex".to_owned());
        MathDomNode::Math(node)
    } else {
        annotation_enum
    };

    // Create semantics using MathDomNodeEnum
    let semantics_enum = MathDomNode::Math(MathNode {
        node_type: MathNodeType::Semantics,
        attributes: KeyMap::default(),
        children: vec![wrapper_enum, annotation_with_encoding],
        classes: ClassList::Empty,
    });

    // Create math element using MathDomNodeEnum
    let mut math_enum = MathDomNode::Math(MathNode {
        node_type: MathNodeType::Math,
        attributes: KeyMap::default(),
        children: vec![semantics_enum],
        classes: ClassList::Empty,
    });

    // Set namespace and display mode
    if let MathDomNode::Math(ref mut math_node) = math_enum {
        math_node.attributes.insert(
            "xmlns".to_owned(),
            "http://www.w3.org/1998/Math/MathML".to_owned(),
        );

        if is_display_mode {
            math_node
                .attributes
                .insert("display".to_owned(), "block".to_owned());
        }
    }

    // Convert back to MathNode for HtmlNode::MathML
    let math_node = if let MathDomNode::Math(node) = math_enum {
        node
    } else {
        // Fallback
        MathNode::builder().node_type(MathNodeType::Math).build()
    };

    // Wrap in span for styling
    let wrapper_class = if for_mathml_only {
        "katex"
    } else {
        "katex-mathml"
    };

    Ok(make_span(
        ClassList::Static(wrapper_class),
        vec![HtmlDomNode::MathML(math_node)],
        None,
        None,
    ))
}