Skip to main content

math_core_renderer_internal/
ast.rs

1use std::fmt::{self, Write};
2use std::num::NonZeroU16;
3
4#[cfg(feature = "serde")]
5use serde::Serialize;
6
7use crate::fmt::new_line_and_indent;
8use crate::itoa::append_u8_as_hex;
9use crate::length::{Length, LengthUnit, LengthValue};
10use crate::symbol::MathMLOperator;
11use crate::table::{Alignment, ArraySpec, ColumnGenerator, LineType, RIGHT_ALIGN, RowLabelInfo};
12use crate::{
13    attribute::{
14        FracAttr, HtmlTextSize, HtmlTextStyle, LetterAttr, MathSpacing, Notation, OpAttrs, Size,
15        Style,
16    },
17    super_char::SuperChar,
18};
19
20/// A wrapper around `str` to do HTML escaping
21/// in the `Display` impl.
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23#[cfg_attr(feature = "serde", derive(Serialize))]
24#[cfg_attr(feature = "serde", serde(transparent))]
25#[repr(transparent)]
26struct EscapeHtml<'a>(&'a str);
27
28impl fmt::Display for EscapeHtml<'_> {
29    #[inline]
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        for c in self.0.chars() {
32            match c {
33                '<' => write!(f, "&lt;")?,
34                '>' => write!(f, "&gt;")?,
35                '&' => write!(f, "&amp;")?,
36                '"' => write!(f, "&quot;")?,
37                '\'' => write!(f, "&#39;")?,
38                _ => write!(f, "{c}")?,
39            }
40        }
41        Ok(())
42    }
43}
44
45/// Stores the contents of [`Node::AHref`].
46/// Needs to be a separate `struct` to keep [`Node`]
47/// 4 words in size
48#[derive(Clone, Copy, Debug)]
49#[cfg_attr(feature = "serde", derive(Serialize))]
50pub struct AHref<'arena> {
51    pub href: &'arena str,
52    pub text: &'arena str,
53}
54
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
56#[cfg_attr(feature = "serde", derive(Serialize))]
57pub struct RowAttrs {
58    // `color: …;` CSS property
59    pub color: Option<(u8, u8, u8)>,
60    // `style` attribute
61    pub style: Option<Style>,
62    // `math-shift: compact;` CSS property
63    pub math_shift_compact: bool,
64}
65
66impl RowAttrs {
67    pub const DEFAULT: Self = Self {
68        color: None,
69        style: None,
70        math_shift_compact: false,
71    };
72}
73
74/// A single sub/sup pair in [`Multicripts`].
75#[derive(Clone, Copy, Debug)]
76#[cfg_attr(feature = "serde", derive(Serialize))]
77pub struct MultiscriptPair<'arena> {
78    pub sub: &'arena Node<'arena>,
79    pub sup: &'arena Node<'arena>,
80}
81
82/// AST node
83#[derive(Debug)]
84#[cfg_attr(feature = "serde", derive(Serialize))]
85pub enum Node<'arena> {
86    /// `<mn>...</mn>`
87    Number(&'arena str),
88    /// `<mi>...</mi>` for a [`SuperChar`].
89    IdentifierChar(SuperChar, LetterAttr),
90    /// `<mi>...</mi>` for a string.
91    IdentifierStr(&'arena str),
92    /// `<mo>...</mo>` for a single character.
93    Operator {
94        op: MathMLOperator,
95        attrs: OpAttrs,
96        size: Option<Size>,
97        left: Option<MathSpacing>,
98        right: Option<MathSpacing>,
99    },
100    /// `<mo>...</mo>` for a string.
101    PseudoOp {
102        attrs: OpAttrs,
103        left: Option<MathSpacing>,
104        right: Option<MathSpacing>,
105        name: &'arena str,
106    },
107    /// `<mspace width="..."/>`
108    Space(Length),
109    /// `<msub>...</msub>`
110    Sub {
111        target: &'arena Node<'arena>,
112        symbol: &'arena Node<'arena>,
113    },
114    /// `<msup>...</msup>`
115    Sup {
116        target: &'arena Node<'arena>,
117        symbol: &'arena Node<'arena>,
118    },
119    /// `<msubsup>...</msubsup>`
120    SubSup {
121        target: &'arena Node<'arena>,
122        sub: &'arena Node<'arena>,
123        sup: &'arena Node<'arena>,
124    },
125    /// `<mover accent="true">...</mover>`
126    OverAccent(MathMLOperator, OpAttrs, &'arena Node<'arena>),
127    /// `<munder accentunder="true">...</munder>`
128    UnderAccent(MathMLOperator, OpAttrs, &'arena Node<'arena>),
129    /// `<mover>...</mover>`
130    Over {
131        symbol: &'arena Node<'arena>,
132        target: &'arena Node<'arena>,
133    },
134    /// `<munder>...</munder>`
135    Under {
136        symbol: &'arena Node<'arena>,
137        target: &'arena Node<'arena>,
138    },
139    /// `<munderover>...</munderover>`
140    UnderOver {
141        target: &'arena Node<'arena>,
142        under: &'arena Node<'arena>,
143        over: &'arena Node<'arena>,
144    },
145    /// `<msqrt>...</msqrt>`
146    Sqrt(&'arena Node<'arena>),
147    /// `<mroot>...</mroot>`
148    Root(&'arena Node<'arena>, &'arena Node<'arena>),
149    /// `<mfrac>...</mfrac>`
150    Frac {
151        /// Numerator
152        num: &'arena Node<'arena>,
153        /// Denominator
154        denom: &'arena Node<'arena>,
155        /// Line thickness
156        lt_value: LengthValue,
157        lt_unit: LengthUnit,
158        attr: Option<FracAttr>,
159    },
160    /// `<mrow>...</mrow>`
161    Row {
162        nodes: &'arena [&'arena Node<'arena>],
163        attrs: RowAttrs,
164    },
165    /// `<mpadded>...</mpadded>`
166    Padded {
167        node: &'arena Node<'arena>,
168        width_0: bool,
169        height_0: bool,
170    },
171    /// `<mphantom>...</mphantom>`
172    Phantom { node: &'arena Node<'arena> },
173    /// `<mtext>...</mtext>`.
174    /// The `str` gets HTML-escaped.
175    Text(Option<HtmlTextStyle>, Option<HtmlTextSize>, &'arena str),
176    /// `<mtext><a href="...">...</mtext>`.
177    /// The link and text get HTML-escaped.
178    AHref(&'arena AHref<'arena>),
179    /// `<mtable>...</mtable>` for matrices and similar constructs
180    Table {
181        align: Alignment,
182        style: Option<Style>,
183        content: &'arena [&'arena Node<'arena>],
184    },
185    /// `<mtable>...</mtable>` for equation arrays like the `align` environment
186    EquationArray {
187        align: Alignment,
188        last_row_info: Option<&'arena RowLabelInfo<'arena>>,
189        content: &'arena [&'arena Node<'arena>],
190    },
191    /// `<mtable>...</mtable>` for the `multline` environment
192    MultLine {
193        num_rows: NonZeroU16,
194        last_row_info: Option<&'arena RowLabelInfo<'arena>>,
195        content: &'arena [&'arena Node<'arena>],
196    },
197    /// `<mtable>...</mtable>` for arrays
198    Array {
199        style: Option<Style>,
200        array_spec: &'arena ArraySpec<'arena>,
201        content: &'arena [&'arena Node<'arena>],
202    },
203    /// `<mtd>...</mtd>`
204    ColumnSeparator,
205    /// `<mtr>...</mtr>`
206    RowSeparator {
207        tag: Option<NonZeroU16>,
208        link_target: Option<&'arena str>,
209    },
210    /// `<menclose>...</menclose>`
211    Enclose {
212        content: &'arena Node<'arena>,
213        notation: Notation,
214    },
215    /// `<mmultiscripts>...</mmultiscripts>`
216    /// Double pointer indirection is to keep `Node`'s size down.
217    /// Ideally we would use some sort of thinslice type
218    Multiscripts {
219        base: &'arena Node<'arena>,
220        pre: &'arena &'arena [MultiscriptPair<'arena>],
221        post: &'arena &'arena [MultiscriptPair<'arena>],
222    },
223    /// This node is used for displaying unknown commands.
224    /// We are not using `<merror>` here because it's rendered with a red border and a yellow
225    /// background, which is not desirable for our use case. We'll just render it as `<mtext>`
226    /// with a custom style.
227    UnknownCommand(&'arena str),
228}
229
230#[cfg(target_arch = "wasm32")]
231static_assertions::assert_eq_size!(Node<'_>, [usize; 4]);
232
233macro_rules! writeln_indent {
234    ($buf:expr, $indent:expr, $($tail:tt)+) => {
235        new_line_and_indent($buf, $indent);
236        write!($buf, $($tail)+)?
237    };
238}
239
240impl Node<'_> {
241    pub const EMPTY_ROW: Self = Self::Row {
242        nodes: &[],
243        attrs: RowAttrs::DEFAULT,
244    };
245
246    pub fn emit(&self, s: &mut String, base_indent: usize) -> std::fmt::Result {
247        // Compute the indentation for the children of the node.
248        let child_indent = if base_indent > 0 {
249            base_indent.saturating_add(1)
250        } else {
251            0
252        };
253
254        // Get the base indentation out of the way.
255        new_line_and_indent(s, base_indent);
256
257        match self {
258            Node::Number(number) => {
259                write!(s, "<mn>{number}</mn>")?;
260            }
261            Node::IdentifierChar(letter, attr) => {
262                let is_upright = matches!(attr, LetterAttr::ForcedUpright);
263                // Only set "mathvariant" if we are not transforming the letter.
264                let write_mrow;
265                if is_upright {
266                    write!(s, "<mrow><mspace/><mi mathvariant=\"normal\">")?;
267                    write_mrow = true;
268                } else if letter.try_as_char().is_none() {
269                    // check if multi-char
270                    write!(s, "<mrow><mspace/><mi>")?;
271                    write_mrow = true;
272                } else {
273                    write!(s, "<mi>")?;
274                    write_mrow = false;
275                }
276                let c = *letter;
277                write!(s, "{c}</mi>")?;
278                if write_mrow {
279                    write!(s, "</mrow>")?;
280                }
281            }
282            Node::Operator {
283                op,
284                attrs,
285                left,
286                right,
287                size,
288            } => {
289                emit_operator_attributes(s, *attrs, *left, *right)?;
290                if let Some(size) = size {
291                    write!(
292                        s,
293                        " minsize=\"{}\" maxsize=\"{}\"",
294                        <&str>::from(size),
295                        <&str>::from(size),
296                    )?;
297                }
298                write!(s, ">{op}</mo>")?;
299            }
300            Node::PseudoOp {
301                attrs,
302                left,
303                right,
304                name,
305            } => {
306                emit_operator_attributes(s, *attrs, *left, *right)?;
307                write!(s, ">{name}</mo>")?;
308            }
309            Node::IdentifierStr(letters) => {
310                // The "<mrow>" with "<mspace/>" is needed to prevent Firefox from adding
311                // extra space around multi-letter identifiers.
312                debug_assert!(
313                    letters.chars().count() > 1,
314                    "single-letter IdentifierStr should be IdentifierChar"
315                );
316                write!(s, "<mrow><mspace/><mi>{}</mi></mrow>", EscapeHtml(letters))?;
317            }
318            Node::Text(text_style, text_size, letters) => {
319                write!(s, "<mtext")?;
320                if let Some(size) = text_size {
321                    write!(s, " style=\"font-size:{}\"", <&str>::from(size))?;
322                }
323                let (open, close) = match text_style {
324                    None => ("", ""),
325                    Some(HtmlTextStyle::Bold) => ("<b>", "</b>"),
326                    Some(HtmlTextStyle::Italic) => ("<i>", "</i>"),
327                    Some(HtmlTextStyle::BoldItalic) => ("<b><i>", "</i></b>"),
328                    Some(HtmlTextStyle::Emphasis) => ("<em>", "</em>"),
329                    Some(HtmlTextStyle::Typewriter) => ("<code>", "</code>"),
330                    Some(HtmlTextStyle::SmallCaps) => {
331                        ("<span style=\"font-variant-caps: small-caps\">", "</span>")
332                    }
333                    Some(HtmlTextStyle::SansSerif) => {
334                        ("<span class=\"math-core-sans-serif-font\">", "</span>")
335                    }
336                    Some(HtmlTextStyle::Serif) => {
337                        ("<span class=\"math-core-serif-font\">", "</span>")
338                    }
339                    Some(HtmlTextStyle::Strikethrough) => ("<s>", "</s>"),
340                };
341                write!(s, ">{open}{}{close}</mtext>", EscapeHtml(letters))?;
342            }
343            Node::Space(space) => {
344                write!(s, "<mspace width=\"")?;
345                space.push_to_string(s);
346                // Work-around for a Firefox bug that causes "rem" to not be processed correctly
347                if matches!(space.unit, LengthUnit::Rem) {
348                    write!(s, "\" style=\"width:")?;
349                    space.push_to_string(s);
350                }
351                write!(s, "\"/>")?;
352            }
353            // The following nodes have exactly two children.
354            node @ (Node::Sub {
355                symbol: second,
356                target: first,
357            }
358            | Node::Sup {
359                symbol: second,
360                target: first,
361            }
362            | Node::Over {
363                symbol: second,
364                target: first,
365            }
366            | Node::Under {
367                symbol: second,
368                target: first,
369            }
370            | Node::Root(second, first)) => {
371                let (open, close) = match node {
372                    Node::Sub { .. } => ("<msub>", "</msub>"),
373                    Node::Sup { .. } => ("<msup>", "</msup>"),
374                    Node::Over { .. } => ("<mover>", "</mover>"),
375                    Node::Under { .. } => ("<munder>", "</munder>"),
376                    Node::Root(_, _) => ("<mroot>", "</mroot>"),
377                    // Compiler is able to infer that this is unreachable.
378                    _ => unreachable!(),
379                };
380                write!(s, "{open}")?;
381                first.emit(s, child_indent)?;
382                second.emit(s, child_indent)?;
383                writeln_indent!(s, base_indent, "{close}");
384            }
385            // The following nodes have exactly three children.
386            node @ (Node::SubSup {
387                target: first,
388                sub: second,
389                sup: third,
390            }
391            | Node::UnderOver {
392                target: first,
393                under: second,
394                over: third,
395            }) => {
396                let (open, close) = match node {
397                    Node::SubSup { .. } => ("<msubsup>", "</msubsup>"),
398                    Node::UnderOver { .. } => ("<munderover>", "</munderover>"),
399                    // Compiler is able to infer that this is unreachable.
400                    _ => unreachable!(),
401                };
402                write!(s, "{open}")?;
403                first.emit(s, child_indent)?;
404                second.emit(s, child_indent)?;
405                third.emit(s, child_indent)?;
406                writeln_indent!(s, base_indent, "{close}");
407            }
408            &Node::Multiscripts { base, pre, post } => {
409                write!(s, "<mmultiscripts>")?;
410                base.emit(s, child_indent)?;
411                for &MultiscriptPair { sub, sup } in *post {
412                    sub.emit(s, child_indent)?;
413                    sup.emit(s, child_indent)?;
414                }
415                if !pre.is_empty() {
416                    writeln_indent!(s, child_indent, "<mprescripts/>");
417                    for &MultiscriptPair { sub, sup } in *pre {
418                        sub.emit(s, child_indent)?;
419                        sup.emit(s, child_indent)?;
420                    }
421                }
422
423                writeln_indent!(s, base_indent, "</mmultiscripts>");
424            }
425            node @ (Node::OverAccent(op, attr, target) | Node::UnderAccent(op, attr, target)) => {
426                let (open, close) = match node {
427                    Node::OverAccent(_, _, _) => ("<mover accent=\"true\">", "</mover>"),
428                    Node::UnderAccent(_, _, _) => ("<munder accentunder=\"true\">", "</munder>"),
429                    // Compiler is able to infer that this is unreachable.
430                    _ => unreachable!(),
431                };
432                write!(s, "{open}")?;
433                target.emit(s, child_indent)?;
434                writeln_indent!(s, child_indent, "<mo");
435                attr.write_to(s);
436                write!(s, ">{op}</mo>")?;
437                writeln_indent!(s, base_indent, "{close}");
438            }
439            Node::Sqrt(content) => {
440                write!(s, "<msqrt>")?;
441                content.emit(s, child_indent)?;
442                writeln_indent!(s, base_indent, "</msqrt>");
443            }
444            Node::Frac {
445                num,
446                denom: den,
447                lt_value: line_length,
448                lt_unit: line_unit,
449                attr,
450            } => {
451                write!(s, "<mfrac")?;
452                let lt = Length::from_parts(*line_length, *line_unit);
453                if let Some(lt) = lt {
454                    write!(s, " linethickness=\"")?;
455                    lt.push_to_string(s);
456                    write!(s, "\"")?;
457                }
458                if let Some(style) = attr {
459                    write!(s, "{}", <&str>::from(style))?;
460                }
461                write!(s, ">")?;
462                num.emit(s, child_indent)?;
463                den.emit(s, child_indent)?;
464                writeln_indent!(s, base_indent, "</mfrac>");
465            }
466            &Node::Row {
467                nodes,
468                attrs:
469                    RowAttrs {
470                        color,
471                        style,
472                        math_shift_compact,
473                    },
474            } => {
475                write!(s, "<mrow")?;
476
477                if color.is_some() || math_shift_compact {
478                    write!(s, " style=\"")?;
479                    if let Some((r, g, b)) = color {
480                        write!(s, "color:#")?;
481                        append_u8_as_hex(s, r);
482                        append_u8_as_hex(s, g);
483                        append_u8_as_hex(s, b);
484                        write!(s, ";")?;
485                    }
486                    if math_shift_compact {
487                        write!(s, "math-shift:compact;")?;
488                    }
489                    write!(s, "\"")?;
490                }
491
492                if let Some(style) = style {
493                    write!(s, "{}", <&str>::from(style))?;
494                }
495
496                write!(s, ">")?;
497
498                if nodes.is_empty() {
499                    write!(s, "</mrow>")?;
500                } else {
501                    for node in nodes {
502                        node.emit(s, child_indent)?;
503                    }
504                    writeln_indent!(s, base_indent, "</mrow>");
505                }
506            }
507            &Node::Padded {
508                node,
509                width_0,
510                height_0,
511            } => {
512                write!(s, "<mpadded")?;
513                if width_0 {
514                    write!(s, r#" width="0""#)?;
515                }
516                if height_0 {
517                    write!(s, r#" height="0""#)?;
518                }
519                write!(s, ">")?;
520                node.emit(s, child_indent)?;
521                writeln_indent!(s, base_indent, "</mpadded>");
522            }
523            Node::Phantom { node } => {
524                write!(s, "<mphantom>")?;
525                node.emit(s, child_indent)?;
526                writeln_indent!(s, base_indent, "</mphantom>");
527            }
528            Node::Table {
529                content,
530                align,
531                style,
532            } => {
533                let mtd_opening = ColumnGenerator::new_predefined(*align);
534
535                write!(s, "<mtable")?;
536                if let Some(style) = style {
537                    write!(s, "{}", <&str>::from(style))?;
538                }
539                write!(s, ">")?;
540                emit_table(
541                    s,
542                    base_indent,
543                    child_indent,
544                    content,
545                    mtd_opening,
546                    None,
547                    None,
548                )?;
549            }
550            node @ (Node::EquationArray {
551                last_row_info,
552                content,
553                ..
554            }
555            | Node::MultLine {
556                last_row_info,
557                content,
558                ..
559            }) => {
560                let (mtd_opening, numbering_cols) = match node {
561                    Node::EquationArray { align, .. } => {
562                        (ColumnGenerator::new_predefined(*align), NumberColums::Wide)
563                    }
564                    Node::MultLine { num_rows, .. } => (
565                        ColumnGenerator::new_multline(*num_rows),
566                        NumberColums::Narrow,
567                    ),
568                    _ => unreachable!(),
569                };
570
571                write!(
572                    s,
573                    r#"<mtable displaystyle="true" scriptlevel="0" style="width: 100%">"#
574                )?;
575                emit_table(
576                    s,
577                    base_indent,
578                    child_indent,
579                    content,
580                    mtd_opening,
581                    Some(numbering_cols),
582                    last_row_info.as_deref(),
583                )?;
584            }
585            Node::Array {
586                style,
587                content,
588                array_spec,
589            } => {
590                let mtd_opening = ColumnGenerator::new_custom(array_spec);
591                write!(s, "<mtable")?;
592                match array_spec.beginning_line {
593                    Some(LineType::Solid) => {
594                        write!(s, " style=\"border-left: 0.05em solid currentcolor\"")?;
595                    }
596                    Some(LineType::Dashed) => {
597                        write!(s, " style=\"border-left: 0.05em dashed currentcolor\"")?;
598                    }
599                    _ => (),
600                }
601                if let Some(style) = style {
602                    write!(s, "{}", <&str>::from(style))?;
603                }
604                write!(s, ">")?;
605                emit_table(
606                    s,
607                    base_indent,
608                    child_indent,
609                    content,
610                    mtd_opening,
611                    None,
612                    None,
613                )?;
614            }
615            Node::RowSeparator { .. } | Node::ColumnSeparator => {
616                // This should only appear in tables where it is handled in `emit_table`.
617                if cfg!(debug_assertions) {
618                    panic!("ColumnSeparator node should be handled in emit_table");
619                }
620            }
621            Node::Enclose { content, notation } => {
622                let notation = *notation;
623                write!(s, "<menclose notation=\"")?;
624                let mut first = true;
625                if notation.contains(Notation::UP_DIAGONAL) {
626                    write!(s, "updiagonalstrike")?;
627                    first = false;
628                }
629                if notation.contains(Notation::DOWN_DIAGONAL) {
630                    if !first {
631                        write!(s, " ")?;
632                    }
633                    write!(s, "downdiagonalstrike")?;
634                }
635                if notation.contains(Notation::HORIZONTAL) {
636                    if !first {
637                        write!(s, " ")?;
638                    }
639                    write!(s, "horizontalstrike")?;
640                }
641                write!(s, "\">")?;
642                content.emit(s, child_indent)?;
643                if notation.contains(Notation::UP_DIAGONAL) {
644                    writeln_indent!(
645                        s,
646                        child_indent,
647                        "<mrow class=\"menclose-updiagonalstrike\"></mrow>"
648                    );
649                }
650                if notation.contains(Notation::DOWN_DIAGONAL) {
651                    writeln_indent!(
652                        s,
653                        child_indent,
654                        "<mrow class=\"menclose-downdiagonalstrike\"></mrow>"
655                    );
656                }
657                if notation.contains(Notation::HORIZONTAL) {
658                    writeln_indent!(
659                        s,
660                        child_indent,
661                        "<mrow class=\"menclose-horizontalstrike\"></mrow>"
662                    );
663                }
664                writeln_indent!(s, base_indent, "</menclose>");
665            }
666            &Node::AHref(&AHref { href, text }) => {
667                write!(
668                    s,
669                    r#"<mtext><a href="{}">{}</a></mtext>"#,
670                    EscapeHtml(href),
671                    EscapeHtml(text)
672                )?;
673            }
674            Node::UnknownCommand(cmd_name) => {
675                write!(s, "<mtext style=\"color:#b22222\">\\{cmd_name}</mtext>")?;
676            }
677        }
678        Ok(())
679    }
680}
681
682fn emit_operator_attributes(
683    s: &mut String,
684    attrs: OpAttrs,
685    left: Option<MathSpacing>,
686    right: Option<MathSpacing>,
687) -> std::fmt::Result {
688    s.push_str("<mo");
689    attrs.write_to(s);
690    match (left, right) {
691        (Some(left), Some(right)) => {
692            write!(
693                s,
694                " lspace=\"{}\" rspace=\"{}\"",
695                <&str>::from(left),
696                <&str>::from(right)
697            )?;
698        }
699        (Some(left), None) => {
700            write!(s, " lspace=\"{}\"", <&str>::from(left))?;
701        }
702        (None, Some(right)) => {
703            write!(s, " rspace=\"{}\"", <&str>::from(right))?;
704        }
705        _ => {}
706    }
707    Ok(())
708}
709
710#[derive(Clone, Copy, Debug, PartialEq, Eq)]
711enum NumberColums {
712    Narrow,
713    Wide,
714}
715
716impl NumberColums {
717    fn dummy_column_opening(
718        self,
719        s: &mut String,
720        child_indent2: usize,
721    ) -> Result<(), std::fmt::Error> {
722        match self {
723            NumberColums::Narrow => {
724                writeln_indent!(s, child_indent2, r#"<mtd style="width: 7.5%"#);
725            }
726            NumberColums::Wide => {
727                writeln_indent!(s, child_indent2, r#"<mtd style="width: 50%"#);
728            }
729        }
730        Ok(())
731    }
732
733    /// Initial dummy column for equation numbering for keeping alignment.
734    #[inline]
735    fn initial_dummy_column(
736        self,
737        s: &mut String,
738        child_indent2: usize,
739    ) -> Result<(), std::fmt::Error> {
740        self.dummy_column_opening(s, child_indent2)?;
741        write!(s, "\"></mtd>")?;
742        Ok(())
743    }
744}
745
746fn emit_table(
747    s: &mut String,
748    base_indent: usize,
749    child_indent: usize,
750    content: &[&Node<'_>],
751    mut col_gen: ColumnGenerator,
752    numbering_cols: Option<NumberColums>,
753    last_row_info: Option<&RowLabelInfo>,
754) -> Result<(), std::fmt::Error> {
755    let child_indent2 = if base_indent > 0 {
756        child_indent.saturating_add(1)
757    } else {
758        0
759    };
760    let child_indent3 = if base_indent > 0 {
761        child_indent2.saturating_add(1)
762    } else {
763        0
764    };
765    writeln_indent!(s, child_indent, "<mtr>");
766    if let Some(numbering_cols) = numbering_cols {
767        numbering_cols.initial_dummy_column(s, child_indent2)?;
768    }
769    col_gen.write_next_mtd(s, child_indent2)?;
770    for node in content {
771        match node {
772            Node::ColumnSeparator => {
773                writeln_indent!(s, child_indent2, "</mtd>");
774                col_gen.write_next_mtd(s, child_indent2)?;
775            }
776            Node::RowSeparator { tag, link_target } => {
777                writeln_indent!(s, child_indent2, "</mtd>");
778                if let Some(numbering_cols) = numbering_cols {
779                    write_equation_num(
780                        s,
781                        child_indent2,
782                        child_indent3,
783                        tag.as_ref().copied(),
784                        *link_target,
785                        numbering_cols,
786                    )?;
787                }
788                writeln_indent!(s, child_indent, "</mtr>");
789                writeln_indent!(s, child_indent, "<mtr>");
790                if let Some(numbering_cols) = numbering_cols {
791                    numbering_cols.initial_dummy_column(s, child_indent2)?;
792                }
793                col_gen.reset_to_new_row();
794                col_gen.write_next_mtd(s, child_indent2)?;
795            }
796            node => {
797                node.emit(s, child_indent3)?;
798            }
799        }
800    }
801    writeln_indent!(s, child_indent2, "</mtd>");
802    if let Some(numbering_cols) = numbering_cols {
803        let (last_tag, last_link_target) = match last_row_info {
804            Some(info) => (info.tag, info.link_target),
805            None => (None, None),
806        };
807        write_equation_num(
808            s,
809            child_indent2,
810            child_indent3,
811            last_tag,
812            last_link_target,
813            numbering_cols,
814        )?;
815    }
816    writeln_indent!(s, child_indent, "</mtr>");
817    writeln_indent!(s, base_indent, "</mtable>");
818    Ok(())
819}
820
821fn write_equation_num(
822    s: &mut String,
823    child_indent2: usize,
824    child_indent3: usize,
825    tag: Option<NonZeroU16>,
826    link_target: Option<&str>,
827    numbering_cols: NumberColums,
828) -> Result<(), std::fmt::Error> {
829    numbering_cols.dummy_column_opening(s, child_indent2)?;
830    if let Some(tag) = tag {
831        write!(s, r#";{RIGHT_ALIGN}""#)?;
832        if let Some(link_target) = link_target {
833            write!(s, r#" id="{link_target}">"#)?;
834        } else {
835            write!(s, ">")?;
836        }
837        writeln_indent!(s, child_indent3, "<mtext>({})</mtext>", tag);
838        writeln_indent!(s, child_indent2, "</mtd>");
839    } else {
840        write!(s, "\"></mtd>")?;
841    }
842    Ok(())
843}
844
845#[cfg(test)]
846mod tests {
847    use super::super::symbol;
848    use super::super::table::{ColumnAlignment, ColumnSpec};
849    use super::*;
850
851    const WORD: usize = std::mem::size_of::<usize>();
852
853    #[test]
854    fn test_struct_sizes() {
855        assert!(std::mem::size_of::<Node>() <= 4 * WORD, "size of Node");
856    }
857
858    pub fn render<'a, 'b>(node: &'a Node<'b>) -> String
859    where
860        'a: 'b,
861    {
862        let mut output = String::new();
863        node.emit(&mut output, 0).unwrap();
864        output
865    }
866
867    #[test]
868    fn render_number() {
869        assert_eq!(render(&Node::Number("3.14")), "<mn>3.14</mn>");
870    }
871
872    #[test]
873    fn render_single_letter_ident() {
874        assert_eq!(
875            render(&Node::IdentifierChar('x'.into(), LetterAttr::Default)),
876            "<mi>x</mi>"
877        );
878        assert_eq!(
879            render(&Node::IdentifierChar('Γ'.into(), LetterAttr::ForcedUpright)),
880            "<mrow><mspace/><mi mathvariant=\"normal\">Γ</mi></mrow>"
881        );
882        assert_eq!(
883            render(&Node::IdentifierChar('𝑥'.into(), LetterAttr::Default)),
884            "<mi>𝑥</mi>"
885        );
886    }
887
888    #[test]
889    fn render_operator_with_spacing() {
890        assert_eq!(
891            render(&Node::Operator {
892                op: symbol::COLON.as_op(),
893                attrs: OpAttrs::empty(),
894                left: Some(MathSpacing::FourMu),
895                right: Some(MathSpacing::FourMu),
896                size: None,
897            }),
898            "<mo lspace=\"0.2222em\" rspace=\"0.2222em\">:</mo>"
899        );
900        assert_eq!(
901            render(&Node::Operator {
902                op: symbol::COLON.as_op(),
903                attrs: OpAttrs::empty(),
904                left: Some(MathSpacing::FourMu),
905                right: Some(MathSpacing::Zero),
906                size: None,
907            }),
908            "<mo lspace=\"0.2222em\" rspace=\"0\">:</mo>"
909        );
910        assert_eq!(
911            render(&Node::Operator {
912                op: symbol::IDENTICAL_TO.as_op(),
913                attrs: OpAttrs::empty(),
914                left: Some(MathSpacing::Zero),
915                right: None,
916                size: None,
917            }),
918            "<mo lspace=\"0\">≡</mo>"
919        );
920        assert_eq!(
921            render(&Node::Operator {
922                op: symbol::PLUS_SIGN.as_op(),
923                attrs: OpAttrs::FORM_PREFIX,
924                left: None,
925                right: None,
926                size: None,
927            }),
928            "<mo form=\"prefix\">+</mo>"
929        );
930        assert_eq!(
931            render(&Node::Operator {
932                op: symbol::N_ARY_SUMMATION.as_op(),
933                attrs: OpAttrs::NO_MOVABLE_LIMITS,
934                left: None,
935                right: None,
936                size: None,
937            }),
938            "<mo movablelimits=\"false\">∑</mo>"
939        );
940    }
941
942    #[test]
943    fn render_pseudo_operator() {
944        assert_eq!(
945            render(&Node::PseudoOp {
946                attrs: OpAttrs::empty(),
947                left: Some(MathSpacing::ThreeMu),
948                right: Some(MathSpacing::ThreeMu),
949                name: "sin"
950            }),
951            "<mo lspace=\"0.1667em\" rspace=\"0.1667em\">sin</mo>"
952        );
953    }
954
955    #[test]
956    fn render_collected_letters() {
957        assert_eq!(
958            render(&Node::IdentifierStr("sin")),
959            "<mrow><mspace/><mi>sin</mi></mrow>"
960        );
961    }
962
963    #[test]
964    fn render_space() {
965        assert_eq!(
966            render(&Node::Space(Length::new(1.0, LengthUnit::Em))),
967            "<mspace width=\"1em\"/>"
968        );
969    }
970
971    #[test]
972    fn render_subscript() {
973        assert_eq!(
974            render(&Node::Sub {
975                target: &Node::IdentifierChar('x'.into(), LetterAttr::Default),
976                symbol: &Node::Number("2"),
977            }),
978            "<msub><mi>x</mi><mn>2</mn></msub>"
979        );
980    }
981
982    #[test]
983    fn render_superscript() {
984        assert_eq!(
985            render(&Node::Sup {
986                target: &Node::IdentifierChar('x'.into(), LetterAttr::Default),
987                symbol: &Node::Number("2"),
988            }),
989            "<msup><mi>x</mi><mn>2</mn></msup>"
990        );
991    }
992
993    #[test]
994    fn render_sub_sup() {
995        assert_eq!(
996            render(&Node::SubSup {
997                target: &Node::IdentifierChar('x'.into(), LetterAttr::Default),
998                sub: &Node::Number("1"),
999                sup: &Node::Number("2"),
1000            }),
1001            "<msubsup><mi>x</mi><mn>1</mn><mn>2</mn></msubsup>"
1002        );
1003    }
1004
1005    #[test]
1006    fn render_over_op() {
1007        assert_eq!(
1008            render(&Node::OverAccent(
1009                symbol::MACRON,
1010                OpAttrs::STRETCHY_FALSE,
1011                &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1012            )),
1013            "<mover accent=\"true\"><mi>x</mi><mo stretchy=\"false\">¯</mo></mover>"
1014        );
1015        assert_eq!(
1016            render(&Node::OverAccent(
1017                symbol::OVERLINE,
1018                OpAttrs::empty(),
1019                &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1020            )),
1021            "<mover accent=\"true\"><mi>x</mi><mo>‾</mo></mover>"
1022        );
1023    }
1024
1025    #[test]
1026    fn render_under_op() {
1027        assert_eq!(
1028            render(&Node::UnderAccent(
1029                symbol::LOW_LINE.as_op(),
1030                OpAttrs::empty(),
1031                &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1032            )),
1033            "<munder accentunder=\"true\"><mi>x</mi><mo>_</mo></munder>"
1034        );
1035    }
1036
1037    #[test]
1038    fn render_overset() {
1039        assert_eq!(
1040            render(&Node::Over {
1041                symbol: &Node::Operator {
1042                    op: symbol::EXCLAMATION_MARK.as_op(),
1043                    attrs: OpAttrs::empty(),
1044                    left: None,
1045                    right: None,
1046                    size: None,
1047                },
1048                target: &Node::Operator {
1049                    op: symbol::EQUALS_SIGN.as_op(),
1050                    attrs: OpAttrs::empty(),
1051                    left: None,
1052                    right: None,
1053                    size: None,
1054                },
1055            }),
1056            "<mover><mo>=</mo><mo>!</mo></mover>"
1057        );
1058    }
1059
1060    #[test]
1061    fn render_underset() {
1062        assert_eq!(
1063            render(&Node::Under {
1064                symbol: &Node::IdentifierChar('θ'.into(), LetterAttr::Default),
1065                target: &Node::PseudoOp {
1066                    attrs: OpAttrs::FORCE_MOVABLE_LIMITS,
1067                    left: Some(MathSpacing::ThreeMu),
1068                    right: Some(MathSpacing::ThreeMu),
1069                    name: "min",
1070                },
1071            }),
1072            "<munder><mo movablelimits=\"true\" lspace=\"0.1667em\" rspace=\"0.1667em\">min</mo><mi>θ</mi></munder>"
1073        );
1074    }
1075
1076    #[test]
1077    fn render_under_over() {
1078        assert_eq!(
1079            render(&Node::UnderOver {
1080                target: &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1081                under: &Node::Number("1"),
1082                over: &Node::Number("2"),
1083            }),
1084            "<munderover><mi>x</mi><mn>1</mn><mn>2</mn></munderover>"
1085        );
1086    }
1087
1088    #[test]
1089    fn render_sqrt() {
1090        assert_eq!(
1091            render(&Node::Sqrt(&Node::IdentifierChar(
1092                'x'.into(),
1093                LetterAttr::Default
1094            ))),
1095            "<msqrt><mi>x</mi></msqrt>"
1096        );
1097    }
1098
1099    #[test]
1100    fn render_root() {
1101        assert_eq!(
1102            render(&Node::Root(
1103                &Node::Number("3"),
1104                &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1105            )),
1106            "<mroot><mi>x</mi><mn>3</mn></mroot>"
1107        );
1108    }
1109
1110    #[test]
1111    fn render_frac() {
1112        let num = &Node::Number("1");
1113        let denom = &Node::Number("2");
1114        let (lt_value, lt_unit) = Length::none().into_parts();
1115        assert_eq!(
1116            render(&Node::Frac {
1117                num,
1118                denom,
1119                lt_value,
1120                lt_unit,
1121                attr: None,
1122            }),
1123            "<mfrac><mn>1</mn><mn>2</mn></mfrac>"
1124        );
1125        assert_eq!(
1126            render(&Node::Frac {
1127                num,
1128                denom,
1129                lt_value,
1130                lt_unit,
1131                attr: Some(FracAttr::DisplayStyleTrue),
1132            }),
1133            "<mfrac displaystyle=\"true\"><mn>1</mn><mn>2</mn></mfrac>"
1134        );
1135        assert_eq!(
1136            render(&Node::Frac {
1137                num,
1138                denom,
1139                lt_value,
1140                lt_unit,
1141                attr: Some(FracAttr::DisplayStyleFalse),
1142            }),
1143            "<mfrac displaystyle=\"false\"><mn>1</mn><mn>2</mn></mfrac>"
1144        );
1145        let (lt_value, lt_unit) = Length::new(-1.0, LengthUnit::Rem).into_parts();
1146        assert_eq!(
1147            render(&Node::Frac {
1148                num,
1149                denom,
1150                lt_value,
1151                lt_unit,
1152                attr: None,
1153            }),
1154            "<mfrac linethickness=\"-1rem\"><mn>1</mn><mn>2</mn></mfrac>"
1155        );
1156        assert_eq!(
1157            render(&Node::Frac {
1158                num,
1159                denom,
1160                lt_value: LengthValue(1.0),
1161                lt_unit: LengthUnit::Em,
1162                attr: None,
1163            }),
1164            "<mfrac linethickness=\"1em\"><mn>1</mn><mn>2</mn></mfrac>"
1165        );
1166        assert_eq!(
1167            render(&Node::Frac {
1168                num,
1169                denom,
1170                lt_value: LengthValue(-1.0),
1171                lt_unit: LengthUnit::Ex,
1172                attr: None,
1173            }),
1174            "<mfrac linethickness=\"-1ex\"><mn>1</mn><mn>2</mn></mfrac>"
1175        );
1176        let (lt_value, lt_unit) = Length::new(2.0, LengthUnit::Rem).into_parts();
1177        assert_eq!(
1178            render(&Node::Frac {
1179                num,
1180                denom,
1181                lt_value,
1182                lt_unit,
1183                attr: None,
1184            }),
1185            "<mfrac linethickness=\"2rem\"><mn>1</mn><mn>2</mn></mfrac>"
1186        );
1187        let (lt_value, lt_unit) = Length::zero().into_parts();
1188        assert_eq!(
1189            render(&Node::Frac {
1190                num,
1191                denom,
1192                lt_value,
1193                lt_unit,
1194                attr: Some(FracAttr::DisplayStyleTrue),
1195            }),
1196            "<mfrac linethickness=\"0\" displaystyle=\"true\"><mn>1</mn><mn>2</mn></mfrac>"
1197        );
1198    }
1199
1200    #[test]
1201    fn render_row() {
1202        let nodes = &[
1203            &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1204            &Node::Operator {
1205                op: symbol::EQUALS_SIGN.as_op(),
1206                attrs: OpAttrs::empty(),
1207                left: None,
1208                right: None,
1209                size: None,
1210            },
1211            &Node::Number("1"),
1212        ];
1213
1214        assert_eq!(
1215            render(&Node::Row {
1216                nodes,
1217                attrs: RowAttrs {
1218                    style: Some(Style::Display),
1219                    ..RowAttrs::DEFAULT
1220                }
1221            }),
1222            "<mrow displaystyle=\"true\" scriptlevel=\"0\"><mi>x</mi><mo>=</mo><mn>1</mn></mrow>"
1223        );
1224
1225        assert_eq!(
1226            render(&Node::Row {
1227                nodes,
1228                attrs: RowAttrs {
1229                    color: Some((0, 0, 0)),
1230                    ..RowAttrs::DEFAULT
1231                }
1232            }),
1233            "<mrow style=\"color:#000000;\"><mi>x</mi><mo>=</mo><mn>1</mn></mrow>"
1234        );
1235    }
1236
1237    #[test]
1238    fn render_padded() {
1239        assert_eq!(
1240            render(&Node::Padded {
1241                node: &Node::Number("x"),
1242                width_0: true,
1243                height_0: false,
1244            }),
1245            "<mpadded width=\"0\"><mn>x</mn></mpadded>"
1246        );
1247    }
1248
1249    #[test]
1250    fn render_phantom() {
1251        assert_eq!(
1252            render(&Node::Phantom {
1253                node: &Node::Number("x")
1254            }),
1255            "<mphantom><mn>x</mn></mphantom>"
1256        );
1257    }
1258
1259    #[test]
1260    fn render_sized_operator() {
1261        assert_eq!(
1262            render(&Node::Operator {
1263                op: symbol::LEFT_PARENTHESIS.as_op(),
1264                attrs: OpAttrs::empty(),
1265                size: Some(Size::Scale1),
1266                left: None,
1267                right: None,
1268            }),
1269            "<mo minsize=\"1.2em\" maxsize=\"1.2em\">(</mo>"
1270        );
1271        assert_eq!(
1272            render(&Node::Operator {
1273                op: symbol::SOLIDUS.as_op(),
1274                attrs: OpAttrs::STRETCHY_TRUE | OpAttrs::SYMMETRIC_TRUE,
1275                size: Some(Size::Scale3),
1276                left: Some(MathSpacing::Zero),
1277                right: Some(MathSpacing::Zero),
1278            }),
1279            "<mo stretchy=\"true\" symmetric=\"true\" lspace=\"0\" rspace=\"0\" minsize=\"2.047em\" maxsize=\"2.047em\">/</mo>"
1280        );
1281    }
1282
1283    #[test]
1284    fn render_text() {
1285        assert_eq!(
1286            render(&Node::Text(None, None, "hello")),
1287            "<mtext>hello</mtext>"
1288        );
1289    }
1290
1291    #[test]
1292    fn render_table() {
1293        let nodes = [
1294            &Node::Number("1"),
1295            &Node::ColumnSeparator,
1296            &Node::Number("2"),
1297            &Node::RowSeparator {
1298                tag: None,
1299                link_target: None,
1300            },
1301            &Node::Number("3"),
1302            &Node::ColumnSeparator,
1303            &Node::Number("4"),
1304        ];
1305
1306        assert_eq!(
1307            render(&Node::Table {
1308                content: &nodes,
1309                align: Alignment::Centered,
1310                style: None,
1311            }),
1312            "<mtable><mtr><mtd><mn>1</mn></mtd><mtd><mn>2</mn></mtd></mtr><mtr><mtd><mn>3</mn></mtd><mtd><mn>4</mn></mtd></mtr></mtable>"
1313        );
1314    }
1315
1316    #[test]
1317    fn render_equation_array() {
1318        let nodes = [
1319            &Node::Number("1"),
1320            &Node::ColumnSeparator,
1321            &Node::Number("2"),
1322            &Node::RowSeparator {
1323                tag: NonZeroU16::new(1),
1324                link_target: None,
1325            },
1326            &Node::Number("3"),
1327            &Node::ColumnSeparator,
1328            &Node::Number("4"),
1329        ];
1330
1331        let info_with_tag = RowLabelInfo {
1332            tag: NonZeroU16::new(2),
1333            link_target: None,
1334        };
1335        assert_eq!(
1336            render(&Node::EquationArray {
1337                content: &nodes,
1338                align: Alignment::Centered,
1339                last_row_info: Some(&info_with_tag),
1340            }),
1341            "<mtable displaystyle=\"true\" scriptlevel=\"0\" style=\"width: 100%\"><mtr><mtd style=\"width: 50%\"></mtd><mtd><mn>1</mn></mtd><mtd><mn>2</mn></mtd><mtd style=\"width: 50%;text-align: right;justify-items: end;\"><mtext>(1)</mtext></mtd></mtr><mtr><mtd style=\"width: 50%\"></mtd><mtd><mn>3</mn></mtd><mtd><mn>4</mn></mtd><mtd style=\"width: 50%;text-align: right;justify-items: end;\"><mtext>(2)</mtext></mtd></mtr></mtable>"
1342        );
1343
1344        assert_eq!(
1345            render(&Node::EquationArray {
1346                content: &nodes,
1347                align: Alignment::Centered,
1348                last_row_info: None,
1349            }),
1350            "<mtable displaystyle=\"true\" scriptlevel=\"0\" style=\"width: 100%\"><mtr><mtd style=\"width: 50%\"></mtd><mtd><mn>1</mn></mtd><mtd><mn>2</mn></mtd><mtd style=\"width: 50%;text-align: right;justify-items: end;\"><mtext>(1)</mtext></mtd></mtr><mtr><mtd style=\"width: 50%\"></mtd><mtd><mn>3</mn></mtd><mtd><mn>4</mn></mtd><mtd style=\"width: 50%\"></mtd></mtr></mtable>"
1351        );
1352    }
1353
1354    #[test]
1355    fn render_array() {
1356        let nodes = [
1357            &Node::Number("1"),
1358            &Node::ColumnSeparator,
1359            &Node::Number("2"),
1360            &Node::RowSeparator {
1361                tag: None,
1362                link_target: None,
1363            },
1364            &Node::Number("3"),
1365            &Node::ColumnSeparator,
1366            &Node::Number("4"),
1367        ];
1368
1369        assert_eq!(
1370            render(&Node::Array {
1371                style: None,
1372                content: &nodes,
1373                array_spec: &ArraySpec {
1374                    beginning_line: None,
1375                    is_sub: false,
1376                    column_spec: &[
1377                        ColumnSpec::WithContent(ColumnAlignment::LeftJustified, None),
1378                        ColumnSpec::WithContent(ColumnAlignment::Centered, None),
1379                    ],
1380                },
1381            }),
1382            "<mtable><mtr><mtd style=\"text-align: left;justify-items: start;\"><mn>1</mn></mtd><mtd><mn>2</mn></mtd></mtr><mtr><mtd style=\"text-align: left;justify-items: start;\"><mn>3</mn></mtd><mtd><mn>4</mn></mtd></mtr></mtable>"
1383        );
1384    }
1385
1386    #[test]
1387    fn render_multiscript() {
1388        assert_eq!(
1389            render(&Node::Multiscripts {
1390                base: &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1391                pre: &const {
1392                    &[MultiscriptPair {
1393                        sub: &Node::Number("1"),
1394                        sup: &Node::EMPTY_ROW,
1395                    }]
1396                },
1397                post: &const { &[] },
1398            }),
1399            "<mmultiscripts><mi>x</mi><mprescripts/><mn>1</mn><mrow></mrow></mmultiscripts>"
1400        );
1401    }
1402
1403    #[test]
1404    fn render_text_transform() {
1405        assert_eq!(
1406            render(&Node::IdentifierChar('a'.into(), LetterAttr::ForcedUpright)),
1407            "<mrow><mspace/><mi mathvariant=\"normal\">a</mi></mrow>"
1408        );
1409        assert_eq!(
1410            render(&Node::IdentifierChar('a'.into(), LetterAttr::ForcedUpright)),
1411            "<mrow><mspace/><mi mathvariant=\"normal\">a</mi></mrow>"
1412        );
1413        assert_eq!(
1414            render(&Node::IdentifierStr("abc")),
1415            "<mrow><mspace/><mi>abc</mi></mrow>"
1416        );
1417        assert_eq!(
1418            render(&Node::IdentifierChar('𝐚'.into(), LetterAttr::Default)),
1419            "<mi>𝐚</mi>"
1420        );
1421        assert_eq!(
1422            render(&Node::IdentifierChar('𝒂'.into(), LetterAttr::Default)),
1423            "<mi>𝒂</mi>"
1424        );
1425        assert_eq!(
1426            render(&Node::IdentifierStr("𝒂𝒃𝒄")),
1427            "<mrow><mspace/><mi>𝒂𝒃𝒄</mi></mrow>"
1428        );
1429    }
1430
1431    #[test]
1432    fn render_enclose() {
1433        let content = Node::Row {
1434            nodes: &[
1435                &Node::IdentifierChar('a'.into(), LetterAttr::Default),
1436                &Node::IdentifierChar('b'.into(), LetterAttr::Default),
1437                &Node::IdentifierChar('c'.into(), LetterAttr::Default),
1438            ],
1439            attrs: RowAttrs::DEFAULT,
1440        };
1441
1442        assert_eq!(
1443            render(&Node::Enclose {
1444                content: &content,
1445                notation: Notation::UP_DIAGONAL | Notation::DOWN_DIAGONAL
1446            }),
1447            "<menclose notation=\"updiagonalstrike downdiagonalstrike\"><mrow><mi>a</mi><mi>b</mi><mi>c</mi></mrow><mrow class=\"menclose-updiagonalstrike\"></mrow><mrow class=\"menclose-downdiagonalstrike\"></mrow></menclose>"
1448        );
1449    }
1450}